Compare commits
198 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33bb3eccb5 | |||
| 4a665da79e | |||
| 1e3b34ec03 | |||
| decf176b4b | |||
| dea74b777e | |||
| 18e574a765 | |||
| 879a6bb02d | |||
| 1a365a45bd | |||
| 54001585e2 | |||
| 0bf552b793 | |||
| 87ba935c6e | |||
| 045e46e111 | |||
| e7573f13d9 | |||
| 9d28807f2c | |||
| e60ebe468b | |||
| 26977780cc | |||
| e06472ecd9 | |||
| ab27629139 | |||
| 3b1fdfac51 | |||
| 087c1ddb5d | |||
| 890cd08f57 | |||
| aabc95768c | |||
| b34b201bd1 | |||
| c356cf96a1 | |||
| 2a31537b80 | |||
| 0cd853e330 | |||
| 59533b997b | |||
| e21120d51d | |||
| c946b73d11 | |||
| 3daf7ed4cb | |||
| f9869ada6d | |||
| 0fc436dc0c | |||
| cd25791160 | |||
| 2a83551eee | |||
| d0889d5250 | |||
| 6958a7b4f6 | |||
| aa7f1b1c60 | |||
| 70d7cf30c2 | |||
| 5912e9e613 | |||
| 497faae3bf | |||
| e6ce8009b1 | |||
| 705353b0da | |||
| 0792c88612 | |||
| a3d1dce2ca | |||
| db2e61a74b | |||
| 03ebea1d92 | |||
| c797e9868e | |||
| 0016b6a427 | |||
| 0d3e92cfda | |||
| 1dd2521183 | |||
| 21a042dc2b | |||
| 85edc88a6d | |||
| c229b4b79b | |||
| 9721941962 | |||
| b777e08371 | |||
| d0b4f8aaf6 | |||
| 916505fbac | |||
| 75a4693f27 | |||
| cd6a1d36ff | |||
| 2342f54179 | |||
| 246b554dab | |||
| 7ee79292fa | |||
| 600e8eec6a | |||
| 3ec1978176 | |||
| 12ea853170 | |||
| 7e41c81d53 | |||
| 2d6c751fb2 | |||
| e0b8d5641e | |||
| faa96d1964 | |||
| 68c3cfaf26 | |||
| 1ad2fec073 | |||
| 1294ce1faa | |||
| 5d62003fe5 | |||
| 12d53afaeb | |||
| 4263b3eb26 | |||
| d58f13e1ce | |||
| 81b3cbda65 | |||
| 0a99eed369 | |||
| 1ec9f2e592 | |||
| a401a318c5 | |||
| 5caad0df2d | |||
| 01e66ba122 | |||
| 923c1635b4 | |||
| c37bc5205b | |||
| 841263bce1 | |||
| e1f433804c | |||
| 2b5d7268ed | |||
| 2cc492bb88 | |||
| f6eb39eb73 | |||
| 2b26fe1efa | |||
| 1f18f151d9 | |||
| a27589418c | |||
| 57cbab916a | |||
| a7a8bfd24c | |||
| 0c964909e0 | |||
| 6511e4ec62 | |||
| 220309db12 | |||
| c3d88c0685 | |||
| e4ecd116a2 | |||
| 8d0477e0f7 | |||
| e7e9cb20d8 | |||
| ebfae9b891 | |||
| 3ab484ddc0 | |||
| 6bcef26446 | |||
| 240444eafd | |||
| d09c9d6633 | |||
| bc44cbc8b7 | |||
| e7060a5e42 | |||
| 4b8f59695b | |||
| da7b86d5c8 | |||
| efe21c79e9 | |||
| ee119fe437 | |||
| d97c403aab | |||
| c41114faf8 | |||
| c850f7603a | |||
| 5463e354fa | |||
| 783b7f4101 | |||
| c28395402b | |||
| 7798530528 | |||
| 25f63d9ad3 | |||
| d060d288fb | |||
| 6099841cef | |||
| d70a7c9403 | |||
| 63eaa739d4 | |||
| 9f8dc9082e | |||
| f1a90e120e | |||
| db5a7776cc | |||
| 6c2c094f64 | |||
| 609a35f251 | |||
| 97c39d5e15 | |||
| 4a82790734 | |||
| 80000793f4 | |||
| 39bea78a9c | |||
| 364fe4a286 | |||
| 38ea78b489 | |||
| 7b5abc6ad9 | |||
| ca55ff8ec5 | |||
| 0841d3e5c8 | |||
| 33d30f5e1f | |||
| 14e979837e | |||
| fa27b299e7 | |||
| 9e3d6f4ca1 | |||
| 26aabd6660 | |||
| 9d07f64154 | |||
| 0f17efe3b4 | |||
| 4999325210 | |||
| 1c6dfea5b5 | |||
| df15789982 | |||
| 272a3e7d6e | |||
| 569b68ec98 | |||
| 631ea96f19 | |||
| dce32b96b7 | |||
| a0cd808456 | |||
| e3745d7504 | |||
| 8546748759 | |||
| cc2ece94c3 | |||
| 6b602c76b8 | |||
| f2f1cd9b6c | |||
| c59e196e41 | |||
| 72aef2426c | |||
| 244f999538 | |||
| 127da516ac | |||
| c0832182f2 | |||
| 9ea7758abb | |||
| f51a6a895e | |||
| 5ccbaf5c23 | |||
| c8bc4a95a9 | |||
| 65a001dad4 | |||
| 572d615d4e | |||
| 9dd97a369c | |||
| 62b4520507 | |||
| 241e19b713 | |||
| 685eb748e8 | |||
| 44426faf16 | |||
| 26952ae0d9 | |||
| 649f0599f8 | |||
| 8d2765313c | |||
| 57f02f0d89 | |||
| 50ac0f0daf | |||
| fa74cce3fc | |||
| 3113479cd2 | |||
| 4eb7734ab9 | |||
| 750d74a2af | |||
| 8a6dc6a29e | |||
| c042519bc9 | |||
| 383b9ae231 | |||
| aee4fc190e | |||
| 0547488f13 | |||
| 49f87c4da3 | |||
| 1a6b550e89 | |||
| f8d07dab61 | |||
| 0434ffc750 | |||
| e2d8d314f6 | |||
| 0c8782ea75 | |||
| c042adc51b | |||
| 776ad6466f | |||
| 9c98638ec0 | |||
| aa8ac6cad9 |
@@ -39,6 +39,16 @@
|
||||
"async": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Artifact|mcp__claude_ai_.*__batch",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/artifact-export-hint.js\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "",
|
||||
"ANTHROPIC_AUTH_TOKEN": ""
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run:*)",
|
||||
@@ -36,11 +40,8 @@
|
||||
"Bash(node -e ':*)",
|
||||
"Bash(node -e \"require\\('./server/lib/transcript-cache'\\); require\\('./scripts/import-history'\\); console.log\\('All modules load OK'\\)\")",
|
||||
"Bash(claude --version)",
|
||||
"Bash(gh pr *)"
|
||||
"Bash(gh pr *)",
|
||||
"Bash(grep *)"
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "",
|
||||
"ANTHROPIC_AUTH_TOKEN": ""
|
||||
}
|
||||
}
|
||||
|
||||
+17
-16
File diff suppressed because one or more lines are too long
+33
-2
@@ -57,20 +57,48 @@ sudo pacman -S git tmux # Arch
|
||||
|
||||
Windows has no native tmux. Run AgentHub under **WSL2** and install it inside
|
||||
the WSL distro — a tmux from Git Bash or MSYS is not what the server executes.
|
||||
|
||||
**PATH for `agenthub`:** put `export PATH="$HOME/.local/bin:$PATH"` in
|
||||
`~/.profile`, not only `~/.bashrc`. A login shell that is not interactive —
|
||||
`sudo -iu <user> agenthub remote login`, an `ssh host agenthub …`, a systemd
|
||||
unit — reads `~/.profile`, and Ubuntu's `~/.bashrc` returns early when the shell
|
||||
is not interactive, so a PATH set only there is never seen (`agenthub: command
|
||||
not found`). Or call it by its full path, `~/.local/bin/agenthub`.
|
||||
|
||||
**WSL2 with `networkingMode=mirrored`:** on some machines a Windows browser
|
||||
cannot reach WSL's `localhost`, so the dashboard must listen on an address
|
||||
Windows can reach. Save it once — every later start, the autostart unit and an
|
||||
update keep it:
|
||||
|
||||
```bash
|
||||
agenthub start --host 0.0.0.0 # every interface (LAN and VPN too)
|
||||
agenthub start --host 192.168.1.20 # or only this address (and 127.0.0.1)
|
||||
```
|
||||
|
||||
A non-loopback bind always gets a token (generated when you give none; `--token
|
||||
<t>` sets your own, `--token off` removes it) and `agenthub start` prints the
|
||||
links to open, each carrying `?token=` — the page keeps it and drops it from
|
||||
the address bar. The server also listens on `127.0.0.1` whenever the bind does
|
||||
not cover it, so hooks and the CLI keep working. `agenthub start --host local`
|
||||
goes back to loopback only.
|
||||
On a native Windows install, workspace terminals are unavailable, but Settings →
|
||||
Agents still detects each AI CLI through `PATHEXT` (`claude.exe`, `codex.cmd`)
|
||||
and runs a `.cmd` updater or npm through `cmd.exe`.
|
||||
|
||||
A terminal pane gets a draggable scrollbar only on **tmux 3.7+** — older
|
||||
versions work, just without it, and `/agenthub-doctor` warns. Homebrew ships the
|
||||
latest (`brew upgrade tmux`); Ubuntu 24.04's apt has 3.4, so build the release:
|
||||
latest (`brew upgrade tmux`); Ubuntu 24.04's apt has 3.4, so build the release
|
||||
into your own `~/.local` — the system's tmux stays as it is:
|
||||
|
||||
```bash
|
||||
sudo apt install build-essential pkg-config libevent-dev libncurses-dev bison
|
||||
curl -fsSL https://github.com/tmux/tmux/releases/download/3.7c/tmux-3.7c.tar.gz | tar xz
|
||||
cd tmux-3.7c && ./configure && make && sudo make install # into /usr/local/bin
|
||||
cd tmux-3.7c && ./configure --prefix="$HOME/.local" && make && make install # into ~/.local/bin
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.profile # ahead of /usr/bin
|
||||
```
|
||||
|
||||
(`sudo make install` instead puts it in `/usr/local/bin` for every account.)
|
||||
|
||||
A tmux server already running keeps its old version until every tmux session
|
||||
has closed (or the machine reboots) — only then do the scrollbars appear.
|
||||
|
||||
@@ -444,6 +472,9 @@ npm run install-hooks
|
||||
| `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
|
||||
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
|
||||
| `DASHBOARD_DATA_DIR` | `~/.claude/agenthub` | AgentHub's data dir; a plugin install keeps its runtime (deps, UI build, logs) in `runtime/` under it |
|
||||
| `DASHBOARD_HOST` | `127.0.0.1` | Address(es) to listen on, comma-separated (`192.168.1.20,100.64.0.7`); `127.0.0.1` is added whenever none covers it. Saved by `agenthub start --host` in `<data dir>/network.json`; the variable wins over the file |
|
||||
| `DASHBOARD_ALLOWED_HOSTS` | — | Extra `Host` names (comma-separated) for a LAN bind. Saved by `agenthub start --allowed-hosts` |
|
||||
| `DASHBOARD_TOKEN` | — | Access token for `/api/*` and the WebSockets. Saved by `agenthub start --token`; the running server publishes it in `<data dir>/runtime/dashboard-token` (0600) for the local CLI, MCP server and statusline |
|
||||
|
||||
Example with a custom port:
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
+406
-88
@@ -147,6 +147,11 @@ function baseUrl() {
|
||||
return require(path.join(REPO_ROOT, "server", "lib", "dashboard-url.js")).dashboardUrl();
|
||||
}
|
||||
|
||||
/** The dashboard-token header (`dashboard-url.js`), {} when none is set. */
|
||||
function dashboardTokenHeaders() {
|
||||
return require(path.join(REPO_ROOT, "server", "lib", "dashboard-url.js")).tokenHeaders();
|
||||
}
|
||||
|
||||
/** Thrown when the dashboard server does not answer — the dispatcher decides
|
||||
* whether the active command has an offline fallback or must abort. */
|
||||
class ServerDownError extends Error {}
|
||||
@@ -162,6 +167,8 @@ async function api(method, pathname, body, options = {}) {
|
||||
method,
|
||||
headers: {
|
||||
...(body && !form ? { "Content-Type": "application/json" } : {}),
|
||||
// DASHBOARD_TOKEN, else the one the running server published.
|
||||
...dashboardTokenHeaders(),
|
||||
// Server mode: inside a dashboard pane, act as the workspace's owner.
|
||||
...(process.env.AGENTHUB_PANE_TOKEN
|
||||
? { "x-agenthub-pane-token": process.env.AGENTHUB_PANE_TOKEN }
|
||||
@@ -1133,6 +1140,107 @@ function renderPricingTable(rules) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub remote login|status|logout|upload` — this LOCAL AgentHub's link
|
||||
* to the company hub (server/lib/remote-client.js, through the running
|
||||
* dashboard's /api/remote-client). The password goes to the hub once and is
|
||||
* never stored; only a device token is, which no command prints.
|
||||
*/
|
||||
async function cmdRemote(flags, positional) {
|
||||
const sub = positional[0] || "status";
|
||||
const show = (st) => {
|
||||
if (!st.enrolled) {
|
||||
console.log(
|
||||
st.expired
|
||||
? c.yellow(
|
||||
`This machine's hub login expired (${st.hub}) — run: agenthub remote login ${st.hub}`
|
||||
)
|
||||
: "Not enrolled with a company hub — run: agenthub remote login <hub url>"
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`${c.green("●")} Enrolled with ${c.bold(st.hub)} as ${c.bold(st.user?.username || "?")}`
|
||||
);
|
||||
console.log(
|
||||
c.dim(
|
||||
` device ${st.device?.name} (#${st.device?.id}), valid until ${new Date(st.device?.expiresAt).toLocaleString()}`
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
c.dim(
|
||||
" Only sessions started on the Company account send their conversation and tokens to the hub (hourly)."
|
||||
)
|
||||
);
|
||||
};
|
||||
if (sub === "login") {
|
||||
const hub = positional[1];
|
||||
if (!hub) {
|
||||
console.error(
|
||||
c.red("✖ usage: agenthub remote login <hub url> [--user NAME] [--name DEVICE]")
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
let username = flags.user ? String(flags.user) : "";
|
||||
if (!username) {
|
||||
if (!process.stdin.isTTY) {
|
||||
console.error(c.red("✖ --user is required when the password comes from stdin"));
|
||||
process.exit(1);
|
||||
}
|
||||
const rl = require("node:readline").createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
username = await new Promise((r) =>
|
||||
rl.question("Hub username: ", (a) => (rl.close(), r(a.trim())))
|
||||
);
|
||||
}
|
||||
const password = await askPassword(`Hub password for ${username}: `);
|
||||
const r = await api(
|
||||
"POST",
|
||||
"/api/remote-client/login",
|
||||
{ hub, username, password, deviceName: flags.name ? String(flags.name) : undefined },
|
||||
{ allowError: true, timeoutMs: 30_000 }
|
||||
);
|
||||
if (r && r.status) {
|
||||
console.error(c.red(`✖ ${r.data?.error?.message || `HTTP ${r.status}`}`));
|
||||
process.exit(1);
|
||||
}
|
||||
show(r);
|
||||
return;
|
||||
}
|
||||
if (sub === "logout") {
|
||||
const r = await post("/api/remote-client/logout", {});
|
||||
console.log(
|
||||
r.wasEnrolled
|
||||
? `${c.green("✔")} Logged out${r.revokedOnHub ? " — the hub revoked this device" : c.yellow(" — the hub could not be reached; the token stops working when it expires or an admin revokes it")}`
|
||||
: "This machine was not enrolled."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sub === "upload") {
|
||||
const r = await post("/api/remote-client/upload", {}, { timeoutMs: 600_000 });
|
||||
console.log(`${c.green("✔")} Sent ${r.bytes} bytes of ${r.sessions} session(s)`);
|
||||
for (const e of r.errors || []) console.log(c.yellow(` ${e.sessionId}: ${e.message}`));
|
||||
return;
|
||||
}
|
||||
if (sub === "status") {
|
||||
const st = await get("/api/remote-client");
|
||||
if (flags.json) return console.log(JSON.stringify(st, null, 2));
|
||||
show(st);
|
||||
for (const u of (st.uploads || []).slice(0, 10)) {
|
||||
console.log(
|
||||
c.dim(
|
||||
` ${u.sessionId.slice(0, 8)} ${String(u.bytes || 0).padStart(9)} B ${u.lastSentAt || "not sent yet"} ${u.cwd || ""}`
|
||||
)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error(c.red(`✖ unknown: agenthub remote ${sub} (login | status | logout | upload)`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function cmdPricing(flags, positional) {
|
||||
const sub = positional[0];
|
||||
if (sub === "set" && positional[1]) {
|
||||
@@ -1281,7 +1389,7 @@ async function cmdDoctor() {
|
||||
// Asked of the SERVER, not of this shell: a run is a tmux session the server
|
||||
// spawns, so the only PATH that decides whether Start Run works is its own —
|
||||
// a systemd --user service never reads your shell profile. Missing tmux is a
|
||||
// warning, not a failure: it costs the run console and `lanes shell`, nothing
|
||||
// warning, not a failure: it costs the run console and `workspace shell`, nothing
|
||||
// else.
|
||||
try {
|
||||
const { available, upgrade } = await get("/api/run/tmux");
|
||||
@@ -1394,6 +1502,64 @@ async function cmdReinstallHooks() {
|
||||
console.log(`${c.green("✔")} Claude Code hooks reinstalled`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save `start`'s network flags into `<data dir>/network.json` (applied by every
|
||||
* later server start): `--host A[,B]` (`local` = back to loopback only),
|
||||
* `--allowed-hosts H,..` (`none` clears), `--token T|auto|off`. A wide bind
|
||||
* with no token gets one made (`auto`), never left open silently. Returns
|
||||
* whether anything changed.
|
||||
*/
|
||||
function saveNetworkFlags(flags) {
|
||||
const net = require(path.join(REPO_ROOT, "server", "lib", "network-config.js"));
|
||||
const patch = {};
|
||||
if (typeof flags.host === "string") {
|
||||
patch.host = flags.host === "local" ? null : flags.host;
|
||||
} else if (flags.host === true) {
|
||||
console.error(c.red("✖ --host needs an address, e.g. --host 0.0.0.0 (or --host local)"));
|
||||
process.exit(1);
|
||||
}
|
||||
if (typeof flags["allowed-hosts"] === "string") {
|
||||
patch.allowedHosts = flags["allowed-hosts"] === "none" ? null : flags["allowed-hosts"];
|
||||
}
|
||||
if (flags.token === "off") patch.token = null;
|
||||
else if (flags.token === "auto" || flags.token === true) patch.token = net.newToken();
|
||||
else if (typeof flags.token === "string") patch.token = flags.token;
|
||||
if (Object.keys(patch).length === 0) {
|
||||
net.applyNetworkConfig();
|
||||
return false;
|
||||
}
|
||||
const saved = net.writeNetworkConfig(patch);
|
||||
const wide = (saved.host || "")
|
||||
.split(",")
|
||||
.map((h) => h.trim())
|
||||
.some((h) => h && !["127.0.0.1", "localhost", "::1", "[::1]"].includes(h));
|
||||
if (wide && !saved.token && !process.env.DASHBOARD_TOKEN) {
|
||||
// Reachable from the network with no token would be an open dashboard.
|
||||
Object.assign(saved, net.writeNetworkConfig({ token: net.newToken() }));
|
||||
console.log(c.yellow(" A token was generated: the dashboard is reachable from the network."));
|
||||
}
|
||||
console.log(`${c.green("✔")} Network settings saved to ${c.dim(net.configPath())}`);
|
||||
net.applyNetworkConfig();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print every URL the dashboard can be opened at (all of this machine's
|
||||
* addresses for a 0.0.0.0 bind), each carrying `?token=` when one is set —
|
||||
* the page stores it and drops it from the address bar.
|
||||
*/
|
||||
function printAccessUrls() {
|
||||
const sec = require(path.join(REPO_ROOT, "server", "lib", "security.js"));
|
||||
const port = Number(new URL(baseUrl()).port) || 4820;
|
||||
const token = sec.getDashboardToken();
|
||||
const urls = sec.accessUrls(port);
|
||||
if (urls.length <= 1 && !token) return;
|
||||
console.log(c.dim(" Open it at:"));
|
||||
for (const u of urls) {
|
||||
console.log(` ${c.bold(token ? `${u}/?token=${encodeURIComponent(token)}` : u)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the dashboard server in the background (production mode, serving the
|
||||
* built client) and wait until /api/health answers. No-ops with a pointer to
|
||||
@@ -1404,8 +1570,16 @@ async function cmdReinstallHooks() {
|
||||
async function cmdStart(flags) {
|
||||
// A Quit (`agenthub stop`) held the plugin bootstrap off; starting ends it.
|
||||
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).clearQuit();
|
||||
const changed = saveNetworkFlags(flags);
|
||||
if (await serverIsUp()) {
|
||||
console.log(`${c.green("●")} Dashboard already running at ${c.bold(baseUrl())}`);
|
||||
if (changed) {
|
||||
console.log(
|
||||
c.yellow(" The new network settings were saved; they apply at the next start: ") +
|
||||
c.bold("agenthub stop && agenthub start")
|
||||
);
|
||||
}
|
||||
printAccessUrls();
|
||||
return;
|
||||
}
|
||||
// A plugin install starts the way a Claude Code session does: its bootstrap
|
||||
@@ -1422,6 +1596,7 @@ async function cmdStart(flags) {
|
||||
while (Date.now() < deadline) {
|
||||
if (await serverIsUp()) {
|
||||
console.log(`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())}`);
|
||||
printAccessUrls();
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
@@ -1471,6 +1646,7 @@ async function cmdStart(flags) {
|
||||
console.log(
|
||||
`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())} ${c.dim(`(pid ${child.pid})`)}`
|
||||
);
|
||||
printAccessUrls();
|
||||
console.log(c.dim(" Stop it with: agenthub stop"));
|
||||
return;
|
||||
}
|
||||
@@ -1693,7 +1869,7 @@ function cmdOpen() {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes add` — adopt a working directory or provision a managed worktree.
|
||||
* `agenthub workspace add` — adopt a working directory or provision a managed worktree.
|
||||
*/
|
||||
async function cmdLanesAdd(args) {
|
||||
const flag = (name) => {
|
||||
@@ -1719,12 +1895,12 @@ async function cmdLanesAdd(args) {
|
||||
if (base) body.base = base;
|
||||
if (slug) body.slug = slug;
|
||||
if (tool) body.tool = tool;
|
||||
const { lane: provisioning } = await post("/api/lanes/worktree", body);
|
||||
const { lane: provisioning } = await post("/api/workspaces/worktree", body);
|
||||
const deadline = Date.now() + 30_000;
|
||||
let lane = provisioning;
|
||||
while (lane.status === "provisioning" && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
({ lane } = await get(`/api/lanes/${provisioning.id}`));
|
||||
({ lane } = await get(`/api/workspaces/${provisioning.id}`));
|
||||
}
|
||||
if (lane.status === "provisioning") {
|
||||
console.error(
|
||||
@@ -1755,12 +1931,12 @@ async function cmdLanesAdd(args) {
|
||||
const body = { cwd, title };
|
||||
if (pipeline) body.pipeline = pipeline;
|
||||
if (tool) body.tool = tool;
|
||||
const { lane } = await post("/api/lanes", body);
|
||||
const { lane } = await post("/api/workspaces", body);
|
||||
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes gc [--dry-run]` — reap orphaned Playwright MCP processes
|
||||
* `agenthub workspace gc [--dry-run]` — reap orphaned Playwright MCP processes
|
||||
* (owning session died) and cap oversized hook logs, across every lane on
|
||||
* this machine. Machine-wide, not one lane's; pure local process/file
|
||||
* action, no HTTP round-trip.
|
||||
@@ -1790,7 +1966,7 @@ function cmdLanesGc(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes profile init <repo>` — detect a Node.js project and scaffold
|
||||
* `agenthub workspace profile init <repo>` — detect a Node.js project and scaffold
|
||||
* `.agenthub/profile/`. Pure filesystem action against the SOURCE repo; does not
|
||||
* talk to the dashboard server at all.
|
||||
*/
|
||||
@@ -1844,9 +2020,9 @@ function cmdLanesProfileInit(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes profile check [<path>]` — validate a profile without needing a
|
||||
* `agenthub workspace profile check [<path>]` — validate a profile without needing a
|
||||
* lane to exist for it yet. Defaults to the current directory, NOT lane-id
|
||||
* resolution (unlike every other `lanes` subcommand) — this is meant to run
|
||||
* resolution (unlike every other `workspace` subcommand) — this is meant to run
|
||||
* against a bare repo right after `profile init`.
|
||||
*/
|
||||
async function cmdLanesProfileCheck(args) {
|
||||
@@ -1883,7 +2059,7 @@ function printLaneFacts(title, facts, fields) {
|
||||
}
|
||||
}
|
||||
|
||||
/** `agenthub lanes reset|remove|purge` — destructive work only after a fact check. */
|
||||
/** `agenthub workspace reset|remove|purge` — destructive work only after a fact check. */
|
||||
async function cmdLanesLifecycle(action, args) {
|
||||
const id = args.find((arg) => !arg.startsWith("--"));
|
||||
const fields = LANE_PREFLIGHT_FIELDS[action];
|
||||
@@ -1894,7 +2070,7 @@ async function cmdLanesLifecycle(action, args) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
|
||||
const preflight = await get(`/api/workspaces/${id}/preflight?action=${action}`);
|
||||
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
|
||||
const keepDb = action === "reset" && args.includes("--keep-db");
|
||||
if (preflight.database) {
|
||||
@@ -1938,7 +2114,7 @@ async function cmdLanesLifecycle(action, args) {
|
||||
const body = { confirm: true, expect };
|
||||
if (args.includes("--force")) body.force = true;
|
||||
if (keepDb) body.keepDb = true;
|
||||
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
|
||||
const result = await post(`/api/workspaces/${id}/${action}`, body, { allowError: true });
|
||||
if (result.status) {
|
||||
const error = result.data?.error || {};
|
||||
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
|
||||
@@ -1961,7 +2137,7 @@ async function cmdLanesLifecycle(action, args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes` — one row per lane: what it is, where it is in its pipeline, and
|
||||
* `agenthub workspaces` — one row per lane: what it is, where it is in its pipeline, and
|
||||
* whether the driving session is still breathing.
|
||||
*/
|
||||
/**
|
||||
@@ -1993,7 +2169,7 @@ function detectionLeadsDeclaration(l) {
|
||||
}
|
||||
|
||||
async function cmdLanes() {
|
||||
const { lanes, counts } = await get("/api/lanes");
|
||||
const { lanes, counts } = await get("/api/workspaces");
|
||||
if (!lanes.length) {
|
||||
console.log(
|
||||
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
|
||||
@@ -2040,7 +2216,7 @@ function toolFromEnv() {
|
||||
*
|
||||
* Only the FIRST positional counts as an id, and only when it is all digits.
|
||||
* Scanning the whole argv for a number would swallow flag values —
|
||||
* `agenthub lanes logs web --tail 4096` would have addressed lane 4096.
|
||||
* `agenthub workspace logs web --tail 4096` would have addressed lane 4096.
|
||||
*
|
||||
* @param {string[]} args - Arguments after the subcommand.
|
||||
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
|
||||
@@ -2051,13 +2227,13 @@ async function resolveLaneArg(args) {
|
||||
return i > -1 ? args[i + 1] : undefined;
|
||||
};
|
||||
|
||||
const explicit = flagValue("lane") || flagValue("workspace");
|
||||
const explicit = flagValue("workspace");
|
||||
if (explicit) return { laneId: explicit, rest: args };
|
||||
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
|
||||
|
||||
const tool = flagValue("tool") || toolFromEnv();
|
||||
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
|
||||
const { lanes } = await get("/api/lanes");
|
||||
const { lanes } = await get("/api/workspaces");
|
||||
|
||||
// A pane's AGENTHUB_LANE_ID beats cwd matching, but only after it is checked
|
||||
// against the live lane list AND the directory: a pane outlives the lane it
|
||||
@@ -2146,7 +2322,7 @@ function printRuntime(runtime) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes up|down|runtime|logs|hook` — a lane's own application stack, as
|
||||
* `agenthub workspace up|down|runtime|logs|hook` — a lane's own application stack, as
|
||||
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
|
||||
* lane id.
|
||||
*
|
||||
@@ -2160,7 +2336,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
const { laneId, rest: laneArgs } = resolved;
|
||||
|
||||
if (sub === "runtime") {
|
||||
printRuntime(await get(`/api/lanes/${laneId}/runtime`));
|
||||
printRuntime(await get(`/api/workspaces/${laneId}/runtime`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2168,7 +2344,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
const body = {};
|
||||
if (laneArgs.includes("--no-build")) body.build = false;
|
||||
if (laneArgs.includes("--qc")) body.qc = true;
|
||||
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
|
||||
const result = await post(`/api/workspaces/${laneId}/up`, body, { allowError: true });
|
||||
if (result.status) {
|
||||
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
|
||||
process.exitCode = 1;
|
||||
@@ -2181,7 +2357,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
const deadline = Date.now() + 15 * 60 * 1000;
|
||||
for (;;) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const runtime = await get(`/api/lanes/${laneId}/runtime`);
|
||||
const runtime = await get(`/api/workspaces/${laneId}/runtime`);
|
||||
if (runtime.healthy) {
|
||||
printRuntime(runtime);
|
||||
return;
|
||||
@@ -2202,7 +2378,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
}
|
||||
|
||||
if (sub === "down") {
|
||||
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
|
||||
const result = await post(`/api/workspaces/${laneId}/down`, {}, { allowError: true });
|
||||
if (result.status) {
|
||||
console.error(
|
||||
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
|
||||
@@ -2223,7 +2399,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
}
|
||||
const i = laneArgs.indexOf("--tail");
|
||||
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
|
||||
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
|
||||
const log = await get(`/api/workspaces/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
|
||||
if (!log.available) {
|
||||
console.log("no logs for this workspace yet.");
|
||||
return;
|
||||
@@ -2241,7 +2417,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
return;
|
||||
}
|
||||
const result = await post(
|
||||
`/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`,
|
||||
`/api/workspaces/${laneId}/hook/${encodeURIComponent(name)}`,
|
||||
{ args: laneArgs.slice(1) },
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2263,7 +2439,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
: "merge";
|
||||
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
|
||||
const result = await post(
|
||||
`/api/lanes/${laneId}/sync-base`,
|
||||
`/api/workspaces/${laneId}/sync-base`,
|
||||
{ mode, branch },
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2342,7 +2518,7 @@ async function defaultHolder(argsAfterName) {
|
||||
|
||||
const resolved = await resolveLaneArg(argsAfterName);
|
||||
if (!resolved) return null;
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
|
||||
}
|
||||
|
||||
@@ -2474,7 +2650,7 @@ function fmtFeatureRow(f) {
|
||||
async function cmdFeatureList(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const { features } = await get(`/api/lanes/${resolved.laneId}/features`);
|
||||
const { features } = await get(`/api/workspaces/${resolved.laneId}/features`);
|
||||
if (!features.length) {
|
||||
console.log("no features activated yet — start one with: agenthub feature activate <slug>");
|
||||
return;
|
||||
@@ -2496,7 +2672,7 @@ async function cmdFeatureActivate(args) {
|
||||
};
|
||||
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||
if (!resolved) return;
|
||||
const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, {
|
||||
const { lane, feature } = await post(`/api/workspaces/${resolved.laneId}/features/activate`, {
|
||||
slug,
|
||||
title: flag("title"),
|
||||
});
|
||||
@@ -2506,13 +2682,13 @@ async function cmdFeatureActivate(args) {
|
||||
}
|
||||
|
||||
/** `agenthub feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
|
||||
/** `agenthub lanes proof-link [<id>] [--cwd path]` — converge the clone-root
|
||||
/** `agenthub workspace proof-link [<id>] [--cwd path]` — converge the clone-root
|
||||
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
|
||||
* by anything else in this codebase; a session or hook calls it explicitly. */
|
||||
async function cmdLanesProofLink(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`);
|
||||
const { linked } = await post(`/api/workspaces/${resolved.laneId}/proof-link`);
|
||||
console.log(
|
||||
linked
|
||||
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
|
||||
@@ -2520,13 +2696,13 @@ async function cmdLanesProofLink(args) {
|
||||
);
|
||||
}
|
||||
|
||||
/** `agenthub lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
||||
/** `agenthub workspace mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
||||
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
|
||||
* profiles. Never run automatically; a session calls it explicitly. */
|
||||
async function cmdLanesMcpSync(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const result = await post(`/api/lanes/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
|
||||
const result = await post(`/api/workspaces/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
|
||||
if (result.status) {
|
||||
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
|
||||
process.exitCode = 1;
|
||||
@@ -2538,7 +2714,7 @@ async function cmdLanesMcpSync(args) {
|
||||
}
|
||||
}
|
||||
|
||||
/** `agenthub lanes integration <name> [<id>]` — check whether a named
|
||||
/** `agenthub workspace integration <name> [<id>]` — check whether a named
|
||||
* integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
|
||||
* its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */
|
||||
async function cmdLanesIntegration(args) {
|
||||
@@ -2551,13 +2727,13 @@ async function cmdLanesIntegration(args) {
|
||||
const resolved = await resolveLaneArg(args.filter((a) => a !== name));
|
||||
if (!resolved) return;
|
||||
const { enabled } = await get(
|
||||
`/api/lanes/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
||||
`/api/workspaces/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
||||
);
|
||||
console.log(enabled ? "on" : "off");
|
||||
process.exitCode = enabled ? 0 : 1;
|
||||
}
|
||||
|
||||
/** `agenthub lanes ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
|
||||
/** `agenthub workspace ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
|
||||
* Actions status snapshot for a ref, via GET /:id/ci/status. Never polls;
|
||||
* the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code
|
||||
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
|
||||
@@ -2576,7 +2752,7 @@ async function cmdLanesCi(args) {
|
||||
const resolved = await resolveLaneArg(args.slice(2));
|
||||
if (!resolved) return;
|
||||
const result = await get(
|
||||
`/api/lanes/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
|
||||
`/api/workspaces/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
|
||||
undefined,
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2590,7 +2766,7 @@ async function cmdLanesCi(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
|
||||
* `agenthub workspace node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
|
||||
* lane's pipeline instead of the whole thing.
|
||||
*
|
||||
* Every node already carries the one command that drives it (`command`, added
|
||||
@@ -2599,7 +2775,7 @@ async function cmdLanesCi(args) {
|
||||
* pasted it into a session by hand. This runs it, in the lane's own cwd:
|
||||
*
|
||||
* - a slash command starts `claude` with it as the prompt;
|
||||
* - anything else (`npm test`, `agenthub lanes hook ci-gate`) is spawned directly.
|
||||
* - anything else (`npm test`, `agenthub workspace hook ci-gate`) is spawned directly.
|
||||
*
|
||||
* With no node id it lists the nodes and their commands, which is also the
|
||||
* error path's answer. `--print` shows the argv and runs nothing.
|
||||
@@ -2621,10 +2797,10 @@ async function cmdLanesNode(args) {
|
||||
const { flags, positional } = parseArgs(args);
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||
// The lane payload's `pipeline_nodes` carries render state but not `aliases`;
|
||||
// the template does. Merge, so the listing can still mark the current node.
|
||||
const { pipelines } = await get("/api/lanes/pipelines");
|
||||
const { pipelines } = await get("/api/workspaces/pipelines");
|
||||
const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state]));
|
||||
const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({
|
||||
...n,
|
||||
@@ -2699,8 +2875,8 @@ async function cmdLanesNode(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
||||
* template a lane renders against. `agenthub lanes add --pipeline` could only set
|
||||
* `agenthub workspace pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
||||
* template a lane renders against. `agenthub workspace add --pipeline` could only set
|
||||
* this at creation time, so every lane added from the dashboard's "+ Add lane"
|
||||
* was stuck on `default` with no way back to a 16-node template.
|
||||
*/
|
||||
@@ -2710,15 +2886,15 @@ async function cmdLanesPipeline(args) {
|
||||
if (!resolved) return;
|
||||
|
||||
if (!target) {
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { pipelines } = await get("/api/lanes/pipelines");
|
||||
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||
const { pipelines } = await get("/api/workspaces/pipelines");
|
||||
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
||||
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
|
||||
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
|
||||
const { lane } = await patch(`/api/workspaces/${resolved.laneId}`, { pipeline: target });
|
||||
console.log(
|
||||
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
|
||||
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
|
||||
@@ -2746,7 +2922,7 @@ async function cmdLanesPipeline(args) {
|
||||
async function cmdLanesShell(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||
const sessionName = `agenthub-lane-${lane.id}`;
|
||||
const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
|
||||
stdio: "inherit",
|
||||
@@ -2771,7 +2947,7 @@ async function cmdFeatureShow(args) {
|
||||
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||
if (!resolved) return;
|
||||
const result = await get(
|
||||
`/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
||||
`/api/workspaces/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
||||
undefined,
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2806,7 +2982,7 @@ async function cmdStage(args) {
|
||||
if (!resolved) return;
|
||||
const laneId = resolved.laneId;
|
||||
|
||||
const { lane } = await post(`/api/lanes/${laneId}/stage`, {
|
||||
const { lane } = await post(`/api/workspaces/${laneId}/stage`, {
|
||||
stage,
|
||||
status: flag("status"),
|
||||
evidence: flag("evidence"),
|
||||
@@ -2827,7 +3003,11 @@ const COMMAND_GROUPS = [
|
||||
"Server",
|
||||
[
|
||||
["status", "", "Up/down indicator for the dashboard server"],
|
||||
["start", "[--port N]", "Start the server in the background and wait for healthy"],
|
||||
[
|
||||
"start",
|
||||
"[--port N] [--host A[,B]|local] [--allowed-hosts H,..] [--token T|auto|off]",
|
||||
"Start the server in the background and wait for healthy; --host/--allowed-hosts/--token are saved (data dir network.json) for every later start",
|
||||
],
|
||||
[
|
||||
"stop",
|
||||
"",
|
||||
@@ -2845,18 +3025,18 @@ const COMMAND_GROUPS = [
|
||||
],
|
||||
[
|
||||
"server",
|
||||
"setup|off|status [--hostname name] [--admin name]",
|
||||
"setup|install-system|tools|off|status [--hostname name] [--admin name]",
|
||||
"Server mode: logins for a shared dashboard (docs/SERVER-MODE.md)",
|
||||
],
|
||||
[
|
||||
"users",
|
||||
"list|add|passwd|role|disable|enable|assign|audit [name]",
|
||||
"list|add|passwd|role|disable|enable|provision|assign|audit [name]",
|
||||
"Server mode's accounts (add: --role admin|analyst|developer)",
|
||||
],
|
||||
[
|
||||
"accounts",
|
||||
"list|add|remove [name] [--tool codex]",
|
||||
"Claude/Codex account pool: new sessions spread over these accounts",
|
||||
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex|cursor]",
|
||||
"Claude/Codex/Cursor account pool: new sessions spread over these accounts; pin = one user always on one",
|
||||
],
|
||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||
],
|
||||
@@ -2934,6 +3114,19 @@ const COMMAND_GROUPS = [
|
||||
["remote-sources rm <id>", "[--purge]", "Remove a source (--purge also deletes its data)"],
|
||||
],
|
||||
],
|
||||
[
|
||||
"Company hub",
|
||||
[
|
||||
[
|
||||
"remote login <hub url>",
|
||||
"[--user NAME --name DEVICE]",
|
||||
"Enroll this machine (Company account)",
|
||||
],
|
||||
["remote status", "[--json]", "Enrollment and what was sent to the hub"],
|
||||
["remote upload", "", "Send the Company-account sessions' new lines now"],
|
||||
["remote logout", "", "Revoke this machine on the hub and forget it"],
|
||||
],
|
||||
],
|
||||
[
|
||||
"Workspaces",
|
||||
[
|
||||
@@ -3101,8 +3294,8 @@ falling back to http://127.0.0.1:4820.
|
||||
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
|
||||
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
|
||||
|
||||
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} still work — the same
|
||||
commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
|
||||
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} were removed — use
|
||||
${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
|
||||
|
||||
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
|
||||
require it to be running (${c.bold("agenthub start")} brings one up in the background).
|
||||
@@ -3512,8 +3705,8 @@ function replHelp() {
|
||||
/** Detailed help for one command (or command prefix): every catalog entry
|
||||
* whose invocation starts with the token. Returns false if none matched. */
|
||||
function commandHelp(token) {
|
||||
// `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES),
|
||||
// so `help lanes` finds the `workspace …` rows and vice versa.
|
||||
// `workspace` and `workspaces` are one command (COMMAND_ALIASES), so
|
||||
// `help workspaces` finds the `workspace …` rows and vice versa.
|
||||
const canon = (words) => {
|
||||
const [head, ...tail] = words.split(" ");
|
||||
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
|
||||
@@ -3774,23 +3967,22 @@ async function cmdRepl() {
|
||||
// prompt. Handlers that need the server throw ServerDownError; the caller
|
||||
// decides whether to fall back to an offline reader or report the refusal.
|
||||
|
||||
/** Command names that are another command under the user-facing word.
|
||||
* `workspace` is the name to use; `lanes` (the handler's own name) and
|
||||
* `--lane` are the OLD names, still working but deprecated. Removing them is
|
||||
* step 4 of docs/plans/workspace-rename/README.md — not before the third
|
||||
* release after the first one that shipped these aliases. */
|
||||
const COMMAND_ALIASES = { workspace: "lanes", workspaces: "lanes" };
|
||||
/** Command names that are another command under the user-facing word. */
|
||||
const COMMAND_ALIASES = { workspaces: "workspace" };
|
||||
const canonicalArgv = (argv) =>
|
||||
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
|
||||
|
||||
/** One stderr line when an old name is typed, so people and agents move over
|
||||
* before step 4 removes it. stderr only: scripts reading stdout see nothing. */
|
||||
function warnOldNames(argv) {
|
||||
const old = [];
|
||||
if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`");
|
||||
if (argv.includes("--lane")) old.push("`--lane` → `--workspace`");
|
||||
if (old.length) {
|
||||
console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`));
|
||||
/** The old names `agenthub workspace` and `--lane` were removed in step 4 of
|
||||
* docs/plans/workspace-rename/README.md. They exit 1 naming the new spelling
|
||||
* — never "Unknown command" or help, so a script written before says why. */
|
||||
function refuseOldNames(argv) {
|
||||
if (argv[0] === "lanes") {
|
||||
console.error(c.red("✖ renamed: use agenthub workspace …"));
|
||||
process.exit(1);
|
||||
}
|
||||
if (argv.includes("--lane")) {
|
||||
console.error(c.red("✖ renamed: use --workspace <id>"));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3803,7 +3995,6 @@ const COMMANDS = (() => {
|
||||
for (const [name] of items) set.add(name.split(" ")[0]);
|
||||
}
|
||||
set.add("help");
|
||||
// The old names stay typeable (and completable) for scripts written before.
|
||||
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
|
||||
return [...set];
|
||||
})();
|
||||
@@ -3812,9 +4003,9 @@ const COMMANDS = (() => {
|
||||
const SUBCOMMANDS = {
|
||||
alerts: ["ack", "ack-all"],
|
||||
service: ["install", "uninstall", "status"],
|
||||
server: ["setup", "off", "status"],
|
||||
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
|
||||
accounts: ["list", "add", "remove"],
|
||||
server: ["setup", "install-system", "tools", "off", "status"],
|
||||
users: ["list", "add", "passwd", "role", "disable", "enable", "provision", "assign", "audit"],
|
||||
accounts: ["list", "add", "remove", "pin", "unpin"],
|
||||
pricing: ["set", "delete", "reset"],
|
||||
webhooks: ["test"],
|
||||
import: ["rescan", "path"],
|
||||
@@ -3905,7 +4096,7 @@ async function newPassword(label) {
|
||||
return first;
|
||||
}
|
||||
|
||||
/** `agenthub server setup|off|status` — turn server mode on or off. */
|
||||
/** `agenthub server setup|install-system|tools|off|status` — turn server mode on or off. */
|
||||
async function cmdServer(sub, flags) {
|
||||
const { settingsEnv, auth } = serverAuth();
|
||||
const file = settingsEnv.settingsFilePath();
|
||||
@@ -4062,7 +4253,7 @@ async function installSystem(flags, settingsEnv) {
|
||||
throw new Error("run this as the account that runs the dashboard, not as root (it uses sudo)");
|
||||
}
|
||||
const which = (bin) =>
|
||||
spawnSync("sh", ["-c", `command -v ${bin}`], { encoding: "utf8" }).stdout.trim();
|
||||
spawnSync("sh", ["-c", 'command -v "$1"', "sh", bin], { encoding: "utf8" }).stdout.trim();
|
||||
const host = os.userInfo().username;
|
||||
const { getDataDir } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js"));
|
||||
const data = getDataDir();
|
||||
@@ -4166,14 +4357,25 @@ async function cmdUsers(sub, positional, flags) {
|
||||
case "list":
|
||||
table(
|
||||
["id", "username", "role", "state"],
|
||||
auth.listUsers().map((u) => [u.id, u.username, u.role, u.disabled ? "disabled" : "active"])
|
||||
auth
|
||||
.listUsers()
|
||||
.map((u) => [
|
||||
u.id,
|
||||
u.username,
|
||||
u.role,
|
||||
u.disabled ? "disabled" : u.mustChangePassword ? "active (new password due)" : "active",
|
||||
])
|
||||
);
|
||||
return;
|
||||
case "add": {
|
||||
need();
|
||||
const role = typeof flags.role === "string" ? flags.role : "developer";
|
||||
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role);
|
||||
console.log(`${c.green("✔")} ${user.username} added (${user.role})`);
|
||||
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role, {
|
||||
mustChange: true,
|
||||
});
|
||||
console.log(
|
||||
`${c.green("✔")} ${user.username} added (${user.role}) — they choose their own password at first login`
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "passwd":
|
||||
@@ -4286,7 +4488,18 @@ async function cmdCodexAccounts(sub, name, flags) {
|
||||
}
|
||||
pool.resetQuota();
|
||||
table(
|
||||
["codex account", "kind", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota", "state"],
|
||||
[
|
||||
"codex account",
|
||||
"kind",
|
||||
"live",
|
||||
"$ 5h",
|
||||
"$ 7d",
|
||||
"tok 5h",
|
||||
"tok 7d",
|
||||
"quota",
|
||||
"state",
|
||||
"pinned",
|
||||
],
|
||||
pool
|
||||
.poolStatus()
|
||||
.map((a) => [
|
||||
@@ -4301,22 +4514,109 @@ async function cmdCodexAccounts(sub, name, flags) {
|
||||
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
|
||||
: "–",
|
||||
a.full ? "full" : "ok",
|
||||
pinnedUsers("codex", a.name),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/** `agenthub accounts … --tool cursor` — the Cursor account pool (server/lib/cursor-pool.js). */
|
||||
async function cmdCursorAccounts(sub, name) {
|
||||
const pool = require(path.join(REPO_ROOT, "server", "lib", "cursor-pool.js"));
|
||||
if (sub === "add") {
|
||||
console.log(
|
||||
c.dim(" Create an API key in the Cursor dashboard (Integrations), then paste it.")
|
||||
);
|
||||
const added = pool.addAccount(name, {
|
||||
apiKey: await askPassword(`Cursor API key for ${name}: `),
|
||||
});
|
||||
console.log(`${c.green("✔")} ${added.name} added`);
|
||||
console.log(c.dim(" New Cursor sessions now spread over the pool's accounts."));
|
||||
return;
|
||||
}
|
||||
if (sub === "remove") {
|
||||
pool.removeAccount(name);
|
||||
console.log(`${c.green("✔")} ${name} removed — running panes keep it until they exit`);
|
||||
return;
|
||||
}
|
||||
if (sub !== undefined && sub !== "list") {
|
||||
throw new Error("usage: agenthub accounts list | add <name> | remove <name> --tool cursor");
|
||||
}
|
||||
table(
|
||||
["cursor account", "live", "pinned"],
|
||||
pool.poolStatus().map((a) => [a.name, a.livePanes, pinnedUsers("cursor", a.name)])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex|cursor] —
|
||||
* that user's new sessions run on that account (server/lib/account-pins.js).
|
||||
*/
|
||||
async function cmdAccountPin(sub, positional, tool) {
|
||||
const pins = require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"));
|
||||
const [first, second] = positional;
|
||||
const username = sub === "pin" ? second : first;
|
||||
if (!username || (sub === "pin" && !first)) {
|
||||
throw new Error(
|
||||
"usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex|cursor]"
|
||||
);
|
||||
}
|
||||
const user = require(path.join(REPO_ROOT, "server", "db.js"))
|
||||
.db.prepare("SELECT id FROM users WHERE username = ?")
|
||||
.get(username);
|
||||
if (!user) throw new Error(`no user ${username}`);
|
||||
const pin = pins.setPin(user.id, tool, sub === "pin" ? first : null);
|
||||
if (!require(path.join(REPO_ROOT, "server", "lib", "auth.js")).isServerMode()) {
|
||||
console.log(c.dim(" Pins apply in server mode only."));
|
||||
}
|
||||
if (!pin.account) {
|
||||
console.log(`${c.green("✔")} ${pin.username} unpinned — new ${tool} sessions use the balancer`);
|
||||
return;
|
||||
}
|
||||
const switched = await pins.applyPin(user.id, tool);
|
||||
console.log(`${c.green("✔")} ${pin.username}'s ${tool} sessions run on ${pin.account}`);
|
||||
console.log(c.dim(` ${SWITCH_NOTE[switched] || SWITCH_NOTE["next-session"]}`));
|
||||
}
|
||||
|
||||
/** What `applyPin` did, for the CLI. */
|
||||
const SWITCH_NOTE = {
|
||||
switched: "Switched now: running sessions take it at their next login check, new ones at once.",
|
||||
kept: "Their home already holds it.",
|
||||
"own-login":
|
||||
"They signed in with their own login, which is left alone: no pool login is written over it.",
|
||||
expired: "That login has expired: new sessions fall back to the balancer until it is re-added.",
|
||||
"next-session": "New sessions use it; running ones keep their account until they end.",
|
||||
};
|
||||
|
||||
/** The `pinned` column: who an admin pinned to `account`. */
|
||||
function pinnedUsers(tool, account) {
|
||||
try {
|
||||
return (
|
||||
require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"))
|
||||
.listPins(tool)
|
||||
.filter((p) => p.account === account)
|
||||
.map((p) => p.username)
|
||||
.join(",") || "–"
|
||||
);
|
||||
} catch {
|
||||
return "–";
|
||||
}
|
||||
}
|
||||
|
||||
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
|
||||
async function cmdAccounts(sub, positional, flags = {}) {
|
||||
serverAuth(); // the same settings + database the server reads
|
||||
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
|
||||
const name = positional[0];
|
||||
if (sub === "add" || sub === "remove") {
|
||||
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex]`);
|
||||
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex|cursor]`);
|
||||
}
|
||||
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
|
||||
throw new Error("--tool is claude (the default) or codex");
|
||||
if (flags.tool != null && !["claude", "codex", "cursor"].includes(flags.tool)) {
|
||||
throw new Error("--tool is claude (the default), codex or cursor");
|
||||
}
|
||||
if (sub === "pin" || sub === "unpin")
|
||||
return cmdAccountPin(sub, positional, flags.tool || "claude");
|
||||
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
|
||||
if (flags.tool === "cursor") return cmdCursorAccounts(sub, name);
|
||||
if (sub === "add") {
|
||||
console.log(
|
||||
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
|
||||
@@ -4337,7 +4637,18 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
await pool.refreshQuotas({ force: true });
|
||||
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
|
||||
table(
|
||||
["account", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota 5h", "quota wk", "state"],
|
||||
[
|
||||
"account",
|
||||
"live",
|
||||
"$ 5h",
|
||||
"$ 7d",
|
||||
"tok 5h",
|
||||
"tok 7d",
|
||||
"quota 5h",
|
||||
"quota wk",
|
||||
"state",
|
||||
"pinned",
|
||||
],
|
||||
pool
|
||||
.poolStatus()
|
||||
.map((a) => [
|
||||
@@ -4356,14 +4667,19 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
: a.quota?.error
|
||||
? `no quota (${a.quota.error})`
|
||||
: "ok",
|
||||
pinnedUsers("claude", a.name),
|
||||
])
|
||||
);
|
||||
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||
if (codex.poolStatus().length) await cmdCodexAccounts("list", null, flags);
|
||||
const cursor = require(path.join(REPO_ROOT, "server", "lib", "cursor-pool.js"));
|
||||
if (cursor.poolStatus().length) await cmdCursorAccounts("list", null);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
c.red("✖ Usage: agenthub accounts list | add <name> | remove <name> [--tool codex]")
|
||||
c.red(
|
||||
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex|cursor]"
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -4373,7 +4689,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
||||
* points, not here, to avoid recursive REPLs. */
|
||||
async function runCommand(argv) {
|
||||
warnOldNames(argv);
|
||||
refuseOldNames(argv);
|
||||
const [cmd, ...rest] = canonicalArgv(argv);
|
||||
const { flags, positional } = parseArgs(rest);
|
||||
switch (cmd) {
|
||||
@@ -4426,6 +4742,8 @@ async function runCommand(argv) {
|
||||
case "remote-sources":
|
||||
case "remotes":
|
||||
return cmdRemoteSources(flags, positional);
|
||||
case "remote":
|
||||
return cmdRemote(flags, positional);
|
||||
case "pricing":
|
||||
return cmdPricing(flags, positional);
|
||||
case "import":
|
||||
@@ -4446,7 +4764,7 @@ async function runCommand(argv) {
|
||||
return cmdReinstallHooks();
|
||||
case "update-check":
|
||||
return cmdUpdateCheck();
|
||||
case "lanes":
|
||||
case "workspace":
|
||||
if (rest[0] === "add") {
|
||||
return cmdLanesAdd(rest.slice(1));
|
||||
}
|
||||
|
||||
+3
-3
@@ -49,8 +49,8 @@ Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent mo
|
||||
|
||||
The client is a single-page application (SPA) built with modern web technologies:
|
||||
|
||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **React 19** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 7** - Full type safety across components, utilities, and API contracts
|
||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
|
||||
|
||||
@@ -1215,7 +1215,7 @@ graph TB
|
||||
|
||||
The client is a production-ready React application with:
|
||||
|
||||
- 🚀 **Modern Stack** - React 18, TypeScript, Vite, Tailwind
|
||||
- 🚀 **Modern Stack** - React 19, TypeScript, Vite, Tailwind
|
||||
- ⚡ **Real-time** - WebSocket integration for live updates
|
||||
- 🧪 **Tested** - Vitest + React Testing Library
|
||||
- 📦 **Optimized** - Code splitting, tree shaking, lazy loading
|
||||
|
||||
Generated
+709
-162
File diff suppressed because it is too large
Load Diff
+15
-14
@@ -18,36 +18,37 @@
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/maple-mono": "^5.3.0",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"d3": "^7.9.0",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"lucide-react": "^1.61.0",
|
||||
"pdfjs-dist": "6.4.299",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^27.0.1",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 411 KiB After Width: | Height: | Size: 465 KiB |
@@ -49,7 +49,7 @@
|
||||
<g transform="translate(20,22)"><rect width="48" height="48" rx="12" fill="#4cc3f5" fill-opacity="0.12"/><rect x="11" y="11" width="11" height="11" rx="3" fill="#4cc3f5"/><rect x="26" y="11" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="11" y="26" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="26" y="26" width="11" height="11" rx="3" fill="#4cc3f5"/></g>
|
||||
<text x="84" y="44" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="21" font-weight="700" fill="#f1f5ff">Every AI coding agent</text>
|
||||
<text x="84" y="72" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Claude Code, Codex, Antigravity,</text>
|
||||
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, Kimi, Grok and more</text>
|
||||
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, omp and more</text>
|
||||
</g>
|
||||
<g transform="translate(792,300)">
|
||||
<rect width="344" height="116" rx="16" fill="#ffffff" fill-opacity="0.045" stroke="#ffffff" stroke-opacity="0.10"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
@@ -132,6 +132,8 @@ const RULE_TYPES: AlertRuleType[] = [
|
||||
"cost_threshold",
|
||||
"budget",
|
||||
];
|
||||
/** Only a hub (server mode) has remote clients to alert about. */
|
||||
const SERVER_RULE_TYPES: AlertRuleType[] = ["remote_unmonitored"];
|
||||
|
||||
type TabKey = "rules" | "channels" | "activity";
|
||||
|
||||
@@ -243,6 +245,8 @@ function buildConfig(form: RuleFormState): AlertRule["config"] {
|
||||
if (form.budget_tool) config.tool = form.budget_tool;
|
||||
return config;
|
||||
}
|
||||
case "remote_unmonitored":
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +282,8 @@ function describeRule(rule: AlertRule, t: (key: string, opts?: Record<string, un
|
||||
period: t(`periods.${c.period ?? "month"}`),
|
||||
thresholds: (c.thresholds ?? []).join("%, ") + "%",
|
||||
});
|
||||
case "remote_unmonitored":
|
||||
return t("ruleDesc.remoteUnmonitored");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +292,8 @@ export function AlertsNotifications() {
|
||||
const { t: ts } = useTranslation("settings");
|
||||
|
||||
// Rules and channels (webhooks) fire for every user's sessions: an admin's.
|
||||
const { isAdmin } = usePermissions();
|
||||
const { isAdmin, local } = usePermissions();
|
||||
const ruleTypes = local ? RULE_TYPES : [...RULE_TYPES, ...SERVER_RULE_TYPES];
|
||||
const [tab, setTab] = useState<TabKey>(isAdmin ? "rules" : "activity");
|
||||
|
||||
// Rules
|
||||
@@ -613,7 +620,7 @@ export function AlertsNotifications() {
|
||||
title={editingId ? t("rules.typeFixed") : undefined}
|
||||
className="select w-full"
|
||||
>
|
||||
{RULE_TYPES.map((type) => (
|
||||
{ruleTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`ruleTypes.${type}`)}
|
||||
</option>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @file AuthCard.tsx
|
||||
* @description The card the signed-out screens share (the login page, the
|
||||
* first-login password change in `FirstPasswordPage.tsx`): a centred form
|
||||
* with the theme's accent glow and grid behind the brand logo, a title and a
|
||||
* subtitle. While it shows, the OS's light/dark applies (`applySystemTheme`),
|
||||
* since no account's theme has been chosen for it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useLayoutEffect, type FormEvent, type ReactNode } from "react";
|
||||
import { BRAND_LOGO } from "../lib/brand";
|
||||
import { applySystemTheme } from "../hooks/useTheme";
|
||||
|
||||
export function AuthCard({
|
||||
title,
|
||||
subtitle,
|
||||
onSubmit,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onSubmit: (e: FormEvent) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
// Follow the OS, live.
|
||||
useLayoutEffect(() => {
|
||||
applySystemTheme();
|
||||
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
mq?.addEventListener?.("change", applySystemTheme);
|
||||
return () => mq?.removeEventListener?.("change", applySystemTheme);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="card relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10"
|
||||
>
|
||||
{/* Accent glow and a grid fading out from the top, drawn from the
|
||||
theme's own accent so it holds in every theme. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-56"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, rgb(var(--accent) / 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--accent) / 0.12) 1px, transparent 1px), radial-gradient(ellipse at top, rgb(var(--accent) / 0.16), transparent 70%)",
|
||||
backgroundSize: "48px 48px, 48px 48px, 100% 100%",
|
||||
backgroundPosition: "center top",
|
||||
maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative space-y-5">
|
||||
<div className="text-center">
|
||||
<img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" />
|
||||
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary">{title}</h1>
|
||||
<p className="mt-2 text-sm text-fg-secondary">{subtitle}</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,12 @@
|
||||
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
||||
* Once an account is known (local mode is one), its own UI preferences are
|
||||
* loaded into this browser (`lib/userPrefs.ts`).
|
||||
* The login page follows the OS's light/dark (`applySystemTheme`).
|
||||
* With DASHBOARD_TOKEN set, a 401 `EUNAUTHORIZED` (from `/auth/me` or any
|
||||
* request, `TOKEN_REQUIRED_EVENT`) shows the token prompt instead: the token is
|
||||
* kept in this browser and the page reloads with it.
|
||||
* The login page follows the OS's light/dark (`AuthCard`). An account whose
|
||||
* password an admin gave (`mustChangePassword`) sees only
|
||||
* `FirstPasswordPage` until it picks its own.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -20,16 +25,23 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { BRAND_LOGO } from "../lib/brand";
|
||||
import { applySystemTheme } from "../hooks/useTheme";
|
||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||
import { AuthCard } from "./AuthCard";
|
||||
import { FirstPasswordPage } from "./FirstPasswordPage";
|
||||
import {
|
||||
api,
|
||||
AUTH_REQUIRED_EVENT,
|
||||
dashboardToken,
|
||||
saveDashboardToken,
|
||||
TOKEN_REQUIRED_EVENT,
|
||||
type AuthState,
|
||||
type AuthUser,
|
||||
} from "../lib/api";
|
||||
import { hydrate } from "../lib/userPrefs";
|
||||
|
||||
type AuthValue = {
|
||||
@@ -71,6 +83,10 @@ export function usePermissions() {
|
||||
// A developer only ever sees their own rows, so everything shown is theirs.
|
||||
canEditLane: (lane?: { owner_id?: number | null } | null) =>
|
||||
isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id),
|
||||
// Owner-only actions on a workspace (rename, reset, remove, share): its
|
||||
// owner or an admin. A workspace shared with this user is not theirs.
|
||||
ownsLane: (lane?: { owner_id?: number | null } | null) =>
|
||||
isAdmin || (!!user && lane?.owner_id === user.id),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,6 +106,8 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
// null = still asking the server. A browser that last saw local mode starts
|
||||
// there and is corrected by the answer (or by a 401) if that changed.
|
||||
const [state, setState] = useState<AuthState | null>(cachedState);
|
||||
// DASHBOARD_TOKEN is set and this browser's token is missing or wrong.
|
||||
const [needToken, setNeedToken] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: number | undefined;
|
||||
@@ -108,6 +126,10 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
})
|
||||
.catch((err: { status?: number; code?: string }) => {
|
||||
if (!alive) return;
|
||||
if (err?.status === 401 && err?.code === "EUNAUTHORIZED") {
|
||||
setNeedToken(true);
|
||||
return;
|
||||
}
|
||||
// An answer that is not this build's: an older server still running
|
||||
// under a freshly built UI (404, or its index.html → EBADRESPONSE), or
|
||||
// DASHBOARD_TOKEN's own 401 — neither is server mode, so render the
|
||||
@@ -129,10 +151,13 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
// Any request answering 401 EAUTH means server mode with no valid login.
|
||||
const onRequired = () => setState({ mode: "server", user: null });
|
||||
window.addEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
||||
const onToken = () => setNeedToken(true);
|
||||
window.addEventListener(TOKEN_REQUIRED_EVENT, onToken);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
||||
window.removeEventListener(TOKEN_REQUIRED_EVENT, onToken);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -157,6 +182,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
[]
|
||||
);
|
||||
|
||||
if (needToken) return <TokenPage />;
|
||||
if (!state) return null;
|
||||
if (state.mode === "server" && !state.user) {
|
||||
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
||||
@@ -165,6 +191,17 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
await api.auth.logout().catch(() => {});
|
||||
setState({ mode: "server", user: null });
|
||||
};
|
||||
// A password an admin gave: nothing else until the user picks their own
|
||||
// (the server refuses the rest anyway — EPWCHANGE).
|
||||
if (state.mode === "server" && state.user?.mustChangePassword) {
|
||||
return (
|
||||
<FirstPasswordPage
|
||||
user={state.user}
|
||||
onDone={() => updateUser({ mustChangePassword: false })}
|
||||
onLogout={() => void logout()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AuthContext.Provider value={{ state, logout, updateUser }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
@@ -178,14 +215,6 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Nobody is signed in, so no account's theme applies: follow the OS, live.
|
||||
useLayoutEffect(() => {
|
||||
applySystemTheme();
|
||||
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
mq?.addEventListener?.("change", applySystemTheme);
|
||||
return () => mq?.removeEventListener?.("change", applySystemTheme);
|
||||
}, []);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
@@ -201,86 +230,122 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Nobody is signed in, so no account's theme applies: AuthCard follows the OS.
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="card relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10"
|
||||
>
|
||||
{/* Accent glow and a grid fading out from the top, drawn from the
|
||||
theme's own accent so it holds in every theme. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-56"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, rgb(var(--accent) / 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--accent) / 0.12) 1px, transparent 1px), radial-gradient(ellipse at top, rgb(var(--accent) / 0.16), transparent 70%)",
|
||||
backgroundSize: "48px 48px, 48px 48px, 100% 100%",
|
||||
backgroundPosition: "center top",
|
||||
maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
|
||||
}}
|
||||
<AuthCard title={t("auth.title")} subtitle={t("auth.subtitle")} onSubmit={submit}>
|
||||
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<span>{t("auth.username")}</span>
|
||||
<input
|
||||
className="input h-11 w-full px-4"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
placeholder={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<div className="relative space-y-5">
|
||||
<div className="text-center">
|
||||
<img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" />
|
||||
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary">
|
||||
{t("auth.title")}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-fg-secondary">{t("auth.subtitle")}</p>
|
||||
</div>
|
||||
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<span>{t("auth.username")}</span>
|
||||
<input
|
||||
className="input h-11 w-full px-4"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
placeholder={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<label htmlFor="login-password">{t("auth.password")}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
className="input h-11 w-full pl-4 pr-11"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")}
|
||||
aria-pressed={showPassword}
|
||||
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{/* Inverted fill (text colour behind the surface colour): black in a
|
||||
light theme, light in a dark one, like the reference design. */}
|
||||
</label>
|
||||
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<label htmlFor="login-password">{t("auth.password")}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
className="input h-11 w-full pl-4 pr-11"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")}
|
||||
aria-pressed={showPassword}
|
||||
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{busy ? t("auth.submitting") : t("auth.submit")}
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{/* Inverted fill (text colour behind the surface colour): black in a
|
||||
light theme, light in a dark one, like the reference design. */}
|
||||
<button
|
||||
type="submit"
|
||||
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? t("auth.submitting") : t("auth.submit")}
|
||||
</button>
|
||||
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DASHBOARD_TOKEN's own prompt: the token is kept in this browser
|
||||
* (`localStorage.dashboard_token`) and the page reloads, so every request,
|
||||
* WebSocket and image starts again with it. A token already saved here that
|
||||
* the server refused is shown as wrong.
|
||||
*/
|
||||
function TokenPage() {
|
||||
const { t } = useTranslation();
|
||||
const [token, setToken] = useState("");
|
||||
const [show, setShow] = useState(false);
|
||||
const hadOne = Boolean(dashboardToken());
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const value = token.trim();
|
||||
if (!value) return;
|
||||
saveDashboardToken(value);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthCard title={t("auth.tokenTitle")} subtitle={t("auth.tokenSubtitle")} onSubmit={submit}>
|
||||
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<label htmlFor="dashboard-token">{t("auth.tokenLabel")}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="dashboard-token"
|
||||
className="input h-11 w-full pl-4 pr-11 font-mono"
|
||||
type={show ? "text" : "password"}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
required
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow((v) => !v)}
|
||||
aria-label={show ? t("auth.hidePassword") : t("auth.showPassword")}
|
||||
aria-pressed={show}
|
||||
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{hadOne && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{t("auth.tokenWrong")}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0"
|
||||
>
|
||||
{t("auth.tokenSubmit")}
|
||||
</button>
|
||||
<p className="text-center text-sm text-fg-secondary">{t("auth.tokenHint")}</p>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* @file BackgroundJobsDock.tsx
|
||||
* @description The one bottom-right stack of background work, rendered once
|
||||
* by `Layout`: a chip per background clone (`lib/cloneJobs.ts`, kept by the
|
||||
* server, so it is back after F5 or a server restart) and per File Explorer
|
||||
* upload (`lib/uploadJobs.ts`). A clone chip shows its queue place, then
|
||||
* git's progress, with **Stop** (click twice) while it runs; once finished,
|
||||
* **Open workspace** (done), the reason plus **Retry** (failed, interrupted,
|
||||
* stopped) — an inline login form when the host wants one, a link to
|
||||
* Settings → Git when it refused the SSH key — and × to dismiss. An upload
|
||||
* chip shows its progress with **Stop**; one a reload cut short says how far
|
||||
* it got. Several chips collapse to one small pill.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ChevronDown, FolderGit2, Square, Upload, X } from "lucide-react";
|
||||
import type { CloneJob } from "../lib/api";
|
||||
import {
|
||||
cancelCloneJob,
|
||||
cloneJobError,
|
||||
dismissCloneJob,
|
||||
ensureCloneJobs,
|
||||
isActiveJob,
|
||||
retryCloneJob,
|
||||
useCloneJobs,
|
||||
} from "../lib/cloneJobs";
|
||||
import { dismissUpload, stopUpload, useUploadJobs, type UploadJob } from "../lib/uploadJobs";
|
||||
import { useArmed } from "../hooks/useArmed";
|
||||
import { CloneProgress } from "./lanes/CloneProgress";
|
||||
|
||||
const chipClass =
|
||||
"pointer-events-auto w-full space-y-1.5 rounded-lg border border-border bg-surface-1 p-2.5 text-xs shadow-xl shadow-black/40";
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
danger,
|
||||
testId,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
danger?: boolean;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
className={`-m-0.5 shrink-0 rounded p-1 hover:bg-surface-2 ${
|
||||
danger ? "text-status-danger" : "text-fg-muted hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CloneJobChip({ job }: { job: CloneJob }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const { arm, isArmed, disarm } = useArmed();
|
||||
const [login, setLogin] = useState({ username: "", token: "", remember: true });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const active = isActiveJob(job);
|
||||
const code = job.error?.code;
|
||||
const laneId = job.kind === "workspace" ? (job.result?.laneId ?? null) : job.laneId;
|
||||
const title =
|
||||
job.state === "done"
|
||||
? t("cloneJobs.done", { name: job.name })
|
||||
: job.state === "cancelled"
|
||||
? t("cloneJobs.cancelledTitle", { name: job.name })
|
||||
: active
|
||||
? t("cloneJobs.cloning", { name: job.name })
|
||||
: t("cloneJobs.failedTitle", { name: job.name });
|
||||
|
||||
const retry = async (withLogin: boolean) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await retryCloneJob(
|
||||
job.id,
|
||||
withLogin
|
||||
? { username: login.username.trim(), password: login.token, remember: login.remember }
|
||||
: undefined
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (!isArmed("stop")) return arm("stop");
|
||||
disarm();
|
||||
void cancelCloneJob(job.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={chipClass} data-testid="clone-job" data-state={job.state} title={job.target}>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderGit2 className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-fg-primary">{title}</span>
|
||||
{active ? (
|
||||
<IconButton
|
||||
label={isArmed("stop") ? t("cloneJobs.stopConfirm") : t("cloneJobs.stop")}
|
||||
onClick={stop}
|
||||
danger={isArmed("stop")}
|
||||
testId="clone-job-stop"
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
label={t("cloneJobs.dismiss")}
|
||||
onClick={() => void dismissCloneJob(job.id)}
|
||||
testId="clone-job-dismiss"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
{active && isArmed("stop") && (
|
||||
<p className="text-[11px] text-status-danger">{t("cloneJobs.stopConfirm")}</p>
|
||||
)}
|
||||
{active && <CloneProgress view={job} />}
|
||||
{job.state === "done" && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate font-mono text-[10px] text-fg-muted">
|
||||
{job.result?.path || job.target}
|
||||
</span>
|
||||
{laneId !== null && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-accent hover:underline"
|
||||
onClick={() => {
|
||||
navigate(`/workspace?lane=${laneId}`);
|
||||
void dismissCloneJob(job.id);
|
||||
}}
|
||||
>
|
||||
{job.kind === "workspace" ? t("cloneJobs.openWorkspace") : t("cloneJobs.open")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!active && job.state !== "done" && (
|
||||
<>
|
||||
<p
|
||||
role={job.state === "cancelled" ? undefined : "alert"}
|
||||
className={`text-[11px] [overflow-wrap:anywhere] ${
|
||||
job.state === "cancelled" ? "text-fg-secondary" : "text-status-danger"
|
||||
}`}
|
||||
>
|
||||
{cloneJobError(job)}{" "}
|
||||
{code === "ESSHAUTH" && (
|
||||
// A router link: a plain <a href> reloaded the whole app.
|
||||
<Link to="/settings/git#git-identity" className="underline">
|
||||
{t("clone.sshSettings")}
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
{code === "ECREDENTIALS" && job.state === "failed" ? (
|
||||
<form
|
||||
data-testid="clone-job-login"
|
||||
className="space-y-1.5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void retry(true);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
autoComplete="username"
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
className="input w-full text-xs"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("clone.token")}
|
||||
placeholder={t("clone.token")}
|
||||
autoComplete="current-password"
|
||||
value={login.token}
|
||||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||
className="input w-full text-xs"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={login.remember}
|
||||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||
/>
|
||||
{t("clone.remember")}
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !login.username.trim() || !login.token}
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{t("cloneJobs.retryLogin")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
code !== "ESSHAUTH" && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void retry(false)}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
{t("cloneJobs.retry")}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-[11px] text-status-danger [overflow-wrap:anywhere]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadChip({ job }: { job: UploadJob }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const { arm, isArmed, disarm } = useArmed();
|
||||
if (job.state === "interrupted") {
|
||||
return (
|
||||
<div className={chipClass} data-testid="upload-job" data-state="interrupted">
|
||||
<div className="flex items-start gap-2">
|
||||
<Upload className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<p className="min-w-0 flex-1 text-[11px] text-fg-secondary [overflow-wrap:anywhere]">
|
||||
{t("uploads.interrupted", {
|
||||
total: job.total,
|
||||
folder: job.folder,
|
||||
done: job.done,
|
||||
})}
|
||||
</p>
|
||||
<IconButton label={t("cloneJobs.dismiss")} onClick={() => dismissUpload(job.id)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const stopping = job.state === "stopping";
|
||||
return (
|
||||
<div className={chipClass} data-testid="fx-progress" data-state={job.state}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-fg-primary">
|
||||
{stopping
|
||||
? t("uploads.stopping")
|
||||
: t("fx.uploading", { done: Math.min(job.done + 1, job.total), total: job.total })}
|
||||
</span>
|
||||
{!stopping && (
|
||||
<IconButton
|
||||
label={isArmed("stop") ? t("uploads.stopConfirm") : t("uploads.stop")}
|
||||
danger={isArmed("stop")}
|
||||
testId="upload-job-stop"
|
||||
onClick={() => {
|
||||
if (!isArmed("stop")) return arm("stop");
|
||||
disarm();
|
||||
stopUpload(job.id);
|
||||
}}
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
{isArmed("stop") && !stopping && (
|
||||
<p className="text-[11px] text-status-danger">{t("uploads.stopConfirm")}</p>
|
||||
)}
|
||||
{job.waiting && !stopping && (
|
||||
<p className="text-[11px] text-status-warning" role="status" data-testid="upload-waiting">
|
||||
{t("uploads.waiting")}
|
||||
</p>
|
||||
)}
|
||||
<p className="truncate text-[11px] text-fg-muted" title={job.folder}>
|
||||
{job.folder}
|
||||
{job.name ? ` · ${job.name}` : ""}
|
||||
</p>
|
||||
<div className="h-1.5 overflow-hidden rounded bg-surface-3">
|
||||
<div
|
||||
className="h-full bg-accent-solid transition-[width]"
|
||||
style={{ width: `${Math.min(100, (job.sent / Math.max(1, job.bytes)) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackgroundJobsDock() {
|
||||
const { t } = useTranslation("lanes");
|
||||
const jobs = useCloneJobs();
|
||||
const uploads = useUploadJobs();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const dockRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => ensureCloneJobs(), []);
|
||||
|
||||
const count = jobs.length + uploads.length;
|
||||
// Toasts stack ABOVE the dock (Toaster reads --jobs-dock-h): they used to
|
||||
// sit on the same corner and cover a chip's × / Retry / Stop.
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const el = dockRef.current;
|
||||
if (!count || !el) {
|
||||
root.style.removeProperty("--jobs-dock-h");
|
||||
return;
|
||||
}
|
||||
const set = () => root.style.setProperty("--jobs-dock-h", `${el.offsetHeight + 8}px`);
|
||||
set();
|
||||
const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(set);
|
||||
ro?.observe(el);
|
||||
return () => {
|
||||
ro?.disconnect();
|
||||
root.style.removeProperty("--jobs-dock-h");
|
||||
};
|
||||
}, [count, collapsed]);
|
||||
if (!count) return null;
|
||||
const running =
|
||||
jobs.filter(isActiveJob).length + uploads.filter((u) => u.state !== "interrupted").length;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dockRef}
|
||||
role="region"
|
||||
aria-label={t("jobs.dock")}
|
||||
data-testid="jobs-dock"
|
||||
className="pointer-events-none fixed bottom-10 right-4 z-[60] flex max-h-[70vh] w-[min(20rem,calc(100vw-2rem))] flex-col items-end gap-2 overflow-y-auto"
|
||||
>
|
||||
{count > 1 && collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="jobs-dock-pill"
|
||||
onClick={() => setCollapsed(false)}
|
||||
title={t("jobs.expand")}
|
||||
className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border bg-surface-1 px-3 py-1 text-xs text-fg-primary shadow-lg shadow-black/40 hover:bg-surface-2"
|
||||
>
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-fg-muted" />
|
||||
{t("jobs.count", { count })}
|
||||
{running > 0 && (
|
||||
<span className="text-fg-muted">· {t("jobs.running", { count: running })}</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{count > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="jobs-dock-collapse"
|
||||
onClick={() => setCollapsed(true)}
|
||||
className="pointer-events-auto flex items-center gap-1 rounded-full border border-border bg-surface-1 px-2 py-0.5 text-[11px] text-fg-secondary shadow hover:bg-surface-2"
|
||||
>
|
||||
{t("jobs.count", { count })}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{uploads.map((u) => (
|
||||
<UploadChip key={u.id} job={u} />
|
||||
))}
|
||||
{jobs.map((j) => (
|
||||
<CloneJobChip key={j.id} job={j} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -165,12 +165,15 @@ export function MenuItem({
|
||||
danger,
|
||||
disabled,
|
||||
testId,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
onSelect: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
/** Hover text — e.g. why a disabled item cannot run. */
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -179,6 +182,7 @@ export function MenuItem({
|
||||
role="menuitem"
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
|
||||
@@ -24,10 +24,18 @@ export function DocumentTitle() {
|
||||
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
|
||||
} else if (path.startsWith("/sessions")) {
|
||||
title = t("sessions");
|
||||
} else if (path.startsWith("/snapshot/")) {
|
||||
title = t("snapshot");
|
||||
} else if (path.startsWith("/workspace")) {
|
||||
title = t("workspace");
|
||||
} else if (path.startsWith("/settings")) {
|
||||
title = t("settings");
|
||||
} else if (path.startsWith("/artifacts")) {
|
||||
title = t("artifacts");
|
||||
} else if (path.startsWith("/files")) {
|
||||
title = t("files");
|
||||
} else if (path.startsWith("/admin")) {
|
||||
title = t("admin");
|
||||
} else if (path !== "/") {
|
||||
title = t("notFound");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @file FirstPasswordPage.tsx
|
||||
* @description The screen an account signed in with a password an admin GAVE
|
||||
* (created or reset on the Users page — `mustChangePassword`) sees instead of
|
||||
* the app: the current password, a new one that must pass every rule (the
|
||||
* `PasswordRules` checklist ticks them as they are typed) and the same again,
|
||||
* sent to `POST /api/me/password`. On success the app opens; "Sign out"
|
||||
* leaves. The server refuses every other request meanwhile (EPWCHANGE).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AuthUser } from "../lib/api";
|
||||
import { passwordProblems } from "../lib/passwordRules";
|
||||
import { AuthCard } from "./AuthCard";
|
||||
import { PasswordRules } from "./users/PasswordRules";
|
||||
|
||||
export function FirstPasswordPage({
|
||||
user,
|
||||
onDone,
|
||||
onLogout,
|
||||
}: {
|
||||
user: AuthUser;
|
||||
onDone: () => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [again, setAgain] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mismatch = again.length > 0 && again !== next;
|
||||
const strong = passwordProblems(next, user.username).length === 0;
|
||||
const ready = current.length > 0 && strong && again === next && !busy;
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.me.changePassword(current, next);
|
||||
onDone();
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
setError(
|
||||
code && t(`users:errors.${code}`, { defaultValue: "" })
|
||||
? t(`users:errors.${code}`)
|
||||
: (err as Error).message
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const field = (
|
||||
label: string,
|
||||
value: string,
|
||||
set: (v: string) => void,
|
||||
autoComplete: string,
|
||||
autoFocus = false
|
||||
) => (
|
||||
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input h-11 w-full px-4"
|
||||
autoComplete={autoComplete}
|
||||
autoFocus={autoFocus}
|
||||
required
|
||||
value={value}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthCard
|
||||
title={t("auth.mustChangeTitle")}
|
||||
subtitle={t("auth.mustChangeSubtitle")}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{field(t("auth.currentPassword"), current, setCurrent, "current-password", true)}
|
||||
{field(t("auth.newPassword"), next, setNext, "new-password")}
|
||||
<PasswordRules password={next} username={user.username} />
|
||||
{field(t("auth.confirmPassword"), again, setAgain, "new-password")}
|
||||
{(mismatch || error) && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{error ?? t("auth.mismatch")}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!ready}
|
||||
>
|
||||
{busy ? t("auth.saving") : t("auth.mustChangeSubmit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full text-center text-sm text-fg-secondary hover:text-fg-primary"
|
||||
onClick={onLogout}
|
||||
>
|
||||
{t("auth.logout")}
|
||||
</button>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @file HtmlFrame.tsx
|
||||
* @description An HTML file rendered as a page, the one way every file view
|
||||
* shows one (a Chat file preview, the Changes panel's Preview, the File
|
||||
* Explorer's viewer): its relative CSS, scripts and images pulled in through
|
||||
* the caller's guarded read (`lib/htmlInline.ts`), then run in the chat
|
||||
* artifact's sandbox — `<iframe srcdoc>` with scripts but never the
|
||||
* dashboard's origin (no `allow-same-origin`). Given no `html`, it reads the
|
||||
* file itself through the same `fetchFile`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { artifactDocument, frameSandbox } from "../lib/artifactView";
|
||||
import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline";
|
||||
|
||||
/** A page read by `fetchFile` is shown up to this size. A self-contained
|
||||
* page (a slide deck with its video and images inlined as base64) is easily
|
||||
* past 10 MB; the iframe renders far larger documents than this. */
|
||||
const HTML_MAX = 64 * 1024 * 1024;
|
||||
|
||||
export function HtmlFrame({
|
||||
path,
|
||||
html,
|
||||
fetchFile,
|
||||
title,
|
||||
className = "",
|
||||
testId = "html-frame",
|
||||
}: {
|
||||
/** The file's path relative to the place `fetchFile` reads from. */
|
||||
path: string;
|
||||
/** The page's text; read through `fetchFile` when absent. */
|
||||
html?: string;
|
||||
/** Reads the page's relative files; none = the page as it is. */
|
||||
fetchFile?: FetchFile | null;
|
||||
title: string;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
const [doc, setDoc] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
// The page shown stays until its successor is ready (a version step, a
|
||||
// re-read): no flash of the spinner between two renders of one file.
|
||||
setFailed(false);
|
||||
(async () => {
|
||||
let text = html;
|
||||
if (text === undefined) {
|
||||
if (!fetchFile) throw new Error("nothing to read the page with");
|
||||
const b = await fetchFile(path);
|
||||
if (b.size > HTML_MAX) throw new Error("too big");
|
||||
text = await blobText(b);
|
||||
}
|
||||
const page = text;
|
||||
return fetchFile ? inlineHtml(page, path, fetchFile).catch(() => page) : page;
|
||||
})().then(
|
||||
(d) => live && setDoc(d),
|
||||
() => live && setFailed(true)
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [html, path, fetchFile]);
|
||||
if (failed) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
|
||||
<FileText className="h-8 w-8" />
|
||||
<p className="break-all font-mono text-xs">{path}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (doc === null) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<iframe
|
||||
data-testid={testId}
|
||||
title={title}
|
||||
srcDoc={artifactDocument("html", doc, false) ?? ""}
|
||||
sandbox={frameSandbox("html")}
|
||||
referrerPolicy="no-referrer"
|
||||
className={`min-h-0 w-full flex-1 border-0 bg-white ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** A `FetchFile` over a download URL builder (a workspace's or a home's). */
|
||||
export function fetchVia(url: (relPath: string) => string): FetchFile {
|
||||
return async (rel) => {
|
||||
const r = await fetch(url(rel), { credentials: "same-origin" });
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
return r.blob();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @file HtmlViewerModal.tsx
|
||||
* @description An HTML file shown as the page in a modal over the whole
|
||||
* screen — the one viewer the File Explorer and the Changes panel's Preview
|
||||
* open (the Chat's file preview opens its own panel as a modal instead, for
|
||||
* its versions and Code tab). The page itself is `HtmlFrame`: the chat
|
||||
* artifact's sandbox, its relative files read through the caller's guarded
|
||||
* `fetchFile`. Keys and clicks stop at the modal, so a shortcut of the view
|
||||
* underneath (the explorer's Delete) never fires from inside it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, X } from "lucide-react";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { HtmlFrame } from "./HtmlFrame";
|
||||
import type { FetchFile } from "../lib/htmlInline";
|
||||
|
||||
export function HtmlViewerModal({
|
||||
path,
|
||||
html,
|
||||
fetchFile,
|
||||
onDownload,
|
||||
onClose,
|
||||
testId = "html-viewer",
|
||||
}: {
|
||||
/** The file's path relative to the place `fetchFile` reads from; also its title. */
|
||||
path: string;
|
||||
/** The page's text; read through `fetchFile` when absent. */
|
||||
html?: string;
|
||||
fetchFile?: FetchFile | null;
|
||||
onDownload?: () => void;
|
||||
onClose: () => void;
|
||||
testId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef, onClose);
|
||||
const name = path.slice(path.lastIndexOf("/") + 1);
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Nothing reaches the view underneath — so Esc, which useDialog's
|
||||
// document listener would never see, closes here.
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={name}
|
||||
data-testid={testId}
|
||||
className="flex h-[92vh] w-[96vw] max-w-7xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-fg-primary">
|
||||
{path}
|
||||
</span>
|
||||
{onDownload && (
|
||||
<HeadButton label={t("fx.download")} onClick={onDownload}>
|
||||
<Download className="h-4 w-4" />
|
||||
</HeadButton>
|
||||
)}
|
||||
<HeadButton label={t("fx.close")} onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</HeadButton>
|
||||
</div>
|
||||
<HtmlFrame path={path} html={html} fetchFile={fetchFile} title={name} />
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
function HeadButton({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
className="rounded-md p-1.5 text-fg-secondary transition-colors hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,14 @@
|
||||
* Escape closes it. Rendered into `document.body`, so no pane, its stacking
|
||||
* layer or a header drawn over a pane can cover it — opened from inside a
|
||||
* pane's Chat view, a `fixed` overlay would otherwise stay in that pane's
|
||||
* layer, under anything stacked above it.
|
||||
* layer, under anything stacked above it. `useDialog` takes the focus while it
|
||||
* is open and gives it back, and its Escape closes only this, the top dialog.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
|
||||
export function ImageLightbox({
|
||||
src,
|
||||
@@ -20,18 +22,18 @@ export function ImageLightbox({
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panel, onClose);
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panel}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={alt}
|
||||
data-testid="image-lightbox"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
|
||||
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8 outline-none"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
|
||||
@@ -82,6 +82,7 @@ import { api, type ImportResult, type ImportBackupResult } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import type { WSMessage, ImportProgressMessage } from "../lib/types";
|
||||
import { PathPickButton } from "./PathPickButton";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
|
||||
type Mode = "rescan" | "path" | "upload" | "backup";
|
||||
|
||||
@@ -247,13 +248,9 @@ export function ImportHistory() {
|
||||
|
||||
const copyArchiveCmd = async () => {
|
||||
if (!guide) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(guide.archive_command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
if (!(await copyText(guide.archive_command, true))) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
const progressText = (() => {
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
* ## Internal dependencies
|
||||
* - `./Sidebar`
|
||||
* - `./UpdateNotifier`
|
||||
* - `./BackgroundJobsDock` (background clones and uploads, app-wide)
|
||||
* - `./lanes/InterruptedSessionsPrompt` (the "resume interrupted sessions?" dialog)
|
||||
* - `./Sagi/SagiMount`
|
||||
*
|
||||
@@ -82,6 +83,7 @@ import { Menu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||
import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { BackgroundJobsDock } from "./BackgroundJobsDock";
|
||||
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
|
||||
import { SagiMount } from "./Sagi/SagiMount";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
@@ -182,6 +184,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
{t("skipToContent")}
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<BackgroundJobsDock />
|
||||
<InterruptedSessionsPrompt />
|
||||
<SagiMount />
|
||||
{onboarding.loaded && !onboarding.onboardedAt && (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file NeedsYouStrip.tsx
|
||||
* @description The Dashboard's pinned "Needs you" strip: every active session
|
||||
* blocked on the user (`awaiting_input_since`), each linking straight to its
|
||||
* of the viewer's OWN blocked on them (`awaiting_input_since`, `mine=1` — an
|
||||
* admin's lists no other user's), each linking straight to its
|
||||
* workspace pane (or replay), else its analysis page. Renders nothing while no
|
||||
* session is waiting. `compact` is the one-line form shown above Analytics.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -27,7 +28,7 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.sessions
|
||||
.list({ awaiting: true, limit: 20 })
|
||||
.list({ awaiting: true, limit: 20, mine: true })
|
||||
.then((r) => {
|
||||
setSessions(r.sessions);
|
||||
setTotal(r.total);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file PdfView.tsx
|
||||
* @description A PDF drawn by pdf.js onto canvases, page after page — the
|
||||
* Artifacts preview's PDF viewer. The browser's own viewer cannot run inside
|
||||
* the sandbox the preview keeps (it blocks plugins), and opening a shared PDF
|
||||
* on the dashboard's origin would be the risk the sandbox exists for; pdf.js
|
||||
* only DRAWS the file (no scripting sandbox loaded, no XFA, no links
|
||||
* followed), so nothing in it runs. pdf.js and its worker are loaded the
|
||||
* first time a PDF is opened (a separate chunk), never with the dashboard.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
/** Pages drawn at most: a 500-page export would otherwise hold every canvas. */
|
||||
const MAX_PAGES = 200;
|
||||
const MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
async function loadPdfJs() {
|
||||
// The legacy build: the modern one needs `Promise.try` and friends, which a
|
||||
// browser a year old lacks — the PDF would then just fail to open.
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
if (!pdfjs.GlobalWorkerOptions.workerSrc) {
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
|
||||
import.meta.url
|
||||
).toString();
|
||||
}
|
||||
return pdfjs;
|
||||
}
|
||||
|
||||
export function PdfView({ url, title }: { url: string; title: string }) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState<"loading" | "ready" | "failed">("loading");
|
||||
const [pages, setPages] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
let destroy: (() => void) | null = null;
|
||||
setState("loading");
|
||||
(async () => {
|
||||
const res = await fetch(url, { credentials: "same-origin" });
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data = new Uint8Array(await res.arrayBuffer());
|
||||
if (data.byteLength > MAX_BYTES) throw new Error("too big");
|
||||
const pdfjs = await loadPdfJs();
|
||||
const task = pdfjs.getDocument({
|
||||
data,
|
||||
enableXfa: false,
|
||||
disableAutoFetch: true,
|
||||
});
|
||||
destroy = () => void task.destroy();
|
||||
const doc = await task.promise;
|
||||
if (!live || !box.current) return;
|
||||
setPages(doc.numPages);
|
||||
const width = Math.max(320, box.current.clientWidth - 32);
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
box.current.replaceChildren();
|
||||
for (let n = 1; n <= Math.min(doc.numPages, MAX_PAGES); n++) {
|
||||
const page = await doc.getPage(n);
|
||||
if (!live || !box.current) return;
|
||||
const base = page.getViewport({ scale: 1 });
|
||||
const viewport = page.getViewport({ scale: (width / base.width) * ratio });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.floor(viewport.width);
|
||||
canvas.height = Math.floor(viewport.height);
|
||||
canvas.style.width = `${Math.floor(viewport.width / ratio)}px`;
|
||||
canvas.className = "mx-auto mb-3 block bg-white shadow";
|
||||
canvas.setAttribute("aria-label", t("pdf.page", { n }));
|
||||
box.current.appendChild(canvas);
|
||||
await page.render({ canvas, viewport }).promise;
|
||||
if (n === 1 && live) setState("ready");
|
||||
}
|
||||
})().catch(() => live && setState("failed"));
|
||||
return () => {
|
||||
live = false;
|
||||
destroy?.();
|
||||
};
|
||||
}, [url, t]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-auto bg-surface-2 p-4" data-testid="pdf-view">
|
||||
{state === "loading" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-fg-muted">
|
||||
<Loader2 className="h-5 w-5 animate-spin" aria-label={t("loading")} />
|
||||
</div>
|
||||
)}
|
||||
{state === "failed" && (
|
||||
<p className="p-6 text-center text-sm text-fg-secondary">{t("pdf.failed")}</p>
|
||||
)}
|
||||
<div ref={box} role="document" aria-label={title} />
|
||||
{pages > MAX_PAGES && (
|
||||
<p className="py-2 text-center text-xs text-fg-muted">
|
||||
{t("pdf.more", { shown: MAX_PAGES, total: pages })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -88,6 +88,7 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { useDataScope } from "../lib/dataScope";
|
||||
import type { ScopeMode } from "../lib/dataScope";
|
||||
import { PathPickButton } from "./PathPickButton";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
|
||||
/** Data-scope choices in display order (the radio group's arrow-key order). */
|
||||
const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"];
|
||||
@@ -863,7 +864,7 @@ export function RemoteSources() {
|
||||
: t("remoteSources.enable", "Enable")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(openEdit)}>{t("common:edit", "Edit")}</MenuItem>
|
||||
<MenuItem onSelect={pick((s) => void navigator.clipboard.writeText(s.host))}>
|
||||
<MenuItem onSelect={pick((s) => void copyText(s.host))}>
|
||||
{t("rowMenu.copyHost")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
|
||||
@@ -1,591 +0,0 @@
|
||||
/**
|
||||
* @file PetChat.tsx
|
||||
* @description The pet's chat window — its whole UI: clicking the pet opens
|
||||
* it. Above the conversation sits what the pet knows (`info`: live / waiting /
|
||||
* errored, the sessions that need you, Mute, Clear alerts — `SagiInfo.tsx`).
|
||||
* Before a chat runs, a question the pet can answer itself (`onLocal`: status,
|
||||
* who are you…) is answered right here; anything else — and every message once
|
||||
* a chat runs — goes to the AI: the first message starts a
|
||||
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
|
||||
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
|
||||
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
|
||||
* chat bubbles read from the session's transcript. Follow-ups are typed into
|
||||
* that session's pane through a terminal attached OFF-SCREEN (`TerminalView`
|
||||
* in a fixed box left of the viewport — `submitToRun` needs a mounted,
|
||||
* attached pane, exactly like the workspace's own Chat view), so nothing new
|
||||
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
|
||||
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
|
||||
* `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and
|
||||
* reopening the window continues it; **New chat** STOPS that session (kills
|
||||
* its pane and archives it, asking first only mid-reply), **Open in workspace**
|
||||
* opens its pane there. Opening never resumes an ended session: once the pane
|
||||
* is gone, the next message starts a new one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { TranscriptMessage } from "../../lib/types";
|
||||
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
|
||||
import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
readRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../lib/panePaste";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { TerminalView } from "../run/TerminalView";
|
||||
import { PaneMenuCard } from "../run/PaneChat";
|
||||
import { MarkdownContent } from "../conversation/MarkdownContent";
|
||||
|
||||
/** Tools whose session the chat can read back (a transcript AgentHub parses). */
|
||||
export const PET_CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||
const CHAT_KEY = "agenthub.pet.chat";
|
||||
const POLL_MS = 1500;
|
||||
const MENU_POLL_MS = 700;
|
||||
// A follow-up waits this long for the off-screen pane to attach.
|
||||
const SEND_TRIES = 20;
|
||||
const SEND_RETRY_MS = 250;
|
||||
|
||||
/** The session the chat is talking to. */
|
||||
export interface PetChatRef {
|
||||
laneId: number;
|
||||
runId: string;
|
||||
tool: string;
|
||||
}
|
||||
|
||||
export function readPetChat(): PetChatRef | null {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial<PetChatRef> | null;
|
||||
if (
|
||||
v &&
|
||||
typeof v.laneId === "number" &&
|
||||
typeof v.runId === "string" &&
|
||||
typeof v.tool === "string"
|
||||
)
|
||||
return { laneId: v.laneId, runId: v.runId, tool: v.tool };
|
||||
} catch {
|
||||
/* unreadable: no chat */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writePetChat(ref: PetChatRef | null) {
|
||||
try {
|
||||
if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref));
|
||||
else localStorage.removeItem(CHAT_KEY);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** The tool a new chat starts: the user's default, when the chat can show it. */
|
||||
export function petChatTool(): string | null {
|
||||
const tool = readDefaultTool() ?? "claude";
|
||||
return PET_CHAT_TOOLS.has(tool) ? tool : null;
|
||||
}
|
||||
|
||||
type Line =
|
||||
| { key: string; role: "user" | "assistant"; text: string }
|
||||
| { key: string; role: "tools"; names: string[] };
|
||||
|
||||
/** A human line's text: Claude Code's own injected lines (`<command-…>`,
|
||||
* `<system-reminder>`…) are not something the user said. */
|
||||
const humanText = (m: TranscriptMessage) =>
|
||||
m.content
|
||||
.filter((c) => c.type === "text" && c.text)
|
||||
.map((c) => c.text!.trim())
|
||||
.filter((t) => t && !t.startsWith("<"))
|
||||
.join("\n\n");
|
||||
|
||||
/** The transcript as chat lines: the user's words, the assistant's prose, and
|
||||
* each run of tool calls between them folded into one chip. */
|
||||
export function chatLines(messages: TranscriptMessage[]): Line[] {
|
||||
const out: Line[] = [];
|
||||
messages.forEach((m, i) => {
|
||||
const sender = m.sender ?? m.type;
|
||||
if (sender === "user") {
|
||||
const text = humanText(m);
|
||||
if (text) out.push({ key: `u${i}`, role: "user", text });
|
||||
return;
|
||||
}
|
||||
if (m.type !== "assistant") return;
|
||||
m.content.forEach((c, j) => {
|
||||
if (c.type === "text" && c.text?.trim()) {
|
||||
out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() });
|
||||
} else if (c.type === "tool_use" && c.name) {
|
||||
const last = out[out.length - 1];
|
||||
if (last?.role === "tools") last.names.push(c.name);
|
||||
else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] });
|
||||
}
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Still working: the last line is the user's, or an assistant turn not ended. */
|
||||
function isBusy(messages: TranscriptMessage[]): boolean {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
const sender = m.sender ?? m.type;
|
||||
if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn";
|
||||
if (sender === "user" && humanText(m)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** "Read ×2 · Bash" */
|
||||
function toolSummary(names: string[]): string {
|
||||
const counts = new Map<string, number>();
|
||||
names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1));
|
||||
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
|
||||
}
|
||||
|
||||
/** What the container made of a message: an answer the pet gives itself, or
|
||||
* `handled` (it took the message elsewhere); null sends it to the AI. */
|
||||
export type PetLocalReply = { answer: string } | { handled: true } | null;
|
||||
|
||||
interface PetChatProps {
|
||||
/** The pet's shown name, for the header and greeting. */
|
||||
name: string;
|
||||
/** Shown in place of the plain name (the rename button). */
|
||||
title?: ReactNode;
|
||||
/** Extra header buttons, before Open in workspace (the pat heart). */
|
||||
headerActions?: ReactNode;
|
||||
/** Under the header: what the pet knows (status, sessions that need you). */
|
||||
info?: ReactNode;
|
||||
/** Asked first while no chat runs. */
|
||||
onLocal?: (text: string) => PetLocalReply;
|
||||
onClose: () => void;
|
||||
/** Opens the chat's pane in its workspace. */
|
||||
onOpenWorkspace: (laneId: number, runId: string) => void;
|
||||
/** The thinking mood while a reply is being written. */
|
||||
onBusy?: (busy: boolean) => void;
|
||||
}
|
||||
|
||||
export function PetChat({
|
||||
name,
|
||||
title,
|
||||
headerActions,
|
||||
info,
|
||||
onLocal,
|
||||
onClose,
|
||||
onOpenWorkspace,
|
||||
onBusy,
|
||||
}: PetChatProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [chat, setChat] = useState<PetChatRef | null>(readPetChat);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
// Sent but not in the transcript yet.
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
||||
// The pet's own answers, given before any chat ran.
|
||||
const [local, setLocal] = useState<Line[]>([]);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const remember = (ref: PetChatRef | null) => {
|
||||
writePetChat(ref);
|
||||
setChat(ref);
|
||||
setSessionId(null);
|
||||
setMessages([]);
|
||||
setMenu(null);
|
||||
};
|
||||
|
||||
// A remembered chat whose pane is gone is over: forget it, the next message starts anew.
|
||||
useEffect(() => {
|
||||
if (!chat) return;
|
||||
let live = true;
|
||||
const check = () =>
|
||||
api.run
|
||||
.get(chat.runId)
|
||||
.then((h) => {
|
||||
if (!live) return;
|
||||
if (h.status !== "running") remember(null);
|
||||
else if (h.sessionId) setSessionId(h.sessionId);
|
||||
})
|
||||
.catch(() => live && remember(null));
|
||||
void check();
|
||||
// Codex learns its session only after a while; Claude's is known at start.
|
||||
const timer = window.setInterval(() => {
|
||||
if (!sessionId) void check();
|
||||
}, POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chat?.runId, sessionId]);
|
||||
|
||||
// The conversation, polled while the window is open.
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let live = true;
|
||||
const load = () =>
|
||||
api.sessions
|
||||
.transcript(sessionId, { limit: 60 })
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setMessages((prev) =>
|
||||
prev.length === r.messages.length &&
|
||||
JSON.stringify(prev[prev.length - 1]) ===
|
||||
JSON.stringify(r.messages[r.messages.length - 1])
|
||||
? prev
|
||||
: r.messages
|
||||
);
|
||||
})
|
||||
.catch(() => undefined); // not started yet: the next poll asks again
|
||||
void load();
|
||||
const timer = window.setInterval(load, POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
// A menu the CLI shows in the pane (permission, trust, a question).
|
||||
useEffect(() => {
|
||||
if (!chat) return;
|
||||
const read = () =>
|
||||
setMenu((prev) => {
|
||||
const next = readRunMenu(chat.runId);
|
||||
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
|
||||
});
|
||||
const timer = window.setInterval(read, MENU_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [chat]);
|
||||
|
||||
const lines = [...local, ...chatLines(messages)];
|
||||
// The pending line shows until the transcript has it.
|
||||
useEffect(() => {
|
||||
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
|
||||
setPending(null);
|
||||
}, [lines, pending]);
|
||||
const busy = starting || !!pending || isBusy(messages);
|
||||
useEffect(() => onBusy?.(busy), [busy, onBusy]);
|
||||
useEffect(() => () => onBusy?.(false), [onBusy]);
|
||||
|
||||
// Keep the newest in sight.
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lines.length, pending, busy, menu]);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
const prompt = text.trim();
|
||||
if (!prompt || starting) return;
|
||||
setError(null);
|
||||
if (!chat && onLocal) {
|
||||
const reply = onLocal(prompt);
|
||||
if (reply && "handled" in reply) return;
|
||||
if (reply) {
|
||||
const n = Date.now();
|
||||
setLocal((prev) => [
|
||||
...prev,
|
||||
{ key: `lu${n}`, role: "user", text: prompt },
|
||||
{ key: `la${n}`, role: "assistant", text: reply.answer },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPending(prompt);
|
||||
if (chat) {
|
||||
// Typed into the attached pane; it may still be attaching.
|
||||
for (let i = 0; i < SEND_TRIES; i++) {
|
||||
if (submitToRun(chat.runId, prompt)) return;
|
||||
await new Promise((r) => setTimeout(r, SEND_RETRY_MS));
|
||||
}
|
||||
setPending(null);
|
||||
setDraft(prompt);
|
||||
setError(t("sagi.chat.notSent"));
|
||||
return;
|
||||
}
|
||||
const tool = petChatTool() ?? "claude";
|
||||
setStarting(true);
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
prompt,
|
||||
...(tool !== lane.tool ? { tool } : {}),
|
||||
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}),
|
||||
});
|
||||
remember({ laneId: r.laneId, runId: r.runId, tool });
|
||||
} catch (err) {
|
||||
setPending(null);
|
||||
setDraft(prompt);
|
||||
setError(
|
||||
t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) })
|
||||
);
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[chat, starting, t, onLocal]
|
||||
);
|
||||
|
||||
// New chat: the old one's session is stopped, not left running unseen —
|
||||
// asking first only while it is still writing a reply.
|
||||
const newChat = async () => {
|
||||
if (chat) {
|
||||
if (
|
||||
busy &&
|
||||
!(await confirmDialog({
|
||||
title: t("sagi.chat.endTitle"),
|
||||
message: t("sagi.chat.endBody"),
|
||||
confirmLabel: t("sagi.chat.endConfirm"),
|
||||
destructive: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api.run.kill(chat.runId, { archive: true });
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
remember(null);
|
||||
setLocal([]);
|
||||
setPending(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const submit = (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!draft.trim()) return;
|
||||
const text = draft;
|
||||
setDraft("");
|
||||
void send(text);
|
||||
};
|
||||
|
||||
const toolLabel = chat?.tool ?? petChatTool() ?? "claude";
|
||||
const empty = lines.length === 0 && !pending;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={t("sagi.chat.label", { name })}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||
<span className="pl-1.5 text-base leading-none" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
|
||||
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
busy
|
||||
? "animate-pulse bg-status-warning"
|
||||
: chat
|
||||
? "bg-status-success"
|
||||
: "bg-fg-muted"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{busy ? t("sagi.chat.typing") : toolLabel}
|
||||
</div>
|
||||
</div>
|
||||
{headerActions}
|
||||
{chat && (
|
||||
<IconButton
|
||||
label={t("sagi.chat.openWorkspace")}
|
||||
onClick={() => onOpenWorkspace(chat.laneId, chat.runId)}
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
label={t("sagi.chat.new")}
|
||||
onClick={() => void newChat()}
|
||||
disabled={(!chat && !local.length) || starting}
|
||||
>
|
||||
<SquarePen size={15} />
|
||||
</IconButton>
|
||||
<IconButton label={t("sagi.chat.close", { name })} onClick={onClose}>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{info}
|
||||
|
||||
{/* conversation */}
|
||||
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||
{empty && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<span className="text-3xl" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<p className="text-sm font-medium text-fg-primary">
|
||||
{t("sagi.chat.emptyTitle", { name })}
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted">{t("sagi.chat.emptyHint", { tool: toolLabel })}</p>
|
||||
</div>
|
||||
)}
|
||||
{lines.map((l) =>
|
||||
l.role === "tools" ? (
|
||||
<div key={l.key} className="flex justify-start">
|
||||
<span className="pet-chat-tools inline-flex max-w-[85%] items-center gap-1 truncate rounded-full px-2 py-0.5 text-[10px] text-fg-muted">
|
||||
<Wrench size={10} className="shrink-0" aria-hidden />
|
||||
<span className="truncate">{toolSummary(l.names)}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : l.role === "user" ? (
|
||||
<div key={l.key} className="flex justify-end">
|
||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white shadow-sm">
|
||||
{l.text}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={l.key} className="flex justify-start">
|
||||
<div className="pet-chat-reply max-w-[90%] break-words rounded-2xl rounded-bl-md px-3 py-2 text-sm text-fg-primary shadow-sm">
|
||||
<MarkdownContent text={l.text} dense prose />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{pending && (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white opacity-70 shadow-sm">
|
||||
{pending}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{busy && !menu && (
|
||||
<div className="flex justify-start" aria-label={t("sagi.chat.typing")}>
|
||||
<div className="pet-chat-reply flex gap-1 rounded-2xl rounded-bl-md px-3 py-3 shadow-sm">
|
||||
<span className="pet-chat-dot" />
|
||||
<span className="pet-chat-dot" />
|
||||
<span className="pet-chat-dot" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{starting && (
|
||||
<p className="flex items-center justify-center gap-1.5 text-[11px] text-fg-muted">
|
||||
<Loader2 size={12} className="animate-spin" aria-hidden />
|
||||
{t("sagi.chat.starting", { tool: toolLabel })}
|
||||
</p>
|
||||
)}
|
||||
{chat && !sessionId && !starting && (
|
||||
<p className="text-center text-[11px] text-fg-muted">{t("sagi.chat.notStarted")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mx-3 mb-1 rounded-lg bg-status-danger/10 px-2 py-1 text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* prompt, or the question the CLI asks */}
|
||||
<div className="pet-chat-foot px-2.5 py-2.5">
|
||||
{menu && chat ? (
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
<PaneMenuCard
|
||||
menu={menu}
|
||||
onChoose={(i) => {
|
||||
if (chooseRunOption(chat.runId, menu, i)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onAnswer={(text) => {
|
||||
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (interruptRun(chat.runId)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="flex items-end gap-1.5">
|
||||
<textarea
|
||||
rows={1}
|
||||
autoFocus
|
||||
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder={t("sagi.chat.placeholder", { name })}
|
||||
aria-label={t("sagi.chat.placeholder", { name })}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!draft.trim() || starting}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
aria-label={t("sagi.panel.send")}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The chat's pane, attached off-screen: the only way a follow-up or a
|
||||
menu answer reaches it. A real size, so tmux does not shrink the pane. */}
|
||||
{chat && (
|
||||
<div className="pet-chat-term" aria-hidden>
|
||||
<TerminalView
|
||||
runId={chat.runId}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
autoFocus={false}
|
||||
// The pane ended: the conversation stays on screen, the next
|
||||
// message starts a new session.
|
||||
onExit={() => {
|
||||
writePetChat(null);
|
||||
setChat(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @file PetPanel.tsx
|
||||
* @description The pet's window — clicking the pet opens it: its name (which
|
||||
* the user can rename), a pat button, and what the pet knows (`info`: live /
|
||||
* waiting / errored, the sessions that need you, Mute, Clear alerts —
|
||||
* `SagiInfo.tsx`). The pet no longer chats: it started a full AI session per
|
||||
* question, a heavy process on a shared server, so the chat was removed
|
||||
* (v2.8.68); this window is where the pet's own care comes next.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export function PetPanel({
|
||||
name,
|
||||
title,
|
||||
headerActions,
|
||||
info,
|
||||
onClose,
|
||||
}: {
|
||||
name: string;
|
||||
/** The name line (a rename button); plain text without it. */
|
||||
title?: ReactNode;
|
||||
/** Buttons before Close (the pat). */
|
||||
headerActions?: ReactNode;
|
||||
/** What the pet knows (`SagiInfo`). */
|
||||
info: ReactNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<div
|
||||
className="pet-chat flex max-h-[calc(100vh-6rem)] w-[22rem] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={t("sagi.panel.label", { name })}
|
||||
>
|
||||
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||
<span className="pl-1.5 text-base leading-none" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
|
||||
</div>
|
||||
{headerActions}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||
onClick={onClose}
|
||||
aria-label={t("sagi.panel.close", { name })}
|
||||
title={t("sagi.panel.close", { name })}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Vertical only: an auto overflow-y makes x scrollable too, and the
|
||||
status chips' row then showed a horizontal scrollbar. */}
|
||||
<div className="min-w-0 overflow-y-auto overflow-x-hidden">{info}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,9 +5,8 @@
|
||||
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
|
||||
* tick, WS reducer) runs only while it is on. Persists across routes and
|
||||
* shares the single WebSocket.
|
||||
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
|
||||
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
|
||||
* itself is answered there, anything else goes to the AI.
|
||||
* Clicking the pet opens its window (`PetPanel`): its name, a pat, and the
|
||||
* status strip (`SagiInfo`). The pet no longer chats (v2.8.68).
|
||||
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
|
||||
* detection, and route navigation. Reactive personality + status/Ask come
|
||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||
@@ -15,10 +14,7 @@
|
||||
* bubble/panel render in a self-clamping flyout so
|
||||
* they never spill off any screen edge regardless of where the mascot is docked.
|
||||
*
|
||||
* The "do the job" path: an unmatched question is the chat's — it starts a
|
||||
* session of the default AI tool in the "Sagi" workspace; a default tool the
|
||||
* chat cannot read back starts there and its session view opens
|
||||
* (`/workspace?lane=<id>&run=<runId>`) — no new LLM backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
||||
* ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
||||
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -91,15 +87,12 @@ import { PetNameButton, SagiInfo } from "./SagiInfo";
|
||||
import { useSagiBrain } from "./useSagiBrain";
|
||||
import { useSagiPosition } from "./useSagiPosition";
|
||||
import { useSagiEnabled } from "./SagiMount";
|
||||
import { matchIntent } from "./intents";
|
||||
import { petName, sagiPrefs } from "./prefs";
|
||||
import type { SagiSubject } from "./brain";
|
||||
import { api } from "../../lib/api";
|
||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { workspaceHref } from "../SessionRowMenu";
|
||||
import { Heart } from "lucide-react";
|
||||
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
|
||||
import { PetPanel } from "./PetPanel";
|
||||
|
||||
// Stroking: this many left/right turns of the pointer over the pet, within
|
||||
// STROKE_MS and with no button held, is a pat; then a pause before the next.
|
||||
@@ -229,7 +222,7 @@ function SagiBody() {
|
||||
// The name it goes by: the user's nickname, the pet's own, else Sagi.
|
||||
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||
// The chat window (PetChat.tsx): the pet's whole UI.
|
||||
// The pet's window (PetPanel.tsx).
|
||||
const [open, setOpen] = useState(false);
|
||||
// The right-click pet menu (`PetMenu.tsx`).
|
||||
const [menu, setMenu] = useState(false);
|
||||
@@ -340,43 +333,6 @@ function SagiBody() {
|
||||
[brain, navigate]
|
||||
);
|
||||
|
||||
// Asked by the chat before any session runs.
|
||||
const onLocal = useCallback(
|
||||
(query: string): PetLocalReply => {
|
||||
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
|
||||
if (result.kind === "answer") {
|
||||
if (result.moment) brain.playMoment(result.moment);
|
||||
return { answer: result.text };
|
||||
}
|
||||
// A question the pet cannot answer: the chat starts a session of the
|
||||
// default AI tool and shows it as chat.
|
||||
if (petChatTool()) return null;
|
||||
// A default tool the chat cannot read back (not Claude/Codex): start it
|
||||
// in the "Sagi" workspace and open its pane there, as before.
|
||||
const prompt = result.prompt;
|
||||
brain.setThinking(true);
|
||||
(async () => {
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||
const tool = readDefaultTool();
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
prompt,
|
||||
...(tool && tool !== lane.tool ? { tool } : {}),
|
||||
});
|
||||
navigate(`/workspace?lane=${r.laneId}&run=${encodeURIComponent(r.runId)}`);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
brain.setBubble(t("sagi.startFailed", { error: msg }));
|
||||
} finally {
|
||||
brain.setThinking(false);
|
||||
}
|
||||
})();
|
||||
return { handled: true };
|
||||
},
|
||||
[brain, navigate, t, name]
|
||||
);
|
||||
|
||||
// Stroke the pet (move the pointer back and forth over it) to pat it.
|
||||
const stroke = useRef({ dir: 0, x: 0, turns: [] as number[], until: 0 });
|
||||
const onStroke = (e: React.PointerEvent) => {
|
||||
@@ -398,8 +354,6 @@ function SagiBody() {
|
||||
s.dir = dir;
|
||||
};
|
||||
|
||||
const onBusy = useCallback((b: boolean) => brain.setThinking(b), [brain.setThinking]);
|
||||
|
||||
const anchor: Anchor = {
|
||||
left: place.left,
|
||||
top: place.top,
|
||||
@@ -412,11 +366,10 @@ function SagiBody() {
|
||||
return (
|
||||
<>
|
||||
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
|
||||
it 1:1). Closing unmounts it; its chat is remembered, so reopening
|
||||
re-attaches the same session. */}
|
||||
it 1:1). */}
|
||||
{open && (
|
||||
<SagiFlyout anchor={anchor} beside>
|
||||
<PetChat
|
||||
<PetPanel
|
||||
name={name}
|
||||
title={
|
||||
<PetNameButton
|
||||
@@ -449,13 +402,7 @@ function SagiBody() {
|
||||
defaultOpen={brain.attention.length > 0}
|
||||
/>
|
||||
}
|
||||
onLocal={onLocal}
|
||||
onClose={() => setOpen(false)}
|
||||
onOpenWorkspace={(laneId, runId) => {
|
||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
onBusy={onBusy}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
@@ -93,7 +93,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
||||
moment = null,
|
||||
}: SagiAvatarProps) {
|
||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||
const rafRef = useRef<number>();
|
||||
const rafRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
||||
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file SagiInfo.tsx
|
||||
* @description What the pet knows, shown inside its chat window (`PetChat`):
|
||||
* @description What the pet knows, shown inside its window (`PetPanel`):
|
||||
* the name you rename by clicking it (`PetNameButton`), and a status strip
|
||||
* (`SagiInfo`) — live / waiting / errored counts and the connection state
|
||||
* always in sight, and on expanding, the sessions that need the user, by
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
|
||||
import { PET_NAME_MAX } from "./prefs";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
|
||||
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
|
||||
const ATTENTION_SHOWN = 4;
|
||||
@@ -70,7 +71,7 @@ export function PetNameButton({
|
||||
onChange={(e) => setRenaming(e.target.value)}
|
||||
onBlur={save}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.key === "Escape") {
|
||||
@@ -117,8 +118,8 @@ export function SagiInfo({
|
||||
className="flex w-full items-center gap-1.5 rounded-lg text-left"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-label={t("sagi.chat.status")}
|
||||
title={t("sagi.chat.status")}
|
||||
aria-label={t("sagi.panel.status")}
|
||||
title={t("sagi.panel.status")}
|
||||
>
|
||||
<StatChip
|
||||
icon={Radio}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* @file PetChat.test.ts
|
||||
* @description Unit tests for the pet chat's transcript-to-bubbles folding (`chatLines`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { chatLines } from "../PetChat";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const msg = (m: Partial<TranscriptMessage>): TranscriptMessage =>
|
||||
({ type: "user", timestamp: null, content: [], ...m }) as TranscriptMessage;
|
||||
|
||||
describe("chatLines", () => {
|
||||
it("keeps the user's words and the assistant's prose, folding tool calls into one chip", () => {
|
||||
const lines = chatLines([
|
||||
msg({ type: "user", sender: "user", content: [{ type: "text", text: "hi" }] }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", text: "hmm" },
|
||||
{ type: "tool_use", name: "Read" },
|
||||
],
|
||||
}),
|
||||
msg({ type: "user", sender: "tool", content: [{ type: "tool_result", output: "x" }] }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", name: "Read" },
|
||||
{ type: "text", text: "hello!" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(lines.map((l) => (l.role === "tools" ? l.names : l.text))).toEqual([
|
||||
"hi",
|
||||
["Read", "Read"],
|
||||
"hello!",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops Claude Code's own injected lines", () => {
|
||||
const lines = chatLines([
|
||||
msg({ sender: "user", content: [{ type: "text", text: "<command-name>/clear</command-name>" }] }),
|
||||
]);
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Sagi.test.tsx
|
||||
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B chat toggle (the chat window is the pet's whole UI: status strip, local answers, New chat stopping the old session), and accessibility attributes.
|
||||
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B window toggle (the pet's window: name, pat, status strip — the pet no longer chats), and accessibility attributes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,6 @@ import { useSagiPosition } from "../useSagiPosition";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sagi } from "../Sagi";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { WSMessage, Session } from "../../../lib/types";
|
||||
|
||||
function renderSagi() {
|
||||
@@ -55,16 +54,15 @@ describe("Sagi widget", () => {
|
||||
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the chat on click and answers a local status question there", () => {
|
||||
it("opens its window on click — status, no chat box", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
expect(panel).toBeInTheDocument();
|
||||
|
||||
const input = within(panel).getByLabelText(/ask sagi anything/i);
|
||||
fireEvent.change(input, { target: { value: "status" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
|
||||
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
|
||||
expect(within(panel).getByText("live")).toBeInTheDocument();
|
||||
expect(within(panel).queryByRole("textbox")).toBeNull();
|
||||
expect(within(panel).queryByRole("button", { name: /new chat/i })).toBeNull();
|
||||
fireEvent.click(within(panel).getByRole("button", { name: /close sagi/i }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("toggles open/closed with Cmd/Ctrl+B and closes with Esc", () => {
|
||||
@@ -89,7 +87,7 @@ describe("Sagi widget", () => {
|
||||
expect(within(btn).getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the errored session in its bubble and in the chat", () => {
|
||||
it("names the errored session in its bubble and in its window", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish({
|
||||
@@ -102,7 +100,7 @@ describe("Sagi widget", () => {
|
||||
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
||||
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
|
||||
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
|
||||
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
|
||||
});
|
||||
@@ -122,54 +120,17 @@ describe("Sagi widget", () => {
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("renames the pet from the chat header", () => {
|
||||
it("renames the pet from its window header", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
||||
const input = screen.getByLabelText("Pet name");
|
||||
fireEvent.change(input, { target: { value: "Mochi" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByRole("dialog", { name: /chat with mochi/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/ask mochi anything/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: /pet mochi/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hands a question it cannot answer to the AI", () => {
|
||||
localStorage.setItem("agenthub.defaultTool", "claude");
|
||||
const ensure = vi.spyOn(api.lanes, "ensure").mockReturnValue(new Promise(() => {}));
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
const input = within(chat).getByLabelText(/ask sagi anything/i);
|
||||
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
|
||||
expect(ensure).toHaveBeenCalledWith({ title: "Sagi" });
|
||||
ensure.mockRestore();
|
||||
});
|
||||
|
||||
it("reopens the remembered chat, and New chat stops its session", async () => {
|
||||
localStorage.setItem(
|
||||
"agenthub.pet.chat",
|
||||
JSON.stringify({ laneId: 7, runId: "agenthub-lane-7-1", tool: "claude" })
|
||||
);
|
||||
const get = vi
|
||||
.spyOn(api.run, "get")
|
||||
.mockReturnValue(new Promise(() => {}) as ReturnType<typeof api.run.get>);
|
||||
const kill = vi.spyOn(api.run, "kill").mockResolvedValue({ ok: true });
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
expect(screen.getByRole("button", { name: /open in workspace/i })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /new chat/i }));
|
||||
});
|
||||
expect(kill).toHaveBeenCalledWith("agenthub-lane-7-1", { archive: true });
|
||||
expect(localStorage.getItem("agenthub.pet.chat")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /open in workspace/i })).not.toBeInTheDocument();
|
||||
get.mockRestore();
|
||||
kill.mockRestore();
|
||||
});
|
||||
|
||||
it("a pat shows its reaction on the pet even while the chat is open", () => {
|
||||
it("a pat shows its reaction on the pet even while its window is open", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
|
||||
@@ -187,26 +148,40 @@ describe("Sagi widget", () => {
|
||||
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reflects the live count in the chat status", () => {
|
||||
it("reflects the live count in its status", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "active"));
|
||||
eventBus.publish(sessionMsg("b", "active"));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
|
||||
// The "live" stat chip shows value 2 next to its label.
|
||||
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
||||
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("counts only the viewer's own sessions (another user's arrive marked mine: false)", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish({ ...sessionMsg("mine", "active"), mine: true });
|
||||
eventBus.publish({ ...sessionMsg("theirs", "active"), mine: false });
|
||||
eventBus.publish({ ...sessionMsg("theirs-err", "error"), mine: false });
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
|
||||
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
||||
expect(within(liveChip).getByText("1")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/theirs/)).toBeNull();
|
||||
});
|
||||
|
||||
it("respects the enabled preference", () => {
|
||||
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
|
||||
renderSagi();
|
||||
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a tap (no movement) still opens the chat", () => {
|
||||
it("a tap (no movement) still opens its window", () => {
|
||||
renderSagi();
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||
act(() => {
|
||||
@@ -214,7 +189,7 @@ describe("Sagi widget", () => {
|
||||
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
||||
});
|
||||
fireEvent.click(btn);
|
||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: /pet sagi/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
|
||||
@@ -243,7 +218,7 @@ describe("Sagi widget", () => {
|
||||
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
|
||||
});
|
||||
fireEvent.click(btn);
|
||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: /pet sagi/i })).toBeInTheDocument();
|
||||
// No position was persisted because no real drag happened.
|
||||
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -529,13 +529,10 @@
|
||||
z-index: 42;
|
||||
}
|
||||
|
||||
/* ════════ the pet's chat window (PetChat.tsx) ════════ */
|
||||
/* ════════ the pet's window (PetPanel.tsx) ════════ */
|
||||
|
||||
/* The reference's soft gradient, in the theme's own accent rather than a fixed
|
||||
colour: a tint of the accent at the top fading into the surface, so it
|
||||
reads in every light and dark theme. Replies sit on the plain surface like
|
||||
the reference's white bubbles; the user's own are accent-solid (white text
|
||||
is only ever on accent-solid). */
|
||||
/* A tint of the theme's accent at the top fading into the surface, so it
|
||||
reads in every light and dark theme. */
|
||||
.pet-chat {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
@@ -547,61 +544,6 @@
|
||||
.pet-chat-head {
|
||||
border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent);
|
||||
}
|
||||
.pet-chat-reply {
|
||||
background: rgb(var(--surface-0));
|
||||
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 12%, transparent);
|
||||
}
|
||||
.pet-chat-tools {
|
||||
background: color-mix(in srgb, rgb(var(--surface-0)) 70%, transparent);
|
||||
border: 1px dashed color-mix(in srgb, rgb(var(--accent)) 25%, transparent);
|
||||
}
|
||||
.pet-chat-foot {
|
||||
background: rgb(var(--surface-0));
|
||||
border-top: 1px solid rgb(var(--border) / 0.6);
|
||||
}
|
||||
/* Three bouncing dots while a reply is written. */
|
||||
.pet-chat-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--accent));
|
||||
opacity: 0.5;
|
||||
animation: pet-chat-dot 1.2s ease-in-out infinite;
|
||||
}
|
||||
.pet-chat-dot:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.pet-chat-dot:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
@keyframes pet-chat-dot {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.35;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
/* The chat's terminal, attached but off-screen. It keeps a real size (so tmux
|
||||
does not shrink the pane to fit it) and a non-zero box (so the paste
|
||||
registry counts it as on screen). */
|
||||
.pet-chat-term {
|
||||
position: fixed;
|
||||
left: -10000px;
|
||||
top: 0;
|
||||
width: 760px;
|
||||
height: 460px;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pet-chat-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════ a pat's reaction, drawn on the pet itself ════════ */
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* burst of the same event into one count), the pet's one-shot animation
|
||||
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
|
||||
* for the UI shell. It also greets once per browser session and makes an
|
||||
* idle pet fidget now and then.
|
||||
* idle pet fidget now and then. It only ever counts the viewer's OWN sessions
|
||||
* (`mine=1` seed, and frames marked `mine: false` skipped).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -197,11 +198,11 @@ export function useSagiBrain(): SagiBrain {
|
||||
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
|
||||
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
|
||||
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
|
||||
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const reactionTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
||||
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const momentTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const lastBubbleAt = useRef(0);
|
||||
const burst = useRef<Burst | null>(null);
|
||||
const momentKey = useRef(0);
|
||||
@@ -278,7 +279,7 @@ export function useSagiBrain(): SagiBrain {
|
||||
}, GREET_DELAY_MS);
|
||||
};
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 100 })
|
||||
.list({ status: "active", limit: 100, mine: true })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
update((prev) => seedSessions(prev, res.sessions, Date.now()));
|
||||
@@ -294,6 +295,9 @@ export function useSagiBrain(): SagiBrain {
|
||||
// Subscribe to the live stream and connection status.
|
||||
useEffect(() => {
|
||||
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
||||
// Each user's pet tells only of their own sessions: an admin/analyst
|
||||
// gets every user's, marked `mine: false` when not theirs.
|
||||
if (msg.mine === false) return;
|
||||
const prev = stateRef.current;
|
||||
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
|
||||
// Most frames only move lastActivityAt, which the moods read at a 1 s
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
* the next option whose label starts with it. Focus returns to the trigger on
|
||||
* every close except Tab.
|
||||
*
|
||||
* ## Search — a combobox once the list is long
|
||||
* With more than `SEARCH_FROM` options (or `searchable`), the popup opens with
|
||||
* a search box on top, focused: typing narrows the list to options whose
|
||||
* label or value contains the text (case-insensitive), ↑/↓ move, Enter picks,
|
||||
* Escape closes. The box is `role="combobox"` controlling the listbox through
|
||||
* `aria-activedescendant`. Options may carry a `group`: a heading is drawn
|
||||
* where the group changes.
|
||||
*
|
||||
* ## Consumers
|
||||
* Used on the Run Claude page and webhook settings form wherever a compact
|
||||
* enum picker is needed.
|
||||
@@ -73,7 +81,11 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, Check, Search } from "lucide-react";
|
||||
|
||||
/** A list longer than this gets a search box (unless `searchable` says otherwise). */
|
||||
export const SEARCH_FROM = 8;
|
||||
|
||||
/** Room above and below `r` (the trigger's rect) for a menu it opens: inside
|
||||
* the nearest `data-menu-bounds` box — a split pane, whose neighbour paints
|
||||
@@ -95,6 +107,8 @@ export interface SelectOption<T extends string> {
|
||||
icon?: ReactNode;
|
||||
/** Draw a rule above this option: it starts a group of its own. */
|
||||
divider?: boolean;
|
||||
/** The group this option belongs to: a heading is drawn where it changes. */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
/** Props for {@link Select}. */
|
||||
@@ -111,6 +125,10 @@ export interface SelectProps<T extends string> {
|
||||
variant?: "field" | "ghost";
|
||||
/** Accessible name and tooltip of the trigger (a ghost one has no label). */
|
||||
label?: string;
|
||||
/** Show the search box: default only for lists longer than `SEARCH_FROM`. */
|
||||
searchable?: boolean;
|
||||
/** The search box's name and placeholder (default: "Search…"). */
|
||||
searchLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,15 +142,26 @@ export function Select<T extends string>({
|
||||
disabled,
|
||||
variant = "field",
|
||||
label,
|
||||
searchable,
|
||||
searchLabel,
|
||||
}: SelectProps<T>) {
|
||||
const { t } = useTranslation("common");
|
||||
const ghost = variant === "ghost";
|
||||
const canSearch = searchable ?? options.length > SEARCH_FROM;
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const [openUp, setOpenUp] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||
const typed = useRef({ text: "", at: 0 });
|
||||
// The options shown: all of them, or those the search text matches.
|
||||
const q = canSearch ? query.trim().toLowerCase() : "";
|
||||
const shown = q
|
||||
? options.filter((o) => `${o.label} ${o.value}`.toLowerCase().includes(q))
|
||||
: options;
|
||||
const id = useId();
|
||||
const optionId = (idx: number) => `${id}-opt-${idx}`;
|
||||
|
||||
@@ -150,6 +179,7 @@ export function Select<T extends string>({
|
||||
// above the trigger (288 px = `max-h-72`), and move focus into the list.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setQuery("");
|
||||
const idx = options.findIndex((o) => o.value === value);
|
||||
setActive(idx >= 0 ? idx : 0);
|
||||
if (buttonRef.current) {
|
||||
@@ -159,14 +189,14 @@ export function Select<T extends string>({
|
||||
);
|
||||
setOpenUp(below < 288 && above > below);
|
||||
}
|
||||
listRef.current?.focus({ preventScroll: true });
|
||||
(canSearch ? searchRef.current : listRef.current)?.focus({ preventScroll: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- id is stable
|
||||
}, [open, active]);
|
||||
}, [open, active, q]);
|
||||
|
||||
const close = (refocus = true) => {
|
||||
setOpen(false);
|
||||
@@ -185,8 +215,12 @@ export function Select<T extends string>({
|
||||
}
|
||||
};
|
||||
|
||||
const onListKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const last = options.length - 1;
|
||||
const onListKey = (e: React.KeyboardEvent<HTMLElement>) => {
|
||||
const last = shown.length - 1;
|
||||
// In the search box, keys that edit text stay the box's own.
|
||||
const inSearch = e.currentTarget === searchRef.current;
|
||||
if (inSearch && (e.key === " " || e.key === "Home" || e.key === "End")) return;
|
||||
if (inSearch && e.key === "Enter" && e.nativeEvent.isComposing) return;
|
||||
const move: Record<string, (a: number) => number> = {
|
||||
ArrowDown: (a) => Math.min(last, a + 1),
|
||||
ArrowUp: (a) => Math.max(0, a - 1),
|
||||
@@ -200,7 +234,7 @@ export function Select<T extends string>({
|
||||
setActive(move[e.key]!);
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
const opt = options[active];
|
||||
const opt = shown[active];
|
||||
if (opt) choose(opt);
|
||||
} else if (e.key === "Escape") {
|
||||
// Stop here: a Select inside a dialog must not close the dialog too.
|
||||
@@ -209,7 +243,7 @@ export function Select<T extends string>({
|
||||
close();
|
||||
} else if (e.key === "Tab") {
|
||||
close(false);
|
||||
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
} else if (!inSearch && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// Type-ahead: keys typed within 500 ms build one prefix; a repeated single
|
||||
// character cycles through the options that start with it.
|
||||
const now = Date.now();
|
||||
@@ -258,57 +292,100 @@ export function Select<T extends string>({
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={listRef}
|
||||
id={`${id}-list`}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
aria-activedescendant={options[active] ? optionId(active) : undefined}
|
||||
onKeyDown={onListKey}
|
||||
onBlur={(e) => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
|
||||
}}
|
||||
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 outline-none ${
|
||||
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} flex max-h-72 flex-col overflow-hidden rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 ${
|
||||
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
||||
}`}
|
||||
>
|
||||
{options.map((opt, idx) => {
|
||||
const isSelected = opt.value === value;
|
||||
const isActive = idx === active;
|
||||
return (
|
||||
<div
|
||||
key={opt.value || "__default__"}
|
||||
id={optionId(idx)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => choose(opt)}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
|
||||
opt.divider ? "mt-1 border-t border-border pt-2" : ""
|
||||
} ${
|
||||
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{opt.icon}
|
||||
<span
|
||||
className={`text-[11px] flex-1 truncate ${
|
||||
isSelected ? "text-accent font-medium" : "text-fg-secondary"
|
||||
{canSearch && (
|
||||
<div className="flex shrink-0 items-center gap-1.5 border-b border-border px-2.5 py-1.5">
|
||||
<Search className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
|
||||
<input
|
||||
ref={searchRef}
|
||||
role="combobox"
|
||||
aria-label={searchLabel ?? t("select.search")}
|
||||
aria-expanded
|
||||
aria-controls={`${id}-list`}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={shown[active] ? optionId(active) : undefined}
|
||||
placeholder={searchLabel ?? t("select.search")}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={onListKey}
|
||||
onBlur={(e) => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node | null))
|
||||
setOpen(false);
|
||||
}}
|
||||
className="min-w-0 flex-1 bg-transparent text-[11px] text-fg-primary placeholder:text-fg-muted focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={listRef}
|
||||
id={`${id}-list`}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
aria-activedescendant={!canSearch && shown[active] ? optionId(active) : undefined}
|
||||
onKeyDown={onListKey}
|
||||
onBlur={(e) => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
|
||||
}}
|
||||
className="min-h-0 flex-1 overflow-auto py-1 outline-none"
|
||||
>
|
||||
{canSearch && shown.length === 0 && (
|
||||
<div className="px-3 py-1.5 text-[11px] text-fg-muted">{t("select.noMatch")}</div>
|
||||
)}
|
||||
{shown.map((opt, idx) => {
|
||||
const isSelected = opt.value === value;
|
||||
const isActive = idx === active;
|
||||
const heading = opt.group && opt.group !== shown[idx - 1]?.group ? opt.group : null;
|
||||
return (
|
||||
<div key={opt.value || "__default__"} role="none">
|
||||
{heading && (
|
||||
<div
|
||||
role="presentation"
|
||||
className="mt-1 border-t border-border px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-fg-muted"
|
||||
>
|
||||
{heading}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
id={optionId(idx)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => choose(opt)}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
|
||||
opt.divider ? "mt-1 border-t border-border pt-2" : ""
|
||||
} ${
|
||||
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{opt.icon}
|
||||
<span
|
||||
className={`text-[11px] flex-1 truncate ${
|
||||
isSelected ? "text-accent font-medium" : "text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
{opt.hint && (
|
||||
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{opt.hint && (
|
||||
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file SessionToolBadge — names the AI coding CLI that produced a session
|
||||
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
|
||||
* its AI Tools label). Renders nothing for a Claude session, so the Sessions
|
||||
* list and session header look as before for every Claude row.
|
||||
* its AI Tools label). Renders nothing for a Claude session, so the session
|
||||
* header looks as before for every Claude row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { api } from "../lib/api";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { CopyButton } from "./event-views/primitives";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
|
||||
export function snapshotUrl(token: string): string {
|
||||
return `${window.location.origin}/snapshot/${token}`;
|
||||
@@ -33,7 +34,7 @@ export function SnapshotButton({ sessionId }: { sessionId: string }) {
|
||||
try {
|
||||
const { snapshot } = await api.snapshots.create(sessionId, { filePaths: redact });
|
||||
setToken(snapshot.token);
|
||||
navigator.clipboard?.writeText(snapshotUrl(snapshot.token)).catch(() => undefined);
|
||||
void copyText(snapshotUrl(snapshot.token));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* which the sidebar owns and which also carries the update check), the version
|
||||
* — with a green dot while a newer release is out —, how many SESSIONS are
|
||||
* running and how many need the user (`GET /api/sessions?status=active` and
|
||||
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
|
||||
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>` — and, on the
|
||||
* `?awaiting=1` — the Dashboard's Needs-you rule —, both `&mine=1`: the user's
|
||||
* OWN sessions even for an admin —, re-read on session
|
||||
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>&mine=1`, the
|
||||
* user's OWN spend even for an admin — and, on the
|
||||
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
||||
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
|
||||
* the URL's `session`, or the focused pane's session from the page's run poll,
|
||||
@@ -21,18 +23,27 @@
|
||||
* its fullest window, live account only — and, on hover or focus, a panel
|
||||
* above grouping every account per tool (saved Claude profiles too): name,
|
||||
* sign-in expiry, and a row per window with its bar, percent and reset time.
|
||||
* Server mode: the user's OWN spend quota (`GET /api/me/quota`, read with the
|
||||
* costs) as "Quota N%" — amber from 80 %, red once used up, when the user can
|
||||
* start nothing and their prompts are refused until it opens again.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { RefreshCw, Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { api, type RunHandle, type UserQuotaStatus } from "../lib/api";
|
||||
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
|
||||
import { AiToolIcon } from "./AiToolIcon";
|
||||
import {
|
||||
HIDDEN_PROFILES_EVENT,
|
||||
isHiddenProfilesKey,
|
||||
readHiddenProfiles,
|
||||
standIn,
|
||||
} from "../lib/hiddenProfilesStorage";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
import { sessionChangeMatcher } from "../lib/sessionChange";
|
||||
@@ -50,9 +61,6 @@ const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
||||
codex: "Codex",
|
||||
antigravity: "Antigravity",
|
||||
cursor: "Cursor",
|
||||
grok: "Grok",
|
||||
kimi: "Kimi",
|
||||
opencode: "OpenCode Go",
|
||||
};
|
||||
|
||||
function localDate(d = new Date()): string {
|
||||
@@ -274,14 +282,48 @@ function QuotaPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/** The profiles hidden in Settings → AI tools → Profiles, kept current. */
|
||||
function useHiddenProfiles(): string[] {
|
||||
const [hidden, setHidden] = useState<string[]>(readHiddenProfiles);
|
||||
useEffect(() => {
|
||||
const again = () => setHidden(readHiddenProfiles());
|
||||
const onStorage = (e: StorageEvent) => isHiddenProfilesKey(e.key) && again();
|
||||
window.addEventListener(HIDDEN_PROFILES_EVENT, again);
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
window.removeEventListener(HIDDEN_PROFILES_EVENT, again);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}, []);
|
||||
return hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude rows without the hidden profiles. When the live login is one of
|
||||
* them, the first visible profile stands in as the live row — its own usage
|
||||
* on the chip and the panel — so the panel never names the hidden account.
|
||||
*/
|
||||
export function withoutHidden(tools: ToolQuota[], hidden: string[]): ToolQuota[] {
|
||||
if (!hidden.length) return tools;
|
||||
const claude = tools.filter((q) => q.id === "claude");
|
||||
const isLive = (q: ToolQuota) => q.active !== false;
|
||||
const decoy = standIn(claude, isLive, hidden);
|
||||
const keep = (q: ToolQuota) =>
|
||||
q.id !== "claude" || !q.accountUuid || !hidden.includes(q.accountUuid);
|
||||
const shown = tools.filter(keep);
|
||||
if (!decoy) return shown;
|
||||
// The stand-in leads the Claude rows, as the live login does.
|
||||
return [{ ...decoy, active: true }, ...shown.filter((q) => q !== decoy)];
|
||||
}
|
||||
|
||||
/** The right-end quota chips; nothing at all until a tool reports one. */
|
||||
function QuotaChips() {
|
||||
const { t } = useTranslation("nav");
|
||||
const [tools, setTools] = useState<ToolQuota[]>([]);
|
||||
const [fetched, setTools] = useState<ToolQuota[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
// Leaving the chips closes the panel only after a beat, and entering the
|
||||
// panel cancels that: the pointer crosses the gap to read it.
|
||||
const closeTimer = useRef<number>();
|
||||
const closeTimer = useRef<number | undefined>(undefined);
|
||||
const show = () => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
setOpen(true);
|
||||
@@ -301,6 +343,8 @@ function QuotaChips() {
|
||||
);
|
||||
const [refreshing, refresh] = useSpin(() => load(true));
|
||||
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
||||
const hidden = useHiddenProfiles();
|
||||
const tools = useMemo(() => withoutHidden(fetched, hidden), [fetched, hidden]);
|
||||
if (!tools.length) return null;
|
||||
return (
|
||||
<>
|
||||
@@ -371,6 +415,8 @@ export function StatusBar({
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
// The budget furthest past one of its thresholds, if any.
|
||||
const [budget, setBudget] = useState<BudgetStatus | null>(null);
|
||||
// Server mode: this user's own spend quota (null: none, or local mode).
|
||||
const [myQuota, setMyQuota] = useState<UserQuotaStatus | null>(null);
|
||||
const location = useLocation();
|
||||
const shown = selectedFrom(location.pathname, location.search);
|
||||
const lane = shown.lane;
|
||||
@@ -385,11 +431,11 @@ export function StatusBar({
|
||||
|
||||
const loadCounts = useCallback(() => {
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 1 })
|
||||
.list({ status: "active", limit: 1, mine: true })
|
||||
.then((r) => setActive(r.total))
|
||||
.catch(() => undefined);
|
||||
api.sessions
|
||||
.list({ awaiting: true, limit: 1 })
|
||||
.list({ awaiting: true, limit: 1, mine: true })
|
||||
.then((r) => setNeedYou(r.total))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
@@ -406,7 +452,7 @@ export function StatusBar({
|
||||
const gen = costGen.current;
|
||||
const live = () => gen === costGen.current;
|
||||
api.pricing
|
||||
.totalCost({ date: localDate() })
|
||||
.totalCost({ date: localDate(), mine: true })
|
||||
.then((r) => live() && setToday(r))
|
||||
.catch(() => undefined);
|
||||
api.budgets
|
||||
@@ -416,6 +462,10 @@ export function StatusBar({
|
||||
if (live()) setBudget(hit[0] ?? null);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
api.me
|
||||
.quota()
|
||||
.then((r) => live() && setMyQuota(r?.quota ?? null))
|
||||
.catch(() => undefined);
|
||||
if (lane !== null) {
|
||||
api.pricing
|
||||
.totalCost({ lane })
|
||||
@@ -526,6 +576,26 @@ export function StatusBar({
|
||||
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
|
||||
</Link>
|
||||
)}
|
||||
{myQuota && (
|
||||
<span
|
||||
data-testid="status-bar-user-quota"
|
||||
title={t(myQuota.exceeded ? "statusBar.userQuotaBlocked" : "statusBar.userQuotaTitle", {
|
||||
spend: usd(myQuota.spend),
|
||||
limit: usd(myQuota.limit_usd),
|
||||
period: t(`statusBar.userQuotaPeriod.${myQuota.period}`),
|
||||
at: myQuota.resetsAt ? formatDateTime(myQuota.resetsAt) : "",
|
||||
})}
|
||||
className={`tabular-nums ${
|
||||
myQuota.exceeded
|
||||
? "text-status-danger"
|
||||
: myQuota.pct >= 80
|
||||
? "text-status-warning"
|
||||
: "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{t("statusBar.userQuota", { pct: Math.min(999, Math.round(myQuota.pct)) })}
|
||||
</span>
|
||||
)}
|
||||
<QuotaChips />
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -8,24 +8,34 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Check, Info, X } from "lucide-react";
|
||||
import { AlertCircle, AlertTriangle, Check, Info, X } from "lucide-react";
|
||||
import { dismissToast, subscribeToasts, type ToastItem } from "../lib/toast";
|
||||
|
||||
const TONE: Record<ToastItem["kind"], string> = {
|
||||
success: "border-status-success/40 bg-status-success/10 text-status-success",
|
||||
info: "border-border bg-surface-2 text-fg-primary",
|
||||
error: "border-status-danger/50 bg-status-danger/10 text-status-danger",
|
||||
warning: "border-status-warning/50 bg-status-warning/10 text-fg-primary",
|
||||
};
|
||||
|
||||
function Item({ item }: { item: ToastItem }) {
|
||||
const { t } = useTranslation("common");
|
||||
const Icon = item.kind === "error" ? AlertCircle : item.kind === "info" ? Info : Check;
|
||||
const Icon =
|
||||
item.kind === "error"
|
||||
? AlertCircle
|
||||
: item.kind === "warning"
|
||||
? AlertTriangle
|
||||
: item.kind === "info"
|
||||
? Info
|
||||
: Check;
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden />
|
||||
<span className="text-xs leading-relaxed flex-1 break-words">{item.message}</span>
|
||||
<span className="text-xs leading-relaxed flex-1 min-w-0 break-words [overflow-wrap:anywhere]">
|
||||
{item.message}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismissToast(item.id)}
|
||||
@@ -44,8 +54,10 @@ export function Toaster() {
|
||||
useEffect(() => subscribeToasts(setItems), []);
|
||||
const errors = items.filter((i) => i.kind === "error");
|
||||
const rest = items.filter((i) => i.kind !== "error");
|
||||
// Above the background-jobs dock (BackgroundJobsDock sets --jobs-dock-h
|
||||
// to its height), so a toast never covers a chip's buttons.
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-10 right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
|
||||
<div className="pointer-events-none fixed bottom-[calc(2.5rem+var(--jobs-dock-h,0px))] right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
|
||||
<div role="status" aria-live="polite" className="flex flex-col gap-2">
|
||||
{rest.map((i) => (
|
||||
<Item key={i.id} item={i} />
|
||||
|
||||
@@ -17,8 +17,15 @@
|
||||
* keeps answering while the chain installs. New skills/slash commands still
|
||||
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
||||
* background process can do for it. Escape, the backdrop and × close it for
|
||||
* this page load only (never while applying); "Skip this version" is the one
|
||||
* persistent, per-version dismiss. After a failed update, "Auto fix" hands the
|
||||
* this page load only — also while an update applies: it keeps running in the
|
||||
* background (the page still reloads itself once the new server is up), and a
|
||||
* failure re-opens the modal with its error. While hidden — or once the status
|
||||
* stops offering the update mid-run — a small chip in the corner keeps showing
|
||||
* it, with the plugin chain's current step from `plugin_update_progress`, and a
|
||||
* click on it shows the modal again. A chain the server reports as running
|
||||
* (started before a reload or from another tab) is adopted the same way;
|
||||
* "Skip this version" is the one
|
||||
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
|
||||
* error to an AI agent: it opens (or adopts) a workspace on the install folder
|
||||
* and starts a Claude Code session there with the error as its prompt — the
|
||||
* agent fixes whatever blocked the update (a dirty lockfile, a failed build…)
|
||||
@@ -182,6 +189,8 @@ export function UpdateNotifier() {
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [fixing, setFixing] = useState(false);
|
||||
/** The running plugin chain's step, from the polls while it runs. */
|
||||
const [progress, setProgress] = useState<UpdateStatusPayload["plugin_update_progress"]>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
||||
@@ -236,20 +245,23 @@ export function UpdateNotifier() {
|
||||
status?.latest_release ||
|
||||
status?.remote_sha ||
|
||||
(installed ? `installed:${installed.root}` : null);
|
||||
// Updating replaces the host's install: only an admin is offered it.
|
||||
// Updating replaces the host's install: only an admin is offered it. A
|
||||
// running update stays shown (unless closed) even once the status stops
|
||||
// offering it — the new version may already be on disk before it restarts.
|
||||
const show = Boolean(
|
||||
isAdmin &&
|
||||
status?.update_available &&
|
||||
dismissKey &&
|
||||
dismissedSha !== dismissKey &&
|
||||
closedKey !== dismissKey
|
||||
closedKey !== dismissKey &&
|
||||
(applying || restarting || (status?.update_available && dismissedSha !== dismissKey))
|
||||
);
|
||||
const busy = applying || restarting || fixing;
|
||||
|
||||
// Closing while an update applies only hides it: the apply request and the
|
||||
// poll that reloads the page keep running, and a failure re-opens it.
|
||||
const closeForNow = useCallback(() => {
|
||||
if (busy || !dismissKey) return;
|
||||
if (!dismissKey) return;
|
||||
setClosedKey(dismissKey);
|
||||
}, [busy, dismissKey]);
|
||||
}, [dismissKey]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (busy || !dismissKey) return;
|
||||
@@ -261,8 +273,8 @@ export function UpdateNotifier() {
|
||||
setDismissedSha(dismissKey);
|
||||
}, [busy, dismissKey]);
|
||||
|
||||
// Escape closes for now — never while an update is applying.
|
||||
useDialog(show, panelRef, busy ? undefined : closeForNow);
|
||||
// Escape closes for now — an applying update carries on in the background.
|
||||
useDialog(show, panelRef, closeForNow);
|
||||
|
||||
const copyCmd = async () => {
|
||||
if (!status?.manual_command) return;
|
||||
@@ -307,6 +319,7 @@ export function UpdateNotifier() {
|
||||
api.updates
|
||||
.status()
|
||||
.then((s) => {
|
||||
setProgress(s.plugin_update_progress ?? null);
|
||||
const v = s.last_plugin_update;
|
||||
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
|
||||
// A failed step, or nothing to pull, leaves the OLD server up: it
|
||||
@@ -315,6 +328,7 @@ export function UpdateNotifier() {
|
||||
syncFromPayload(s);
|
||||
setRestarting(false);
|
||||
if (v.ok) toast.info(t("pluginAlreadyCurrent"));
|
||||
else setClosedKey(null); // closed while it ran: show why it failed
|
||||
return;
|
||||
}
|
||||
// A new version answering is back too, even if a poll never
|
||||
@@ -333,9 +347,23 @@ export function UpdateNotifier() {
|
||||
[syncFromPayload, t]
|
||||
);
|
||||
|
||||
// A chain already running on the server (started before a reload, in another
|
||||
// tab, or from the old bundle) is adopted: this tab shows its steps, then
|
||||
// reloads onto the new server like the tab that started it.
|
||||
const running = status?.plugin_update_progress ?? null;
|
||||
const adoptedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!running || !isAdmin || applying || restarting || adoptedRef.current) return;
|
||||
adoptedRef.current = true;
|
||||
setProgress(running);
|
||||
setRestarting(true);
|
||||
pollUntilBack(true, status?.local_version ?? null);
|
||||
}, [running, isAdmin, applying, restarting, pollUntilBack, status?.local_version]);
|
||||
|
||||
const applyNow = async () => {
|
||||
if (applying || restarting) return;
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
setApplying(true);
|
||||
try {
|
||||
const result = await api.updates.apply();
|
||||
@@ -359,6 +387,8 @@ export function UpdateNotifier() {
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
// Not applied: re-open a modal closed while it ran, so the error is seen.
|
||||
setClosedKey(null);
|
||||
};
|
||||
|
||||
// Hand the failure to an AI agent: a workspace on the install folder (the
|
||||
@@ -384,6 +414,54 @@ export function UpdateNotifier() {
|
||||
}
|
||||
};
|
||||
|
||||
const stepText = progress?.step
|
||||
? t(`pluginStep.${progress.step}`, {
|
||||
index: progress.index,
|
||||
total: progress.total,
|
||||
defaultValue: progress.step,
|
||||
})
|
||||
: null;
|
||||
|
||||
// Hidden while it runs — closed, or the status no longer offers the update
|
||||
// (the new version is already on disk): a chip keeps it in sight.
|
||||
if (!show && isAdmin && (applying || restarting)) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="update-chip"
|
||||
onClick={() => setClosedKey(null)}
|
||||
title={t("chipHint")}
|
||||
className="fixed bottom-24 right-4 z-[60] w-72 rounded-lg border border-accent/30 bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
|
||||
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" aria-hidden />
|
||||
<span className="truncate">
|
||||
{status?.latest_release
|
||||
? t("chipTitle", { version: status.latest_release })
|
||||
: t("updating")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-[11px] text-fg-muted">
|
||||
{stepText || (applying ? t("updating") : t("restartingShort"))}
|
||||
</span>
|
||||
{progress && progress.total > 0 ? (
|
||||
<span
|
||||
className="mt-1.5 block h-1 overflow-hidden rounded bg-surface-2"
|
||||
role="progressbar"
|
||||
aria-valuenow={progress.index}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={progress.total}
|
||||
>
|
||||
<span
|
||||
className="block h-full bg-accent-solid transition-all"
|
||||
style={{ width: `${(Math.max(progress.index - 1, 0) / progress.total) * 100}%` }}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!show || !status) return null;
|
||||
|
||||
const refLabel = status.remote_ref || "origin";
|
||||
@@ -436,7 +514,6 @@ export function UpdateNotifier() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForNow}
|
||||
disabled={busy}
|
||||
aria-label={t("later")}
|
||||
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
|
||||
>
|
||||
@@ -513,7 +590,14 @@ export function UpdateNotifier() {
|
||||
{/* The plugin chain pulls a new cache directory and reinstalls
|
||||
dependencies before it restarts, so it takes minutes, not
|
||||
seconds — saying "restarting" there would look hung. */}
|
||||
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
||||
<span>
|
||||
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
||||
{stepText ? (
|
||||
<span className="block mt-0.5 font-medium" data-testid="update-step">
|
||||
{stepText}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -559,6 +559,8 @@ export function WebhookSettings() {
|
||||
headerRows: [],
|
||||
})
|
||||
}
|
||||
// A short, fixed list of providers: no search box.
|
||||
searchable={false}
|
||||
options={providers.map((p) => ({ value: p.type, label: p.label }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
||||
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
||||
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
||||
* The user's own GROUPS (`lib/workspaceGroups.ts`, kept per account on the
|
||||
* server like the other preferences) arrange the list under foldable headers —
|
||||
* each group in its order, then "Ungrouped" — inside each owner group when
|
||||
* there are owner groups. A workspace joins a group from its menu, by a drop
|
||||
* on the group's header, by a drag that lands it below a row of that
|
||||
* group, or by Alt+↑/↓ past the group's edge; a header is dragged onto
|
||||
* another to reorder the groups, and its menu renames, moves, folds every
|
||||
* group, adds a new workspace straight into it or deletes it (its
|
||||
* workspaces stay, ungrouped). A folded header still shows the worst state
|
||||
* of its workspaces (needs you, error, running) and marks the selected one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -30,7 +40,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, FolderPlus, MoreHorizontal, Plus, Search } from "lucide-react";
|
||||
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
@@ -46,7 +56,31 @@ import {
|
||||
writeLaneOrder,
|
||||
} from "../lib/laneOrderStorage";
|
||||
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
|
||||
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
|
||||
import {
|
||||
arrangeByGroup,
|
||||
assignGroup,
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
groupOf,
|
||||
moveGroup,
|
||||
moveGroupBefore,
|
||||
pruneWorkspaceGroups,
|
||||
renameGroup,
|
||||
setAllFolded,
|
||||
setGroupFolded,
|
||||
useWorkspaceGroups,
|
||||
type WorkspaceGroup,
|
||||
} from "../lib/workspaceGroups";
|
||||
import { confirmDialog } from "../lib/confirm";
|
||||
import { ContextMenu, MenuItem, MenuSeparator } from "./ContextMenu";
|
||||
import {
|
||||
LANE_DRAG_TYPE,
|
||||
WorkspaceRow,
|
||||
isLaneDrag,
|
||||
workspaceDot,
|
||||
workspaceDotLabel,
|
||||
workspaceName,
|
||||
} from "./lanes/WorkspaceRow";
|
||||
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
||||
import { UserAvatar } from "./users/UserAvatar";
|
||||
@@ -56,6 +90,9 @@ import { UserAvatar } from "./users/UserAvatar";
|
||||
const AddLaneModal = lazy(() =>
|
||||
import("./lanes/AddLaneModal").then((m) => ({ default: m.AddLaneModal }))
|
||||
);
|
||||
const WorkspaceShareModal = lazy(() =>
|
||||
import("./lanes/WorkspaceShareModal").then((m) => ({ default: m.WorkspaceShareModal }))
|
||||
);
|
||||
const DestructiveLaneModal = lazy(() =>
|
||||
import("./lanes/DestructiveLaneModal").then((m) => ({ default: m.DestructiveLaneModal }))
|
||||
);
|
||||
@@ -82,7 +119,9 @@ export function WorkspaceTree() {
|
||||
const selected = selectedFrom(location.pathname, location.search);
|
||||
const { lanes, refresh: refreshLanes } = useLanes();
|
||||
const { state: auth } = useAuth();
|
||||
const { seesAll, canEditLane } = usePermissions();
|
||||
const { seesAll, canEditLane, ownsLane } = usePermissions();
|
||||
// Sharing a workspace with other users (server mode): the open dialog's lane.
|
||||
const [sharing, setSharing] = useState<Lane | null>(null);
|
||||
const meId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
|
||||
// Server mode staff see every user's workspaces, so the list says whose.
|
||||
const grouped = auth.mode === "server" && seesAll;
|
||||
@@ -98,8 +137,23 @@ export function WorkspaceTree() {
|
||||
);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
|
||||
// "New workspace in this group": the group the added workspace joins.
|
||||
const [addToGroup, setAddToGroup] = useState<string | null>(null);
|
||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||
const [orderTick, setOrderTick] = useState(0);
|
||||
const groups = useWorkspaceGroups();
|
||||
const hasGroups = groups.groups.length > 0;
|
||||
const [renamingGroup, setRenamingGroup] = useState<string | null>(null);
|
||||
const [groupMenu, setGroupMenu] = useState<{
|
||||
group: WorkspaceGroup;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
// A removed workspace leaves the stored groups (they hold ids only).
|
||||
const laneIdsKey = lanes.map((l) => l.id).join(",");
|
||||
useEffect(() => {
|
||||
if (laneIdsKey) pruneWorkspaceGroups(laneIdsKey.split(",").map(Number));
|
||||
}, [laneIdsKey]);
|
||||
|
||||
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
|
||||
// status or session moves, so that is when the live-pane list is re-read;
|
||||
@@ -126,8 +180,9 @@ export function WorkspaceTree() {
|
||||
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
|
||||
}, []);
|
||||
|
||||
const openAdd = (sourceRepo?: string) => {
|
||||
const openAdd = (sourceRepo?: string, groupId: string | null = null) => {
|
||||
setAddPrefill(sourceRepo ? { sourceRepo } : null);
|
||||
setAddToGroup(groupId);
|
||||
setAddOpen(true);
|
||||
api.run
|
||||
.cwds()
|
||||
@@ -148,8 +203,9 @@ export function WorkspaceTree() {
|
||||
const toolFilter =
|
||||
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
|
||||
const sorted = useMemo(() => {
|
||||
const list = sortByStoredOrder(lanes, order).filter(
|
||||
(l) => !toolFilter || l.tool === toolFilter
|
||||
const list = arrangeByGroup(
|
||||
sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
|
||||
groups
|
||||
);
|
||||
if (!grouped) return list;
|
||||
// Stable: each owner's workspaces keep the stored order inside the group.
|
||||
@@ -157,7 +213,7 @@ export function WorkspaceTree() {
|
||||
return [...list].sort(
|
||||
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
|
||||
);
|
||||
}, [lanes, order, toolFilter, grouped, meId]);
|
||||
}, [lanes, order, toolFilter, grouped, meId, groups]);
|
||||
const q = query.trim().toLowerCase();
|
||||
const nameHit = (l: Lane) =>
|
||||
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
|
||||
@@ -190,6 +246,26 @@ export function WorkspaceTree() {
|
||||
setOrderTick((n) => n + 1);
|
||||
};
|
||||
|
||||
// Alt+↑/↓. Past the edge of its group the workspace stays where it is on
|
||||
// screen and joins the next group instead (the arrangement would otherwise
|
||||
// put it straight back): last of the group above, first of the one below —
|
||||
// opened if folded, so the focused row stays shown. The shown order is
|
||||
// written as it stands, so the store agrees. Never across an owner's section.
|
||||
const nudge = (from: number, to: number) => {
|
||||
const lane = sorted[from];
|
||||
const next = sorted[to];
|
||||
if (lane && next && hasGroups && ownerKey(lane) === ownerKey(next)) {
|
||||
const target = groupOf(groups, next.id);
|
||||
if ((target?.id ?? null) !== (groupOf(groups, lane.id)?.id ?? null)) {
|
||||
assignGroup(lane.id, target?.id ?? null);
|
||||
if (target?.folded) setGroupFolded(target.id, false);
|
||||
reorder((ids) => [...ids]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
reorder((ids) => moveLane(ids, from, to));
|
||||
};
|
||||
|
||||
// A row only starts a drag; the whole section takes the drop, so a gap — a
|
||||
// workspace's session list, the space above or below a row — lands too.
|
||||
// `dropAt` is the insertion point in `sorted`, drawn as a line.
|
||||
@@ -223,11 +299,74 @@ export function WorkspaceTree() {
|
||||
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
|
||||
// Number("") is 0: an empty payload must not move the first row.
|
||||
const from = raw === "" ? NaN : Number(raw);
|
||||
if (!Number.isInteger(from)) return;
|
||||
const lane = sorted[from];
|
||||
// Dropped on a group's header: into that group, at its top.
|
||||
const header = (e.target as HTMLElement).closest<HTMLElement>("[data-group-drop]");
|
||||
if (header && lane) {
|
||||
const gid = header.dataset.groupDrop || null;
|
||||
assignGroup(lane.id, gid);
|
||||
const first = sorted.findIndex(
|
||||
(l) => (groups.of[l.id] ?? null) === gid && l.id !== lane.id
|
||||
);
|
||||
if (first >= 0) reorder((ids) => moveBefore(ids, from, first));
|
||||
return;
|
||||
}
|
||||
const before = dropIndexAt(e.clientY);
|
||||
if (Number.isInteger(from)) reorder((ids) => moveBefore(ids, from, before));
|
||||
// Elsewhere it joins the group of the row it now sits under (the line
|
||||
// drawn above a header closes the section before it).
|
||||
if (lane && hasGroups) {
|
||||
const rest = sorted.filter((l) => l.id !== lane.id);
|
||||
const at = sorted.slice(0, before).filter((l) => l.id !== lane.id).length;
|
||||
const neighbour = rest[at - 1] ?? rest[0];
|
||||
if (neighbour) assignGroup(lane.id, groups.of[neighbour.id] ?? null);
|
||||
}
|
||||
reorder((ids) => moveBefore(ids, from, before));
|
||||
},
|
||||
};
|
||||
|
||||
// A group no workspace is in yet still shows (to be renamed, or dropped on),
|
||||
// just above the ungrouped ones; a search hides it.
|
||||
const emptyGroups = q
|
||||
? []
|
||||
: groups.groups.filter((g) => !sorted.some((l) => groups.of[l.id] === g.id));
|
||||
const firstUngrouped = hasGroups ? sorted.findIndex((l) => !groupOf(groups, l.id)) : -1;
|
||||
const groupHeader = (g: WorkspaceGroup | null, members: Lane[], first: boolean) => (
|
||||
<GroupHeader
|
||||
key={g?.id ?? "none"}
|
||||
group={g}
|
||||
count={members.length}
|
||||
// What a folded group hides: the most urgent of its workspaces, and
|
||||
// whether the open one is among them.
|
||||
worst={worstOf(members)}
|
||||
holdsSelected={members.some((l) => l.id === selected.lane)}
|
||||
onGroupDrop={(dragged) => moveGroupBefore(dragged, g?.id ?? null)}
|
||||
folded={!!g?.folded && !q}
|
||||
renaming={!!g && renamingGroup === g.id}
|
||||
first={first}
|
||||
onToggle={() => g && setGroupFolded(g.id, !g.folded)}
|
||||
onMenu={(x, y) => g && setGroupMenu({ group: g, x, y })}
|
||||
onRenameEnd={(name) => {
|
||||
setRenamingGroup(null);
|
||||
if (g && name !== null) renameGroup(g.id, name);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const newGroup = (laneIds: number[] = []) => {
|
||||
const id = createGroup(t("tree.groupDefaultName"), laneIds);
|
||||
setRenamingGroup(id);
|
||||
};
|
||||
const removeGroup = async (g: WorkspaceGroup) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("tree.groupDelete"),
|
||||
message: t("tree.groupDeleteConfirm", { name: g.name }),
|
||||
confirmLabel: t("tree.groupDelete"),
|
||||
});
|
||||
if (!ok) return;
|
||||
deleteGroup(g.id);
|
||||
};
|
||||
|
||||
const rename = async (lane: Lane, title: string | null) => {
|
||||
setRenamingId(null);
|
||||
if (title === null || title === (lane.title || "")) return;
|
||||
@@ -241,6 +380,7 @@ export function WorkspaceTree() {
|
||||
|
||||
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => {
|
||||
if (action === "rename") setRenamingId(lane.id);
|
||||
else if (action === "share") setSharing(lane);
|
||||
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
|
||||
else if (action === "reset" || action === "remove") setDestructive({ lane, action });
|
||||
};
|
||||
@@ -304,6 +444,16 @@ export function WorkspaceTree() {
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-tree-new-group"
|
||||
onClick={() => newGroup()}
|
||||
aria-label={t("tree.newGroup")}
|
||||
title={t("tree.newGroup")}
|
||||
className={headBtn}
|
||||
>
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-tree-add"
|
||||
@@ -347,6 +497,16 @@ export function WorkspaceTree() {
|
||||
: ownerShown(lane) || `#${lane.owner_id}`;
|
||||
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
||||
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
||||
// The user's own group, inside the owner's.
|
||||
const ug = groupOf(groups, lane.id);
|
||||
const ugKey = ug?.id ?? "";
|
||||
const sameSection = (l: Lane) =>
|
||||
ownerKey(l) === owner && (groupOf(groups, l.id)?.id ?? "") === ugKey;
|
||||
const prev = sorted[i - 1];
|
||||
const sectionStart = hasGroups && (groupStart || !prev || !sameSection(prev));
|
||||
const sectionShown = sorted.some((l) => sameSection(l) && visible(l));
|
||||
const sectionFolded = !!ug?.folded && !q;
|
||||
const hiddenRow = folded || sectionFolded;
|
||||
return (
|
||||
<div key={lane.id} className="relative">
|
||||
{groupStart && groupShown && (
|
||||
@@ -381,8 +541,13 @@ export function WorkspaceTree() {
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{i === firstUngrouped && emptyGroups.map((g) => groupHeader(g, [], false))}
|
||||
{sectionStart &&
|
||||
sectionShown &&
|
||||
!folded &&
|
||||
groupHeader(ug, sorted.filter(sameSection), i === 0 || !!groupStart)}
|
||||
{dropAt === i && <DropLine />}
|
||||
{visible(lane) && !folded && (
|
||||
{visible(lane) && !hiddenRow && (
|
||||
<WorkspaceRow
|
||||
lane={lane}
|
||||
index={i}
|
||||
@@ -398,13 +563,14 @@ export function WorkspaceTree() {
|
||||
}}
|
||||
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
||||
onNewSession={() => select(lane.id)}
|
||||
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
|
||||
onReorder={nudge}
|
||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||
onRenameStart={() => setRenamingId(lane.id)}
|
||||
// A workspace shared with this user keeps its owner's name.
|
||||
onRenameStart={() => ownsLane(lane) && setRenamingId(lane.id)}
|
||||
onRenameEnd={(title) => void rename(lane, title)}
|
||||
/>
|
||||
)}
|
||||
{expanded(lane.id) && !folded && (
|
||||
{expanded(lane.id) && !hiddenRow && (
|
||||
<WorkspaceSessionList
|
||||
lane={lane}
|
||||
liveRuns={liveRuns}
|
||||
@@ -420,6 +586,8 @@ export function WorkspaceTree() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{firstUngrouped === -1 &&
|
||||
emptyGroups.map((g, k) => groupHeader(g, [], k === 0 && sorted.length === 0))}
|
||||
{dropAt === sorted.length && (
|
||||
<div className="relative">
|
||||
<DropLine />
|
||||
@@ -440,9 +608,94 @@ export function WorkspaceTree() {
|
||||
y={menu.y}
|
||||
onClose={() => setMenu(null)}
|
||||
onAction={(a) => onMenuAction(menu.lane, a)}
|
||||
groups={groups.groups}
|
||||
groupId={groups.of[menu.lane.id] ?? null}
|
||||
onGroup={(target) => {
|
||||
const id = menu.lane.id;
|
||||
setMenu(null);
|
||||
if (target === "new") newGroup([id]);
|
||||
else assignGroup(id, target);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{groupMenu && (
|
||||
<ContextMenu
|
||||
x={groupMenu.x}
|
||||
y={groupMenu.y}
|
||||
onClose={() => setGroupMenu(null)}
|
||||
testId={`workspace-group-menu-${groupMenu.group.id}`}
|
||||
>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
setRenamingGroup(groupMenu.group.id);
|
||||
setGroupMenu(null);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupRename")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={groups.groups[0]?.id === groupMenu.group.id}
|
||||
onSelect={() => {
|
||||
moveGroup(groupMenu.group.id, -1);
|
||||
setGroupMenu(null);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupMoveUp")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={groups.groups[groups.groups.length - 1]?.id === groupMenu.group.id}
|
||||
onSelect={() => {
|
||||
moveGroup(groupMenu.group.id, 1);
|
||||
setGroupMenu(null);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupMoveDown")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
testId="workspace-group-add"
|
||||
onSelect={() => {
|
||||
const g = groupMenu.group;
|
||||
setGroupMenu(null);
|
||||
openAdd(undefined, g.id);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupAddWorkspace")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
testId="workspace-group-fold-all"
|
||||
onSelect={() => {
|
||||
setGroupMenu(null);
|
||||
setAllFolded(true);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupFoldAll")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
testId="workspace-group-unfold-all"
|
||||
onSelect={() => {
|
||||
setGroupMenu(null);
|
||||
setAllFolded(false);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupUnfoldAll")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
testId="workspace-group-delete"
|
||||
onSelect={() => {
|
||||
const g = groupMenu.group;
|
||||
setGroupMenu(null);
|
||||
void removeGroup(g);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupDelete")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
<Suspense fallback={null}>
|
||||
{sharing && <WorkspaceShareModal lane={sharing} onClose={() => setSharing(null)} />}
|
||||
{destructive && (
|
||||
<DestructiveLaneModal
|
||||
lane={destructive.lane}
|
||||
@@ -460,8 +713,10 @@ export function WorkspaceTree() {
|
||||
onClose={() => {
|
||||
setAddOpen(false);
|
||||
setAddPrefill(null);
|
||||
setAddToGroup(null);
|
||||
}}
|
||||
onAdded={(lane) => {
|
||||
if (addToGroup) assignGroup(lane.id, addToGroup);
|
||||
void refreshLanes();
|
||||
select(lane.id);
|
||||
}}
|
||||
@@ -472,9 +727,167 @@ export function WorkspaceTree() {
|
||||
);
|
||||
}
|
||||
|
||||
/** What a group header's drag carries: the group's id. */
|
||||
const GROUP_DRAG_TYPE = "application/x-agenthub-workspace-group";
|
||||
const isGroupDrag = (e: { dataTransfer: DataTransfer | null }) =>
|
||||
!!e.dataTransfer?.types?.includes(GROUP_DRAG_TYPE);
|
||||
|
||||
// needsYou outranks an error, an error a running agent; idle/active say nothing.
|
||||
const URGENCY: Record<string, number> = { needsYou: 3, error: 2, running: 1 };
|
||||
/** The most urgent of `lanes` by its dot, or null when all are at rest. */
|
||||
function worstOf(lanes: Lane[]): Lane | null {
|
||||
let best: Lane | null = null;
|
||||
let top = 0;
|
||||
for (const l of lanes) {
|
||||
const u = URGENCY[workspaceDotLabel(l)] ?? 0;
|
||||
if (u > top) [best, top] = [l, u];
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** A workspace's owner group: its user id, or "none". */
|
||||
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
|
||||
|
||||
/**
|
||||
* A section's header: a user group (foldable, menu, inline rename) or, with
|
||||
* `group` null, "Ungrouped". Both take a dropped workspace (`data-group-drop`).
|
||||
*/
|
||||
function GroupHeader({
|
||||
group,
|
||||
count,
|
||||
worst,
|
||||
holdsSelected,
|
||||
folded,
|
||||
renaming,
|
||||
first,
|
||||
onToggle,
|
||||
onMenu,
|
||||
onRenameEnd,
|
||||
onGroupDrop,
|
||||
}: {
|
||||
group: WorkspaceGroup | null;
|
||||
count: number;
|
||||
/** The most urgent workspace inside, drawn as its dot while folded. */
|
||||
worst: Lane | null;
|
||||
/** The open workspace is in this group (marked while folded). */
|
||||
holdsSelected: boolean;
|
||||
folded: boolean;
|
||||
renaming: boolean;
|
||||
first: boolean;
|
||||
onToggle: () => void;
|
||||
onMenu: (x: number, y: number) => void;
|
||||
/** The typed name, or null when the edit was cancelled. */
|
||||
onRenameEnd: (name: string | null) => void;
|
||||
/** A group header (by id) dropped here: it goes before this group, or last on "Ungrouped". */
|
||||
onGroupDrop: (draggedId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [draft, setDraft] = useState(group?.name ?? "");
|
||||
const [dropOver, setDropOver] = useState(false);
|
||||
useEffect(() => {
|
||||
if (renaming) setDraft(group?.name ?? "");
|
||||
}, [renaming, group?.name]);
|
||||
const label = group ? group.name : t("tree.ungrouped");
|
||||
return (
|
||||
<div
|
||||
data-group-drop={group?.id ?? ""}
|
||||
data-testid={`workspace-group-${group?.id ?? "none"}`}
|
||||
onContextMenu={(e) => {
|
||||
if (!group) return;
|
||||
e.preventDefault();
|
||||
onMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
// A group (never "Ungrouped") is dragged by its header to reorder.
|
||||
draggable={!!group && !renaming}
|
||||
onDragStart={(e) => {
|
||||
if (!group) return;
|
||||
e.dataTransfer.setData(GROUP_DRAG_TYPE, group.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!isGroupDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDropOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDropOver(false)}
|
||||
onDrop={(e) => {
|
||||
setDropOver(false);
|
||||
if (!isGroupDrag(e)) return;
|
||||
e.preventDefault();
|
||||
const id = e.dataTransfer.getData(GROUP_DRAG_TYPE);
|
||||
if (id) onGroupDrop(id);
|
||||
}}
|
||||
className={`group/gh flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-semibold text-fg-muted ${first ? "" : "mt-1.5"} ${dropOver ? "shadow-[inset_0_2px_0_rgb(var(--accent))]" : ""}`}
|
||||
>
|
||||
{renaming && group ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
maxLength={60}
|
||||
aria-label={t("tree.groupNameLabel")}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onBlur={() => onRenameEnd(draft)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onRenameEnd(draft);
|
||||
else if (e.key === "Escape") onRenameEnd(null);
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-1 py-0.5 text-[11px] font-normal text-fg-primary"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={group ? !folded : undefined}
|
||||
disabled={!group}
|
||||
title={group ? t("tree.groupToggle", { name: label }) : undefined}
|
||||
onClick={onToggle}
|
||||
className="flex min-w-0 flex-1 items-center gap-1 text-left hover:text-fg-primary disabled:hover:text-fg-muted"
|
||||
>
|
||||
{group ? (
|
||||
folded ? (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={`truncate uppercase tracking-wide ${folded && holdsSelected ? "text-accent" : ""}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="ml-1 shrink-0 font-normal tabular-nums">{count}</span>
|
||||
{folded && worst && (
|
||||
<span
|
||||
data-testid={`workspace-group-state-${group?.id ?? "none"}`}
|
||||
className="ml-1 flex shrink-0 items-center"
|
||||
>
|
||||
<span aria-hidden className={`h-1.5 w-1.5 rounded-full ${workspaceDot(worst)}`} />
|
||||
<span className="sr-only">{t(`row.state.${workspaceDotLabel(worst)}`)}</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{group && !renaming && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("tree.groupMenu")}
|
||||
title={t("tree.groupMenu")}
|
||||
onClick={(e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
onMenu(r.left, r.bottom);
|
||||
}}
|
||||
className="rounded p-0.5 opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover/gh:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where a dragged workspace would land: a line on its parent's top edge. */
|
||||
function DropLine() {
|
||||
return (
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AgentCard } from "../AgentCard";
|
||||
import type { Agent } from "../../lib/types";
|
||||
import { formatModelName, fmtCost } from "../../lib/format";
|
||||
|
||||
function renderCard(element: JSX.Element) {
|
||||
function renderCard(element: React.JSX.Element) {
|
||||
return render(<MemoryRouter>{element}</MemoryRouter>);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,28 +6,38 @@
|
||||
* `useWorkLanes` mirror the server's roles: an admin changes everything, an
|
||||
* analyst reads everything but works only in their own workspaces, a
|
||||
* developer only ever sees (and so changes) their own. The login page wears the
|
||||
* OS's light/dark; a sign-in swaps in the account's saved appearance.
|
||||
* OS's light/dark; a sign-in swaps in the account's saved appearance. A
|
||||
* password an admin gave shows only the "choose your own password" page,
|
||||
* whose button waits for every rule, until the change succeeds.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../i18n";
|
||||
|
||||
const me = vi.fn();
|
||||
const login = vi.fn();
|
||||
const prefs = vi.fn();
|
||||
const changePassword = vi.fn();
|
||||
const LANES = [
|
||||
{ id: 1, owner_id: 5 },
|
||||
{ id: 2, owner_id: 6 },
|
||||
];
|
||||
vi.mock("../../lib/api", () => ({
|
||||
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
|
||||
TOKEN_REQUIRED_EVENT: "agenthub:token-required",
|
||||
dashboardToken: () => localStorage.getItem("dashboard_token"),
|
||||
saveDashboardToken: (t: string) => localStorage.setItem("dashboard_token", t),
|
||||
api: {
|
||||
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
|
||||
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
|
||||
me: { prefs: () => prefs(), savePrefs: () => Promise.resolve({ prefs: {} }) },
|
||||
me: {
|
||||
prefs: () => prefs(),
|
||||
savePrefs: () => Promise.resolve({ prefs: {} }),
|
||||
changePassword: (c: string, n: string) => changePassword(c, n),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -56,10 +66,30 @@ beforeEach(() => {
|
||||
me.mockReset();
|
||||
login.mockReset();
|
||||
prefs.mockReset().mockResolvedValue({ prefs: {} });
|
||||
changePassword.mockReset().mockResolvedValue({ ok: true });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("AuthGate", () => {
|
||||
it("makes an account with an admin-given password choose its own first", async () => {
|
||||
const ue = userEvent.setup();
|
||||
me.mockResolvedValue({ mode: "server", user: { ...user, mustChangePassword: true } });
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
const save = await screen.findByRole("button", { name: "Save and continue" });
|
||||
expect(screen.queryByText("app")).toBeNull();
|
||||
await ue.type(screen.getByLabelText("Current password"), "123");
|
||||
await ue.type(screen.getByLabelText("New password"), "weakpass");
|
||||
await ue.type(screen.getByLabelText("Confirm new password"), "weakpass");
|
||||
expect(save).toBeDisabled();
|
||||
await ue.clear(screen.getByLabelText("New password"));
|
||||
await ue.clear(screen.getByLabelText("Confirm new password"));
|
||||
await ue.type(screen.getByLabelText("New password"), "Green-Tea-42");
|
||||
await ue.type(screen.getByLabelText("Confirm new password"), "Green-Tea-42");
|
||||
await ue.click(save);
|
||||
expect(changePassword).toHaveBeenCalledWith("123", "Green-Tea-42");
|
||||
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the app in local mode", async () => {
|
||||
me.mockResolvedValue({ mode: "local" });
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
@@ -90,12 +120,42 @@ describe("AuthGate", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the app when an older server or a dashboard token answers instead", async () => {
|
||||
for (const err of [
|
||||
{ status: 404 },
|
||||
{ status: 200, code: "EBADRESPONSE" },
|
||||
{ status: 401, code: "EUNAUTHORIZED" },
|
||||
]) {
|
||||
it("asks for the dashboard token when DASHBOARD_TOKEN refuses this browser", async () => {
|
||||
me.mockRejectedValue(Object.assign(new Error("x"), { status: 401, code: "EUNAUTHORIZED" }));
|
||||
const reload = vi.fn();
|
||||
const loc = window.location;
|
||||
// @ts-expect-error test override
|
||||
delete window.location;
|
||||
// @ts-expect-error test override
|
||||
window.location = { ...loc, reload };
|
||||
try {
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
const input = await screen.findByLabelText("Token");
|
||||
expect(screen.queryByText("app")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/token was not accepted/)).not.toBeInTheDocument();
|
||||
fireEvent.change(input, { target: { value: " s3cret " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
expect(localStorage.getItem("dashboard_token")).toBe("s3cret");
|
||||
expect(reload).toHaveBeenCalled();
|
||||
} finally {
|
||||
// @ts-expect-error test override
|
||||
window.location = loc;
|
||||
}
|
||||
});
|
||||
|
||||
it("says a saved token was refused, and asks on any request's 401 too", async () => {
|
||||
localStorage.setItem("dashboard_token", "old");
|
||||
me.mockResolvedValue({ mode: "local" });
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("agenthub:token-required"));
|
||||
});
|
||||
expect(await screen.findByText(/token was not accepted/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the app when an older server answers instead", async () => {
|
||||
for (const err of [{ status: 404 }, { status: 200, code: "EBADRESPONSE" }]) {
|
||||
me.mockReset();
|
||||
me.mockRejectedValue(Object.assign(new Error("x"), err));
|
||||
const { unmount } = render(<AuthGate>app</AuthGate>);
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* @file Tests for the background jobs store (`lib/cloneJobs.ts`,
|
||||
* `lib/uploadJobs.ts`) and its dock: a clone started in the background is
|
||||
* announced once when the store sees it finish (toast + window event); a
|
||||
* fresh mount (F5) shows what the server kept — an interrupted clone with
|
||||
* Retry, a login failure with an inline login form that retries with it —
|
||||
* without toasting it again; × dismisses; Stop (click twice) cancels a
|
||||
* running clone, which then reads as stopped and can be retried; an older
|
||||
* server that cannot stop says so. An upload shows with Stop, and one a
|
||||
* reload cut short is shown as interrupted until dismissed. Several chips
|
||||
* collapse to one pill.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import "../../i18n";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: {
|
||||
clone: vi.fn(),
|
||||
cloneFolder: vi.fn(),
|
||||
cloneJobs: vi.fn(),
|
||||
retryCloneJob: vi.fn(),
|
||||
dismissCloneJob: vi.fn(),
|
||||
cancelCloneJob: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../lib/toast", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
import { api, type CloneJob } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import {
|
||||
CLONE_JOB_EVENT,
|
||||
refreshCloneJobs,
|
||||
resetCloneJobsForTests,
|
||||
startClone,
|
||||
} from "../../lib/cloneJobs";
|
||||
import { beginUpload, reloadUploadsForTests } from "../../lib/uploadJobs";
|
||||
import { BackgroundJobsDock } from "../BackgroundJobsDock";
|
||||
|
||||
const base: CloneJob = {
|
||||
id: "j1",
|
||||
kind: "workspace",
|
||||
state: "running",
|
||||
url: "https://git.example/o/repo.git",
|
||||
name: "repo",
|
||||
parent: "/home/an",
|
||||
target: "/home/an/repo",
|
||||
laneId: null,
|
||||
position: null,
|
||||
phase: "receiving",
|
||||
label: null,
|
||||
percent: 40,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "https://git.example/o/repo.git", parent: "/home/an", name: "repo" },
|
||||
};
|
||||
const job = (over: Partial<CloneJob> = {}): CloneJob => ({ ...base, ...over });
|
||||
|
||||
const renderDock = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<BackgroundJobsDock />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetCloneJobsForTests();
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
reloadUploadsForTests();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [] });
|
||||
vi.mocked(api.lanes.dismissCloneJob).mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
describe("cloneJobs store", () => {
|
||||
it("announces a background clone once, when it sees it finish", async () => {
|
||||
const onDone = vi.fn();
|
||||
const events: CloneJob[] = [];
|
||||
const listen = (e: Event) => events.push((e as CustomEvent<CloneJob>).detail);
|
||||
window.addEventListener(CLONE_JOB_EVENT, listen);
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({ job: job({ state: "queued", position: 1 }) });
|
||||
await startClone(
|
||||
{ kind: "workspace", body: { url: base.url, parent: "/home/an" } },
|
||||
{ onDone }
|
||||
);
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }));
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
|
||||
const done = job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } });
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [done] });
|
||||
await refreshCloneJobs();
|
||||
await refreshCloneJobs();
|
||||
expect(toast.success).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(toast.success).mock.calls[0]![0]).toMatch(/Cloned repo into \/home\/an\/repo/);
|
||||
expect(events).toEqual([done]);
|
||||
expect(onDone).toHaveBeenCalledWith(done, undefined);
|
||||
window.removeEventListener(CLONE_JOB_EVENT, listen);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackgroundJobsDock", () => {
|
||||
it("shows a running clone's progress after a fresh mount, without a toast", async () => {
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("clone-job");
|
||||
expect(chip).toHaveTextContent("Cloning repo");
|
||||
expect(within(chip).getByRole("progressbar")).toHaveAttribute("aria-valuenow", "40");
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an interrupted clone after F5 and retries it", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [
|
||||
job({
|
||||
state: "interrupted",
|
||||
error: { code: "EINTERRUPTED", message: "server restarted" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({
|
||||
job: job({ id: "j2", state: "queued", position: 1 }),
|
||||
});
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("clone-job");
|
||||
expect(chip).toHaveTextContent("AgentHub restarted while this clone was running.");
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
await user.click(within(chip).getByRole("button", { name: "Retry" }));
|
||||
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("clone-job")).toHaveTextContent("Waiting in the clone queue — #1")
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and retries with it", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [
|
||||
job({
|
||||
state: "failed",
|
||||
error: {
|
||||
code: "ECREDENTIALS",
|
||||
message: "login",
|
||||
auth: { protocol: "https", host: "git.example" },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
|
||||
renderDock();
|
||||
const form = await screen.findByTestId("clone-job-login");
|
||||
expect(screen.getByTestId("clone-job")).toHaveTextContent("git.example needs a login");
|
||||
await user.type(within(form).getByLabelText("Username"), "ann");
|
||||
await user.type(within(form).getByLabelText("Password or access token"), "tok");
|
||||
await user.click(within(form).getByRole("button", { name: "Retry with this login" }));
|
||||
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", {
|
||||
username: "ann",
|
||||
password: "tok",
|
||||
remember: true,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
|
||||
);
|
||||
});
|
||||
|
||||
it("dismisses a finished clone", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } })],
|
||||
});
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("clone-job");
|
||||
expect(within(chip).getByRole("button", { name: "Open workspace" })).toBeInTheDocument();
|
||||
await user.click(within(chip).getByTestId("clone-job-dismiss"));
|
||||
expect(api.lanes.dismissCloneJob).toHaveBeenCalledWith("j1");
|
||||
expect(screen.queryByTestId("clone-job")).toBeNull();
|
||||
});
|
||||
|
||||
it("stops a running clone on a second click, then lets it be retried", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
|
||||
vi.mocked(api.lanes.cancelCloneJob).mockResolvedValue({
|
||||
job: job({ state: "cancelled", error: { code: "ECANCELLED", message: "cancelled" } }),
|
||||
});
|
||||
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
|
||||
renderDock();
|
||||
const stop = await screen.findByTestId("clone-job-stop");
|
||||
await user.click(stop);
|
||||
expect(api.lanes.cancelCloneJob).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("clone-job")).toHaveTextContent("Click again to stop");
|
||||
await user.click(screen.getByTestId("clone-job-stop"));
|
||||
expect(api.lanes.cancelCloneJob).toHaveBeenCalledWith("j1");
|
||||
const chip = await screen.findByText("You stopped this clone.");
|
||||
expect(chip).toBeInTheDocument();
|
||||
// The chip says it; no toast repeats it on top of the chip.
|
||||
expect(toast.info).not.toHaveBeenCalled();
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
|
||||
);
|
||||
});
|
||||
|
||||
it("says so when the server cannot stop a clone yet", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
|
||||
vi.mocked(api.lanes.cancelCloneJob).mockRejectedValue(
|
||||
Object.assign(new Error("HTTP 404"), { status: 404 })
|
||||
);
|
||||
renderDock();
|
||||
await user.click(await screen.findByTestId("clone-job-stop"));
|
||||
await user.click(screen.getByTestId("clone-job-stop"));
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/cannot stop a clone yet/))
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an upload with Stop, which aborts it", async () => {
|
||||
renderDock();
|
||||
let up!: ReturnType<typeof beginUpload>;
|
||||
act(() => {
|
||||
up = beginUpload({ folder: "one/src", total: 3, bytes: 300 });
|
||||
up.update({ name: "a.txt", done: 1, sent: 150 });
|
||||
});
|
||||
const chip = await screen.findByTestId("fx-progress");
|
||||
expect(chip).toHaveTextContent("Uploading 2/3");
|
||||
expect(chip).toHaveTextContent("one/src · a.txt");
|
||||
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
|
||||
expect(up.signal.aborted).toBe(false);
|
||||
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
|
||||
expect(up.signal.aborted).toBe(true);
|
||||
act(() => up.end());
|
||||
expect(screen.queryByTestId("fx-progress")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an upload a reload cut short until it is dismissed", async () => {
|
||||
localStorage.setItem(
|
||||
"agenthub.uploads.inflight",
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "up-old",
|
||||
folder: "one/src",
|
||||
name: "b.txt",
|
||||
done: 2,
|
||||
total: 5,
|
||||
sent: 10,
|
||||
bytes: 50,
|
||||
state: "running",
|
||||
beat: Date.now() - 60_000,
|
||||
},
|
||||
])
|
||||
);
|
||||
act(() => reloadUploadsForTests());
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("upload-job");
|
||||
expect(chip).toHaveTextContent(
|
||||
"The upload of 5 file(s) to one/src was interrupted by a page reload — 2 sent."
|
||||
);
|
||||
fireEvent.click(within(chip).getByRole("button", { name: "Dismiss" }));
|
||||
expect(screen.queryByTestId("upload-job")).toBeNull();
|
||||
expect(localStorage.getItem("agenthub.uploads.inflight")).toBeNull();
|
||||
});
|
||||
|
||||
it("collapses several chips to one pill", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [job(), job({ id: "j2", name: "other" })],
|
||||
});
|
||||
renderDock();
|
||||
await waitFor(() => expect(screen.getAllByTestId("clone-job")).toHaveLength(2));
|
||||
await user.click(screen.getByTestId("jobs-dock-collapse"));
|
||||
expect(screen.queryByTestId("clone-job")).toBeNull();
|
||||
expect(screen.getByTestId("jobs-dock-pill")).toHaveTextContent("2 background tasks");
|
||||
await user.click(screen.getByTestId("jobs-dock-pill"));
|
||||
expect(screen.getAllByTestId("clone-job")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("wraps a long SSH error, links to Settings in-app and lifts toasts above itself", async () => {
|
||||
const long = "mdpartners@mdpartners.git.backlog.jp:/PETITGIFT_CONSOLE4/console4-specs.git";
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [
|
||||
job({
|
||||
state: "failed",
|
||||
url: long,
|
||||
error: { code: "ESSHAUTH", message: "the SSH login was refused" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
renderDock();
|
||||
const alert = await screen.findByRole("alert");
|
||||
// A URL with no space to break at must still wrap inside the chip.
|
||||
expect(alert.className).toContain("[overflow-wrap:anywhere]");
|
||||
// A router link, not a full page reload.
|
||||
const link = screen.getByRole("link", { name: /Settings/ });
|
||||
expect(link).toHaveAttribute("href", "/settings/git#git-identity");
|
||||
await waitFor(() =>
|
||||
expect(document.documentElement.style.getPropertyValue("--jobs-dock-h")).not.toBe("")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -34,5 +34,13 @@ describe("DocumentTitle", () => {
|
||||
expect(titleFor("/")).toMatch(/^Dashboard · /);
|
||||
expect(titleFor("/sessions")).toMatch(/^Sessions · /);
|
||||
expect(titleFor("/settings")).toMatch(/^Settings · /);
|
||||
expect(titleFor("/artifacts")).toMatch(/^Artifacts · /);
|
||||
expect(titleFor("/files")).toMatch(/^Files · /);
|
||||
expect(titleFor("/admin")).toMatch(/^Administration · /);
|
||||
expect(titleFor("/snapshot/abc")).toMatch(/^Snapshot · /);
|
||||
});
|
||||
|
||||
it("titles an unknown route as not found", () => {
|
||||
expect(titleFor("/nope")).toMatch(/^Not found · /);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,14 +58,25 @@ describe("MarkdownPreview", () => {
|
||||
describe("FileViewer — markdown preview", () => {
|
||||
const data = { text: "# Hello\n", truncated: false } as never;
|
||||
|
||||
it("switches a markdown file between raw and preview", async () => {
|
||||
it("opens a markdown file rendered, and switches it to raw and back", async () => {
|
||||
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
|
||||
expect(screen.queryByTestId("markdown-preview")).toBeNull();
|
||||
expect(screen.getByText("# Hello")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Preview" }));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
|
||||
expect(screen.queryByTestId("markdown-preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens it as source when Settings → Appearance says Raw", () => {
|
||||
window.localStorage.setItem("agenthub.markdown.view", "raw");
|
||||
try {
|
||||
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId("markdown-preview")).toBeNull();
|
||||
expect(screen.getByText("# Hello")).toBeInTheDocument();
|
||||
} finally {
|
||||
window.localStorage.removeItem("agenthub.markdown.view");
|
||||
}
|
||||
});
|
||||
|
||||
it("offers no preview for another file type", () => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @file Tests for PdfView: pdf.js (mocked — jsdom has no canvas) draws one
|
||||
* canvas per page, scripting off, and a PDF that cannot be read says so.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import "../../i18n";
|
||||
|
||||
const getDocument = vi.fn();
|
||||
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({
|
||||
GlobalWorkerOptions: { workerSrc: "" },
|
||||
getDocument: (...a: unknown[]) => getDocument(...a),
|
||||
}));
|
||||
|
||||
import { PdfView } from "../PdfView";
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
beforeEach(() => {
|
||||
getDocument.mockReset();
|
||||
globalThis.fetch = vi.fn(
|
||||
async () => new Response(new Uint8Array([37, 80, 68, 70]))
|
||||
) as typeof fetch;
|
||||
});
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
describe("PdfView", () => {
|
||||
it("draws every page on its own canvas, with XFA off", async () => {
|
||||
const render1 = vi.fn(() => ({ promise: Promise.resolve() }));
|
||||
getDocument.mockReturnValue({
|
||||
destroy: vi.fn(),
|
||||
promise: Promise.resolve({
|
||||
numPages: 3,
|
||||
getPage: async () => ({
|
||||
getViewport: ({ scale }: { scale: number }) => ({
|
||||
width: 600 * scale,
|
||||
height: 800 * scale,
|
||||
}),
|
||||
render: render1,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const { container } = render(<PdfView url="/api/artifacts/1/raw?path=a.pdf" title="a.pdf" />);
|
||||
await waitFor(() => expect(container.querySelectorAll("canvas")).toHaveLength(3));
|
||||
expect(render1).toHaveBeenCalledTimes(3);
|
||||
expect(getDocument.mock.calls[0]![0]).toMatchObject({ enableXfa: false });
|
||||
expect(screen.getByLabelText("Page 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so when the file cannot be read", async () => {
|
||||
getDocument.mockReturnValue({ destroy: vi.fn(), promise: Promise.reject(new Error("bad")) });
|
||||
render(<PdfView url="/x" title="x.pdf" />);
|
||||
expect(await screen.findByText(/could not be shown/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -105,7 +105,61 @@ describe("Select (listbox button)", () => {
|
||||
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380));
|
||||
vi.stubGlobal("innerHeight", 2000);
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByRole("listbox").className).toContain("bottom-full");
|
||||
expect(screen.getByRole("listbox").parentElement!.className).toContain("bottom-full");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("turns a long list into a combobox: type to narrow, Enter picks, groups keep headings", async () => {
|
||||
const onChange = vi.fn();
|
||||
const options = [
|
||||
{ value: "", label: "Switch branch…" },
|
||||
...["develop", "main", "feat/campaign-pause", "fix/campaign-step", "chore/a", "chore/b"].map(
|
||||
(v) => ({ value: v, label: v })
|
||||
),
|
||||
...["origin/feat/campaign-statistic", "origin/x", "origin/y"].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
group: "On the remote",
|
||||
})),
|
||||
];
|
||||
render(<Select value="" onChange={onChange} options={options} label="Switch branch…" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Switch branch…" }));
|
||||
const search = screen.getByRole("combobox", { name: "Search…" });
|
||||
expect(search).toHaveFocus();
|
||||
expect(screen.getByText("On the remote")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(search, { target: { value: "CAMPAIGN" } });
|
||||
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
|
||||
"feat/campaign-pause",
|
||||
"fix/campaign-step",
|
||||
"origin/feat/campaign-statistic",
|
||||
]);
|
||||
fireEvent.keyDown(search, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(search, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith("fix/campaign-step");
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Switch branch…" }));
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: "zzz" } });
|
||||
expect(screen.getByText("No match")).toBeInTheDocument();
|
||||
fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" });
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a short list a plain listbox (no search box)", () => {
|
||||
render(
|
||||
<Select
|
||||
value="a"
|
||||
onChange={vi.fn()}
|
||||
options={[
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
]}
|
||||
label="Pick"
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pick" }));
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
expect(screen.getByRole("listbox")).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +129,20 @@ vi.mock("../../lib/api", () => ({
|
||||
daily_costs: [],
|
||||
})),
|
||||
},
|
||||
me: {
|
||||
// Server mode: this user's own spend quota, used up.
|
||||
quota: vi.fn().mockResolvedValue({
|
||||
quota: {
|
||||
limit_usd: 10,
|
||||
period: "day",
|
||||
spend: 12,
|
||||
pct: 120,
|
||||
exceeded: true,
|
||||
resetsAt: "2026-10-29T17:00:00.000Z",
|
||||
since: "2026-10-28T17:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
},
|
||||
budgets: {
|
||||
status: vi.fn().mockResolvedValue({
|
||||
budgets: [
|
||||
@@ -148,10 +162,10 @@ vi.mock("../../lib/api", () => ({
|
||||
}));
|
||||
|
||||
import { api } from "../../lib/api";
|
||||
import { StatusBar } from "../StatusBar";
|
||||
import { StatusBar, withoutHidden } from "../StatusBar";
|
||||
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
||||
|
||||
const at = (url: string, ui: JSX.Element) =>
|
||||
const at = (url: string, ui: React.JSX.Element) =>
|
||||
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
|
||||
|
||||
describe("StatusBar", () => {
|
||||
@@ -162,13 +176,16 @@ describe("StatusBar", () => {
|
||||
const conn = screen.getByRole("button", { name: /Live/ });
|
||||
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
|
||||
const cost = await screen.findByText("Today $1.23 (12.3M Tokens)");
|
||||
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today() });
|
||||
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today(), mine: true });
|
||||
expect(cost).toHaveAttribute(
|
||||
"title",
|
||||
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
|
||||
);
|
||||
await screen.findByText("1 need you");
|
||||
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
|
||||
// The user's OWN sessions, even for an admin who sees every user's rows.
|
||||
for (const [p] of vi.mocked(api.sessions.list).mock.calls)
|
||||
expect(p).toMatchObject({ mine: true });
|
||||
await user.click(conn);
|
||||
expect(onConnectionClick).toHaveBeenCalled();
|
||||
// Polite live region: the connection and who is waiting, nothing that ticks.
|
||||
@@ -183,6 +200,16 @@ describe("StatusBar", () => {
|
||||
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
|
||||
});
|
||||
|
||||
it("shows the user's own spend quota, red once used up", async () => {
|
||||
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
|
||||
const chip = await screen.findByTestId("status-bar-user-quota");
|
||||
expect(chip.textContent).toBe("Quota 120%");
|
||||
expect(chip.className).toContain("text-status-danger");
|
||||
expect(chip.getAttribute("title")).toMatch(
|
||||
/^Your spend quota is used up \(\$12\.00 of \$10\.00 today\)/
|
||||
);
|
||||
});
|
||||
|
||||
it("says Disconnected when the socket is down", () => {
|
||||
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
||||
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
|
||||
@@ -238,7 +265,7 @@ describe("StatusBar", () => {
|
||||
expect(work).toHaveTextContent("5h95%Resets in 41m");
|
||||
expect(work).toHaveTextContent("Week32%");
|
||||
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
|
||||
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
|
||||
expect(personal).toHaveTextContent("Check quota in Settings → AI tools → Profiles");
|
||||
expect(personal).not.toHaveTextContent("in use");
|
||||
expect(codex).toHaveTextContent("dev@example.com");
|
||||
expect(codex).toHaveTextContent("Sign-in expired");
|
||||
@@ -280,3 +307,35 @@ describe("StatusBar", () => {
|
||||
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withoutHidden", () => {
|
||||
const row = (accountUuid: string, active: boolean, pct: number) => ({
|
||||
id: "claude" as const,
|
||||
active,
|
||||
accountUuid,
|
||||
account: `${accountUuid}@x.y`,
|
||||
windows: [{ label: "5h", usedPercent: pct, resetsAt: null }],
|
||||
});
|
||||
const codex = { id: "codex" as const, active: true, windows: [] };
|
||||
|
||||
it("leaves the list alone when nothing is hidden", () => {
|
||||
const list = [row("u1", true, 82), row("u2", false, 19), codex];
|
||||
expect(withoutHidden(list, [])).toBe(list);
|
||||
});
|
||||
|
||||
it("drops a hidden saved profile", () => {
|
||||
const out = withoutHidden([row("u1", true, 82), row("u2", false, 19), codex], ["u2"]);
|
||||
expect(out.map((q) => q.accountUuid ?? q.id)).toEqual(["u1", "codex"]);
|
||||
});
|
||||
|
||||
it("puts the first visible profile, with its own usage, in place of a hidden live login", () => {
|
||||
const out = withoutHidden(
|
||||
[row("u1", true, 82), row("u2", false, 19), row("u3", false, 0), codex],
|
||||
["u1"]
|
||||
);
|
||||
expect(out.map((q) => q.accountUuid ?? q.id)).toEqual(["u2", "u3", "codex"]);
|
||||
expect(out[0]?.active).toBe(true);
|
||||
expect(out[0]?.windows[0]?.usedPercent).toBe(19);
|
||||
expect(out[1]?.active).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -298,12 +298,102 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
||||
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores Escape while an update is applying", async () => {
|
||||
applyMock.mockReturnValue(new Promise(() => {}));
|
||||
it("closes while an update applies, and re-opens if it then fails", async () => {
|
||||
let fail: (e: Error) => void = () => {};
|
||||
applyMock.mockReturnValue(new Promise((_, reject) => (fail = reject)));
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||
);
|
||||
fail(new Error("Command failed: npm run build"));
|
||||
expect(await screen.findByText("Command failed: npm run build")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateNotifier — hidden while it runs", () => {
|
||||
it("keeps a chip with the chain's step in sight and re-opens from it", async () => {
|
||||
const PLUGIN: UpdateStatusPayload = {
|
||||
...BASE_STATUS,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
commits_behind: 0,
|
||||
local_version: "1.5.0",
|
||||
release_check: "ok",
|
||||
latest_release: "v1.6.0",
|
||||
};
|
||||
statusMock.mockResolvedValue(PLUGIN);
|
||||
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
|
||||
statusMock.mockResolvedValue({
|
||||
...PLUGIN,
|
||||
plugin_update_progress: {
|
||||
step: "plugin_update",
|
||||
index: 2,
|
||||
total: 3,
|
||||
started_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
const step = i18n.t("updates:pluginStep.plugin_update", { index: 2, total: 3 });
|
||||
expect(await screen.findByTestId("update-step", {}, { timeout: 4000 })).toHaveTextContent(step);
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
const chip = await screen.findByTestId("update-chip");
|
||||
expect(chip).toHaveTextContent(i18n.t("updates:chipTitle", { version: "v1.6.0" }));
|
||||
expect(chip).toHaveTextContent(step);
|
||||
await userEvent.click(chip);
|
||||
expect(
|
||||
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }))
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("update-chip")).toBeNull();
|
||||
});
|
||||
|
||||
it("adopts a chain the server reports as running, and keeps it once the update is no longer offered", async () => {
|
||||
const running: UpdateStatusPayload = {
|
||||
...BASE_STATUS,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
commits_behind: 0,
|
||||
local_version: "1.5.0",
|
||||
release_check: "ok",
|
||||
latest_release: "v1.6.0",
|
||||
// The new version is already installed: the status no longer offers it.
|
||||
update_available: false,
|
||||
plugin_update_progress: {
|
||||
step: "bootstrap",
|
||||
index: 3,
|
||||
total: 3,
|
||||
started_at: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
statusMock.mockResolvedValue(running);
|
||||
render(<UpdateNotifier />);
|
||||
const step = i18n.t("updates:pluginStep.bootstrap", { index: 3, total: 3 });
|
||||
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
|
||||
expect(screen.queryByText(i18n.t("updates:updateNow"))).toBeNull();
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
const chip = await screen.findByTestId("update-chip");
|
||||
expect(chip).toHaveTextContent(step);
|
||||
await userEvent.click(chip);
|
||||
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
|
||||
});
|
||||
|
||||
it("shows no chip when nothing is running", async () => {
|
||||
render(<UpdateNotifier />);
|
||||
await screen.findByText(i18n.t("updates:title"));
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||
);
|
||||
expect(screen.queryByTestId("update-chip")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
* @description Tests for the sidebar's workspace list: one flat row per
|
||||
* workspace in the stored order, a row expanding to its sessions (remembered),
|
||||
* a live session opening its pane and an ended one its transcript, the
|
||||
* 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
|
||||
* (a live one's pane stopped first),
|
||||
* selection
|
||||
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
||||
* search, the row menu's items by kind and tool, and the status dot.
|
||||
* search, the row menu's items by kind and tool, the status dot, and the
|
||||
* user's own groups (menu, Alt+arrow past a group's edge, a folded group's
|
||||
* state dot, dragging a header to reorder, fold all).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -91,6 +94,20 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// The Add modal reports the workspace it made (id 2 here); the tree does the rest.
|
||||
vi.mock("../lanes/AddLaneModal", () => ({
|
||||
AddLaneModal: ({ onAdded, onClose }: { onAdded: (l: Lane) => void; onClose: () => void }) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="stub-add-lane"
|
||||
onClick={() => {
|
||||
onAdded(lane(2, "/r/sub"));
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
import { api } from "../../lib/api";
|
||||
import { WorkspaceTree } from "../WorkspaceTree";
|
||||
import { workspaceDot } from "../lanes/WorkspaceRow";
|
||||
@@ -209,6 +226,8 @@ describe("WorkspaceTree", () => {
|
||||
await user.type(input, "Better name{Enter}");
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" });
|
||||
await user.click(screen.getByTestId("workspace-session-archive-s-live"));
|
||||
// A live session's Archive stops its pane first — never a hidden pane left running.
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||
});
|
||||
|
||||
@@ -576,6 +595,185 @@ describe("WorkspaceTree", () => {
|
||||
menu = screen.getByTestId("workspace-row-menu-3");
|
||||
expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("groups workspaces under named, foldable headers, ungrouped last", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-row-1");
|
||||
// No group yet: no headers at all.
|
||||
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
|
||||
|
||||
// A new group from the row menu holds that workspace and asks for a name.
|
||||
await user.click(screen.getByTestId("workspace-menu-3"));
|
||||
await user.click(screen.getByTestId("workspace-move-group-new"));
|
||||
const name = await screen.findByRole("textbox", { name: "Group name" });
|
||||
await user.clear(name);
|
||||
await user.type(name, "Backend{Enter}");
|
||||
const stored = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
|
||||
expect(stored.groups).toEqual([expect.objectContaining({ name: "Backend" })]);
|
||||
const gid = stored.groups[0].id as string;
|
||||
expect(stored.of).toEqual({ 3: gid });
|
||||
await waitFor(() =>
|
||||
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
|
||||
);
|
||||
expect(screen.getByTestId(`workspace-group-${gid}`)).toHaveTextContent("Backend1");
|
||||
expect(screen.getByTestId("workspace-group-none")).toHaveTextContent("Ungrouped2");
|
||||
|
||||
// Another workspace joins it from its menu.
|
||||
await user.click(screen.getByTestId("workspace-menu-2"));
|
||||
await user.click(screen.getByTestId(`workspace-move-group-${gid}`));
|
||||
await waitFor(() =>
|
||||
expect(rowIds()).toEqual(["workspace-row-2", "workspace-row-3", "workspace-row-1"])
|
||||
);
|
||||
|
||||
// Folding hides its rows; the ungrouped one stays.
|
||||
await user.click(
|
||||
within(screen.getByTestId(`workspace-group-${gid}`)).getByRole("button", {
|
||||
expanded: true,
|
||||
})
|
||||
);
|
||||
expect(rowIds()).toEqual(["workspace-row-1"]);
|
||||
|
||||
// Deleting the group only ungroups: every workspace is still listed.
|
||||
fireEvent.contextMenu(screen.getByTestId(`workspace-group-${gid}`));
|
||||
await user.click(screen.getByTestId("workspace-group-delete"));
|
||||
await waitFor(() => expect(rowIds()).toHaveLength(3));
|
||||
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
|
||||
expect(api.lanes.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a new, empty group to be named, above the ungrouped workspaces", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-row-1");
|
||||
await user.click(screen.getByTestId("workspace-tree-new-group"));
|
||||
const name = await screen.findByRole("textbox", { name: "Group name" });
|
||||
await user.clear(name);
|
||||
await user.type(name, "Later{Enter}");
|
||||
const gid = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!).groups[0].id;
|
||||
const header = screen.getByTestId(`workspace-group-${gid}`);
|
||||
expect(header).toHaveTextContent("Later0");
|
||||
expect(
|
||||
header.compareDocumentPosition(screen.getByTestId("workspace-group-none")) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
expect(rowIds()).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceTree groups", () => {
|
||||
// Groups A = [1], B = [3]; 2 ungrouped. Stored order 1, 2, 3.
|
||||
const seed = (extra: { folded?: boolean } = {}) =>
|
||||
localStorage.setItem(
|
||||
"agenthub.workspace.groups",
|
||||
JSON.stringify({
|
||||
groups: [
|
||||
{ id: "ga", name: "Alpha", ...extra },
|
||||
{ id: "gb", name: "Beta" },
|
||||
],
|
||||
of: { 1: "ga", 3: "gb" },
|
||||
})
|
||||
);
|
||||
const stored = () => JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
|
||||
|
||||
it("moves a workspace into the next group with Alt+arrow past its edge", async () => {
|
||||
seed();
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await waitFor(() =>
|
||||
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
|
||||
);
|
||||
// The last of Alpha, Alt+↓: the first of Beta.
|
||||
screen.getByTestId("workspace-open-1").focus();
|
||||
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
|
||||
expect(stored().of).toEqual({ 1: "gb", 3: "gb" });
|
||||
await waitFor(() =>
|
||||
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
|
||||
);
|
||||
expect(screen.getByTestId("workspace-group-gb")).toHaveTextContent("Beta2");
|
||||
// The last of Beta, Alt+↓: out into the ungrouped ones.
|
||||
screen.getByTestId("workspace-open-3").focus();
|
||||
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
|
||||
expect(stored().of).toEqual({ 1: "gb" });
|
||||
expect(api.lanes.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a folded group's most urgent state and marks the open workspace", async () => {
|
||||
lanes[0] = lane(1, "/r", { title: "Repo", status: "running" });
|
||||
seed({ folded: true });
|
||||
renderTree("/workspace?lane=1");
|
||||
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-2"]));
|
||||
const state = screen.getByTestId("workspace-group-state-ga");
|
||||
expect(state).toHaveTextContent("running");
|
||||
expect(within(screen.getByTestId("workspace-group-ga")).getByText("Alpha")).toHaveClass(
|
||||
"text-accent"
|
||||
);
|
||||
// An open group shows its rows instead.
|
||||
expect(screen.queryByTestId("workspace-group-state-gb")).toBeNull();
|
||||
});
|
||||
|
||||
it("reorders groups by dragging a header onto another, last onto Ungrouped", async () => {
|
||||
seed();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-group-gb");
|
||||
const data = new Map<string, string>();
|
||||
const dataTransfer = {
|
||||
get types() {
|
||||
return [...data.keys()];
|
||||
},
|
||||
setData: (k: string, v: string) => data.set(k, v),
|
||||
getData: (k: string) => data.get(k) ?? "",
|
||||
dropEffect: "move",
|
||||
effectAllowed: "move",
|
||||
};
|
||||
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
|
||||
fireEvent.dragOver(screen.getByTestId("workspace-group-ga"), { dataTransfer });
|
||||
fireEvent.drop(screen.getByTestId("workspace-group-ga"), { dataTransfer });
|
||||
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["gb", "ga"]);
|
||||
await waitFor(() =>
|
||||
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
|
||||
);
|
||||
data.clear();
|
||||
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
|
||||
fireEvent.drop(screen.getByTestId("workspace-group-none"), { dataTransfer });
|
||||
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["ga", "gb"]);
|
||||
// A header drag never moves a workspace.
|
||||
expect(stored().of).toEqual({ 1: "ga", 3: "gb" });
|
||||
});
|
||||
|
||||
it("puts a workspace added from a group's menu into that group", async () => {
|
||||
seed();
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-group-gb");
|
||||
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
|
||||
await user.click(screen.getByTestId("workspace-group-add"));
|
||||
await user.click(await screen.findByTestId("stub-add-lane"));
|
||||
expect(stored().of).toEqual({ 1: "ga", 2: "gb", 3: "gb" });
|
||||
// The header's own Add belongs to no group: what it adds stays where it is.
|
||||
act(() =>
|
||||
localStorage.setItem(
|
||||
"agenthub.workspace.groups",
|
||||
JSON.stringify({ ...stored(), of: { 1: "ga" } })
|
||||
)
|
||||
);
|
||||
await user.click(screen.getByTestId("workspace-tree-add"));
|
||||
await user.click(await screen.findByTestId("stub-add-lane"));
|
||||
expect(stored().of).toEqual({ 1: "ga" });
|
||||
});
|
||||
|
||||
it("folds and unfolds every group from a header's menu", async () => {
|
||||
seed();
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-group-ga");
|
||||
fireEvent.contextMenu(screen.getByTestId("workspace-group-ga"));
|
||||
await user.click(screen.getByTestId("workspace-group-fold-all"));
|
||||
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-2"]));
|
||||
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
|
||||
await user.click(screen.getByTestId("workspace-group-unfold-all"));
|
||||
await waitFor(() => expect(rowIds()).toHaveLength(3));
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspaceDot", () => {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @file MiniChart.tsx
|
||||
* @description The Administration page's small single-series charts: a bar
|
||||
* chart (a user's daily spend) and a line chart (System Health's CPU and
|
||||
* memory history), plain SVG in the app's theme tokens. One series each — the
|
||||
* card's title names it, so there is no legend — thin marks, rounded bar ends
|
||||
* on the baseline, a recessive grid with a max label, and a hover tooltip
|
||||
* (crosshair for the line, per bar for the bars) whose text is the caller's.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useRef, useState, type MouseEvent } from "react";
|
||||
|
||||
export type ChartPoint = { x: string; y: number };
|
||||
|
||||
const H = 120;
|
||||
const PAD_TOP = 14;
|
||||
const PAD_BOTTOM = 18;
|
||||
|
||||
/**
|
||||
* @param props.points - In x order; `x` is the axis/tooltip label.
|
||||
* @param props.kind - `bar` or `line`.
|
||||
* @param props.format - Formats a value for the max label and the tooltip.
|
||||
* @param props.max - A fixed top (e.g. 100 for a percentage); else the data's max.
|
||||
* @param props.label - The chart's accessible name.
|
||||
*/
|
||||
export function MiniChart({
|
||||
points,
|
||||
kind,
|
||||
format,
|
||||
max,
|
||||
label,
|
||||
emptyText,
|
||||
}: {
|
||||
points: ChartPoint[];
|
||||
kind: "bar" | "line";
|
||||
format: (v: number) => string;
|
||||
max?: number;
|
||||
label: string;
|
||||
emptyText?: string;
|
||||
}) {
|
||||
const ref = useRef<SVGSVGElement>(null);
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
if (points.length === 0)
|
||||
return (
|
||||
<div className="flex h-[120px] items-center justify-center text-xs text-fg-muted">
|
||||
{emptyText ?? "—"}
|
||||
</div>
|
||||
);
|
||||
|
||||
const W = 600;
|
||||
const top = Math.max(max ?? 0, ...points.map((p) => p.y), 1e-9);
|
||||
const plotH = H - PAD_TOP - PAD_BOTTOM;
|
||||
const yOf = (v: number) => PAD_TOP + plotH - (v / top) * plotH;
|
||||
const step = W / points.length;
|
||||
const xOf = (i: number) =>
|
||||
kind === "bar" ? i * step + step / 2 : (i / Math.max(1, points.length - 1)) * W;
|
||||
|
||||
const onMove = (e: MouseEvent<SVGSVGElement>) => {
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (!r || r.width === 0) return;
|
||||
const fx = ((e.clientX - r.left) / r.width) * W;
|
||||
const i =
|
||||
kind === "bar"
|
||||
? Math.floor(fx / step)
|
||||
: Math.round((fx / W) * Math.max(1, points.length - 1));
|
||||
setHover(Math.max(0, Math.min(points.length - 1, i)));
|
||||
};
|
||||
|
||||
const h = hover != null ? points[hover] : null;
|
||||
const barW = Math.max(2, Math.min(24, step - 2));
|
||||
const line = points
|
||||
.map((p, i) => `${i ? "L" : "M"}${xOf(i).toFixed(1)},${yOf(p.y).toFixed(1)}`)
|
||||
.join("");
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg
|
||||
ref={ref}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="block h-[120px] w-full"
|
||||
role="img"
|
||||
aria-label={label}
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{[0, 0.5, 1].map((f) => (
|
||||
<line
|
||||
key={f}
|
||||
x1={0}
|
||||
x2={W}
|
||||
y1={yOf(top * f)}
|
||||
y2={yOf(top * f)}
|
||||
className="stroke-border"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeDasharray={f === 0 ? undefined : "3 3"}
|
||||
/>
|
||||
))}
|
||||
{kind === "bar" ? (
|
||||
points.map((p, i) => {
|
||||
const y = yOf(p.y);
|
||||
const bh = Math.max(0, PAD_TOP + plotH - y);
|
||||
const r = Math.min(4, barW / 2, bh);
|
||||
const x = xOf(i) - barW / 2;
|
||||
const base = PAD_TOP + plotH;
|
||||
// Rounded top only; the base sits flat on the axis.
|
||||
const d =
|
||||
bh <= 0
|
||||
? ""
|
||||
: `M${x},${base}V${y + r}Q${x},${y} ${x + r},${y}H${x + barW - r}Q${x + barW},${y} ${x + barW},${y + r}V${base}Z`;
|
||||
return (
|
||||
<path key={i} d={d} className={hover === i ? "fill-accent" : "fill-accent-solid"} />
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<path
|
||||
d={`${line}L${W},${PAD_TOP + plotH}L0,${PAD_TOP + plotH}Z`}
|
||||
className="fill-accent-solid"
|
||||
opacity={0.12}
|
||||
/>
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
className="stroke-accent-solid"
|
||||
strokeWidth={2}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{hover != null && (
|
||||
<line
|
||||
x1={xOf(hover)}
|
||||
x2={xOf(hover)}
|
||||
y1={PAD_TOP}
|
||||
y2={PAD_TOP + plotH}
|
||||
className="stroke-fg-muted"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
<span className="pointer-events-none absolute left-0 top-0 text-[10px] text-fg-muted tabular-nums">
|
||||
{format(top)}
|
||||
</span>
|
||||
<div className="pointer-events-none flex justify-between text-[10px] text-fg-muted">
|
||||
<span>{points[0]!.x}</span>
|
||||
<span>{points[points.length - 1]!.x}</span>
|
||||
</div>
|
||||
{h && hover != null && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 whitespace-nowrap rounded-md border border-border bg-surface-1 px-2 py-1 text-[11px] shadow-lg"
|
||||
style={{ left: `${Math.min(92, Math.max(8, (xOf(hover) / W) * 100))}%` }}
|
||||
role="status"
|
||||
>
|
||||
<div className="text-fg-muted">{h.x}</div>
|
||||
<div className="font-medium text-fg-primary tabular-nums">{format(h.y)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
/**
|
||||
* @file ServerPanel.tsx
|
||||
* @description The Administration page's Server tab: the machine AgentHub
|
||||
* runs on — hostname, OS, uptime, CPU and its load, memory and swap, every
|
||||
* real disk (HDD/SSD, how full), any NVIDIA GPU — and each user's live share
|
||||
* of it (`GET /api/host`, server/lib/host-stats.js): CPU % of the whole
|
||||
* machine, resident memory, process count, the busiest processes by name, and
|
||||
* the disk their home takes (`GET /api/users/storage`, as a share of the disk
|
||||
* that holds the homes). Re-read every `POLL_MS` while shown; Refresh re-reads
|
||||
* at once. Read-only — the only action here is the members' tmux card
|
||||
* (`MemberTmuxCard`, an admin's restart).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
MonitorCog,
|
||||
RefreshCw,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type HostStats,
|
||||
type HostUsageGroup,
|
||||
type ServerUserUsage,
|
||||
type UserStorage,
|
||||
} from "../../lib/api";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { StatCard } from "../StatCard";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
import { MemberTmuxCard } from "../users/MemberTmuxCard";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
|
||||
const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||
|
||||
/** A thin bar, amber past 75 %, red past 90 %. */
|
||||
function Meter({ value, label }: { value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
|
||||
return (
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
|
||||
>
|
||||
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return d > 0 ? t("server.uptimeDays", { d, h }) : t("server.uptimeHours", { h, m });
|
||||
}
|
||||
|
||||
export function ServerPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("admin");
|
||||
const [stats, setStats] = useState<HostStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
|
||||
const [open, setOpen] = useState<Set<string>>(new Set());
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.stats()
|
||||
.then((s) => {
|
||||
setStats(s);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
}, POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
// Names, pictures and home sizes: read once, they change slowly.
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setUsers(r.users))
|
||||
.catch(() => setUsers([]));
|
||||
api.users
|
||||
.storage()
|
||||
.then((r) => setStorage(r.storage))
|
||||
.catch(() => setStorage({}));
|
||||
}, []);
|
||||
|
||||
const byId = new Map((users ?? []).map((u) => [u.id, u]));
|
||||
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
|
||||
const loading = !stats && !error;
|
||||
const mem = stats?.memory;
|
||||
const toggle = (key: string) =>
|
||||
setOpen((s) => {
|
||||
const n = new Set(s);
|
||||
if (n.has(key)) n.delete(key);
|
||||
else n.add(key);
|
||||
return n;
|
||||
});
|
||||
|
||||
const groupName = (g: HostUsageGroup) => {
|
||||
if (g.kind === "host") return t("server.hostGroup");
|
||||
if (g.kind === "system") return t("server.systemGroup");
|
||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||
return u?.displayName || g.username || "";
|
||||
};
|
||||
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="server-panel">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<Server className="w-4 h-4 text-accent" />
|
||||
<span className="truncate">{stats?.hostname ?? t("server.title")}</span>
|
||||
</h2>
|
||||
{stats && (
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{[stats.os.distro ?? stats.os.platform, stats.os.release, stats.os.arch].join(" · ")}
|
||||
{" · "}
|
||||
{t("server.uptime", { v: uptimeText(t, stats.uptime) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
className="btn-ghost"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
|
||||
{t("common:refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">{error}</span>
|
||||
<button type="button" onClick={refresh} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label={t("server.cpu")}
|
||||
value={stats?.usage ? fmtPct(stats.usage.cpu) : "—"}
|
||||
icon={Cpu}
|
||||
trend={
|
||||
stats
|
||||
? t("server.cores", {
|
||||
n: stats.cpu.cores,
|
||||
load: stats.cpu.load.map((l) => l.toFixed(2)).join(" "),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
raw={stats?.cpu.model ?? undefined}
|
||||
help={t("server.cpuHelp")}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.memory")}
|
||||
value={mem ? fmtPct(pct(mem.used, mem.total)) : "—"}
|
||||
icon={MemoryStick}
|
||||
trend={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.swap")}
|
||||
value={mem && mem.swapTotal ? fmtPct(pct(mem.swapUsed, mem.swapTotal)) : "—"}
|
||||
icon={MonitorCog}
|
||||
trend={mem ? `${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}` : undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.homesDisk")}
|
||||
value={homesDisk ? fmtPct(pct(homesDisk.used, homesDisk.total)) : "—"}
|
||||
icon={HardDrive}
|
||||
trend={
|
||||
homesDisk
|
||||
? `${formatBytes(homesDisk.free)} ${t("server.free")} · ${homesDisk.mount}`
|
||||
: undefined
|
||||
}
|
||||
help={t("server.homesDiskHelp")}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<div className="px-5 pt-4 pb-2">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.usageTitle")}</h3>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("server.usageHelp", { s: (stats?.usage?.sampleMs ?? 1000) / 1000 })}
|
||||
</p>
|
||||
</div>
|
||||
{stats && !stats.usage ? (
|
||||
<p className="px-5 pb-4 text-sm text-fg-muted">{t("server.usageUnavailable")}</p>
|
||||
) : (
|
||||
<table className="w-full min-w-[760px]" data-testid="server-usage-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("server.who")}</th>
|
||||
<th className={`${th} w-[18%]`}>{t("server.cpu")}</th>
|
||||
<th className={`${th} w-[22%]`}>{t("server.memory")}</th>
|
||||
<th className={`${th} text-right`}>{t("server.processes")}</th>
|
||||
<th className={`${th} w-[18%]`}>{t("server.home")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-6 text-sm text-fg-muted">
|
||||
{t("server.sampling")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{stats?.usage?.groups.map((g) => {
|
||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||
const home = g.userId != null && storage ? storage[g.userId] : undefined;
|
||||
const isOpen = open.has(g.key);
|
||||
const name = groupName(g);
|
||||
return (
|
||||
<Fragment key={g.key}>
|
||||
<tr className="hover:bg-surface-4/40 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
{g.top.length ? (
|
||||
isOpen ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-fg-muted" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-fg-muted" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3.5" />
|
||||
)}
|
||||
{g.kind === "user" ? (
|
||||
<UserAvatar
|
||||
name={name}
|
||||
avatarAt={u?.avatarAt}
|
||||
userId={g.userId}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-surface-4">
|
||||
<Server className="w-3.5 h-3.5 text-fg-muted" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm text-fg-primary truncate">{name}</span>
|
||||
{g.kind === "user" && u?.displayName && (
|
||||
<span className="block text-[11px] text-fg-muted">{g.username}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">{fmtPct(g.cpu)}</div>
|
||||
<Meter value={g.cpu} label={`${name} ${t("server.cpu")}`} />
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(g.memory)}{" "}
|
||||
<span className="text-fg-muted">({fmtPct(g.memoryPercent)})</span>
|
||||
</div>
|
||||
<Meter value={g.memoryPercent} label={`${name} ${t("server.memory")}`} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||
{g.processes}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
{home ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(home.bytes)}
|
||||
{homesDisk && (
|
||||
<span className="text-fg-muted">
|
||||
{" "}
|
||||
({fmtPct(pct(home.bytes, homesDisk.total))})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{homesDisk && (
|
||||
<Meter
|
||||
value={pct(home.bytes, homesDisk.total)}
|
||||
label={`${name} ${t("server.home")}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-fg-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen &&
|
||||
g.top.map((p) => (
|
||||
<tr key={`${g.key}:${p.pid}`} className="bg-surface-2/40">
|
||||
<td className="pl-14 pr-5 py-1.5 text-xs text-fg-secondary font-mono truncate">
|
||||
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
||||
</td>
|
||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||
{fmtPct(p.cpu)}
|
||||
</td>
|
||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||
{formatBytes(p.memory)}
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats && stats.disks.length > 0 && (
|
||||
<div className="card overflow-x-auto">
|
||||
<h3 className="px-5 pt-4 pb-2 text-sm font-semibold text-fg-primary">
|
||||
{t("server.disks")}
|
||||
</h3>
|
||||
<table className="w-full min-w-[640px]" data-testid="server-disks-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("server.mount")}</th>
|
||||
<th className={th}>{t("server.device")}</th>
|
||||
<th className={`${th} w-[30%]`}>{t("server.used")}</th>
|
||||
<th className={`${th} text-right`}>{t("server.free")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{stats.disks.map((d) => (
|
||||
<tr key={d.device}>
|
||||
<td className="px-5 py-3 text-sm text-fg-primary">
|
||||
<span className="font-mono">{d.mount}</span>
|
||||
{d.mount === stats.homesMount && (
|
||||
<span className="badge ml-2 py-0 text-[10px]">{t("server.homesBadge")}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-fg-secondary">
|
||||
<span className="font-mono">{d.device}</span> · {d.type}
|
||||
{d.kind && <span className="badge ml-2 py-0 text-[10px]">{d.kind}</span>}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(d.used)} / {formatBytes(d.total)}{" "}
|
||||
<span className="text-fg-muted">({fmtPct(pct(d.used, d.total))})</span>
|
||||
</div>
|
||||
<Meter value={pct(d.used, d.total)} label={d.mount} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||
{formatBytes(d.free)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && stats.gpus.length > 0 && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.gpus")}</h3>
|
||||
{stats.gpus.map((g, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-fg-primary">{g.name}</span>
|
||||
<span className="text-fg-muted tabular-nums">
|
||||
{fmtPct(g.utilization)} · {formatBytes(g.memoryUsed)} /{" "}
|
||||
{formatBytes(g.memoryTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<Meter value={g.utilization} label={g.name} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MemberTmuxCard isAdmin={isAdmin} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,18 +14,19 @@
|
||||
* and tick the ones to add; or type `name@marketplace` by hand, which is
|
||||
* also where a link the server cannot read (ssh, a private repo) ends up.
|
||||
* - `FileUploadPanel` — one file from the user's computer (≤ 25 MB).
|
||||
* - `SkillNameField`/`splitSkillMd` — also used by the page's skill editor.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, FolderOpen, Package, Sparkles, Upload, X } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { api, type MarketplaceInfo } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
const field = "w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
|
||||
const label = "block text-[11px] font-medium text-fg-secondary";
|
||||
const field = "input w-full";
|
||||
const label = "block text-xs text-fg-secondary";
|
||||
const hint = "text-[11px] text-fg-muted";
|
||||
|
||||
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
|
||||
@@ -155,8 +156,19 @@ function Tabs<T extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
/** SKILL.md split for editing: its frontmatter `name`/`description` and the body. */
|
||||
export function splitSkillMd(text: string): { name: string; description: string; body: string } {
|
||||
const clean = text.replace(/^\uFEFF/, "");
|
||||
const fm = /^---\r?\n[\s\S]*?\r?\n---[^\n]*(\r?\n|$)/.exec(clean);
|
||||
return {
|
||||
name: frontmatterField(clean, "name"),
|
||||
description: frontmatterField(clean, "description"),
|
||||
body: (fm ? clean.slice(fm[0].length) : clean).replace(/^\s+/, ""),
|
||||
};
|
||||
}
|
||||
|
||||
/** The name field with the folder/command it becomes. */
|
||||
function SkillNameField({
|
||||
export function SkillNameField({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
@@ -175,7 +187,7 @@ function SkillNameField({
|
||||
{t("skill.name")}
|
||||
{required ? " *" : ""}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder="release-notes"
|
||||
@@ -296,7 +308,7 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("skill.description")}
|
||||
<textarea
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
rows={2}
|
||||
value={description}
|
||||
disabled={many}
|
||||
@@ -310,7 +322,7 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("form.title")}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
value={title}
|
||||
disabled={many}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
@@ -362,7 +374,6 @@ export function SkillAddPanel({
|
||||
dragging ? "border-accent bg-accent/10" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<Upload className="mx-auto mb-1 h-5 w-5 text-fg-muted" />
|
||||
<p className="text-xs text-fg-secondary">{t("skill.dropHint")}</p>
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-2">
|
||||
<button
|
||||
@@ -370,14 +381,14 @@ export function SkillAddPanel({
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" /> {t("skill.pickFiles")}
|
||||
{t("skill.pickFiles")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => folderInput.current?.click()}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" /> {t("skill.pickFolder")}
|
||||
{t("skill.pickFolder")}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
@@ -452,7 +463,10 @@ export function SkillAddPanel({
|
||||
/>
|
||||
)}
|
||||
{meta}
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
@@ -489,7 +503,7 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("skill.description")} *
|
||||
<textarea
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
rows={2}
|
||||
value={description}
|
||||
placeholder={t("skill.descriptionHint")}
|
||||
@@ -499,7 +513,7 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("skill.content")} *
|
||||
<textarea
|
||||
className={`${field} mt-0.5 font-mono text-xs`}
|
||||
className={`${field} mt-1 font-mono text-xs`}
|
||||
rows={10}
|
||||
value={content}
|
||||
placeholder={t("skill.contentHint")}
|
||||
@@ -509,12 +523,15 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("form.title")}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
@@ -534,7 +551,7 @@ export function SkillAddPanel({
|
||||
{mode === "existing" && (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
className={field}
|
||||
className="select w-full"
|
||||
aria-label={t("form.workspace")}
|
||||
value={laneId ?? ""}
|
||||
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
||||
@@ -551,7 +568,6 @@ export function SkillAddPanel({
|
||||
<ul className="max-h-72 space-y-0.5 overflow-auto">
|
||||
{skills.map((s) => (
|
||||
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs">
|
||||
<Sparkles className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
|
||||
<span className="text-fg-muted">
|
||||
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
|
||||
@@ -696,7 +712,7 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
||||
>
|
||||
<label className={label}>
|
||||
{t("plugin.link")}
|
||||
<div className="mt-0.5 flex gap-2">
|
||||
<div className="mt-1 flex gap-2">
|
||||
<input
|
||||
className={field}
|
||||
value={link}
|
||||
@@ -728,10 +744,7 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
||||
{info && (
|
||||
<div className="space-y-2" data-testid="market-plugins">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-fg-primary">
|
||||
<Package className="mr-1 inline h-4 w-4 text-fg-muted" />
|
||||
{info.name}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-fg-primary">{info.name}</p>
|
||||
{info.description && <p className={hint}>{info.description}</p>}
|
||||
</div>
|
||||
{info.plugins.length === 0 ? (
|
||||
@@ -777,7 +790,15 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-sm"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-sm"
|
||||
@@ -853,7 +874,10 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
@@ -902,7 +926,7 @@ export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
|
||||
<input
|
||||
type="file"
|
||||
data-testid="file-upload"
|
||||
className="mt-0.5 block w-full text-xs text-fg-secondary"
|
||||
className="mt-1 block w-full text-xs text-fg-secondary file:mr-3 file:cursor-pointer file:rounded-lg file:border file:border-border-light file:bg-surface-2 file:px-3 file:py-1.5 file:text-xs file:text-fg-primary hover:file:bg-surface-4"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
@@ -920,7 +944,7 @@ export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
|
||||
className="btn-primary shrink-0 text-sm"
|
||||
disabled={busy || !file || tooBig}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" /> {t("file.upload")}
|
||||
{t("file.upload")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* already-fetched data as props (path/data/error) rather than fetching
|
||||
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
|
||||
* config page) owns the fetch. A markdown file gets a Raw / Preview switch
|
||||
* (`MarkdownPreview`); Raw is the default.
|
||||
* (`MarkdownPreview`), opening as Settings → Appearance says
|
||||
* (`lib/markdownView.ts`: Preview by default).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -15,6 +16,7 @@ import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react";
|
||||
import type { CcFileResponse } from "../../lib/api";
|
||||
import { CopyButton } from "./configPrimitives";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||
|
||||
export function FileViewer({
|
||||
state,
|
||||
@@ -24,7 +26,7 @@ export function FileViewer({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [preview, setPreview] = useState(readMarkdownPreview);
|
||||
const canPreview = isMarkdown(state.path) && !!state.data;
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef, onClose);
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* @file AgentDetails.tsx
|
||||
* @description What the chat shows of a subagent, like Claude Desktop: an
|
||||
* Agent/Task call's facts (type, model, status, time, tokens, tool calls),
|
||||
* the task it was given and the report it returned — read off the call, its
|
||||
* result (`tool_result.agent`) and, for a background agent, the
|
||||
* `<task-notification>` that closed it — and, one click further, the
|
||||
* subagent's own conversation as a small chat right there in the step, every
|
||||
* step of it. The chat's owner (ConversationView) supplies the links through
|
||||
* `AgentContext`: which agent transcript a call launched, the notifications
|
||||
* seen, and how to render one agent's conversation (a nested
|
||||
* ConversationView — passed in, so this module never imports it back).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, ChevronRight, CircleSlash, Loader2, XCircle } from "lucide-react";
|
||||
import type { TranscriptContent, TranscriptMessage } from "../../lib/types";
|
||||
import { fmt, formatModelName, formatMs } from "../../lib/format";
|
||||
import { MarkdownContent } from "./MarkdownContent";
|
||||
|
||||
export type AgentStatus = "running" | "done" | "error" | "stopped";
|
||||
|
||||
/** One subagent as the chat knows it from the call that launched it. */
|
||||
export interface AgentRef {
|
||||
/** The Agent/Task `tool_use` id. */
|
||||
toolUseId: string;
|
||||
/** Its own transcript's `agent_id`; null while nothing names it yet. */
|
||||
agentId: string | null;
|
||||
type: string;
|
||||
description: string;
|
||||
model: string | null;
|
||||
status: AgentStatus;
|
||||
background: boolean;
|
||||
prompt: string | null;
|
||||
report: string | null;
|
||||
durationMs: number | null;
|
||||
tokens: number | null;
|
||||
toolUses: number | null;
|
||||
}
|
||||
|
||||
/** What a `<task-notification>` said about a background agent. */
|
||||
export interface AgentNotice {
|
||||
status: string;
|
||||
result: string | null;
|
||||
durationMs: number | null;
|
||||
tokens: number | null;
|
||||
toolUses: number | null;
|
||||
}
|
||||
|
||||
export interface AgentLinks {
|
||||
/** tool_use id → agent transcript id, from the session's transcript list. */
|
||||
ids: Map<string, string>;
|
||||
/** tool_use id → the notification that closed that background agent. */
|
||||
notices: Map<string, AgentNotice>;
|
||||
/** The session runs in a pane: a call with no result yet still works. */
|
||||
live: boolean;
|
||||
/** One agent's own conversation, as a chat that fills its box. */
|
||||
render?: (agent: AgentRef) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const AgentContext = createContext<AgentLinks>({
|
||||
ids: new Map(),
|
||||
notices: new Map(),
|
||||
live: false,
|
||||
});
|
||||
|
||||
const AGENT_TOOLS = new Set(["Agent", "Task"]);
|
||||
export const isAgentCall = (b: TranscriptContent) =>
|
||||
b.type === "tool_use" && AGENT_TOOLS.has(b.name ?? "");
|
||||
|
||||
// The result names the agent's own transcript (`agent-<id>.jsonl`).
|
||||
const AGENT_ID = /agentId:\s*([\w-]+)/;
|
||||
const USAGE = /<usage>([\s\S]*?)<\/usage>/;
|
||||
|
||||
const tag = (text: string, name: string) =>
|
||||
text.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))?.[1]?.trim() ?? null;
|
||||
|
||||
/** The time, tokens and tool calls of a `<usage>` block, in either form
|
||||
* Claude Code writes it: `<total_tokens>N</…>` tags or `total_tokens: N`. */
|
||||
export function parseUsage(text: string): Pick<AgentNotice, "durationMs" | "tokens" | "toolUses"> {
|
||||
const body = text.match(USAGE)?.[1] ?? "";
|
||||
const num = (...names: string[]) => {
|
||||
for (const n of names) {
|
||||
const v = body.match(new RegExp(`${n}(?:>|:\\s*)(\\d+)`))?.[1];
|
||||
if (v) return Number(v);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
durationMs: num("duration_ms"),
|
||||
tokens: num("subagent_tokens", "total_tokens"),
|
||||
toolUses: num("tool_uses"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Each `<task-notification>` in the transcript, by the call it closes. */
|
||||
export function agentNotices(messages: TranscriptMessage[]): Map<string, AgentNotice> {
|
||||
const out = new Map<string, AgentNotice>();
|
||||
for (const msg of messages) {
|
||||
if (msg.type !== "user") continue;
|
||||
for (const c of msg.content) {
|
||||
if (c.type !== "text" || !c.text?.includes("<task-notification>")) continue;
|
||||
for (const part of c.text.split("<task-notification>").slice(1)) {
|
||||
const id = tag(part, "tool-use-id");
|
||||
if (!id) continue;
|
||||
out.set(id, {
|
||||
status: tag(part, "status") ?? "completed",
|
||||
result: tag(part, "result"),
|
||||
...parseUsage(part),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A report without the footer Claude Code appends for the orchestrator. */
|
||||
function stripFooter(text: string): string {
|
||||
return text
|
||||
.replace(USAGE, "")
|
||||
.replace(/^agentId:.*$/m, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Everything the chat can say about the subagent `block` launched. */
|
||||
export function agentRef(
|
||||
block: TranscriptContent,
|
||||
result: TranscriptContent | null | undefined,
|
||||
links: AgentLinks
|
||||
): AgentRef {
|
||||
const raw = block.input as Record<string, unknown> | undefined;
|
||||
const input = raw && !("_truncated" in raw) ? raw : {};
|
||||
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
|
||||
const id = block.id ?? "";
|
||||
const facts = result?.agent;
|
||||
const output = result?.output ?? "";
|
||||
const background = facts?.status === "async_launched" || output.includes("Async agent launched");
|
||||
const notice = background ? links.notices.get(id) : undefined;
|
||||
const usage = !background && output ? parseUsage(output) : null;
|
||||
|
||||
let status: AgentStatus;
|
||||
if (result?.is_error) status = "error";
|
||||
else if (!result || (background && !notice)) status = links.live ? "running" : "stopped";
|
||||
else if (notice && notice.status !== "completed")
|
||||
status = notice.status === "failed" ? "error" : "stopped";
|
||||
else status = "done";
|
||||
|
||||
return {
|
||||
toolUseId: id,
|
||||
agentId: facts?.id ?? output.match(AGENT_ID)?.[1] ?? links.ids.get(id) ?? null,
|
||||
type: str(input.subagent_type) ?? "Agent",
|
||||
description: str(input.description) ?? "",
|
||||
model: facts?.model ?? str(input.model),
|
||||
status,
|
||||
background,
|
||||
prompt: str(input.prompt),
|
||||
report: background ? (notice?.result ?? null) : output ? stripFooter(output) || null : null,
|
||||
durationMs: facts?.duration_ms ?? notice?.durationMs ?? usage?.durationMs ?? null,
|
||||
tokens: facts?.tokens ?? notice?.tokens ?? usage?.tokens ?? null,
|
||||
toolUses: facts?.tool_uses ?? notice?.toolUses ?? usage?.toolUses ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_ICON: Record<AgentStatus, React.ReactNode> = {
|
||||
running: <Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />,
|
||||
done: <CheckCircle2 className="h-3.5 w-3.5 flex-shrink-0 text-status-success" />,
|
||||
error: <XCircle className="h-3.5 w-3.5 flex-shrink-0 text-status-danger" />,
|
||||
stopped: <CircleSlash className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />,
|
||||
};
|
||||
|
||||
/** One muted line: its status as an icon, then type · model · time ·
|
||||
* tokens · tool calls, as far as they are known. */
|
||||
export function AgentFacts({ agent }: { agent: AgentRef }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const parts = [
|
||||
agent.type,
|
||||
agent.model && formatModelName(agent.model),
|
||||
agent.background && t("conversation.agent.background"),
|
||||
agent.durationMs != null && formatMs(agent.durationMs),
|
||||
agent.tokens != null && t("conversation.agentContext", { tokens: fmt(agent.tokens) }),
|
||||
agent.toolUses != null && t("conversation.agent.toolUses", { count: agent.toolUses }),
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<div
|
||||
data-testid="agent-facts"
|
||||
title={t(`conversation.agent.status.${agent.status}`)}
|
||||
className="flex min-w-0 items-center gap-1.5 text-xs text-fg-muted"
|
||||
>
|
||||
{STATUS_ICON[agent.status]}
|
||||
<span className="sr-only">{t(`conversation.agent.status.${agent.status}`)}</span>
|
||||
<span className="truncate">{parts.join(" · ")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const linkClass =
|
||||
"inline-flex items-center gap-0.5 text-xs text-fg-muted transition-colors hover:text-fg-secondary";
|
||||
|
||||
/** The subagent's own conversation, a thread hung under its step by a line
|
||||
* on the left — as tall as it is, up to 24rem, then it scrolls. */
|
||||
export function AgentChatBox({ agent }: { agent: AgentRef }) {
|
||||
const links = useContext(AgentContext);
|
||||
return (
|
||||
<div data-testid="agent-chat" className="mt-2 border-l-2 border-border pl-3">
|
||||
{links.render?.(agent)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An Agent/Task step opened in the chat: one line of facts, its report as
|
||||
* prose, and "View conversation ›", which puts the agent's own thread (its
|
||||
* task, every step, its reply) in the report's place. With no transcript
|
||||
* to show, the task it was given opens on its own instead. */
|
||||
export function AgentStepBody({
|
||||
block,
|
||||
result,
|
||||
}: {
|
||||
block: TranscriptContent;
|
||||
result: TranscriptContent | null;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const links = useContext(AgentContext);
|
||||
const agent = agentRef(block, result, links);
|
||||
const canOpen = !!agent.agentId && !!links.render;
|
||||
const [thread, setThread] = useState(false);
|
||||
const [task, setTask] = useState(false);
|
||||
return (
|
||||
<div data-testid="agent-step" className="space-y-1.5">
|
||||
<AgentFacts agent={agent} />
|
||||
{thread ? (
|
||||
<AgentChatBox agent={agent} />
|
||||
) : (
|
||||
<>
|
||||
{!canOpen && agent.prompt && task && (
|
||||
<div className="max-h-60 overflow-auto border-l-2 border-border pl-3 text-fg-secondary">
|
||||
<MarkdownContent text={agent.prompt} dense />
|
||||
</div>
|
||||
)}
|
||||
{agent.report && (
|
||||
<div
|
||||
className={`max-h-60 overflow-auto ${agent.status === "error" ? "text-status-danger" : "text-fg-secondary"}`}
|
||||
>
|
||||
<MarkdownContent text={agent.report} dense />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="agent-open"
|
||||
aria-expanded={thread}
|
||||
onClick={() => setThread(!thread)}
|
||||
className={linkClass}
|
||||
>
|
||||
{t(thread ? "conversation.agent.hide" : "conversation.agent.open")}
|
||||
<ChevronRight className={`h-3 w-3 transition-transform ${thread ? "-rotate-90" : ""}`} />
|
||||
</button>
|
||||
) : (
|
||||
agent.prompt && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={task}
|
||||
onClick={() => setTask(!task)}
|
||||
className={linkClass}
|
||||
>
|
||||
{t("conversation.agent.prompt")}
|
||||
<ChevronRight className={`h-3 w-3 transition-transform ${task ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,9 @@
|
||||
* A caller that knows the session has ended passes `ended`: nothing more is
|
||||
* being written, so the catch-up poll drops from 3 s to 60 s and the
|
||||
* sub-agent list stops being rescanned — a `new_event` frame, a reconnect or
|
||||
* the view being shown again still fetch the tail at once.
|
||||
* the view being shown again still fetch the tail at once. The main
|
||||
* transcript hands MessageList its `sessionId`, so the list can read its
|
||||
* background agents' own transcripts for their progress.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -71,11 +73,17 @@ import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
import { MessageList, promptTokens } from "./MessageList";
|
||||
import { agentNotices, type AgentLinks, type AgentRef } from "./AgentDetails";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
||||
import { throttle } from "../../lib/throttle";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
||||
import type {
|
||||
TranscriptMessage,
|
||||
TranscriptInfo,
|
||||
TranscriptResult,
|
||||
WSMessage,
|
||||
} from "../../lib/types";
|
||||
|
||||
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
|
||||
// PostToolUse / Stop, which means a user-typed message (no hook) and any
|
||||
@@ -93,6 +101,9 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||
// at most this often: the first event still fetches at once, the last is never
|
||||
// lost, and a long transcript is not re-read for every single frame.
|
||||
const EVENT_FETCH_MS = 1500;
|
||||
// The server saw this session's transcript grow (`transcript_changed`, itself
|
||||
// at most every 250 ms): the new lines are on disk, so read them nearly at once.
|
||||
const PUSH_FETCH_MS = 300;
|
||||
|
||||
interface ConversationViewProps {
|
||||
sessionId: string;
|
||||
@@ -100,8 +111,15 @@ interface ConversationViewProps {
|
||||
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
|
||||
* page-height cap — the parent sizes it and puts a composer under it. */
|
||||
fill?: boolean;
|
||||
/** A subagent's conversation inside its step: no toolbar, as tall as its
|
||||
* content up to 24rem, then it scrolls. */
|
||||
compact?: boolean;
|
||||
/** Opens a file named in a turn's "Edited N files" card. */
|
||||
onOpenFile?: (path: string) => void;
|
||||
/** Opens an artifact card's file in the chat's artifact panel. */
|
||||
onOpenArtifact?: (path: string) => void;
|
||||
/** The messages on screen (for an artifact's versions), as they change. */
|
||||
onMessages?: (messages: TranscriptMessage[]) => void;
|
||||
/** The session runs in a pane: show the step in progress. */
|
||||
live?: boolean;
|
||||
/** Changed (a chat's send): back to the end, and stick to it. */
|
||||
@@ -125,7 +143,10 @@ function ConversationViewPane({
|
||||
sessionId,
|
||||
initialTranscriptId,
|
||||
fill = false,
|
||||
compact = false,
|
||||
onOpenFile,
|
||||
onOpenArtifact,
|
||||
onMessages,
|
||||
live = false,
|
||||
follow = 0,
|
||||
waiting = false,
|
||||
@@ -137,19 +158,21 @@ function ConversationViewPane({
|
||||
}: ConversationViewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
const shown = messages;
|
||||
// A live pane's session has no row until its first hook — and Claude Code
|
||||
// runs no hook before the folder is trusted, so a trust prompt (or any
|
||||
// other question before the session starts) waits unseen in the terminal.
|
||||
const [notStarted, setNotStarted] = useState(false);
|
||||
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
|
||||
useEffect(() => onMessages?.(shown), [shown, onMessages]);
|
||||
useEffect(() => {
|
||||
if (!onContext) return;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
for (let i = shown.length - 1; i >= 0; i--) {
|
||||
const m = shown[i]!;
|
||||
if (m.type === "assistant" && m.usage)
|
||||
return onContext(promptTokens(m.usage), m.model ?? null);
|
||||
}
|
||||
}, [messages, onContext]);
|
||||
}, [shown, onContext]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
@@ -157,6 +180,8 @@ function ConversationViewPane({
|
||||
initialTranscriptId ?? null
|
||||
);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
// The edits of the turn the oldest loaded page starts inside of (`turn_edits`).
|
||||
const [leadEdits, setLeadEdits] = useState<TranscriptResult["turn_edits"]>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
|
||||
const [showNewMsg, setShowNewMsg] = useState(false);
|
||||
@@ -184,9 +209,31 @@ function ConversationViewPane({
|
||||
// Refresh-button spinner state - separate from initial `loading` so the
|
||||
// existing skeleton doesn't blink during a manual refresh.
|
||||
|
||||
// A subagent's own conversation opens inside its step as a small chat: this
|
||||
// same view, on that agent's transcript.
|
||||
const renderAgent = useCallback(
|
||||
(a: AgentRef) => (
|
||||
<ConversationView
|
||||
key={a.agentId}
|
||||
sessionId={sessionId}
|
||||
initialTranscriptId={a.agentId}
|
||||
compact
|
||||
live={live && a.status === "running"}
|
||||
ended={a.status !== "running"}
|
||||
/>
|
||||
),
|
||||
[sessionId, live]
|
||||
);
|
||||
const agentLinks = useMemo<AgentLinks>(() => {
|
||||
const ids = new Map<string, string>();
|
||||
for (const tr of transcripts) if (tr.tool_use_id) ids.set(tr.tool_use_id, tr.id);
|
||||
return { ids, notices: agentNotices(shown), live, render: renderAgent };
|
||||
}, [transcripts, shown, live, renderAgent]);
|
||||
|
||||
// Load available transcript list (also rescanned on a short interval so
|
||||
// newly-spawned subagents appear in the dropdown without a page reload).
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let cancelled = false;
|
||||
async function loadTranscripts() {
|
||||
try {
|
||||
@@ -226,6 +273,12 @@ function ConversationViewPane({
|
||||
firstLineRef.current = 0;
|
||||
|
||||
async function load() {
|
||||
// No session yet: no history.
|
||||
if (!sessionId) {
|
||||
setMessages([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
@@ -237,6 +290,7 @@ function ConversationViewPane({
|
||||
if (cancelled) return;
|
||||
setNotStarted(false);
|
||||
setMessages(result.messages);
|
||||
setLeadEdits(result.turn_edits);
|
||||
setTotal(result.total);
|
||||
setHasMore(result.has_more);
|
||||
lastLineRef.current = result.last_line;
|
||||
@@ -269,6 +323,7 @@ function ConversationViewPane({
|
||||
// parsed message we've seen. The server already de-overlaps via
|
||||
// afterLine, so we can safely append.
|
||||
const fetchNewMessages = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
if (fetchingRef.current) {
|
||||
// Coalesce: remember a trigger arrived during this fetch and re-run
|
||||
// exactly once when the in-flight request settles.
|
||||
@@ -296,6 +351,7 @@ function ConversationViewPane({
|
||||
// Seed the view in a single render so the user sees the whole
|
||||
// catch-up batch instead of a blank panel followed by a partial one.
|
||||
setMessages(result.messages);
|
||||
setLeadEdits(result.turn_edits);
|
||||
firstLineRef.current = result.first_line;
|
||||
setHasMore(result.has_more);
|
||||
} else {
|
||||
@@ -330,7 +386,12 @@ function ConversationViewPane({
|
||||
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
|
||||
[fetchNewMessages]
|
||||
);
|
||||
const fetchPushed = useMemo(
|
||||
() => throttle(() => void fetchNewMessages(), PUSH_FETCH_MS),
|
||||
[fetchNewMessages]
|
||||
);
|
||||
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
|
||||
useEffect(() => () => fetchPushed.cancel(), [fetchPushed]);
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
||||
// Hidden tab or hidden pane: catching up once on show is enough.
|
||||
@@ -339,13 +400,14 @@ function ConversationViewPane({
|
||||
fetchSoon();
|
||||
return;
|
||||
}
|
||||
if (msg.type !== "new_event") return;
|
||||
if (msg.type !== "new_event" && msg.type !== "transcript_changed") return;
|
||||
const data = msg.data as { session_id?: string };
|
||||
if (data.session_id !== sessionId) return;
|
||||
fetchSoon();
|
||||
if (msg.type === "transcript_changed") fetchPushed();
|
||||
else fetchSoon();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [sessionId, fetchSoon, onScreen]);
|
||||
}, [sessionId, fetchSoon, fetchPushed, onScreen]);
|
||||
|
||||
onShowRef.current = () => {
|
||||
if (document.visibilityState === "visible") void fetchNewMessages();
|
||||
@@ -434,6 +496,7 @@ function ConversationViewPane({
|
||||
firstLineRef.current = result.first_line;
|
||||
|
||||
setMessages((prev) => [...result.messages, ...prev]);
|
||||
setLeadEdits(result.turn_edits);
|
||||
setHasMore(result.has_more);
|
||||
|
||||
// Preserve scroll position (don't jump to top)
|
||||
@@ -507,7 +570,7 @@ function ConversationViewPane({
|
||||
|
||||
// Auto-scroll to bottom after initial load
|
||||
useEffect(() => {
|
||||
if (!loading && messages.length > 0) {
|
||||
if (!loading && shown.length > 0) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -516,7 +579,7 @@ function ConversationViewPane({
|
||||
<div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
|
||||
{/* Toolbar - always rendered after the initial load so users can
|
||||
refresh even when no messages have streamed yet. */}
|
||||
{!loading && !fill && (
|
||||
{!loading && !fill && !compact && (
|
||||
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
|
||||
{transcripts.length > 1 && (
|
||||
<div className="relative">
|
||||
@@ -564,7 +627,13 @@ function ConversationViewPane({
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
|
||||
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
||||
style={
|
||||
compact
|
||||
? { maxHeight: "24rem" }
|
||||
: fill
|
||||
? { minHeight: 0 }
|
||||
: { maxHeight: "calc(100vh - 320px)", minHeight: 200 }
|
||||
}
|
||||
>
|
||||
{/* A chat keeps its first turn clear of the pane's top-right controls
|
||||
(the outline's file button floats there). */}
|
||||
@@ -587,10 +656,12 @@ function ConversationViewPane({
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||
<div
|
||||
className={`flex items-center justify-center text-fg-muted text-sm ${compact ? "py-3" : "py-12"}`}
|
||||
>
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
) : messages.length === 0 && notStarted ? (
|
||||
) : shown.length === 0 && notStarted ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
@@ -606,7 +677,7 @@ function ConversationViewPane({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
) : shown.length === 0 ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
@@ -615,13 +686,20 @@ function ConversationViewPane({
|
||||
</div>
|
||||
) : (
|
||||
<MessageList
|
||||
messages={messages}
|
||||
messages={shown}
|
||||
loading={false}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
live={live}
|
||||
waiting={waiting}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
stopped={stopped}
|
||||
agentLinks={agentLinks}
|
||||
compact={compact}
|
||||
leadEdits={leadEdits}
|
||||
sessionId={
|
||||
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -648,7 +726,7 @@ function ConversationViewPane({
|
||||
)}
|
||||
|
||||
{/* New messages indicator */}
|
||||
{showNewMsg && !fill && (
|
||||
{showNewMsg && !fill && !compact && (
|
||||
<button
|
||||
onClick={() => {
|
||||
scrollToBottom();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||
* and auto-linked URLs. A file path — absolute, or relative with a folder and
|
||||
* and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
|
||||
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
|
||||
* inline code or as a markdown link's target — opens through `OpenFileContext`
|
||||
* when the page provides one (the page resolves a relative one against its own
|
||||
@@ -63,6 +63,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { CodeBlock } from "./CodeBlock";
|
||||
import { onLinkClick } from "../../lib/openInPreview";
|
||||
|
||||
type Block =
|
||||
| { kind: "code"; lang: string; code: string }
|
||||
@@ -272,8 +273,14 @@ const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?
|
||||
// (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder.
|
||||
const REL_PATH_RE =
|
||||
/^(?:\.{1,2}\/)*(?:\.?[\w@-][\w@-]*\/)(?:[\w.@-]+\/)*[\w.@-]*[\w@-]\.[A-Za-z][A-Za-z0-9]{0,9}(?::\d+(?::\d+)?)?(?![\p{L}\p{N}_/])/u;
|
||||
// A Windows absolute path (`E:\notes\a.xlsx`, `C:/x/y.ts:9`): a drive letter and one segment at least.
|
||||
const WIN_PATH_RE =
|
||||
/^[A-Za-z]:[\\/](?:[^\s\\/<>()'"`,;:|*?]+[\\/])*[^\s\\/<>()'"`,;:|*?]*[^\s\\/<>()'"`,;:|*?.!](?::\d+(?::\d+)?)?/;
|
||||
// In inline code the whole text is the path, so a Windows one may hold spaces.
|
||||
const WIN_CODE_PATH_RE = /^[A-Za-z]:[\\/][^<>"|?*\n]*[^\s<>"|?*\\/.]$/;
|
||||
/** A path at the start of `text`, absolute or relative, or null. */
|
||||
const matchPath = (text: string) => PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
|
||||
const matchPath = (text: string) =>
|
||||
PATH_RE.exec(text)?.[0] ?? WIN_PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
|
||||
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
|
||||
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
|
||||
// A markdown link target that is a page, not a file.
|
||||
@@ -299,7 +306,13 @@ const LABEL_CLASS = (word: string) =>
|
||||
|
||||
function UrlLink({ url }: { url: string }) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => onLinkClick(e, url)}
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
@@ -393,7 +406,13 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
{codeM[1]}
|
||||
</code>
|
||||
);
|
||||
push(matchPath(codeM[1]!) === codeM[1] ? <PathLink path={codeM[1]!}>{code}</PathLink> : code);
|
||||
push(
|
||||
matchPath(codeM[1]!) === codeM[1] || WIN_CODE_PATH_RE.test(codeM[1]!) ? (
|
||||
<PathLink path={codeM[1]!}>{code}</PathLink>
|
||||
) : (
|
||||
code
|
||||
)
|
||||
);
|
||||
i += codeM[0].length;
|
||||
continue;
|
||||
}
|
||||
@@ -454,6 +473,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => onLinkClick(e, href)}
|
||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||
>
|
||||
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @file AgentDetails.test.tsx
|
||||
* @description Verifies what the chat shows of a subagent: an Agent step's
|
||||
* facts, task and report (from its result, or the task-notification that
|
||||
* closed a background one), and that "View conversation" opens that
|
||||
* agent's own conversation in place, rendered by the chat's owner.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MessageList } from "../MessageList";
|
||||
import { agentNotices, agentRef, parseUsage, type AgentLinks } from "../AgentDetails";
|
||||
import type { TranscriptContent, TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const at = new Date().toISOString();
|
||||
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
|
||||
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
|
||||
|
||||
const call: TranscriptContent = {
|
||||
type: "tool_use",
|
||||
id: "t1",
|
||||
name: "Agent",
|
||||
input: { description: "Map the hooks", subagent_type: "Explore", prompt: "Read **server/**" },
|
||||
};
|
||||
const links = (p: Partial<AgentLinks> = {}): AgentLinks => ({
|
||||
ids: new Map(),
|
||||
notices: new Map(),
|
||||
live: false,
|
||||
...p,
|
||||
});
|
||||
|
||||
describe("agentRef", () => {
|
||||
it("reads a foreground agent's facts and report, without the orchestrator's footer", () => {
|
||||
const a = agentRef(
|
||||
call,
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: "The hooks live in server/routes/hooks.js.\n\nagentId: a1b2 (for resuming)",
|
||||
agent: {
|
||||
id: "a1b2",
|
||||
model: "claude-opus-5-5",
|
||||
status: "completed",
|
||||
duration_ms: 65_000,
|
||||
tokens: 12_000,
|
||||
tool_uses: 7,
|
||||
},
|
||||
},
|
||||
links()
|
||||
);
|
||||
expect(a).toMatchObject({
|
||||
agentId: "a1b2",
|
||||
type: "Explore",
|
||||
status: "done",
|
||||
background: false,
|
||||
prompt: "Read **server/**",
|
||||
report: "The hooks live in server/routes/hooks.js.",
|
||||
durationMs: 65_000,
|
||||
tokens: 12_000,
|
||||
toolUses: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes a background agent with its notification's report and usage", () => {
|
||||
const notice = m({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "<task-notification>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n<result>All done</result>\n<usage><subagent_tokens>88007</subagent_tokens><tool_uses>31</tool_uses><duration_ms>132350</duration_ms></usage>\n</task-notification>",
|
||||
},
|
||||
],
|
||||
});
|
||||
const launched: TranscriptContent = {
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: "Async agent launched successfully.\nagentId: a9b4",
|
||||
};
|
||||
const running = agentRef(call, launched, links({ live: true }));
|
||||
expect(running).toMatchObject({ status: "running", background: true, report: null });
|
||||
const done = agentRef(call, launched, links({ notices: agentNotices([notice]) }));
|
||||
expect(done).toMatchObject({
|
||||
agentId: "a9b4",
|
||||
status: "done",
|
||||
report: "All done",
|
||||
tokens: 88007,
|
||||
toolUses: 31,
|
||||
durationMs: 132350,
|
||||
});
|
||||
});
|
||||
|
||||
it("finds the transcript from the session's list when the result names none", () => {
|
||||
const a = agentRef(call, null, links({ ids: new Map([["t1", "c3d4"]]) }));
|
||||
expect(a).toMatchObject({ agentId: "c3d4", status: "stopped" });
|
||||
});
|
||||
|
||||
it("parses the line form of a usage block", () => {
|
||||
expect(parseUsage("<usage>total_tokens: 500\ntool_uses: 3\nduration_ms: 900</usage>")).toEqual({
|
||||
tokens: 500,
|
||||
toolUses: 3,
|
||||
durationMs: 900,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("an Agent step in the chat", () => {
|
||||
it("shows the agent's facts and report, and opens its conversation in place", () => {
|
||||
const render_ = vi.fn((a: { agentId: string | null }) => <p>chat of {a.agentId}</p>);
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
agentLinks={links({ render: render_ })}
|
||||
messages={[
|
||||
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
|
||||
m({ type: "assistant", sender: "assistant", stop_reason: "tool_use", content: [call] }),
|
||||
m({
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: "Found **3** hooks",
|
||||
agent: {
|
||||
id: "a1b2",
|
||||
model: null,
|
||||
status: "completed",
|
||||
duration_ms: null,
|
||||
tokens: null,
|
||||
tool_uses: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /ran an agent/i }));
|
||||
expect(screen.getByTestId("agent-facts").textContent).toContain("Explore");
|
||||
expect(screen.getByTestId("agent-facts").textContent).toContain("done");
|
||||
expect(screen.getByTestId("agent-facts").textContent).toContain("4 tool calls");
|
||||
expect(screen.getByTestId("agent-step").textContent).toContain("Found 3 hooks");
|
||||
expect(screen.queryByTestId("agent-chat")).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("agent-open"));
|
||||
expect(screen.getByTestId("agent-chat").textContent).toBe("chat of a1b2");
|
||||
// The thread takes the report's place.
|
||||
expect(screen.getByTestId("agent-step").textContent).not.toContain("Found 3 hooks");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @file ConversationView.pushed.test.tsx
|
||||
* @description The server says a session's transcript grew
|
||||
* (`transcript_changed`): the chat showing that session reads the new lines
|
||||
* at once, and again within a short window after a burst — not at the hook
|
||||
* throttle's 1.5 s pace — while another session's notice fetches nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConversationView } from "../ConversationView";
|
||||
import { api } from "../../../lib/api";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
sessions: {
|
||||
transcripts: vi.fn(async () => ({ transcripts: [] })),
|
||||
transcript: vi.fn(async () => ({
|
||||
messages: [
|
||||
{
|
||||
type: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
timestamp: "2026-10-21T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
has_more: false,
|
||||
first_line: 1,
|
||||
last_line: 1,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const pushed = (session_id: string) =>
|
||||
act(() => eventBus.publish({ type: "transcript_changed", data: { session_id } } as never));
|
||||
const calls = () => vi.mocked(api.sessions.transcript).mock.calls.length;
|
||||
|
||||
describe("ConversationView on transcript_changed", () => {
|
||||
it("reads its own session's new lines at once, and a burst within 300 ms", async () => {
|
||||
render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
vi.useFakeTimers();
|
||||
const before = calls();
|
||||
pushed("s1");
|
||||
expect(calls()).toBe(before + 1);
|
||||
pushed("s1");
|
||||
pushed("s1");
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
});
|
||||
expect(calls()).toBe(before + 2);
|
||||
});
|
||||
|
||||
it("ignores another session's transcript", async () => {
|
||||
render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
const before = calls();
|
||||
pushed("s2");
|
||||
await act(async () => {});
|
||||
expect(calls()).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,20 @@ describe("file links", () => {
|
||||
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]);
|
||||
});
|
||||
|
||||
it("opens a Windows path, bare or in inline code", () => {
|
||||
const open = withOpen(
|
||||
"File: `E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx`. Also C:/src/a.ts:9, and `D:\\My Docs\\b.md`"
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "C:/src/a.ts:9" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "D:\\My Docs\\b.md" }));
|
||||
expect(open.mock.calls).toEqual([
|
||||
["E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx"],
|
||||
["C:/src/a.ts"],
|
||||
["D:\\My Docs\\b.md"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens a markdown link to a file instead of navigating", () => {
|
||||
const open = withOpen("[the panel](client/src/A%20B.tsx#L12) and [web](https://x.dev)");
|
||||
fireEvent.click(screen.getByRole("button", { name: "the panel" }));
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @file MessageList.agents.test.tsx
|
||||
* @description Verifies a live chat's background-agents card reads each
|
||||
* running agent's own transcript (by the agentId its launch result names)
|
||||
* and shows the step it is on and its context size (issue #81).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MessageList, agentProgress } from "../MessageList";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { sessions: { transcript: vi.fn() } } };
|
||||
});
|
||||
|
||||
const at = new Date().toISOString();
|
||||
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
|
||||
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
|
||||
|
||||
// The subagent's own transcript: its task, then a search still running.
|
||||
const tail: TranscriptMessage[] = [
|
||||
m({ sender: "orchestrator", content: [{ type: "text", text: "Find copyId" }] }),
|
||||
m({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
stop_reason: "tool_use",
|
||||
usage: { input_tokens: 300, output_tokens: 20, cache_read_input_tokens: 54_000 },
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "g1",
|
||||
name: "Grep",
|
||||
input: { pattern: "copyId", description: "Searching for copyId" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const main: TranscriptMessage[] = [
|
||||
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
|
||||
m({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
stop_reason: "tool_use",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "t1",
|
||||
name: "Agent",
|
||||
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
m({
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: JSON.stringify([
|
||||
{ type: "text", text: "Async agent launched successfully.\nagentId: a4118367b503adecc" },
|
||||
]),
|
||||
},
|
||||
],
|
||||
}),
|
||||
m({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
stop_reason: "end_turn",
|
||||
content: [{ type: "text", text: "waiting" }],
|
||||
}),
|
||||
];
|
||||
|
||||
describe("background agents' progress", () => {
|
||||
const rects = Element.prototype.getClientRects;
|
||||
beforeEach(() => {
|
||||
// jsdom lays nothing out: make the card count as on screen.
|
||||
Element.prototype.getClientRects = () => [{}] as unknown as DOMRectList;
|
||||
vi.mocked(api.sessions.transcript).mockResolvedValue({
|
||||
messages: tail,
|
||||
total: 2,
|
||||
has_more: false,
|
||||
first_line: 1,
|
||||
last_line: 2,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
Element.prototype.getClientRects = rects;
|
||||
vi.mocked(api.sessions.transcript).mockReset();
|
||||
});
|
||||
|
||||
it("reads the step an agent is on and its context", () => {
|
||||
expect(agentProgress(tail)).toEqual({ step: "Searching for copyId", context: 54_300 });
|
||||
expect(agentProgress(tail.slice(0, 1))).toEqual({ step: null, context: 0 });
|
||||
});
|
||||
|
||||
it("shows them on the agent's line, from its own transcript", async () => {
|
||||
render(<MessageList live loading={false} messages={main} sessionId="s1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("chat-background-agent-step")).toHaveTextContent(
|
||||
"Searching for copyId"
|
||||
)
|
||||
);
|
||||
expect(api.sessions.transcript).toHaveBeenCalledWith("s1", {
|
||||
agent_id: "a4118367b503adecc",
|
||||
limit: 20,
|
||||
});
|
||||
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent("↓ 54.3K tokens");
|
||||
});
|
||||
|
||||
it("reads nothing without the session", () => {
|
||||
render(<MessageList live loading={false} messages={main} />);
|
||||
expect(screen.getByTestId("chat-background-agent")).toBeInTheDocument();
|
||||
expect(api.sessions.transcript).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,10 @@
|
||||
* Desktop's "Edited N files" card: one row per file with the lines added and
|
||||
* removed summed over the turn, a failed call left out, a row click handed
|
||||
* to the caller, and a row (or the header, every file) dragged as `@path`
|
||||
* attachments for a prompt box.
|
||||
* attachments for a prompt box. Files a subagent changed (its Agent
|
||||
* result's `edits`, read from its own transcript) are counted in the same
|
||||
* card, never as an artifact; so are the files a turn changed before the
|
||||
* loaded page began (`leadEdits`, the page's `turn_edits`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
@@ -46,7 +49,7 @@ describe("the Edited N files card", () => {
|
||||
user("fix it"),
|
||||
call("1", "Edit", "/w/a.ts"),
|
||||
result("1", { added: 3, removed: 1 }),
|
||||
call("2", "Write", "/w/b.md"),
|
||||
call("2", "Write", "/w/b.json"),
|
||||
result("2", { added: 10, removed: 0 }),
|
||||
call("3", "Edit", "/w/a.ts"),
|
||||
result("3", { added: 2, removed: 2 }),
|
||||
@@ -65,8 +68,35 @@ describe("the Edited N files card", () => {
|
||||
expect(within(card).getByText("+15")).toBeTruthy();
|
||||
expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added
|
||||
expect(within(card).queryByText("c.ts")).toBeNull(); // failed
|
||||
fireEvent.click(within(card).getByTitle("/w/b.md"));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
|
||||
fireEvent.click(within(card).getByTitle("/w/b.json"));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/b.json");
|
||||
});
|
||||
|
||||
it("shows a page or document the turn made as an artifact card, opened in the chat's panel", () => {
|
||||
const onOpenFile = vi.fn();
|
||||
const onOpenArtifact = vi.fn();
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
messages={[
|
||||
user("make a page"),
|
||||
call("1", "Write", "/w/page.html"),
|
||||
result("1", { added: 20, removed: 0 }),
|
||||
call("2", "Edit", "/w/App.tsx"),
|
||||
result("2", { added: 1, removed: 1 }),
|
||||
user("thanks"),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const cards = screen.getAllByTestId("artifact-card");
|
||||
// A .tsx it only edited is project code, not an artifact.
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]!.textContent).toContain("page");
|
||||
fireEvent.click(cards[0]!);
|
||||
expect(onOpenArtifact).toHaveBeenCalledWith("/w/page.html");
|
||||
expect(within(screen.getByTestId("edited-files")).getByTitle("/w/App.tsx")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("drags a row as its @path attachment, and the header as every file", () => {
|
||||
@@ -77,7 +107,7 @@ describe("the Edited N files card", () => {
|
||||
user("fix it"),
|
||||
call("1", "Edit", "/w/a.ts"),
|
||||
result("1", { added: 1, removed: 0 }),
|
||||
call("2", "Write", "/w/b.md"),
|
||||
call("2", "Write", "/w/b.json"),
|
||||
result("2", { added: 2, removed: 0 }),
|
||||
]}
|
||||
/>
|
||||
@@ -90,14 +120,81 @@ describe("the Edited N files card", () => {
|
||||
});
|
||||
return data;
|
||||
};
|
||||
const one = drag(within(card).getByTitle("/w/b.md"));
|
||||
const one = drag(within(card).getByTitle("/w/b.json"));
|
||||
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
|
||||
{ path: "@/w/b.md", name: "b.md" },
|
||||
{ path: "@/w/b.json", name: "b.json" },
|
||||
]);
|
||||
expect(one["text/plain"]).toBe("/w/b.md");
|
||||
expect(one["text/plain"]).toBe("/w/b.json");
|
||||
const all = drag(within(card).getByText("Edited 2 files"));
|
||||
expect(
|
||||
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
||||
).toEqual(["@/w/a.ts", "@/w/b.md"]);
|
||||
).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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
* layout), Main agent / System under their own label — instead of treating
|
||||
* every `type:"user"` line as the user (reported transcript mis-attribution) —
|
||||
* and folds a run of tool calls into one line; a typed `/command` or `@path`
|
||||
* shows as a chip.
|
||||
* shows as a chip. A live chat lists the background subagents still running
|
||||
* and does not call their turn done (issue #81).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
|
||||
import { MessageList, backgroundAgents, turnStats, workLabel, workingOn } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||
@@ -137,7 +138,28 @@ describe("MessageList — sender attribution", () => {
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/↓ 123/)).toBeTruthy();
|
||||
// In the hover bar's tooltip, beside the model and time.
|
||||
expect(screen.getByTestId("chat-actions").getAttribute("title")).toMatch(/↓ 123/);
|
||||
});
|
||||
|
||||
it("shows a local command as one line with its output, never its caveat", () => {
|
||||
const line = (text: string, sender: "user" | "system" = "user") =>
|
||||
msg({ type: "user", sender, content: [{ type: "text", text }] });
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[
|
||||
line("<local-command-caveat>Caveat: the command below…</local-command-caveat>", "system"),
|
||||
line("<command-name>/model</command-name><command-args>opus</command-args>"),
|
||||
line("<local-command-stdout>Set model to Opus</local-command-stdout>"),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const row = screen.getByTestId("chat-command");
|
||||
expect(row).toHaveTextContent("/model opus");
|
||||
expect(row).toHaveTextContent("Set model to Opus");
|
||||
expect(screen.queryByText(/Caveat/)).toBeNull();
|
||||
expect(screen.queryByTestId("chat-user")).toBeNull();
|
||||
});
|
||||
|
||||
it("ends a live chat with the step still running", () => {
|
||||
@@ -167,7 +189,9 @@ describe("MessageList — sender attribution", () => {
|
||||
const row = screen.getByTestId("chat-running-step");
|
||||
expect(row).toHaveTextContent("Run the full client suite");
|
||||
// Like the terminal: the turn's time since the prompt, and its output.
|
||||
expect(row).toHaveTextContent(/\(1m \ds · ↑ 3\.5K tokens\)/);
|
||||
// (the output shows on hover).
|
||||
expect(row).toHaveTextContent(/1m \ds/);
|
||||
expect(row).toHaveTextContent(/↑ 3\.5K tokens/);
|
||||
// Its result arrived: nothing is running any more.
|
||||
rerender(
|
||||
<MessageList
|
||||
@@ -340,7 +364,88 @@ describe("MessageList — sender attribution", () => {
|
||||
content: [{ type: "text", text: "[Request interrupted by user]" }],
|
||||
});
|
||||
expect(state([prompt, call, result, stopped])).toBeNull();
|
||||
// A local command (`/clear`) is answered by the CLI itself: no turn.
|
||||
const local = (text: string) =>
|
||||
msg({ type: "user", sender: "user", timestamp: at, content: [{ type: "text", text }] });
|
||||
const clear = local("<command-name>/clear</command-name>");
|
||||
const out = local("<local-command-stdout></local-command-stdout>");
|
||||
expect(state([clear, out])).toBeNull();
|
||||
// A prompt command (a skill) is still answered by the model.
|
||||
expect(state([clear])).toEqual(thinking);
|
||||
// Silent for 15 minutes with nothing running: the turn died.
|
||||
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
|
||||
});
|
||||
|
||||
describe("background subagents", () => {
|
||||
const at = new Date().toISOString();
|
||||
const launch = msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
stop_reason: "tool_use",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "t1",
|
||||
name: "Agent",
|
||||
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const launched = msg({
|
||||
timestamp: at,
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: JSON.stringify([{ type: "text", text: "Async agent launched successfully." }]),
|
||||
},
|
||||
],
|
||||
});
|
||||
const reply = msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
stop_reason: "end_turn",
|
||||
content: [{ type: "text", text: "waiting for the agent" }],
|
||||
});
|
||||
const notified = msg({
|
||||
sender: "system",
|
||||
timestamp: at,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "<task-notification>\n<task-id>a1</task-id>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n</task-notification>",
|
||||
},
|
||||
],
|
||||
});
|
||||
const base = [msg({ sender: "user", timestamp: at }), launch, launched, reply];
|
||||
|
||||
it("lists an agent until its notification names the call", () => {
|
||||
const results = (m: TranscriptMessage[]) =>
|
||||
new Map(
|
||||
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
|
||||
);
|
||||
expect(backgroundAgents(base, results(base)).map((a) => a.block.id)).toEqual(["t1"]);
|
||||
const all = [...base, notified];
|
||||
expect(backgroundAgents(all, results(all))).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not call the turn done while the agent works", () => {
|
||||
const { rerender } = render(<MessageList live loading={false} messages={base} />);
|
||||
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent(
|
||||
"ExploreChecking copyId coverage"
|
||||
);
|
||||
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Replied in");
|
||||
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("1 agent still running");
|
||||
rerender(<MessageList live loading={false} messages={[...base, notified]} />);
|
||||
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
|
||||
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in");
|
||||
});
|
||||
|
||||
it("shows nothing once the session is no longer live", () => {
|
||||
render(<MessageList loading={false} messages={base} />);
|
||||
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
// ───────────────────────── Copy button ─────────────────────────
|
||||
|
||||
@@ -111,13 +112,9 @@ export function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard API can fail in insecure contexts - silently ignore.
|
||||
}
|
||||
if (!(await copyText(text, true))) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,777 @@
|
||||
/**
|
||||
* @file HostHealth.tsx
|
||||
* @description The machine AgentHub runs on, as part of the Dashboard's
|
||||
* System Health tab (`GET /api/host`, server/lib/host-stats.js — every
|
||||
* signed-in user sees all of it). In the tab's own card style:
|
||||
* `HostCard` — hostname, OS, uptime, CPU model and load, the AgentHub process
|
||||
* (pid, uptime, Node RSS) and bars for CPU, memory, swap and the V8 heap;
|
||||
* `HostSections` — the tab's whole "machine" group: `HostCard` beside the
|
||||
* CPU / memory history (`GET /api/host/history`, kept in memory since the
|
||||
* server started); then CPU (every thread as a compact cell, temperatures,
|
||||
* GPUs), disks (how full, read / write rate) with network interfaces, and the
|
||||
* busiest processes with who each counts for (history, CPU threads and
|
||||
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
|
||||
* live CPU / RAM share (a tile per user; click for their busiest processes; hover
|
||||
* for — admin/analyst, from `GET /api/users` — their role, $ spent today,
|
||||
* workspaces, sessions and last sign-in). Also exports `useHostStats` (a poll every `pollMs` while
|
||||
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
|
||||
* Administration users table shares for each home's share of the homes disk.
|
||||
* Read-only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Activity,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
ListTree,
|
||||
Network,
|
||||
Server,
|
||||
Thermometer,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type HostHistory,
|
||||
type HostHistoryRange,
|
||||
type HostStats,
|
||||
type HostUsageGroup,
|
||||
type ServerUserUsage,
|
||||
} from "../../lib/api";
|
||||
import { fmt, fmtCost, formatBytes, formatDateTime } from "../../lib/format";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { Tip } from "../Tip";
|
||||
import { MiniChart } from "../admin/MiniChart";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
const HISTORY_POLL_MS = 30000;
|
||||
const USERS_POLL_MS = 60000;
|
||||
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
|
||||
|
||||
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
export const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||
|
||||
/** `GET /api/host`, re-read every `POLL_MS` while the tab is visible. */
|
||||
export type HostState = {
|
||||
stats: HostStats | null;
|
||||
error: string | null;
|
||||
reload: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function useHostStats(enabled = true, pollMs = POLL_MS): HostState {
|
||||
const [stats, setStats] = useState<HostStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const reload = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.stats()
|
||||
.then((s) => {
|
||||
setStats(s);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
void reload();
|
||||
if (!pollMs) return;
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void reload();
|
||||
}, pollMs);
|
||||
return () => clearInterval(id);
|
||||
}, [enabled, pollMs, reload]);
|
||||
return { stats, error, reload };
|
||||
}
|
||||
|
||||
/** A thin bar, amber past 75 %, red past 90 %. */
|
||||
export function Meter({ value, label }: { value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
|
||||
return (
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
|
||||
>
|
||||
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The System Health tab's own bar: h-2, green / amber / red by level. */
|
||||
function HealthBar({
|
||||
label,
|
||||
value,
|
||||
tip,
|
||||
warn = 70,
|
||||
bad = 90,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
tip?: string;
|
||||
warn?: number;
|
||||
bad?: number;
|
||||
}) {
|
||||
const v = value == null ? 0 : Math.max(0, Math.min(100, value));
|
||||
const tone = v > bad ? "bg-status-danger" : v > warn ? "bg-status-warning" : "bg-status-success";
|
||||
return (
|
||||
<Tip block raw={tip}>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-fg-muted">{label}</span>
|
||||
<span className="text-fg-secondary font-mono">
|
||||
{value == null ? "—" : `${Math.round(v)}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="w-full bg-surface-3 rounded-full h-2"
|
||||
>
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-700 ${tone}`}
|
||||
style={{ width: `${v}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tip>
|
||||
);
|
||||
}
|
||||
|
||||
/** One CPU thread in the CPU card's compact grid: its number, % and a thin bar. */
|
||||
function CoreCell({ n, value, label }: { n: number; value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v > 90 ? "text-status-danger" : v > 70 ? "text-status-warning" : "text-fg-secondary";
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-fg-muted">{n}</span>
|
||||
<span className={tone}>{Math.round(v)}%</span>
|
||||
</div>
|
||||
<Meter value={v} label={label} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHead({
|
||||
icon: Icon,
|
||||
tone,
|
||||
title,
|
||||
right,
|
||||
}: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
tone: string;
|
||||
title: string;
|
||||
right?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Icon className={`w-4 h-4 flex-shrink-0 ${tone}`} />
|
||||
<span className="text-xs text-fg-muted uppercase tracking-wider truncate">{title}</span>
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{label}</span>
|
||||
<span className="ml-auto min-w-0 truncate text-right text-xs text-fg-secondary font-mono">
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return d > 0 ? t("host.uptimeDays", { d, h }) : t("host.uptimeHours", { h, m });
|
||||
}
|
||||
|
||||
/** The process's own heap, from `GET /api/settings/info` (the host read has none). */
|
||||
export type HeapInfo = {
|
||||
heapUsed: number;
|
||||
heapTotal: number;
|
||||
heapLimit?: number;
|
||||
external: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The tab's first card: the machine and the AgentHub process on it.
|
||||
* `heap` comes from the tab's settings read.
|
||||
*/
|
||||
export function HostCard({ host, heap }: { host: HostState; heap?: HeapInfo }) {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const s = host.stats;
|
||||
const mem = s?.memory;
|
||||
const heapMax = heap ? heap.heapLimit || heap.heapTotal : 0;
|
||||
const heapPct = heap && heapMax > 0 ? (heap.heapUsed / heapMax) * 100 : null;
|
||||
return (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-facts">
|
||||
<CardHead
|
||||
icon={Server}
|
||||
tone="text-status-success"
|
||||
title={t("host.title")}
|
||||
right={
|
||||
s && (
|
||||
<span className="text-[10px] font-mono text-fg-muted truncate" title={s.hostname}>
|
||||
{s.hostname}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{!s ? (
|
||||
<p className="text-xs text-fg-muted">{host.error ?? t("host.sampling")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2.5">
|
||||
<Row label={t("host.os")}>
|
||||
{s.os.distro ?? s.os.platform} · {s.os.release}
|
||||
</Row>
|
||||
<Row label={t("host.uptime")}>{uptimeText(t, s.uptime)}</Row>
|
||||
<Row label={t("host.cpuModel")}>
|
||||
<Tip raw={s.cpu.model ?? undefined}>
|
||||
<span>
|
||||
{s.cpu.physicalCores
|
||||
? `${t("host.physical", { n: s.cpu.physicalCores })} · `
|
||||
: ""}
|
||||
{t("host.threads", { n: s.cpu.cores })} · {s.os.arch}
|
||||
</span>
|
||||
</Tip>
|
||||
</Row>
|
||||
{s.os.platform !== "win32" && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">
|
||||
{t("host.load")}
|
||||
</span>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
{s.cpu.load.slice(0, 3).map((load, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > s.cpu.cores ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
|
||||
>
|
||||
{load.toFixed(2)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{s.process && (
|
||||
<Row label={t("host.agenthub")}>
|
||||
<Tip raw={t("host.agenthubTip", { pid: s.process.pid, node: s.process.node })}>
|
||||
<span>
|
||||
{uptimeText(t, s.process.uptime)} · {formatBytes(s.process.rss)}
|
||||
</span>
|
||||
</Tip>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-border/40 pt-3 space-y-3">
|
||||
{s.usage && (
|
||||
<HealthBar label={t("host.cpu")} value={s.usage.cpu} tip={t("host.cpuHelp")} />
|
||||
)}
|
||||
<HealthBar
|
||||
label={t("host.memory")}
|
||||
value={mem ? pct(mem.used, mem.total) : null}
|
||||
tip={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
|
||||
/>
|
||||
{mem && mem.swapTotal > 0 && (
|
||||
<HealthBar
|
||||
label={t("host.swap")}
|
||||
value={pct(mem.swapUsed, mem.swapTotal)}
|
||||
tip={`${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}`}
|
||||
/>
|
||||
)}
|
||||
{heap && (
|
||||
<HealthBar
|
||||
label={t("health.heap")}
|
||||
value={heapPct}
|
||||
warn={60}
|
||||
bad={85}
|
||||
tip={t("health.heapTip", {
|
||||
pct: (heapPct ?? 0).toFixed(1),
|
||||
used: formatBytes(heap.heapUsed),
|
||||
limit: formatBytes(heapMax),
|
||||
allocated: formatBytes(heap.heapTotal),
|
||||
external: formatBytes(heap.external),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The tab's machine group: host + history, CPU / disks / processes, users. */
|
||||
export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo }) {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { stats } = host;
|
||||
const [range, setRange] = useState<HostHistoryRange>("1h");
|
||||
const [history, setHistory] = useState<HostHistory | null>(null);
|
||||
// The user tile whose busiest processes are shown.
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
// Each account's role and spend for the tiles' hover card. `GET /api/users`
|
||||
// is admin/analyst only, so a developer gets no card.
|
||||
const { seesAll, local } = usePermissions();
|
||||
const { t: tu } = useTranslation("users");
|
||||
const [accounts, setAccounts] = useState<Map<number, ServerUserUsage>>(new Map());
|
||||
const hasPeople = (stats?.usage?.groups ?? []).some((g) => g.kind === "user");
|
||||
useEffect(() => {
|
||||
if (!seesAll || local || !hasPeople) return;
|
||||
const load = () =>
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setAccounts(new Map(r.users.map((u) => [u.id, u]))))
|
||||
.catch(() => {});
|
||||
void load();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
}, USERS_POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [seesAll, local, hasPeople]);
|
||||
|
||||
const loadHistory = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.history(range)
|
||||
.then(setHistory)
|
||||
.catch(() => setHistory(null)),
|
||||
[range]
|
||||
);
|
||||
useEffect(() => {
|
||||
void loadHistory();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void loadHistory();
|
||||
}, HISTORY_POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [loadHistory]);
|
||||
|
||||
// Same tree as the full render below, so the card is not remounted once stats arrive.
|
||||
if (!stats)
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<HostCard host={host} heap={heap} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const groups = stats.usage?.groups ?? [];
|
||||
const groupName = (g: HostUsageGroup) => {
|
||||
if (g.kind === "host") return t("host.hostGroup");
|
||||
if (g.kind === "system") return t("host.systemGroup");
|
||||
return g.displayName || g.username || "";
|
||||
};
|
||||
const groupByKey = new Map(groups.map((g) => [g.key, g]));
|
||||
const ownerOf = (key: string) => {
|
||||
const g = groupByKey.get(key);
|
||||
if (g) return groupName(g);
|
||||
if (key === "host") return t("host.hostGroup");
|
||||
if (key === "system") return t("host.systemGroup");
|
||||
return key;
|
||||
};
|
||||
const userTip = (g: HostUsageGroup) => {
|
||||
const u = g.userId != null ? accounts.get(g.userId) : undefined;
|
||||
// No account row (a developer, or not read yet): no card at all.
|
||||
if (!u) return "";
|
||||
const lines = [groupName(g)];
|
||||
{
|
||||
lines.push(
|
||||
t("host.userTipRole", { v: tu(`roles.${u.role}`) }) +
|
||||
(u.disabled ? ` · ${t("host.userTipDisabled")}` : "")
|
||||
);
|
||||
if (u.today)
|
||||
lines.push(
|
||||
t("host.userTipToday", { cost: fmtCost(u.today.cost), tokens: fmt(u.today.tokens) })
|
||||
);
|
||||
lines.push(t("host.userTipWork", { w: u.workspaces, s: u.sessions }));
|
||||
lines.push(
|
||||
t("host.userTipLastLogin", {
|
||||
v: u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("host.userTipNever"),
|
||||
})
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
};
|
||||
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
|
||||
const timeLabel = (iso: string) =>
|
||||
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
const histPoints = (key: "cpu" | "memory") =>
|
||||
(history?.points ?? [])
|
||||
.filter((p) => p[key] != null)
|
||||
.map((p) => ({ x: timeLabel(p.at), y: p[key] as number }));
|
||||
const perCore = stats.cpu.perCore ?? [];
|
||||
const network = stats.network ?? [];
|
||||
const temps = stats.temperatures ?? [];
|
||||
const topProcs = stats.topProcesses ?? [];
|
||||
const others = groups.filter((g) => g.kind !== "user");
|
||||
const people = groups.filter((g) => g.kind === "user");
|
||||
// History, per-thread CPU and processes are read from /proc: on Windows and
|
||||
// macOS their cards would only ever be empty, so they are left out.
|
||||
const linux = stats.os.platform === "linux";
|
||||
const showCores = perCore.length > 0 || temps.length > 0 || stats.gpus.length > 0;
|
||||
const showProcs = !!stats.usage || others.length > 0;
|
||||
|
||||
// A whole disk's I/O goes on the first mounted file system that lives on it
|
||||
// (`/dev/sda1` → `sda`, `/dev/nvme0n1p2` → `nvme0n1`); a disk with nothing
|
||||
// mounted gets a row of its own.
|
||||
const ioLeft = new Map((stats.diskIo ?? []).map((d) => [d.name, d]));
|
||||
const diskRows = stats.disks.map((d) => {
|
||||
const base = d.device.split("/").pop() ?? "";
|
||||
const key = [base, base.replace(/p?\d+$/, "")].find((k) => ioLeft.has(k));
|
||||
const io = key ? ioLeft.get(key) : undefined;
|
||||
if (key) ioLeft.delete(key);
|
||||
return { disk: d, io };
|
||||
});
|
||||
const loneIo = [...ioLeft.values()];
|
||||
|
||||
const rate = (down?: number, up?: number) => (
|
||||
<span className="flex items-center gap-2 text-[10px] font-mono text-fg-muted">
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<ArrowDown className="w-2.5 h-2.5" />
|
||||
{down == null ? "—" : perSec(down)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<ArrowUp className="w-2.5 h-2.5" />
|
||||
{up == null ? "—" : perSec(up)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<HostCard host={host} heap={heap} />
|
||||
|
||||
{/* CPU and memory over time. */}
|
||||
{linux && (
|
||||
<div className="card p-5 flex flex-col gap-4 lg:col-span-2" data-testid="server-history">
|
||||
<CardHead
|
||||
icon={Activity}
|
||||
tone="text-accent"
|
||||
title={t("host.historyTitle")}
|
||||
right={
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("host.range")}
|
||||
className="flex gap-1 rounded-lg border border-border bg-surface-1 p-0.5"
|
||||
>
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => setRange(r)}
|
||||
aria-pressed={range === r}
|
||||
className={`rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||
range === r
|
||||
? "bg-surface-4 text-fg-secondary"
|
||||
: "text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{t(`host.range${r}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyCpu")}</div>
|
||||
<MiniChart
|
||||
kind="line"
|
||||
points={histPoints("cpu")}
|
||||
max={100}
|
||||
format={fmtPct}
|
||||
label={t("host.historyCpu")}
|
||||
emptyText={t("host.historyNone")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyMemory")}</div>
|
||||
<MiniChart
|
||||
kind="line"
|
||||
points={histPoints("memory")}
|
||||
max={100}
|
||||
format={fmtPct}
|
||||
label={t("host.historyMemory")}
|
||||
emptyText={t("host.historyNone")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{history?.startedAt && (
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
{t("host.historyHelp", {
|
||||
s: history.intervalMs / 1000,
|
||||
at: formatDateTime(history.startedAt),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 empty:hidden">
|
||||
{/* CPU: every thread as a compact cell, then temperatures and GPUs. */}
|
||||
{showCores && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-cores">
|
||||
<CardHead
|
||||
icon={Cpu}
|
||||
tone="text-syntax-keyword"
|
||||
title={t("host.cpu")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.threads", { n: perCore.length || stats.cpu.cores })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{perCore.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-x-3 gap-y-2">
|
||||
{perCore.map((v, i) => (
|
||||
<CoreCell key={i} n={i} value={v} label={t("host.coreN", { n: i })} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{temps.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-2"
|
||||
data-testid="server-temperatures"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-fg-muted uppercase tracking-wider">
|
||||
<Thermometer className="w-3 h-3 text-status-warning" />
|
||||
{t("host.temperatures")}
|
||||
</div>
|
||||
{temps.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary min-w-0 truncate" title={s.chip}>
|
||||
{s.label} <span className="text-fg-muted">· {s.chip}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded ${
|
||||
s.celsius >= 90
|
||||
? "bg-status-danger/20 text-status-danger"
|
||||
: s.celsius >= 75
|
||||
? "bg-status-warning/20 text-status-warning"
|
||||
: "bg-surface-3 text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{s.celsius.toFixed(0)} °C
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{stats.gpus.length > 0 && (
|
||||
<div className="border-t border-border/40 pt-3 space-y-3">
|
||||
{stats.gpus.map((g, i) => (
|
||||
<HealthBar
|
||||
key={i}
|
||||
label={g.name}
|
||||
value={g.utilization}
|
||||
tip={`${formatBytes(g.memoryUsed)} / ${formatBytes(g.memoryTotal)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disks (with their I/O) and network interfaces. */}
|
||||
{(diskRows.length > 0 || loneIo.length > 0 || network.length > 0) && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-disks-table">
|
||||
<CardHead
|
||||
icon={HardDrive}
|
||||
tone="text-syntax-builtin"
|
||||
title={t("host.storageNetwork")}
|
||||
/>
|
||||
<div className="space-y-3" data-testid="server-disk-io">
|
||||
{diskRows.map(({ disk: d, io }) => (
|
||||
<div key={d.device} className="space-y-1">
|
||||
<HealthBar
|
||||
label={`${d.mount}${d.kind ? ` · ${d.kind}` : ""}${d.mount === stats.homesMount ? ` · ${t("host.homesBadge")}` : ""}`}
|
||||
value={pct(d.used, d.total)}
|
||||
warn={75}
|
||||
tip={`${d.device} · ${d.type}\n${formatBytes(d.used)} / ${formatBytes(d.total)} · ${formatBytes(d.free)} ${t("host.free")}`}
|
||||
/>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{formatBytes(d.free)} {t("host.free")}
|
||||
</span>
|
||||
{io && rate(io.read, io.write)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loneIo.map((io) => (
|
||||
<div key={io.name} className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary font-mono">{io.name}</span>
|
||||
<span className="ml-auto">{rate(io.read, io.write)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{network.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-2"
|
||||
data-testid="server-network"
|
||||
>
|
||||
{network.map((n) => (
|
||||
<div key={n.name} className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-fg-secondary">
|
||||
<Network className="w-3 h-3 text-fg-muted" />
|
||||
<span className="font-mono">{n.name}</span>
|
||||
</span>
|
||||
<span className="ml-auto">{rate(n.rx, n.tx)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The busiest processes, and what belongs to no user. */}
|
||||
{showProcs && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-top-processes">
|
||||
<CardHead
|
||||
icon={ListTree}
|
||||
tone="text-syntax-function"
|
||||
title={t("host.topProcesses")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.sampled", { s: (stats.usage?.sampleMs ?? 1000) / 1000 })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{!stats.usage ? (
|
||||
<p className="text-xs text-fg-muted">{t("host.usageUnavailable")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topProcs.map((p) => (
|
||||
<Tip block key={p.pid} raw={`#${p.pid} · ${ownerOf(p.group)}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="min-w-0 flex-1 truncate text-xs">
|
||||
<span className="font-mono text-fg-secondary">{p.name}</span>
|
||||
<span className="ml-1.5 text-[10px] text-fg-muted">{ownerOf(p.group)}</span>
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{formatBytes(p.memory)}
|
||||
</span>
|
||||
<span
|
||||
className={`w-12 text-right text-[10px] font-mono px-1.5 py-0.5 rounded ${p.cpu >= 50 ? "bg-status-warning/20 text-status-warning" : "bg-surface-3 text-fg-secondary"}`}
|
||||
>
|
||||
{fmtPct(p.cpu)}
|
||||
</span>
|
||||
</div>
|
||||
</Tip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{others.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-1.5"
|
||||
data-testid="server-usage-table"
|
||||
>
|
||||
{others.map((g) => (
|
||||
<div key={g.key} className="flex items-center gap-3 text-[10px]">
|
||||
<span className="min-w-0 flex-1 truncate text-fg-muted" title={groupName(g)}>
|
||||
{groupName(g)}
|
||||
</span>
|
||||
<span className="font-mono text-fg-secondary">
|
||||
{fmtPct(g.cpu)} · {formatBytes(g.memory)} ·{" "}
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Each user's live share of the machine (server mode). */}
|
||||
{people.length > 0 && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-users">
|
||||
<CardHead
|
||||
icon={Users}
|
||||
tone="text-syntax-string"
|
||||
title={t("host.usersTitle")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.usersCount", { n: people.length })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{people.map((g) => {
|
||||
const name = groupName(g);
|
||||
const isOpen = open === g.key;
|
||||
return (
|
||||
<div key={g.key} className="space-y-2" data-testid="server-user">
|
||||
{/* The hover card sits on the name row only: over the whole
|
||||
tile it stacked on the CPU bar's own help tip. */}
|
||||
<Tip raw={userTip(g)} block>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
<UserAvatar
|
||||
name={name}
|
||||
userId={g.userId}
|
||||
avatarAt={g.avatarAt}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</button>
|
||||
</Tip>
|
||||
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
|
||||
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
|
||||
{isOpen && (
|
||||
<ul className="space-y-1 rounded-md bg-surface-2/60 px-2 py-1.5">
|
||||
{g.top.map((p) => (
|
||||
<li
|
||||
key={p.pid}
|
||||
className="flex justify-between gap-2 text-[10px] font-mono"
|
||||
>
|
||||
<span className="truncate text-fg-secondary">
|
||||
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-fg-muted">
|
||||
{fmtPct(p.cpu)} · {formatBytes(p.memory)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* @file HostHealth.test.tsx
|
||||
* @description The machine on the Dashboard's System Health tab
|
||||
* (`HostHealth.tsx`, `GET /api/host`), its whole machine group: the first card names the host, its OS,
|
||||
* load and the AgentHub process with bars for CPU, memory, swap and heap; the
|
||||
* rows under it show every CPU thread, temperatures, each disk with its I/O
|
||||
* rate and each network interface, the CPU / memory history with its range
|
||||
* switch (`GET /api/host/history`), and the busiest processes with who each
|
||||
* counts for, the processes of no user in a short list of their own, and
|
||||
* each user's live CPU / RAM as a tile (their busiest processes on click; on
|
||||
* hover, for staff, their role and $ spent today);
|
||||
* off Linux the cards it can never fill are left out.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import type { HostStats } from "../../../lib/api";
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
const stats: HostStats = {
|
||||
at: "2026-10-15T00:00:00Z",
|
||||
hostname: "box",
|
||||
os: { platform: "linux", release: "7.0.0", distro: "Ubuntu 26.04", arch: "x64" },
|
||||
uptime: 90000,
|
||||
cpu: { model: "i5-7500", cores: 4, load: [1, 0.5, 0.25] },
|
||||
memory: { total: 16 * GB, available: 8 * GB, used: 8 * GB, swapTotal: 4 * GB, swapUsed: 0 },
|
||||
disks: [
|
||||
{
|
||||
device: "/dev/sdb2",
|
||||
mount: "/",
|
||||
type: "ext4",
|
||||
kind: "SSD",
|
||||
total: 200 * GB,
|
||||
used: 50 * GB,
|
||||
free: 150 * GB,
|
||||
},
|
||||
{
|
||||
device: "/dev/sda1",
|
||||
mount: "/data",
|
||||
type: "ext4",
|
||||
kind: "HDD",
|
||||
total: 500 * GB,
|
||||
used: 0,
|
||||
free: 500 * GB,
|
||||
},
|
||||
],
|
||||
homesMount: "/",
|
||||
gpus: [],
|
||||
usage: {
|
||||
cpu: 40,
|
||||
sampleMs: 1000,
|
||||
groups: [
|
||||
{
|
||||
key: "user:2",
|
||||
kind: "user",
|
||||
userId: 2,
|
||||
username: "mia",
|
||||
cpu: 25,
|
||||
memory: 2 * GB,
|
||||
memoryPercent: 12.5,
|
||||
processes: 3,
|
||||
top: [{ pid: 77, name: "claude", cpu: 20, memory: GB }],
|
||||
},
|
||||
{ key: "host", kind: "host", cpu: 5, memory: GB, memoryPercent: 6.3, processes: 90, top: [] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
host: { stats: vi.fn(), history: vi.fn() },
|
||||
me: { avatarUrl: () => null },
|
||||
users: { avatarUrl: () => null, list: vi.fn() },
|
||||
}));
|
||||
const perms = vi.hoisted(() => ({ seesAll: true, local: true, isAdmin: true }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api };
|
||||
});
|
||||
|
||||
import { HostSections, useHostStats } from "../HostHealth";
|
||||
|
||||
function Tab() {
|
||||
const host = useHostStats();
|
||||
return (
|
||||
<HostSections
|
||||
host={host}
|
||||
heap={{ heapUsed: GB / 4, heapTotal: GB / 2, heapLimit: GB, external: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const full: HostStats = {
|
||||
...stats,
|
||||
usage: {
|
||||
...stats.usage!,
|
||||
groups: stats.usage!.groups.map((g) => (g.kind === "user" ? { ...g, displayName: "Mia" } : g)),
|
||||
},
|
||||
bootTime: "2026-10-14T00:00:00Z",
|
||||
cpu: { ...stats.cpu, physicalCores: 4, perCore: [10, 20, 30, 40] },
|
||||
network: [{ name: "enp4s0", rx: 2048, tx: 1024 }],
|
||||
diskIo: [{ name: "sda", read: 4096, write: 0 }],
|
||||
temperatures: [{ chip: "coretemp", label: "Package id 0", celsius: 55 }],
|
||||
topProcesses: [{ pid: 77, name: "claude", cpu: 20, memory: GB, group: "user:2" }],
|
||||
process: { pid: 9, node: "v24.0.0", uptime: 3600, rss: GB, heapUsed: GB / 2 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.assign(perms, { seesAll: true, local: true, isAdmin: true });
|
||||
api.host.stats.mockResolvedValue(full);
|
||||
api.host.history.mockResolvedValue({
|
||||
range: "1h",
|
||||
intervalMs: 30000,
|
||||
running: true,
|
||||
startedAt: "2026-10-15T00:00:00Z",
|
||||
points: [
|
||||
{
|
||||
at: "2026-10-15T00:00:00Z",
|
||||
cpu: 30,
|
||||
memory: 50,
|
||||
memoryUsed: 0,
|
||||
swapUsed: 0,
|
||||
load1: 1,
|
||||
users: {},
|
||||
},
|
||||
{
|
||||
at: "2026-10-15T00:00:30Z",
|
||||
cpu: 40,
|
||||
memory: 52,
|
||||
memoryUsed: 0,
|
||||
swapUsed: 0,
|
||||
load1: 1,
|
||||
users: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostHealth", () => {
|
||||
it("names the machine and the AgentHub process, with bars", async () => {
|
||||
render(<Tab />);
|
||||
const card = await screen.findByTestId("server-facts");
|
||||
expect(await within(card).findByText("box")).toBeTruthy();
|
||||
expect(card.textContent).toContain("Ubuntu 26.04");
|
||||
expect(card.textContent).toContain("1.0 GB");
|
||||
expect(within(card).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
|
||||
"40"
|
||||
);
|
||||
expect(within(card).getByRole("meter", { name: "Memory" }).getAttribute("aria-valuenow")).toBe(
|
||||
"50"
|
||||
);
|
||||
expect(within(card).getByRole("meter", { name: "V8 Heap" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows every thread, sensors, disks with I/O, network, history and processes", async () => {
|
||||
render(<Tab />);
|
||||
expect(await screen.findByTestId("server-history")).toBeTruthy();
|
||||
expect(await screen.findByRole("img", { name: /CPU/ })).toBeTruthy();
|
||||
expect(screen.getByTestId("server-cores").textContent).toContain("40%");
|
||||
expect(screen.getByTestId("server-temperatures").textContent).toContain("55 °C");
|
||||
const disks = screen.getByTestId("server-disk-io").textContent;
|
||||
expect(disks).toContain("/data · HDD");
|
||||
expect(disks).toContain("4.0 KB/s");
|
||||
expect(screen.getByTestId("server-network").textContent).toContain("enp4s0");
|
||||
const top = screen.getByTestId("server-top-processes");
|
||||
expect(await within(top).findByText("Mia")).toBeTruthy();
|
||||
const other = screen.getByTestId("server-usage-table");
|
||||
expect(within(other).queryByText("Mia")).toBeNull();
|
||||
expect(within(other).getByText(/^Host account/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "24 h" }));
|
||||
expect(api.host.history).toHaveBeenLastCalledWith("24h");
|
||||
});
|
||||
|
||||
it("shows each user's live CPU and RAM, their processes on click", async () => {
|
||||
render(<Tab />);
|
||||
const tile = await screen.findByTestId("server-user");
|
||||
expect(tile.textContent).toContain("Mia");
|
||||
expect(within(tile).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
|
||||
"25"
|
||||
);
|
||||
expect(tile.textContent).toContain("2.0 GB");
|
||||
expect(within(tile).queryByText("claude")).toBeNull();
|
||||
fireEvent.click(within(tile).getByRole("button", { expanded: false }));
|
||||
expect(within(tile).getByText("claude")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("names the role and today's spend when a user tile is hovered", async () => {
|
||||
Object.assign(perms, { local: false });
|
||||
api.users.list.mockResolvedValue({
|
||||
users: [
|
||||
{
|
||||
id: 2,
|
||||
username: "mia",
|
||||
role: "developer",
|
||||
disabled: false,
|
||||
workspaces: 2,
|
||||
sessions: 5,
|
||||
today: { cost: 1.5, tokens: 12000 },
|
||||
last7d: { cost: 0, tokens: 0 },
|
||||
last30d: { cost: 0, tokens: 0 },
|
||||
lastLoginAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<Tab />);
|
||||
const tile = await screen.findByTestId("server-user");
|
||||
// The card exists only once the users list has arrived.
|
||||
const nameRow = () => within(tile).getByRole("button").parentElement!;
|
||||
const tip = await vi.waitFor(() => {
|
||||
fireEvent.mouseEnter(nameRow());
|
||||
return screen.getByText(/Role: Developer/);
|
||||
});
|
||||
expect(tip.textContent).not.toContain("Login");
|
||||
expect(tip.textContent).toMatch(/Today: \$1\.5/);
|
||||
expect(tip.textContent).toContain("2 workspaces · 5 sessions");
|
||||
// Only the name row carries it: the CPU bar keeps its own help tip alone.
|
||||
fireEvent.mouseLeave(within(tile).getByRole("button").parentElement!);
|
||||
fireEvent.mouseEnter(within(tile).getByRole("meter", { name: "CPU" }));
|
||||
expect(screen.queryByText(/Role: Developer/)).toBeNull();
|
||||
});
|
||||
|
||||
it("gives a developer no hover card, without reading the users list", async () => {
|
||||
Object.assign(perms, { seesAll: false, local: false, isAdmin: false });
|
||||
render(<Tab />);
|
||||
const tile = await screen.findByTestId("server-user");
|
||||
fireEvent.mouseEnter(within(tile).getByRole("button").parentElement!);
|
||||
expect(screen.queryByText(/Today:/)).toBeNull();
|
||||
expect(api.users.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves out what Windows cannot report instead of empty cards", async () => {
|
||||
api.host.stats.mockResolvedValue({
|
||||
...stats,
|
||||
os: { platform: "win32", release: "10.0.26300", distro: null, arch: "x64" },
|
||||
cpu: { ...stats.cpu, load: [0, 0, 0], perCore: [] },
|
||||
disks: [],
|
||||
usage: null,
|
||||
});
|
||||
api.host.history.mockResolvedValue({
|
||||
range: "1h",
|
||||
intervalMs: 30000,
|
||||
running: false,
|
||||
points: [],
|
||||
});
|
||||
render(<Tab />);
|
||||
const card = await screen.findByTestId("server-facts");
|
||||
expect(await within(card).findByText(/win32/)).toBeTruthy();
|
||||
expect(within(card).queryByRole("meter", { name: "CPU" })).toBeNull();
|
||||
expect(card.textContent).not.toContain("Load");
|
||||
expect(within(card).getByRole("meter", { name: "Memory" })).toBeTruthy();
|
||||
for (const id of [
|
||||
"server-history",
|
||||
"server-cores",
|
||||
"server-top-processes",
|
||||
"server-disks-table",
|
||||
])
|
||||
expect(screen.queryByTestId(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,11 @@
|
||||
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
|
||||
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
|
||||
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
|
||||
* Settings → Git). There is no AI tool picker: the lane is created with the
|
||||
* Settings → Git; **Partial clone** makes it blobless). The clone is a
|
||||
* background job (`lib/cloneJobs.ts`): the modal closes once the server
|
||||
* accepted it, the app-wide dock shows its queue place, git's progress and
|
||||
* outcome, and the new workspace is selected when it ends while the user is
|
||||
* still on that page. There is no AI tool picker: the lane is created with the
|
||||
* default AI tool (Settings → AI Tools), and each new session picks its own
|
||||
* AI on the workspace home. Either
|
||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||
@@ -30,6 +34,7 @@ import { useDialog } from "../../hooks/useDialog";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { api } from "../../lib/api";
|
||||
import { startClone } from "../../lib/cloneJobs";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
@@ -116,6 +121,7 @@ export function AddLaneModal({
|
||||
// server asked for one — a login for the URL's https host.
|
||||
const [cloneUrl, setCloneUrl] = useState("");
|
||||
const [cloneName, setCloneName] = useState("");
|
||||
const [clonePartial, setClonePartial] = useState(false);
|
||||
const [login, setLogin] = useState<{
|
||||
host: string;
|
||||
username: string;
|
||||
@@ -159,6 +165,7 @@ export function AddLaneModal({
|
||||
setMode("repo");
|
||||
setCloneUrl("");
|
||||
setCloneName("");
|
||||
setClonePartial(false);
|
||||
setLogin(null);
|
||||
setSshRefused(false);
|
||||
setSourceRepo("");
|
||||
@@ -236,24 +243,46 @@ export function AddLaneModal({
|
||||
const url = cloneUrl.trim();
|
||||
if (!url) return;
|
||||
setSshRefused(false);
|
||||
// The new workspace is selected when the clone ends only while the user
|
||||
// is still on the page they started it from; elsewhere the dock's
|
||||
// "Open workspace" does it.
|
||||
const from = window.location.pathname;
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
onAdded(result.lane);
|
||||
await startClone(
|
||||
{
|
||||
kind: "workspace",
|
||||
body: {
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(clonePartial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onDone: (job, lane) => {
|
||||
if (window.location.pathname !== from) return;
|
||||
if (lane) return onAdded(lane);
|
||||
const id = job.result?.laneId;
|
||||
if (id == null) return;
|
||||
api.lanes
|
||||
.get(id)
|
||||
.then((r) => onAdded(r.lane))
|
||||
.catch(() => undefined);
|
||||
},
|
||||
}
|
||||
);
|
||||
// Accepted: the dock carries the clone from here.
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -547,6 +576,19 @@ export function AddLaneModal({
|
||||
spellCheck={false}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<label
|
||||
className="mt-2 flex items-center gap-1.5 text-[11px] text-fg-secondary"
|
||||
title={t("clone.partialHint")}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="add-lane-clone-partial"
|
||||
checked={clonePartial}
|
||||
onChange={(e) => setClonePartial(e.target.checked)}
|
||||
/>
|
||||
{t("clone.partial")}
|
||||
</label>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("cloneJobs.backgroundHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -827,7 +869,7 @@ export function AddLaneModal({
|
||||
{done
|
||||
? t("addLaneSetupClose")
|
||||
: busy && mode === "clone"
|
||||
? t("clone.cloning")
|
||||
? t("cloneJobs.starting")
|
||||
: t("add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* @file CloneHereDialog.tsx
|
||||
* @description The Files page's **New → Clone repository**: clones a URL into
|
||||
* the folder the File Explorer has open, through the same
|
||||
* `POST /api/lanes/clone` as Add workspace → Clone — the server holds the URL
|
||||
* to its allowlist and the folder to the user's own, and clones as the
|
||||
* folder's account with that account's own SSH key / HTTPS login — and opens
|
||||
* a workspace on the new folder. The clone is a server-side background job
|
||||
* (`lib/cloneJobs.ts`): the dialog closes as soon as the server accepted it
|
||||
* and the app-wide dock (`BackgroundJobsDock`) carries its progress and
|
||||
* outcome, across pages, F5 and server restarts. A refusal before the clone
|
||||
* starts (a bad URL, a folder that is not the user's, a 409 `ECREDENTIALS` /
|
||||
* `ESSHAUTH`) stays in the form: a login row (sent once, optionally
|
||||
* remembered), or a pointer to Settings → Git.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { cloneDirName } from "./AddLaneModal";
|
||||
import { startClone } from "../../lib/cloneJobs";
|
||||
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
export function CloneHereDialog({
|
||||
open,
|
||||
parent,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** The absolute folder the clone goes into. */
|
||||
parent: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
const [url, setUrl] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [login, setLogin] = useState<Login | null>(null);
|
||||
const [sshRefused, setSshRefused] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useDialog(open, panelRef, onClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setUrl("");
|
||||
setName("");
|
||||
setLogin(null);
|
||||
setSshRefused(false);
|
||||
setBusy(false);
|
||||
setError(null);
|
||||
}, [open, parent]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const folder = name.trim() || cloneDirName(url);
|
||||
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||
|
||||
const submit = async () => {
|
||||
const u = url.trim();
|
||||
if (!u || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setSshRefused(false);
|
||||
try {
|
||||
await startClone({
|
||||
kind: "workspace",
|
||||
body: {
|
||||
url: u,
|
||||
parent,
|
||||
name: name.trim() || undefined,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
// Accepted: the dock takes it from here.
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || u;
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
||||
setLogin((l) => ({
|
||||
host,
|
||||
username: l?.username ?? "",
|
||||
token: "",
|
||||
remember: l?.remember ?? true,
|
||||
}));
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
setSshRefused(true);
|
||||
setError(t("clone.sshRefused", { host }));
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||
onClick={onClose}
|
||||
// A portal still bubbles through React: keep keys away from the File
|
||||
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
data-testid="fx-clone-dialog"
|
||||
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2
|
||||
id={titleId}
|
||||
className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<FolderGit2 className="h-4 w-4" />
|
||||
{t("fx.cloneRepo")}
|
||||
</h2>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-url`}>
|
||||
{t("clone.urlLabel")}
|
||||
</label>
|
||||
<input
|
||||
id={`${titleId}-url`}
|
||||
autoFocus
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://host/owner/repo.git"
|
||||
disabled={busy}
|
||||
className="input w-full font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-fg-muted">{t("clone.urlHint")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-name`}>
|
||||
{t("clone.nameLabel")}
|
||||
</label>
|
||||
<input
|
||||
id={`${titleId}-name`}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={cloneDirName(url)}
|
||||
disabled={busy}
|
||||
className="input w-full font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 break-all text-[11px] text-fg-muted">
|
||||
{t("clone.parentHint", { path: into })}
|
||||
</p>
|
||||
</div>
|
||||
{login && (
|
||||
<div
|
||||
data-testid="fx-clone-login"
|
||||
className="space-y-2 rounded-md border border-border p-2"
|
||||
>
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
autoComplete="username"
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
disabled={busy}
|
||||
className="input w-full text-xs"
|
||||
/>
|
||||
<input
|
||||
aria-label={t("clone.token")}
|
||||
placeholder={t("clone.token")}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={login.token}
|
||||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||
disabled={busy}
|
||||
className="input w-full text-xs"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-xs text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={login.remember}
|
||||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||
disabled={busy}
|
||||
/>
|
||||
{t("clone.remember")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}{" "}
|
||||
{sshRefused && (
|
||||
<a href="/settings/git#git-identity" className="underline">
|
||||
{t("clone.sshSettings")}
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[11px] text-fg-muted">{t("cloneJobs.backgroundHint")}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} className="btn-ghost text-xs">
|
||||
{t("fx.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !url.trim()}
|
||||
className="btn-primary text-xs"
|
||||
data-testid="fx-clone-submit"
|
||||
>
|
||||
{busy ? t("cloneJobs.starting") : t("fx.cloneSubmit")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file CloneProgress — the live line under a running clone: its place in the
|
||||
* server's clone queue while it waits (clones run a few at a time machine-wide,
|
||||
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
|
||||
* with a bar. It renders the progress fields of a background clone job
|
||||
* (`lib/cloneJobs.ts`, polled by the store) — it fetches nothing itself.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CloneJob } from "../../lib/api";
|
||||
|
||||
export type CloneProgressFields = Pick<
|
||||
CloneJob,
|
||||
"state" | "position" | "phase" | "label" | "percent" | "detail"
|
||||
> & { running?: number; waiting?: number; limit?: number };
|
||||
|
||||
export function CloneProgress({ view }: { view: CloneProgressFields }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
|
||||
if (view.state !== "queued" && view.state !== "running") return null;
|
||||
|
||||
if (view.state === "queued") {
|
||||
return (
|
||||
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
|
||||
{view.waiting !== undefined && view.running !== undefined && view.limit !== undefined
|
||||
? t("clone.queued", {
|
||||
position: view.position ?? 1,
|
||||
waiting: view.waiting,
|
||||
running: view.running,
|
||||
limit: view.limit,
|
||||
})
|
||||
: t("cloneJobs.queued", { position: view.position ?? 1 })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const phase = view.phase
|
||||
? view.phase === "other"
|
||||
? view.label || ""
|
||||
: t(`clone.phase.${view.phase}`)
|
||||
: t("clone.connecting");
|
||||
const percent = view.percent ?? 0;
|
||||
return (
|
||||
<div data-testid="clone-progress" className="space-y-1">
|
||||
<p className="flex justify-between gap-2 text-[11px] text-fg-secondary">
|
||||
<span className="truncate">
|
||||
{phase}
|
||||
{view.phase ? ` ${percent}%` : ""}
|
||||
</span>
|
||||
{view.detail && (
|
||||
<span className="shrink-0 truncate font-mono text-[10px] text-fg-muted">
|
||||
{view.detail}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{view.phase && (
|
||||
<div
|
||||
className="h-1 overflow-hidden rounded bg-surface-2"
|
||||
role="progressbar"
|
||||
aria-valuenow={percent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={phase}
|
||||
>
|
||||
<div className="h-full bg-accent-solid transition-all" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,18 +4,63 @@
|
||||
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and
|
||||
* added as an extra folder, one after another. A host that wants a login
|
||||
* (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed
|
||||
* URLs with it, like Add workspace → Clone.
|
||||
* URLs with it, like Add workspace → Clone. Before anything runs, each URL
|
||||
* is checked against the user's own git identity (`GET /api/me/git`): an SSH
|
||||
* link while the account has no SSH key is not cloned at all — the user is
|
||||
* sent to Settings → Git first — and an HTTPS host with no saved login offers
|
||||
* the login row up front (optional: a public repo needs none). The identity is
|
||||
* read again when the window regains focus, so a key made in another tab
|
||||
* counts at once. Each clone is a background job (`lib/cloneJobs.ts`): once
|
||||
* the server accepted it the app-wide dock shows its queue place, git's
|
||||
* progress and outcome (it survives leaving the page, F5 and a server
|
||||
* restart), and the folder list is re-read when it ends; **Partial clone**
|
||||
* sends `partial: true`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
import { api, type LaneSourcesView } from "../../lib/api";
|
||||
import { api, type CloneJob, type GitIdentity, type LaneSourcesView } from "../../lib/api";
|
||||
import { CLONE_JOB_EVENT, startClone } from "../../lib/cloneJobs";
|
||||
|
||||
type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string };
|
||||
type Row = {
|
||||
url: string;
|
||||
state: "wait" | "busy" | "done" | "failed";
|
||||
note?: string;
|
||||
/** The note points at Settings → Git (no key, or a key the host refused). */
|
||||
ssh?: boolean;
|
||||
};
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
/** How a clone URL reaches its host: `ssh://…` or scp-like `user@host:path`, else HTTPS. */
|
||||
export function cloneTarget(url: string): { kind: "ssh" | "https"; host: string } | null {
|
||||
const scp = /^(?:[^@\s/]+@)?([^:/\s]+):(?!\/\/)/.exec(url);
|
||||
if (scp?.[1]) return { kind: "ssh", host: scp[1] };
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.protocol === "ssh:") return { kind: "ssh", host: u.hostname };
|
||||
if (u.protocol === "https:" || u.protocol === "http:") return { kind: "https", host: u.host };
|
||||
} catch {
|
||||
/* not a URL yet — the server's allowlist answers that one */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** What the identity lacks for one URL: `ssh` blocks the clone, `https` only asks. */
|
||||
function missingFor(
|
||||
url: string,
|
||||
id: GitIdentity | null
|
||||
): { kind: "ssh" | "https"; host: string } | null {
|
||||
const target = cloneTarget(url);
|
||||
if (!id || !target) return null;
|
||||
if (target.kind === "ssh") {
|
||||
const keys = id.sshKeys?.length ?? (id.ssh ? 1 : 0);
|
||||
return keys > 0 ? null : target;
|
||||
}
|
||||
return id.https.some((h) => h.host === target.host) ? null : target;
|
||||
}
|
||||
|
||||
export function CloneSourcesForm({
|
||||
laneId,
|
||||
parent,
|
||||
@@ -31,6 +76,42 @@ export function CloneSourcesForm({
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [login, setLogin] = useState<Login | null>(null);
|
||||
const [partial, setPartial] = useState(false);
|
||||
const [identity, setIdentity] = useState<GitIdentity | null>(null);
|
||||
|
||||
// An unknown identity (an older server, a failed read) checks nothing: the
|
||||
// server's own ECREDENTIALS / ESSHAUTH answers still cover it.
|
||||
const loadIdentity = useCallback(
|
||||
() =>
|
||||
Promise.resolve()
|
||||
.then(() => api.gitIdentity.get())
|
||||
.then(setIdentity)
|
||||
.catch(() => {}),
|
||||
[]
|
||||
);
|
||||
// A background clone into THIS workspace ended: its folder is now in the
|
||||
// list (the server added it), so read the list again.
|
||||
const onAddedRef = useRef(onAdded);
|
||||
onAddedRef.current = onAdded;
|
||||
useEffect(() => {
|
||||
const onJob = (e: Event) => {
|
||||
const job = (e as CustomEvent<CloneJob>).detail;
|
||||
if (job?.state !== "done" || job.kind !== "source" || job.laneId !== laneId) return;
|
||||
Promise.resolve()
|
||||
.then(() => api.lanes.sources(laneId))
|
||||
.then((view) => onAddedRef.current(view))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
window.addEventListener(CLONE_JOB_EVENT, onJob);
|
||||
return () => window.removeEventListener(CLONE_JOB_EVENT, onJob);
|
||||
}, [laneId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadIdentity();
|
||||
const onFocus = () => void loadIdentity();
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => window.removeEventListener("focus", onFocus);
|
||||
}, [loadIdentity]);
|
||||
|
||||
const urls = (s: string) => [
|
||||
...new Set(
|
||||
@@ -44,28 +125,42 @@ export function CloneSourcesForm({
|
||||
const cloneAll = async (list: string[]) => {
|
||||
if (busy || !list.length) return;
|
||||
setBusy(true);
|
||||
const next: Row[] = list.map((url) => ({ url, state: "wait" }));
|
||||
// An SSH link with no key on the account would only fail at the host:
|
||||
// it is not tried, and its row says what to set up first.
|
||||
const next: Row[] = list.map((url) => {
|
||||
const miss = missingFor(url, identity);
|
||||
return miss?.kind === "ssh"
|
||||
? { url, state: "failed", note: t("clone.sshNoKey", { host: miss.host }), ssh: true }
|
||||
: { url, state: "wait" };
|
||||
});
|
||||
const show = () => setRows([...next]);
|
||||
show();
|
||||
let asked: Login | null = null;
|
||||
for (const row of next) {
|
||||
if (row.state === "failed") continue;
|
||||
row.state = "busy";
|
||||
show();
|
||||
try {
|
||||
const view = await api.lanes.cloneFolder(laneId, {
|
||||
url: row.url,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
await startClone({
|
||||
kind: "source",
|
||||
laneId,
|
||||
body: {
|
||||
url: row.url,
|
||||
...(partial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
// Accepted: the dock shows its progress; the folder list is re-read
|
||||
// when it ends (the CLONE_JOB_EVENT listener above).
|
||||
row.state = "done";
|
||||
onAdded(view);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || row.url;
|
||||
@@ -80,6 +175,7 @@ export function CloneSourcesForm({
|
||||
};
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
row.note = t("clone.sshRefused", { host });
|
||||
row.ssh = true;
|
||||
} else {
|
||||
row.note = e.message || String(err);
|
||||
}
|
||||
@@ -99,6 +195,18 @@ export function CloneSourcesForm({
|
||||
|
||||
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
|
||||
|
||||
// Checked as the user types, so a missing key is said before Clone is pressed.
|
||||
const missing = urls(text)
|
||||
.map((u) => missingFor(u, identity))
|
||||
.filter((m): m is NonNullable<typeof m> => m !== null);
|
||||
const sshHosts = [...new Set(missing.filter((m) => m.kind === "ssh").map((m) => m.host))];
|
||||
const httpsHosts = [...new Set(missing.filter((m) => m.kind === "https").map((m) => m.host))];
|
||||
const settingsLink = (
|
||||
<a href="/settings/git#git-identity" target="_blank" rel="noreferrer" className="underline">
|
||||
{t("clone.sshSettings")}
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
|
||||
@@ -112,6 +220,41 @@ export function CloneSourcesForm({
|
||||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
|
||||
<label
|
||||
className="flex items-center gap-1.5 text-[11px] text-fg-secondary"
|
||||
title={t("clone.partialHint")}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="workspace-clone-partial"
|
||||
checked={partial}
|
||||
onChange={(e) => setPartial(e.target.checked)}
|
||||
/>
|
||||
{t("clone.partial")}
|
||||
</label>
|
||||
{sshHosts.length > 0 && (
|
||||
<p
|
||||
role="alert"
|
||||
data-testid="workspace-clone-need-ssh"
|
||||
className="text-[11px] text-status-danger"
|
||||
>
|
||||
{t("home.cloneNeedSsh", { hosts: sshHosts.join(", ") })} {settingsLink}
|
||||
</p>
|
||||
)}
|
||||
{httpsHosts.length > 0 && !login && (
|
||||
<p data-testid="workspace-clone-need-login" className="text-[11px] text-status-warning">
|
||||
{t("home.cloneNeedLogin", { hosts: httpsHosts.join(", ") })}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() =>
|
||||
setLogin({ host: httpsHosts[0] ?? "", username: "", token: "", remember: true })
|
||||
}
|
||||
>
|
||||
{t("home.cloneEnterLogin")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{login && (
|
||||
<div className="space-y-1.5 rounded border border-border-light p-2">
|
||||
<div className="flex gap-2">
|
||||
@@ -178,7 +321,15 @@ export function CloneSourcesForm({
|
||||
{r.url}
|
||||
</span>
|
||||
</span>
|
||||
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>}
|
||||
{r.state === "done" && (
|
||||
<span className="ml-[18px] block text-fg-muted">{t("cloneJobs.started")}</span>
|
||||
)}
|
||||
{r.note && (
|
||||
<span className="ml-[18px] block text-status-danger">
|
||||
{r.note}
|
||||
{r.ssh && <> {settingsLink}</>}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -15,13 +15,20 @@
|
||||
* workspace's Files tab. Behaviour: delete → the account's Trash (no confirm:
|
||||
* restorable 7 days); Copy / Cut then Paste in the same or ANOTHER workspace
|
||||
* (the clipboard outlives the dialog); upload files or whole folders (picker
|
||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
|
||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch —
|
||||
* the progress lives in the app-wide dock (`lib/uploadJobs.ts`), so an
|
||||
* upload keeps going when the user opens Trash or another page, and can be
|
||||
* stopped there; New → Clone repository runs as a background clone job;
|
||||
* drag items onto a folder to move them (Ctrl: copy); download a file as
|
||||
* itself, anything else as one zip; a taken name asks Replace / Keep both /
|
||||
* itself, anything else as one zip — except an HTML file, which opens as the
|
||||
* page (`HtmlViewer`, Download on its header and right-click menu); a taken name asks Replace / Keep both /
|
||||
* Skip. Deleting forever asks first when it empties the Trash or touches
|
||||
* another user's. An admin's Trash opens on one folder per user (with its
|
||||
* item count); each folder is THAT user's Trash, to restore from or purge
|
||||
* without waiting the 7 days.
|
||||
* The sidebar nests each workspace under its owner's home (a chevron drops
|
||||
* them down, remembered per browser); one with no owner home stays in a flat
|
||||
* Workspaces list.
|
||||
* Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*`
|
||||
* (server/lib/file-explorer.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -82,21 +89,74 @@ import {
|
||||
type FsTrashItem,
|
||||
type FsHome,
|
||||
type FsPlace,
|
||||
type GitSource,
|
||||
isHomePlace,
|
||||
newUploadId,
|
||||
} from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { fetchVia } from "../HtmlFrame";
|
||||
import { HtmlViewerModal } from "../HtmlViewerModal";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { CloneHereDialog } from "./CloneHereDialog";
|
||||
import { CLONE_JOB_EVENT } from "../../lib/cloneJobs";
|
||||
import { beginUpload } from "../../lib/uploadJobs";
|
||||
import type { CloneJob } from "../../lib/api";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAuth } from "../AuthGate";
|
||||
|
||||
/** One upload batch at most (the server caps one file the same). */
|
||||
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
|
||||
/** Files up to this size travel together, many per request (`uploadBatch`):
|
||||
* their cost is per FILE (a request, a path walk, a sudo), not per byte. */
|
||||
const SMALL_FILE = 4 * 1024 * 1024;
|
||||
const PACK_BYTES = 32 * 1024 * 1024;
|
||||
const PACK_FILES = 1000;
|
||||
/** Keeps a pack's path list well under the server's 1 MB JSON body (upload-check). */
|
||||
const PACK_NAME_BYTES = 400 * 1024;
|
||||
|
||||
/** Small files in packs (each ≥ 2 files) and the rest, one by one. */
|
||||
export function packUploads<T extends { file: Blob; rel: string }>(files: T[]) {
|
||||
const packs: T[][] = [];
|
||||
const singles: T[] = [];
|
||||
let cur: T[] = [];
|
||||
let bytes = 0;
|
||||
let names = 0;
|
||||
for (const f of files) {
|
||||
if (f.file.size > SMALL_FILE) {
|
||||
singles.push(f);
|
||||
continue;
|
||||
}
|
||||
const n = f.rel.length * 3 + 64;
|
||||
if (
|
||||
cur.length &&
|
||||
(cur.length >= PACK_FILES || bytes + f.file.size > PACK_BYTES || names + n > PACK_NAME_BYTES)
|
||||
) {
|
||||
packs.push(cur);
|
||||
cur = [];
|
||||
bytes = 0;
|
||||
names = 0;
|
||||
}
|
||||
cur.push(f);
|
||||
bytes += f.file.size;
|
||||
names += n;
|
||||
}
|
||||
if (cur.length) packs.push(cur);
|
||||
for (const p of packs.filter((p) => p.length < 2)) singles.push(...p);
|
||||
return { packs: packs.filter((p) => p.length >= 2), singles };
|
||||
}
|
||||
const MIME = "application/x-agenthub-fx";
|
||||
const VIEW_KEY = "agenthub.files.view";
|
||||
const HIDDEN_KEY = "agenthub.files.hidden";
|
||||
/** The sidebar homes whose workspaces are dropped down (a JSON list of home ids). */
|
||||
const HOMES_OPEN_KEY = "agenthub.files.homesOpen";
|
||||
/** A sidebar user row; `noHome`: it only groups an owner's workspaces, the
|
||||
* viewer may not open that home (an analyst's view of other users). */
|
||||
type UserRow = FsHome & { noHome?: boolean };
|
||||
|
||||
type Clip = { place: FsPlace; paths: string[]; cut: boolean };
|
||||
// The clipboard outlives the dialog and a workspace switch, like a desktop's.
|
||||
@@ -116,6 +176,7 @@ const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
|
||||
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
const errCode = (e: unknown) => (e as { code?: string })?.code;
|
||||
const isFolder = (e: FsEntry) => e.type === "dir" || e.type === "linkdir";
|
||||
const isHtml = (e: FsEntry) => !isFolder(e) && /\.html?$/i.test(e.name);
|
||||
const ext = (name: string) => name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
||||
const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"]);
|
||||
const CODE = new Set([
|
||||
@@ -175,6 +236,8 @@ async function walkEntry(
|
||||
out.push({ file, rel: `${prefix}${entry.name}` });
|
||||
return;
|
||||
}
|
||||
// The server takes nothing into .git: never read one off the disk at all.
|
||||
if (entry.name === ".git") return;
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
// readEntries answers in pages until it answers [].
|
||||
for (;;) {
|
||||
@@ -234,7 +297,7 @@ export function FileBrowser({
|
||||
/** A home to open on (`me`, or another user's for an admin). */
|
||||
initialHome?: string | null;
|
||||
session?: string | null;
|
||||
source?: number | null;
|
||||
source?: GitSource | null;
|
||||
initialPath?: string;
|
||||
/** Called after anything changed files, so a tree elsewhere can re-read. */
|
||||
onChanged?: () => void;
|
||||
@@ -268,16 +331,20 @@ export function FileBrowser({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
|
||||
const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null);
|
||||
const [cloning, setCloning] = useState(false);
|
||||
const [ask, setAsk] = useState<Ask | null>(null);
|
||||
const [askAll, setAskAll] = useState(false);
|
||||
const [progress, setProgress] = useState<{
|
||||
name: string;
|
||||
done: number;
|
||||
total: number;
|
||||
sent: number;
|
||||
bytes: number;
|
||||
} | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
const { state: auth } = useAuth();
|
||||
const myId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
|
||||
const [openHomes, setOpenHomes] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const v: unknown = JSON.parse(readPref(HOMES_OPEN_KEY, '["me"]'));
|
||||
return new Set(Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
|
||||
} catch {
|
||||
return new Set(["me"]);
|
||||
}
|
||||
});
|
||||
const [clip, setClip] = useState<Clip | null>(clipboard);
|
||||
const [dropping, setDropping] = useState(false);
|
||||
const [menu, setMenu] = useState<"new" | "upload" | "more" | null>(null);
|
||||
@@ -302,18 +369,39 @@ export function FileBrowser({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [laneId, initialHome, session, source, initialPath]);
|
||||
|
||||
useEffect(() => {
|
||||
// Re-read after every change here too: deleting a workspace's folder takes
|
||||
// it out of the places (a workspace whose folder is gone has nothing to show).
|
||||
const loadLanes = useCallback(() => {
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => setLanes(r.lanes))
|
||||
.catch(() => setLanes([]));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
loadLanes();
|
||||
api.files
|
||||
.homes()
|
||||
.then((r) => setHomes(r.homes))
|
||||
.catch(() => setHomes([]));
|
||||
}, []);
|
||||
}, [loadLanes]);
|
||||
// Another user's home (an admin's view) is read-only.
|
||||
const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly;
|
||||
// Clone into the open folder: the absolute path of a workspace's own folder
|
||||
// or of the user's own home (never a session worktree, an extra folder or
|
||||
// another user's home); the server checks the folder again.
|
||||
const cloneRoot = isHomePlace(target)
|
||||
? target.home === "me"
|
||||
? (homes.find((h) => h.id === "me")?.dir ?? null)
|
||||
: null
|
||||
: typeof target === "number"
|
||||
? (lanes.find((l) => l.id === target)?.cwd ?? null)
|
||||
: null;
|
||||
const cloneParent =
|
||||
cloneRoot && view === "files"
|
||||
? path
|
||||
? `${cloneRoot.replace(/\/+$/, "")}/${path}`
|
||||
: cloneRoot
|
||||
: null;
|
||||
|
||||
/** Go to `p` in workspace `tgt`, remembering where we were for Back. */
|
||||
const go = (p: string, tgt: FsPlace = target) => {
|
||||
@@ -356,6 +444,8 @@ export function FileBrowser({
|
||||
} else if (view === "trash") setTrash((await api.files.trash(target)).items);
|
||||
else setListing(await api.files.list(target, path));
|
||||
} catch (e) {
|
||||
// Never leave the last folder's entries under another folder's error.
|
||||
if (view === "files") setListing(null);
|
||||
// A dashboard that does not know /fs yet (not restarted) answers 404.
|
||||
setError(
|
||||
(e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e)
|
||||
@@ -372,8 +462,20 @@ export function FileBrowser({
|
||||
|
||||
const changed = () => {
|
||||
void load();
|
||||
loadLanes();
|
||||
onChanged?.();
|
||||
};
|
||||
// A background clone finished (the dock announced it): its folder and
|
||||
// workspace are new here, wherever it was started from.
|
||||
const changedRef = useRef(changed);
|
||||
changedRef.current = changed;
|
||||
useEffect(() => {
|
||||
const onJob = (e: Event) => {
|
||||
if ((e as CustomEvent<CloneJob>).detail?.state === "done") changedRef.current();
|
||||
};
|
||||
window.addEventListener(CLONE_JOB_EVENT, onJob);
|
||||
return () => window.removeEventListener(CLONE_JOB_EVENT, onJob);
|
||||
}, []);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const all = listing?.entries ?? [];
|
||||
@@ -384,13 +486,28 @@ export function FileBrowser({
|
||||
}, [listing, showHidden, query]);
|
||||
const selectedEntries = entries.filter((e) => selected.has(e.path));
|
||||
|
||||
// An upload outlives this view (its progress is in the dock): once the view
|
||||
// is gone nobody can answer a name clash, so it keeps both copies.
|
||||
const mountedRef = useRef(true);
|
||||
const pendingAsk = useRef<Ask["resolve"] | null>(null);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
pendingAsk.current?.({ choice: "keep", all: true });
|
||||
pendingAsk.current = null;
|
||||
};
|
||||
}, []);
|
||||
/** A choice for taken names: Replace / Keep both / Skip (null = cancel). */
|
||||
const askConflict = (names: string[], many: boolean) =>
|
||||
new Promise<{ choice: FsConflict | null; all: boolean }>((resolve) => {
|
||||
if (!mountedRef.current) return resolve({ choice: "keep", all: true });
|
||||
pendingAsk.current = resolve;
|
||||
setAskAll(false);
|
||||
setAsk({ names, many, resolve });
|
||||
});
|
||||
const answer = (choice: FsConflict | null) => {
|
||||
pendingAsk.current = null;
|
||||
ask?.resolve({ choice, all: askAll });
|
||||
setAsk(null);
|
||||
};
|
||||
@@ -413,7 +530,13 @@ export function FileBrowser({
|
||||
await fn();
|
||||
if (done) toast.success(done);
|
||||
} catch (e) {
|
||||
toast.error(errCode(e) === "EUPGRADE" ? t("fx.needsUpgrade") : errText(e));
|
||||
toast.error(
|
||||
errCode(e) === "EUPGRADE"
|
||||
? t("fx.needsUpgrade")
|
||||
: errCode(e) === "ESECRET"
|
||||
? t("fx.secret")
|
||||
: errText(e)
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
changed();
|
||||
@@ -421,8 +544,11 @@ export function FileBrowser({
|
||||
};
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────────
|
||||
// An HTML page opens as itself (`HtmlViewer`); any other file downloads.
|
||||
const [viewing, setViewing] = useState<FsEntry | null>(null);
|
||||
const openEntry = (e: FsEntry) => {
|
||||
if (isFolder(e)) go(e.path);
|
||||
else if (isHtml(e)) setViewing(e);
|
||||
else download([e]);
|
||||
};
|
||||
const goUp = () => {
|
||||
@@ -530,8 +656,14 @@ export function FileBrowser({
|
||||
};
|
||||
|
||||
/** Upload `files` (each with its path inside the pick/drop) under `dir`. */
|
||||
const uploadAll = async (files: { file: File; rel: string }[], dir = path) => {
|
||||
const uploadAll = async (picked: { file: File; rel: string }[], dir = path) => {
|
||||
setMenu(null);
|
||||
// A dropped/picked repo carries its .git, which the server refuses file by
|
||||
// file — skip it here with ONE notice instead of a toast per object.
|
||||
const files = picked.filter((f) => !f.rel.split("/").includes(".git"));
|
||||
if (files.length < picked.length) {
|
||||
toast.info(t("fx.gitSkipped", { count: picked.length - files.length }));
|
||||
}
|
||||
if (!files.length) return;
|
||||
const bytes = files.reduce((n, f) => n + f.file.size, 0);
|
||||
if (bytes > FX_BATCH_MAX_BYTES) {
|
||||
@@ -541,37 +673,132 @@ export function FileBrowser({
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
// The place is fixed now: the user may open another folder (or leave the
|
||||
// page) while this keeps sending; its progress lives in the dock.
|
||||
const place = target;
|
||||
const job = beginUpload({
|
||||
folder: `${placeName(place)}${dir ? `/${dir}` : ""}`,
|
||||
total: files.length,
|
||||
bytes,
|
||||
});
|
||||
const { signal } = job;
|
||||
let sent = 0;
|
||||
let done = 0;
|
||||
let saved = 0;
|
||||
let forAll: FsConflict | null = null;
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const { file, rel } = files[i]!;
|
||||
const dest = join(dir, rel);
|
||||
setProgress({ name: rel, done: i, total: files.length, sent, bytes });
|
||||
const send = (c: FsConflict) =>
|
||||
api.files.upload(target, dest, file, c, (n) =>
|
||||
setProgress({ name: rel, done: i, total: files.length, sent: sent + n, bytes })
|
||||
);
|
||||
try {
|
||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
const a = await askConflict([rel], files.length > 1);
|
||||
if (!a.choice) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
|
||||
if (a.all) forAll = a.choice;
|
||||
return send(a.choice);
|
||||
});
|
||||
if (!r.skipped) saved++;
|
||||
} catch (e) {
|
||||
if (errCode(e) === "ECANCEL") break;
|
||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||
}
|
||||
sent += file.size;
|
||||
// false once the server says a batch cannot be written here (an old helper).
|
||||
let batchable = true;
|
||||
const show = (name: string, extra = 0) => job.update({ name, done, sent: sent + extra });
|
||||
const onWait = (waiting: boolean) => job.update({ waiting });
|
||||
/** One file; false = the user cancelled. */
|
||||
const one = async ({ file, rel }: { file: File; rel: string }) => {
|
||||
if (signal.aborted) return false;
|
||||
const dest = join(dir, rel);
|
||||
show(rel);
|
||||
// One id for every attempt: a retry with another `conflict` reuses the staged bytes.
|
||||
const upload = newUploadId();
|
||||
const send = (c: FsConflict) =>
|
||||
api.files.uploadStaged(place, dest, file, c, {
|
||||
upload,
|
||||
onProgress: (n) => show(rel, n),
|
||||
onWait,
|
||||
signal,
|
||||
});
|
||||
try {
|
||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
const a = await askConflict([rel], files.length > 1);
|
||||
if (!a.choice) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
|
||||
if (a.all) forAll = a.choice;
|
||||
return send(a.choice);
|
||||
});
|
||||
if (!r.skipped) saved++;
|
||||
} catch (e) {
|
||||
if (errCode(e) === "ECANCEL" || errCode(e) === "EABORT") return false;
|
||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||
} finally {
|
||||
onWait(false);
|
||||
void api.files.dropUpload(place, upload);
|
||||
}
|
||||
sent += file.size;
|
||||
done++;
|
||||
return true;
|
||||
};
|
||||
/** Many small files in one request: "ok", "cancel", or "fallback" (send them one by one). */
|
||||
const pack = async (b: { file: File; rel: string }[]) => {
|
||||
const label = `${b[0]!.rel} (+${b.length - 1})`;
|
||||
const size = b.reduce((n, f) => n + f.file.size, 0);
|
||||
show(label);
|
||||
// Settled BEFORE the bytes go: a 409 after a 32 MB body would waste it.
|
||||
const choose = async (taken: string[]) => {
|
||||
if (forAll) return forAll;
|
||||
if (!taken.length) return "fail" as const;
|
||||
const a = await askConflict(taken, files.length > 1);
|
||||
if (a.all && a.choice) forAll = a.choice;
|
||||
return a.choice;
|
||||
};
|
||||
if (signal.aborted) return "cancel";
|
||||
const upload = newUploadId();
|
||||
try {
|
||||
const c = await api.files.uploadCheck(
|
||||
place,
|
||||
dir,
|
||||
b.map((f) => f.rel)
|
||||
);
|
||||
if (!c.batch) {
|
||||
batchable = false;
|
||||
return "fallback";
|
||||
}
|
||||
let policy = await choose(c.taken);
|
||||
if (!policy) return "cancel";
|
||||
const send = (p: FsConflict) =>
|
||||
api.files.uploadBatchStaged(place, dir, b, p, {
|
||||
upload,
|
||||
onProgress: (n) => show(label, n),
|
||||
onWait,
|
||||
signal,
|
||||
});
|
||||
const r = await send(policy).catch(async (e) => {
|
||||
// Taken meanwhile: ask about those, then once more.
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
policy = await choose((e as { names?: string[] }).names ?? []);
|
||||
if (!policy) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
|
||||
return send(policy);
|
||||
});
|
||||
saved += r.saved;
|
||||
} catch (e) {
|
||||
const code = errCode(e);
|
||||
if (code === "ECANCEL" || code === "EABORT") return "cancel";
|
||||
// Refused before a byte was written (a bad name, an old helper…):
|
||||
// each file then gets its own answer.
|
||||
if (code === "EUPGRADE") batchable = false;
|
||||
if (code && /^E(UPGRADE|BADNAME|BADPATH|SECRET|BADBODY|TOOBIG|EXISTS)$/.test(code)) {
|
||||
return "fallback";
|
||||
}
|
||||
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
|
||||
} finally {
|
||||
onWait(false);
|
||||
void api.files.dropUpload(place, upload);
|
||||
}
|
||||
sent += size;
|
||||
done += b.length;
|
||||
return "ok";
|
||||
};
|
||||
try {
|
||||
const { packs, singles } = packUploads(files);
|
||||
let go = true;
|
||||
for (const b of packs) {
|
||||
const r = batchable ? await pack(b) : "fallback";
|
||||
if (r === "cancel") go = false;
|
||||
if (r === "fallback") for (const f of b) if (go && !(await one(f))) go = false;
|
||||
if (!go) break;
|
||||
}
|
||||
for (const f of singles) if (!go || !(await one(f))) break;
|
||||
} finally {
|
||||
setProgress(null);
|
||||
job.end();
|
||||
setBusy(false);
|
||||
if (saved) toast.success(t("fx.uploaded", { count: saved }));
|
||||
if (signal.aborted) toast.info(t("uploads.stopped", { count: saved, total: files.length }));
|
||||
else if (saved) toast.success(t("fx.uploaded", { count: saved }));
|
||||
changed();
|
||||
}
|
||||
};
|
||||
@@ -633,7 +860,7 @@ export function FileBrowser({
|
||||
writePref(VIEW_KEY, l);
|
||||
};
|
||||
const onKey = (e: ReactKeyboardEvent) => {
|
||||
if (renaming || creating || ask) return;
|
||||
if (renaming || creating || ask || cloning) return;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
const k = e.key.toLowerCase();
|
||||
// Another user's home is read-only: no key that writes.
|
||||
@@ -772,6 +999,59 @@ export function FileBrowser({
|
||||
onCancel={() => setRenaming(null)}
|
||||
/>
|
||||
) : null;
|
||||
// Each workspace drops down under its owner: the caller's own under My
|
||||
// home, another user's under theirs (`owner_name` = that home's id). An
|
||||
// owner whose home this viewer may not open (an analyst's view of other
|
||||
// users) still gets a row, one that only groups. A workspace with no owner
|
||||
// stays in the flat Workspaces list. Which rows are open is per browser.
|
||||
// A workspace whose folder was deleted has nothing to browse — it stays a
|
||||
// place only while it is the one open (so its error has a row to sit on).
|
||||
const placeLanes = useMemo(
|
||||
() =>
|
||||
lanes.filter((l) => !l.folder_missing || (!isHomePlace(target) && laneOf(target) === l.id)),
|
||||
[lanes, target]
|
||||
);
|
||||
const { lanesOfHome, userRows } = useMemo(() => {
|
||||
const ids = new Set(homes.map((h) => h.id));
|
||||
const map = new Map<string, Lane[]>();
|
||||
const extra = new Map<string, UserRow>();
|
||||
for (const l of placeLanes) {
|
||||
const home =
|
||||
myId != null && l.owner_id === myId && ids.has("me")
|
||||
? "me"
|
||||
: l.owner_name && l.owner_name !== "me"
|
||||
? l.owner_name
|
||||
: null;
|
||||
if (!home) continue;
|
||||
map.set(home, [...(map.get(home) ?? []), l]);
|
||||
if (!ids.has(home) && !extra.has(home)) {
|
||||
extra.set(home, {
|
||||
id: home,
|
||||
label: l.owner_display_name || home,
|
||||
readOnly: true,
|
||||
noHome: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
const rows: UserRow[] = [
|
||||
...homes.filter((h) => h.id !== "me"),
|
||||
...[...extra.values()].sort((a, b) => a.label.localeCompare(b.label)),
|
||||
];
|
||||
return { lanesOfHome: map, userRows: rows };
|
||||
}, [homes, placeLanes, myId]);
|
||||
const grouped = new Set([...lanesOfHome.values()].flat().map((l) => l.id));
|
||||
const looseLanes = placeLanes.filter((l) => !grouped.has(l.id));
|
||||
// A home holding the open workspace shows it, whatever was stored.
|
||||
const homeIsOpen = (id: string, own: Lane[]) =>
|
||||
openHomes.has(id) ||
|
||||
(view === "files" && !isHomePlace(target) && own.some((l) => l.id === laneOf(target)));
|
||||
const toggleHome = (id: string, own: Lane[]) => {
|
||||
const next = new Set(openHomes);
|
||||
if (homeIsOpen(id, own)) next.delete(id);
|
||||
else next.add(id);
|
||||
setOpenHomes(next);
|
||||
writePref(HOMES_OPEN_KEY, JSON.stringify([...next]));
|
||||
};
|
||||
const placeClass = (active: boolean) =>
|
||||
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] ${
|
||||
active ? "bg-accent/15 font-medium text-accent" : "text-fg-secondary hover:bg-surface-2"
|
||||
@@ -784,67 +1064,118 @@ export function FileBrowser({
|
||||
onClick={() => setMenu(null)}
|
||||
data-testid="file-explorer"
|
||||
>
|
||||
{viewing && (
|
||||
<HtmlViewer
|
||||
entry={viewing}
|
||||
place={target}
|
||||
onDownload={() => download([viewing])}
|
||||
onClose={() => setViewing(null)}
|
||||
/>
|
||||
)}
|
||||
{cloneParent && (
|
||||
<CloneHereDialog open={cloning} parent={cloneParent} onClose={() => setCloning(false)} />
|
||||
)}
|
||||
{/* ── Places ── */}
|
||||
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
|
||||
{homes.some((h) => h.id === "me") && (
|
||||
<div className="pt-3">
|
||||
{(() => {
|
||||
const laneRow = (l: Lane, nested: boolean) => (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
onClick={() => go("", { home: "me" })}
|
||||
onContextMenu={(e) => placeCtx.open(e, { home: "me" })}
|
||||
className={placeClass(
|
||||
view === "files" && isHomePlace(target) && target.home === "me"
|
||||
)}
|
||||
title={l.cwd}
|
||||
onClick={() => go("", l.id)}
|
||||
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
|
||||
className={`${placeClass(
|
||||
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
|
||||
)}${nested ? " pl-7" : ""}`}
|
||||
>
|
||||
<House className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{t("fx.myHome")}</span>
|
||||
<FolderGit2 className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{l.title || l.cwd.split("/").pop()}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{homes.some((h) => h.id !== "me") && (
|
||||
<>
|
||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("fx.userHomes")}
|
||||
</p>
|
||||
{homes
|
||||
.filter((h) => h.id !== "me")
|
||||
.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
title={t("fx.readOnlyHome")}
|
||||
onClick={() => go("", { home: h.id })}
|
||||
onContextMenu={(e) => placeCtx.open(e, { home: h.id })}
|
||||
className={placeClass(
|
||||
view === "files" && isHomePlace(target) && target.home === h.id
|
||||
)}
|
||||
>
|
||||
<UserRound className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{h.label}</span>
|
||||
<Eye className="ml-auto h-3 w-3 shrink-0 opacity-60" />
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("fx.places")}
|
||||
</p>
|
||||
{lanes.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
title={l.cwd}
|
||||
onClick={() => go("", l.id)}
|
||||
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
|
||||
className={placeClass(
|
||||
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
|
||||
)}
|
||||
>
|
||||
<FolderGit2 className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{l.title || l.cwd.split("/").pop()}</span>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
// A home row with a chevron: its user's workspaces drop down under it.
|
||||
const homeRow = (h: UserRow) => {
|
||||
const own = lanesOfHome.get(h.id) ?? [];
|
||||
const open = own.length > 0 && homeIsOpen(h.id, own);
|
||||
const mine = h.id === "me";
|
||||
// No home to open: the name drops the list down too.
|
||||
const browse = !h.noHome;
|
||||
return (
|
||||
<div key={h.id} data-testid={`fx-home-${h.id}`}>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("fx.toggleWorkspaces", {
|
||||
name: mine ? t("fx.myHome") : h.label,
|
||||
})}
|
||||
aria-expanded={open}
|
||||
disabled={!own.length}
|
||||
onClick={() => toggleHome(h.id, own)}
|
||||
className="flex h-6 w-5 shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-2 disabled:opacity-0"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={mine || !browse ? undefined : t("fx.readOnlyHome")}
|
||||
aria-expanded={browse ? undefined : open}
|
||||
onClick={() => (browse ? go("", { home: h.id }) : toggleHome(h.id, own))}
|
||||
onContextMenu={browse ? (e) => placeCtx.open(e, { home: h.id }) : undefined}
|
||||
className={placeClass(
|
||||
browse && view === "files" && isHomePlace(target) && target.home === h.id
|
||||
)}
|
||||
>
|
||||
{mine ? (
|
||||
<House className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<UserRound className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{mine ? t("fx.myHome") : h.label}</span>
|
||||
{own.length > 0 && (
|
||||
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
|
||||
{own.length}
|
||||
</span>
|
||||
)}
|
||||
{!mine && browse && (
|
||||
<Eye
|
||||
className={`${own.length ? "" : "ml-auto "}h-3 w-3 shrink-0 opacity-60`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{open && own.map((l) => laneRow(l, true))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const me = homes.find((h) => h.id === "me");
|
||||
const others = userRows;
|
||||
return (
|
||||
<>
|
||||
{me && <div className="pt-3">{homeRow(me)}</div>}
|
||||
{others.length > 0 && (
|
||||
<>
|
||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{others.some((h) => !h.noHome) ? t("fx.userHomes") : t("fx.users")}
|
||||
</p>
|
||||
{others.map(homeRow)}
|
||||
</>
|
||||
)}
|
||||
{looseLanes.length > 0 && (
|
||||
<>
|
||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("fx.places")}
|
||||
</p>
|
||||
{looseLanes.map((l) => laneRow(l, false))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</nav>
|
||||
<div className="border-t border-border p-2" data-testid="fx-trashes">
|
||||
<button type="button" onClick={openTrashes} className={placeClass(view === "trash")}>
|
||||
@@ -1018,6 +1349,17 @@ export function FileBrowser({
|
||||
>
|
||||
{t("fx.newFile")}
|
||||
</DropItem>
|
||||
{cloneParent && (
|
||||
<DropItem
|
||||
icon={<FolderGit2 className="h-4 w-4" />}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
setCloning(true);
|
||||
}}
|
||||
>
|
||||
{t("fx.cloneRepo")}
|
||||
</DropItem>
|
||||
)}
|
||||
</DropButton>
|
||||
<DropButton
|
||||
label={t("fx.upload")}
|
||||
@@ -1378,26 +1720,6 @@ export function FileBrowser({
|
||||
<span className="ml-auto hidden md:inline">{t("fx.dropHint")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
<div
|
||||
data-testid="fx-progress"
|
||||
className="absolute bottom-12 right-4 w-72 rounded-lg border border-border bg-surface-1 p-3 shadow-xl"
|
||||
>
|
||||
<p className="truncate text-xs font-medium">
|
||||
{t("fx.uploading", { done: progress.done + 1, total: progress.total })}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-fg-muted">{progress.name}</p>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded bg-surface-3">
|
||||
<div
|
||||
className="h-full bg-accent-solid transition-[width]"
|
||||
style={{
|
||||
width: `${Math.min(100, (progress.sent / Math.max(1, progress.bytes)) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ctx.menu && (
|
||||
@@ -1405,13 +1727,16 @@ export function FileBrowser({
|
||||
{menuItem ? (
|
||||
<>
|
||||
<MenuItem onSelect={close(() => openEntry(menuItem))}>
|
||||
{isFolder(menuItem) ? t("fx.open") : t("fx.download")}
|
||||
{isFolder(menuItem) || isHtml(menuItem) ? t("fx.open") : t("fx.download")}
|
||||
</MenuItem>
|
||||
{(isFolder(menuItem) || menuTargets.length > 1) && (
|
||||
<MenuItem onSelect={close(() => download(menuTargets))}>
|
||||
{t("fx.downloadZip")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{isHtml(menuItem) && menuTargets.length === 1 && (
|
||||
<MenuItem onSelect={close(() => download([menuItem]))}>{t("fx.download")}</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
{!ro && (
|
||||
<MenuItem onSelect={close(() => putClip(menuTargets, true))}>
|
||||
@@ -1449,6 +1774,11 @@ export function FileBrowser({
|
||||
{t("fx.newFolder")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={close(() => startCreate("file"))}>{t("fx.newFile")}</MenuItem>
|
||||
{cloneParent && (
|
||||
<MenuItem onSelect={close(() => setCloning(true))}>
|
||||
{t("fx.cloneRepo")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={close(() => fileInput.current?.click())}>
|
||||
{t("fx.uploadFiles")}
|
||||
@@ -1627,7 +1957,7 @@ export function FileExplorer({
|
||||
onClose: () => void;
|
||||
laneId: number;
|
||||
session?: string | null;
|
||||
source?: number | null;
|
||||
source?: GitSource | null;
|
||||
initialPath?: string;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
@@ -1660,6 +1990,36 @@ export function FileExplorer({
|
||||
);
|
||||
}
|
||||
|
||||
/** An HTML file as the page, in the shared modal viewer (`HtmlViewerModal`):
|
||||
* its relative CSS, scripts and images read through the same download route. */
|
||||
function HtmlViewer({
|
||||
entry,
|
||||
place,
|
||||
onDownload,
|
||||
onClose,
|
||||
}: {
|
||||
entry: FsEntry;
|
||||
place: FsPlace;
|
||||
onDownload: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const placeKey = JSON.stringify(place);
|
||||
const fetchFile = useMemo(
|
||||
() => fetchVia((p) => api.files.downloadUrl(place, p)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[placeKey]
|
||||
);
|
||||
return (
|
||||
<HtmlViewerModal
|
||||
path={entry.path}
|
||||
fetchFile={fetchFile}
|
||||
onDownload={onDownload}
|
||||
onClose={onClose}
|
||||
testId="fx-html-viewer"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadButton({
|
||||
label,
|
||||
onClick,
|
||||
@@ -1839,7 +2199,7 @@ function InlineName({
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") onDone();
|
||||
if (isSubmitEnter(e)) onDone();
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
onBlur={onDone}
|
||||
|
||||
@@ -13,9 +13,14 @@
|
||||
* side, each block with the diff's Undo / Keep while nothing
|
||||
* is unsaved): Changes folds the unchanged lines away, Whole file
|
||||
* shows them; any other file opens on its read-only diff; a
|
||||
* whole markdown file can also be read rendered (Preview), and an
|
||||
* whole markdown file can also be read rendered (Preview), an HTML
|
||||
* file's Preview beside Whole file opens the page in a modal
|
||||
* (`HtmlViewerModal`, its relative CSS, scripts and images read
|
||||
* like downloads), and an
|
||||
* image file is shown as the picture (`GET …/git/raw`)
|
||||
* history — recent commits; opening one shows that commit's diff
|
||||
* history — the commit graph (`git/HistoryView.tsx`: all branches, search,
|
||||
* details, multi-select cherry-pick/revert, compare, browse at a
|
||||
* revision); Show History on a file is `git/FileHistory.tsx`
|
||||
* browse — quick-open by path, or the repo's tracked file tree (the
|
||||
* mode switcher's Files button; at full width the sidebar);
|
||||
* a row dragged onto a prompt box attaches it as `@path`
|
||||
@@ -51,9 +56,16 @@
|
||||
* .gitignore (`ignore`, untracked only). While a merge is open the box is
|
||||
* hidden: a partial commit cannot finish one, Complete does.
|
||||
*
|
||||
* History rewrites through its commit menu: Revert (any commit), Undo commit
|
||||
* (the top one; the server refuses a pushed one) and, while another branch's
|
||||
* history is shown, Cherry-pick onto this branch. The commit row's Amend box
|
||||
* **Shelve (n)** beside Discard sets the ticked files aside (`shelve`, with
|
||||
* an optional one-line note), and the Shelves list (`LaneShelves`) above the
|
||||
* file list restores or deletes what was shelved — a git stash under the
|
||||
* hood, its recovery always a button here.
|
||||
*
|
||||
* History rewrites through HistoryView's commit menu and selection: Revert and
|
||||
* Cherry-pick (one or several commits, previewed in `CherryPickDialog`, a
|
||||
* paused one finished from `PickBanner`), Undo commit (the top one; the
|
||||
* server refuses a pushed one), Reset branch to here… and Edit commits after
|
||||
* this one… (`GitTools`). The commit row's Amend box
|
||||
* folds the ticked files into the last commit (none ticked: rewords it). All
|
||||
* four ask first (`confirmDialog`).
|
||||
*
|
||||
@@ -77,9 +89,19 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Suspense,
|
||||
lazy,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Archive,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -103,9 +125,10 @@ import {
|
||||
Trash2,
|
||||
UnfoldVertical,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
@@ -126,10 +149,17 @@ import { LaneConflictView } from "./LaneConflictView";
|
||||
import { FileExplorer } from "./FileExplorer";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { fetchVia } from "../HtmlFrame";
|
||||
import { HtmlViewerModal } from "../HtmlViewerModal";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
import { LaneShelves, withAgentConfirm } from "./LaneShelves";
|
||||
import { useGitChanged } from "../../lib/gitChanged";
|
||||
import { GitTools, GitToolsButton, RebaseSkipButton, type GitTool } from "./git/GitTools";
|
||||
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
|
||||
import { dragProjectPath } from "../../lib/promptAttach";
|
||||
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
|
||||
import { HistoryView } from "./git/HistoryView";
|
||||
import { FileHistory } from "./git/FileHistory";
|
||||
import { PickBanner } from "./git/PickBanner";
|
||||
import type {
|
||||
LaneChangeFile,
|
||||
LaneChangeRange,
|
||||
@@ -149,6 +179,10 @@ const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!;
|
||||
|
||||
type Mode = "overview" | "review" | "file" | "history" | "browse";
|
||||
|
||||
/** A cherry-pick or revert: its banner is PickBanner, not the merge one (a
|
||||
* merge and a rebase keep theirs). */
|
||||
const isPick = (op: string | null | undefined) => op === "cherry-pick" || op === "revert";
|
||||
|
||||
/** Colour per porcelain status letter, matching LaneGitHeader's file panel. */
|
||||
/** JetBrains' VCS colours: unversioned orange, added green, deleted grey,
|
||||
* modified (and renamed) blue. */
|
||||
@@ -184,6 +218,9 @@ function Delta({
|
||||
);
|
||||
}
|
||||
|
||||
/** An HTML page, which Preview opens as the page (`HtmlViewerModal`). */
|
||||
const isHtml = (p: string | null | undefined) => !!p && /\.html?$/i.test(p);
|
||||
|
||||
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
|
||||
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
|
||||
// CodeMirror is loaded only once a file is edited.
|
||||
@@ -452,8 +489,9 @@ function FileActions({
|
||||
title={t("changes.discardConfirm")}
|
||||
disabled={busy}
|
||||
onClick={() => onAct("discard", f.path, true)}
|
||||
className={`${HUNK_CHIP} border-status-danger bg-status-danger/20 text-status-danger`}
|
||||
className="flex shrink-0 items-center gap-1 rounded bg-status-danger/10 px-1 py-0.5 text-[11px] leading-3 text-status-danger hover:bg-status-danger/20 disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t("changes.discardShortConfirm")}
|
||||
</button>
|
||||
) : (
|
||||
@@ -753,54 +791,6 @@ function ChangeTreeNodes({
|
||||
);
|
||||
}
|
||||
|
||||
/** History's page size, and the most the server reads (`listCommits`). */
|
||||
const HISTORY_PAGE = 50;
|
||||
const HISTORY_MAX = 200;
|
||||
|
||||
// The history's commit graph: one fixed-height row per commit so its lanes
|
||||
// join up row to row. Mid-tone hues that read on dark and light themes alike.
|
||||
const GRAPH_ROW = 34;
|
||||
const GRAPH_COL = 12;
|
||||
const GRAPH_COLORS = [
|
||||
"#3b82f6",
|
||||
"#22c55e",
|
||||
"#f59e0b",
|
||||
"#a855f7",
|
||||
"#ef4444",
|
||||
"#14b8a6",
|
||||
"#ec4899",
|
||||
"#84cc16",
|
||||
];
|
||||
|
||||
function GraphCell({ row, width }: { row: GraphRow; width: number }) {
|
||||
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
|
||||
const y = (f: number) => f * GRAPH_ROW;
|
||||
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
width={width * GRAPH_COL}
|
||||
height={GRAPH_ROW}
|
||||
className="shrink-0"
|
||||
data-testid="lane-changes-graph"
|
||||
>
|
||||
{row.segs.map((g, i) => {
|
||||
const ym = (y(g.y1) + y(g.y2)) / 2;
|
||||
return (
|
||||
<path
|
||||
key={i}
|
||||
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
|
||||
fill="none"
|
||||
stroke={hue(g.color)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<circle cx={x(row.col)} cy={y(0.5)} r={3.5} fill={hue(row.color)} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaneChangesPanel({
|
||||
laneId,
|
||||
session,
|
||||
@@ -826,7 +816,7 @@ export function LaneChangesPanel({
|
||||
session?: string;
|
||||
/** Show and act on that extra folder's repo instead (`lane_sources` id);
|
||||
* wins over `session`, and is keyed on like it. */
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
|
||||
onNewLane?: (branch?: string) => void;
|
||||
/** The panel has the whole width: the file tree becomes a sidebar and a
|
||||
@@ -843,6 +833,8 @@ export function LaneChangesPanel({
|
||||
const opOpen = !!(git?.available && git.merging);
|
||||
// Every git read and write below goes to the same tree as those facts.
|
||||
const target = gitTarget(laneId, session, source);
|
||||
// The open history-rewrite / remote / merge-editor tool (./git/GitTools).
|
||||
const [gitTool, setGitTool] = useState<GitTool | null>(null);
|
||||
// A diff line's comment goes into this workspace's pane, as a paste.
|
||||
const comment = useCallback(
|
||||
(text: string) => {
|
||||
@@ -858,6 +850,11 @@ export function LaneChangesPanel({
|
||||
// Bumped by Refresh and by a git bar action: a pull or a switch moves
|
||||
// history and the tree too, not just the diff `load` re-reads.
|
||||
const [reread, setReread] = useState(0);
|
||||
// Any git write on this workspace — from this panel's bar, another repo's
|
||||
// panel or the session view — re-reads everything here (`rereadAll`, set
|
||||
// once it is defined below).
|
||||
const rereadRef = useRef<(() => void) | null>(null);
|
||||
useGitChanged(laneId, () => rereadRef.current?.());
|
||||
const [range, setRange] = useState<LaneChangeRange>("work");
|
||||
// Local mode: the files are already on this machine — no upload, download
|
||||
// or File Explorer (the desktop's file manager does all three).
|
||||
@@ -873,9 +870,13 @@ export function LaneChangesPanel({
|
||||
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
|
||||
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
|
||||
const [whole, setWhole] = useState(false);
|
||||
// A whole markdown file shown rendered instead of as source; per file.
|
||||
const [mdPreview, setMdPreview] = useState(false);
|
||||
useEffect(() => setMdPreview(false), [openFile]);
|
||||
// A whole markdown file shown rendered instead of as source; per file,
|
||||
// opening as Settings → Appearance says (`lib/markdownView.ts`).
|
||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||
useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]);
|
||||
// An HTML file's page, open in the modal viewer (Preview beside Whole file).
|
||||
const [htmlOpen, setHtmlOpen] = useState(false);
|
||||
useEffect(() => setHtmlOpen(false), [openFile, mode]);
|
||||
const [data, setData] = useState<LaneChanges | null>(null);
|
||||
// A folder that is not a repo (an empty new workspace, a parent of clones)
|
||||
// has no changes, review or history — only its files, which Files reads
|
||||
@@ -887,7 +888,6 @@ export function LaneChangesPanel({
|
||||
if (notRepo && (mode === "overview" || mode === "review" || mode === "history"))
|
||||
setMode(full ? "overview" : "browse");
|
||||
}, [notRepo, mode, full]);
|
||||
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
|
||||
// A folder's children, keyed by its path ("" is the root) — fetched once,
|
||||
// on first expand, and kept so re-collapsing doesn't re-fetch.
|
||||
const [treeChildren, setTreeChildren] = useState<Record<string, LaneTreeEntry[]>>({});
|
||||
@@ -957,6 +957,9 @@ export function LaneChangesPanel({
|
||||
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
|
||||
const pendingScroll = useRef<number | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
// Shelve (n) opened its note row; the note typed there.
|
||||
const [shelving, setShelving] = useState(false);
|
||||
const [shelfNote, setShelfNote] = useState("");
|
||||
// The commit row amends the last commit instead of making a new one.
|
||||
const [amend, setAmend] = useState(false);
|
||||
// The last commit is already on its upstream: the server refuses to amend
|
||||
@@ -1054,30 +1057,18 @@ export function LaneChangesPanel({
|
||||
}, [load, mode]);
|
||||
|
||||
// History and the file tree are their own reads, each fetched when its mode
|
||||
// is entered rather than alongside the diff nobody is looking at.
|
||||
// History reads HISTORY_PAGE commits, and Load more asks for the next page
|
||||
// (up to the server's 200).
|
||||
const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
setHistoryLoading(true);
|
||||
void api.lanes
|
||||
.gitLog(target, {
|
||||
limit: historyLimit,
|
||||
...(historyRef ? { ref: historyRef } : {}),
|
||||
...(historyFile ? { file: historyFile } : {}),
|
||||
})
|
||||
.then(forLane(laneId, (r) => setCommits(r.commits)))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
|
||||
}, [mode, laneId, reread, historyLimit, historyRef, historyFile]);
|
||||
// is entered rather than alongside the diff nobody is looking at. The commit
|
||||
// graph and a file's history read themselves (git/HistoryView, FileHistory).
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
let live = true;
|
||||
void api.lanes
|
||||
.gitBranches(target)
|
||||
.then(forLane(laneId, (r) => setHistoryBranches(r.branches)))
|
||||
.catch(() => setHistoryBranches([]));
|
||||
.then(forLane(laneId, (r) => live && setHistoryBranches(r.branches)))
|
||||
.catch(() => live && setHistoryBranches([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [mode, laneId, reread]);
|
||||
|
||||
// Re-run when a lane switch empties the cache too: the full-width sidebar
|
||||
@@ -1171,24 +1162,33 @@ export function LaneChangesPanel({
|
||||
// own fetch — and only while the viewer is actually showing it.
|
||||
useEffect(() => {
|
||||
if (mode !== "file" || !(whole || editable) || !openFile) return;
|
||||
// Opening file B before file A's read came back must not show (and let the
|
||||
// editor save) A's text under B's header.
|
||||
let live = true;
|
||||
void api.lanes
|
||||
.gitFile(target, openFile, range === "commit" && sha ? sha : "work")
|
||||
.then(
|
||||
forLane(laneId, (r) =>
|
||||
setText(
|
||||
r.available
|
||||
? {
|
||||
path: r.path,
|
||||
lines: r.lines,
|
||||
truncated: r.truncated,
|
||||
binary: r.binary,
|
||||
sha: r.sha,
|
||||
}
|
||||
: null
|
||||
)
|
||||
forLane(
|
||||
laneId,
|
||||
(r) =>
|
||||
live &&
|
||||
setText(
|
||||
r.available
|
||||
? {
|
||||
path: r.path,
|
||||
lines: r.lines,
|
||||
truncated: r.truncated,
|
||||
binary: r.binary,
|
||||
sha: r.sha,
|
||||
}
|
||||
: null
|
||||
)
|
||||
)
|
||||
)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [mode, whole, editable, openFile, laneId, range, sha]);
|
||||
// The editor's original side, read beside the working file. A file that is
|
||||
// not there (new since) diffs against nothing: every line is added.
|
||||
@@ -1196,10 +1196,17 @@ export function LaneChangesPanel({
|
||||
useEffect(() => {
|
||||
if (mode !== "file" || !(whole || editable) || !openFile || range === "commit") return;
|
||||
const rev = range === "branch" && mergeBase ? mergeBase : "HEAD";
|
||||
let live = true;
|
||||
void api.lanes.gitFile(target, openFile, rev).then(
|
||||
forLane(laneId, (r) => setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")),
|
||||
forLane(laneId, () => setBaseText(""))
|
||||
forLane(
|
||||
laneId,
|
||||
(r) => live && setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")
|
||||
),
|
||||
forLane(laneId, () => live && setBaseText(""))
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [mode, whole, editable, openFile, laneId, range, mergeBase]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1218,7 +1225,6 @@ export function LaneChangesPanel({
|
||||
setTreeBusy(false);
|
||||
setBusy(false);
|
||||
setError(null);
|
||||
setCommits(null);
|
||||
setData(null);
|
||||
setDraft(null);
|
||||
setCollapsed(new Set());
|
||||
@@ -1299,7 +1305,6 @@ export function LaneChangesPanel({
|
||||
if (!ok) return;
|
||||
setHistoryFile(path);
|
||||
setHistoryRef(null);
|
||||
setCommits(null);
|
||||
setMode("history");
|
||||
});
|
||||
};
|
||||
@@ -1422,6 +1427,80 @@ export function LaneChangesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// Discard selected: exactly what Commit would take — the ticked files, and
|
||||
// only the ticked blocks of a partly ticked one (#87: it used to discard
|
||||
// every file, ticked or not) — in one `discard`, asked once in a dialog
|
||||
// that names how many untracked files it will delete.
|
||||
const discardTicked = async () => {
|
||||
if (!tickedPaths.length) return;
|
||||
// Tracked and untracked counted apart: an untracked file is deleted, not
|
||||
// reverted, and counting it in both told "10 changed + 10 new" for 10.
|
||||
const untracked = files.filter((f) => f.untracked && tickedPaths.includes(f.path)).length;
|
||||
const tracked = tickedPaths.length - untracked;
|
||||
const ok = await confirmDialog({
|
||||
title: t("changes.discardTickedTitle"),
|
||||
message: t(
|
||||
untracked === 0
|
||||
? "changes.discardTickedConfirmTracked"
|
||||
: tracked === 0
|
||||
? "changes.discardTickedConfirmNew"
|
||||
: "changes.discardTickedConfirm",
|
||||
{ count: tracked, untracked }
|
||||
),
|
||||
confirmLabel: t("changes.discardTicked", { count: tickedPaths.length }),
|
||||
});
|
||||
if (!ok) return;
|
||||
const lane = laneId;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { paths, hunks } = await selection();
|
||||
await api.lanes.gitAction(target, "discard", {
|
||||
paths,
|
||||
...(hunks ? { hunks, context: CONTEXT_STEPS[0] } : {}),
|
||||
confirm: true,
|
||||
});
|
||||
if (laneNow.current !== lane) return;
|
||||
setConfirmDiscard(null);
|
||||
refreshGit();
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (laneNow.current === lane) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Shelve (n): the ticked files (whole files — a stash has no hunks) set
|
||||
// aside with the note; all of them when every file is ticked.
|
||||
const shelveTicked = async () => {
|
||||
if (!tickedPaths.length) return;
|
||||
const lane = laneId;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const all = tickedPaths.length >= files.length;
|
||||
const res = await withAgentConfirm((confirm) =>
|
||||
api.lanes.gitAction(target, "shelve", {
|
||||
...(all ? {} : { paths: tickedPaths }),
|
||||
...(shelfNote.trim() ? { note: shelfNote.trim() } : {}),
|
||||
...(confirm ? { confirm } : {}),
|
||||
})
|
||||
);
|
||||
if (laneNow.current !== lane || !res) return;
|
||||
toast.success(t("shelves.shelved", { count: res.shelf?.files.length ?? tickedPaths.length }));
|
||||
setShelving(false);
|
||||
setShelfNote("");
|
||||
refreshGit();
|
||||
setReread((n) => n + 1);
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (laneNow.current === lane) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
|
||||
const mergeAct = async (
|
||||
action: "resolve" | "merge-commit" | "merge-abort",
|
||||
@@ -1509,7 +1588,6 @@ export function LaneChangesPanel({
|
||||
|
||||
const fileCtx = useContextMenu<LaneChangeFile>();
|
||||
const treeCtx = useContextMenu<LaneTreeEntry>();
|
||||
const commitCtx = useContextMenu<LaneCommit>();
|
||||
const viewCommit = (sha: string) => {
|
||||
setSha(sha);
|
||||
setRange("commit");
|
||||
@@ -1685,63 +1763,15 @@ export function LaneChangesPanel({
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{commitCtx.menu && (
|
||||
<ContextMenu
|
||||
x={commitCtx.menu.x}
|
||||
y={commitCtx.menu.y}
|
||||
onClose={commitCtx.close}
|
||||
testId="lane-changes-commit-menu"
|
||||
>
|
||||
<MenuItem onSelect={pick(commitCtx.close, () => viewCommit(commitCtx.menu!.item.sha))}>
|
||||
{t("changes.viewCommit")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.sha))}>
|
||||
{t("changes.copySha")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.subject))}>
|
||||
{t("changes.copySubject")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
{historyRef ? (
|
||||
<MenuItem
|
||||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
||||
testId="lane-changes-menu-cherry-pick"
|
||||
onSelect={pick(
|
||||
commitCtx.close,
|
||||
() => void historyAct("cherry-pick", commitCtx.menu!.item)
|
||||
)}
|
||||
>
|
||||
{t("changes.cherryPick", { branch: git?.available ? git.branch : "HEAD" })}
|
||||
</MenuItem>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem
|
||||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
||||
testId="lane-changes-menu-revert"
|
||||
onSelect={pick(
|
||||
commitCtx.close,
|
||||
() => void historyAct("revert", commitCtx.menu!.item)
|
||||
)}
|
||||
>
|
||||
{t("changes.revertCommit")}
|
||||
</MenuItem>
|
||||
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
||||
testId="lane-changes-menu-undo-commit"
|
||||
onSelect={pick(
|
||||
commitCtx.close,
|
||||
() => void historyAct("undo-commit", commitCtx.menu!.item)
|
||||
)}
|
||||
>
|
||||
{t("changes.undoCommit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
<GitTools
|
||||
tool={gitTool}
|
||||
target={target}
|
||||
branch={git?.available ? git.branch : null}
|
||||
onClose={() => setGitTool(null)}
|
||||
onOpen={setGitTool}
|
||||
onChanged={() => void rereadAll()}
|
||||
onViewCommit={viewCommit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1923,6 +1953,7 @@ export function LaneChangesPanel({
|
||||
setReread((n) => n + 1);
|
||||
return load();
|
||||
};
|
||||
rereadRef.current = () => void rereadAll();
|
||||
const [rereading, runReread] = useSpin(rereadAll);
|
||||
|
||||
// Files tab transfers (issue #78): a download is a plain attachment link; an
|
||||
@@ -1988,6 +2019,15 @@ export function LaneChangesPanel({
|
||||
});
|
||||
|
||||
const dirty = draft !== null && draft !== text?.lines.join("\n");
|
||||
// Rendered only where the toggle is offered: a whole markdown file.
|
||||
const mdShown = mdPreview && whole && isMarkdown(openFile);
|
||||
// An HTML page's relative CSS/scripts/images, read like a download — only
|
||||
// from the working tree: a commit's page would get today's files.
|
||||
const htmlFetch = useMemo(
|
||||
() => (range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p))),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[range, JSON.stringify(target)]
|
||||
);
|
||||
dirtyRef.current = dirty;
|
||||
useUnsavedGuard(dirty);
|
||||
const dirtyChangeRef = useRef(onDirtyChange);
|
||||
@@ -2313,10 +2353,7 @@ export function LaneChangesPanel({
|
||||
aria-pressed={mode === m}
|
||||
onClick={() => {
|
||||
// The History button is the branch's; Show History, a file's.
|
||||
if (m === "history" && historyFile) {
|
||||
setHistoryFile(null);
|
||||
setCommits(null);
|
||||
}
|
||||
if (m === "history" && historyFile) setHistoryFile(null);
|
||||
setMode(m);
|
||||
}}
|
||||
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||
@@ -2331,6 +2368,12 @@ export function LaneChangesPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!notRepo && (
|
||||
<GitToolsButton
|
||||
onPick={setGitTool}
|
||||
hasRemote={git?.available ? git.hasRemote : undefined}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={runReread}
|
||||
@@ -2504,6 +2547,31 @@ export function LaneChangesPanel({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* An HTML file opens as the page in a modal over the screen;
|
||||
a commit's page once its text is read (Whole file). */}
|
||||
{mode === "file" && isHtml(openFile) && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-preview"
|
||||
disabled={dirty || (range === "commit" && !text)}
|
||||
onClick={() => setHtmlOpen(true)}
|
||||
className="flex items-center gap-1 rounded border border-border bg-surface-2 px-2 py-1 text-[11px] text-fg-secondary hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
{t("changes.preview")}
|
||||
</button>
|
||||
)}
|
||||
{htmlOpen && openFile && (
|
||||
<HtmlViewerModal
|
||||
path={openFile}
|
||||
// A commit's page is its text at that commit (no sibling
|
||||
// files); a working one is read whole from the disk.
|
||||
html={range === "commit" ? text?.lines.join("\n") : undefined}
|
||||
fetchFile={htmlFetch}
|
||||
onDownload={!local && range !== "commit" ? () => download(openFile) : undefined}
|
||||
onClose={() => setHtmlOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2514,7 +2582,17 @@ export function LaneChangesPanel({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{git?.available && git.merging && (
|
||||
{git?.available && git.merging && isPick(git.operation) && (
|
||||
<PickBanner
|
||||
target={target}
|
||||
conflicts={git.conflicts ?? 0}
|
||||
reread={reread}
|
||||
busy={busy}
|
||||
onBusy={setBusy}
|
||||
onDone={() => void rereadAll()}
|
||||
/>
|
||||
)}
|
||||
{git?.available && git.merging && !isPick(git.operation) && (
|
||||
<div
|
||||
data-testid="lane-merge-banner"
|
||||
className="mb-2 flex flex-wrap items-center gap-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
|
||||
@@ -2532,6 +2610,13 @@ export function LaneChangesPanel({
|
||||
>
|
||||
{t("changes.mergeCommit", { op: git.operation ?? "merge" })}
|
||||
</button>
|
||||
{git.operation === "rebase" && (
|
||||
<RebaseSkipButton
|
||||
target={target}
|
||||
disabled={busy}
|
||||
onChanged={() => void rereadAll()}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
@@ -2548,6 +2633,12 @@ export function LaneChangesPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changes set aside (git stash entries AgentHub made): Restore and
|
||||
Delete, never dropped on their own. Absent while there are none. */}
|
||||
{git?.available && mode !== "file" && (
|
||||
<LaneShelves target={target} reread={reread} onChanged={rereadAll} />
|
||||
)}
|
||||
|
||||
{/* In single-file mode the DIFF owns the scrolling, so its horizontal
|
||||
bar sits at the bottom of the visible box instead of at the bottom of
|
||||
a 2000-line container nobody has scrolled to yet. Every other mode is
|
||||
@@ -2560,103 +2651,40 @@ export function LaneChangesPanel({
|
||||
}`}
|
||||
>
|
||||
{mode === "history" ? (
|
||||
<ul data-testid="lane-changes-history">
|
||||
{historyFile && (
|
||||
<li
|
||||
data-testid="lane-changes-history-file"
|
||||
className="mb-1 flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t("changes.historyOfFile", { path: historyFile })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHistoryFile(null);
|
||||
setCommits(null);
|
||||
}}
|
||||
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{t("changes.historyWholeBranch")}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{historyBranches.length > 1 && (
|
||||
<li className="mb-1 flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary">
|
||||
<span className="shrink-0">{t("changes.historyOf")}</span>
|
||||
<select
|
||||
data-testid="lane-changes-history-ref"
|
||||
value={historyRef ?? ""}
|
||||
onChange={(e) => {
|
||||
setHistoryRef(e.target.value || null);
|
||||
setCommits(null);
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
|
||||
>
|
||||
<option value="">{git?.available ? git.branch : "HEAD"}</option>
|
||||
{historyBranches
|
||||
.filter((b) => !(git?.available && b === git.branch))
|
||||
.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</li>
|
||||
)}
|
||||
{(() => {
|
||||
const list = commits ?? [];
|
||||
const graph = commitGraph(list);
|
||||
return list.map((c, i) => (
|
||||
<li key={c.sha}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
historyFile ? openAtCommit(historyFile, c.sha) : viewCommit(c.sha)
|
||||
}
|
||||
onContextMenu={(e) => commitCtx.open(e, c)}
|
||||
style={{ height: GRAPH_ROW }}
|
||||
className="flex w-full items-center gap-1.5 rounded px-1 text-left hover:bg-surface-2"
|
||||
>
|
||||
<GraphCell row={graph.rows[i]!} width={graph.width} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
|
||||
{c.short}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary">
|
||||
{c.subject}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
|
||||
<span className="truncate">{c.author}</span>
|
||||
<span>{new Date(c.date).toLocaleString()}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
));
|
||||
})()}
|
||||
{commits === null && (
|
||||
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
|
||||
)}
|
||||
{commits && commits.length === 0 && (
|
||||
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
|
||||
)}
|
||||
{commits && commits.length >= historyLimit && historyLimit < HISTORY_MAX && (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-history-more"
|
||||
disabled={historyLoading}
|
||||
onClick={() => setHistoryLimit((n) => Math.min(n + HISTORY_PAGE, HISTORY_MAX))}
|
||||
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
{historyLoading ? t("changes.loading") : t("changes.loadMore")}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
historyFile ? (
|
||||
<FileHistory
|
||||
key={`${laneId}:${historyFile}`}
|
||||
target={target}
|
||||
path={historyFile}
|
||||
reread={reread}
|
||||
onOpenVersion={openAtCommit}
|
||||
onViewCommit={viewCommit}
|
||||
onWholeBranch={() => setHistoryFile(null)}
|
||||
/>
|
||||
) : (
|
||||
<HistoryView
|
||||
key={laneId}
|
||||
target={target}
|
||||
branch={git?.available ? git.branch : null}
|
||||
branches={historyBranches}
|
||||
historyRef={historyRef}
|
||||
onHistoryRef={setHistoryRef}
|
||||
reread={reread}
|
||||
disabled={busy || opOpen}
|
||||
headPushed={headPushed}
|
||||
onViewCommit={viewCommit}
|
||||
onOpenFileAtCommit={openAtCommit}
|
||||
onUndoCommit={(c) => void historyAct("undo-commit", c)}
|
||||
onResetHere={(c) => setGitTool({ kind: "reset", commit: c })}
|
||||
onEditAfter={(sha) => setGitTool({ kind: "interactive", base: sha })}
|
||||
onPaused={() => {
|
||||
setRange("work");
|
||||
setMode("overview");
|
||||
void rereadAll();
|
||||
}}
|
||||
onChanged={() => void rereadAll()}
|
||||
/>
|
||||
)
|
||||
) : mode === "browse" && !full ? (
|
||||
browsePane
|
||||
) : !data ? (
|
||||
@@ -2751,7 +2779,65 @@ export function LaneChangesPanel({
|
||||
>
|
||||
{t(amend ? "changes.amendAndPush" : "changes.commitAndPush")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-discard-all"
|
||||
disabled={busy || tickedPaths.length === 0}
|
||||
onClick={() => void discardTicked()}
|
||||
title={t("changes.discardTickedTitle")}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
{t("changes.discardTicked", { count: tickedPaths.length })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-shelve"
|
||||
aria-expanded={shelving}
|
||||
disabled={busy || tickedPaths.length === 0}
|
||||
onClick={() => setShelving((v) => !v)}
|
||||
title={t("shelves.shelveHint")}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Archive className="h-3 w-3" aria-hidden />
|
||||
{t("shelves.shelveTicked", { count: tickedPaths.length })}
|
||||
</button>
|
||||
</div>
|
||||
{shelving && (
|
||||
<div
|
||||
data-testid="lane-changes-shelve-row"
|
||||
className="flex w-full flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<input
|
||||
value={shelfNote}
|
||||
autoFocus
|
||||
maxLength={200}
|
||||
onChange={(e) => setShelfNote(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) void shelveTicked();
|
||||
if (e.key === "Escape") setShelving(false);
|
||||
}}
|
||||
placeholder={t("shelves.notePlaceholder")}
|
||||
aria-label={t("shelves.notePlaceholder")}
|
||||
className="min-w-[10rem] flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || tickedPaths.length === 0}
|
||||
onClick={() => void shelveTicked()}
|
||||
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("shelves.shelveApply", { count: tickedPaths.length })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShelving(false)}
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-[11px] text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{t("shelves.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* The work range splits like JetBrains' Commit window: tracked
|
||||
@@ -2872,6 +2958,9 @@ export function LaneChangesPanel({
|
||||
editWanted.current = true;
|
||||
setWhole(true);
|
||||
}}
|
||||
onMergeEditor={() =>
|
||||
setGitTool({ kind: "merge", path: openFile, onResolved: goBack })
|
||||
}
|
||||
/>
|
||||
) : mode === "file" ? (
|
||||
current || (whole && text) || openImage ? (
|
||||
@@ -2902,6 +2991,7 @@ export function LaneChangesPanel({
|
||||
{t("changes.annotate")}
|
||||
</button>
|
||||
)}
|
||||
{/* An HTML file's Preview sits beside Whole file instead. */}
|
||||
{whole && text && !dirty && isMarkdown(openFile) && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -2990,7 +3080,7 @@ export function LaneChangesPanel({
|
||||
range === "commit" && sha ? sha : "work"
|
||||
)}
|
||||
/>
|
||||
) : !mdPreview &&
|
||||
) : !mdShown &&
|
||||
(whole || editable) &&
|
||||
(draft !== null || (editable && text === null)) ? (
|
||||
// The editor, once both texts are in: not the diff first.
|
||||
@@ -3046,7 +3136,7 @@ export function LaneChangesPanel({
|
||||
</Suspense>
|
||||
)
|
||||
) : whole ? (
|
||||
text && mdPreview ? (
|
||||
text && mdShown ? (
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
|
||||
<MarkdownPreview text={text.lines.join("\n")} />
|
||||
</div>
|
||||
|
||||
@@ -8,14 +8,15 @@
|
||||
* (`gitSaveFile` with its `sha`, 409 ESTALE otherwise) and then runs
|
||||
* `resolve done`, which re-checks that no marker is left. Take all mine /
|
||||
* theirs is `resolve ours|theirs` for the whole file; Edit by hand hands the
|
||||
* file to the panel's plain editor.
|
||||
* file to the panel's plain editor; Merge editor opens the 3-way editor
|
||||
* (`./git/MergeEditor`, base/current/incoming side by side).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||
import {
|
||||
applyChoices,
|
||||
parseConflicts,
|
||||
@@ -42,12 +43,15 @@ export function LaneConflictView({
|
||||
path,
|
||||
onResolved,
|
||||
onEdit,
|
||||
onMergeEditor,
|
||||
}: {
|
||||
/** Open the 3-way merge editor (./git/MergeEditor) on this file. */
|
||||
onMergeEditor?: () => void;
|
||||
laneId: number;
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
path: string;
|
||||
/** The file left the conflict (resolved, or a whole side taken). */
|
||||
onResolved: () => void;
|
||||
@@ -176,6 +180,17 @@ export function LaneConflictView({
|
||||
<button type="button" className={BTN} disabled={busy} onClick={onEdit}>
|
||||
{t("conflict.editByHand")}
|
||||
</button>
|
||||
{onMergeEditor && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="conflict-open-merge-editor"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={onMergeEditor}
|
||||
>
|
||||
{t("gitTools:merge.open")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -179,6 +179,18 @@ export function langFor(filePath: string) {
|
||||
}
|
||||
|
||||
/** Tokenized line text. `emph` marks the span that actually changed. */
|
||||
/** The tail of a row the server cut down (`LaneDiffLine.elided`). */
|
||||
function Elided({ n }: { n?: number }) {
|
||||
const { t, i18n } = useTranslation("lanes");
|
||||
if (!n) return null;
|
||||
const size = new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n);
|
||||
return (
|
||||
<span className="ml-1 select-none rounded bg-surface-3 px-1 font-sans text-[10px] text-fg-muted">
|
||||
{t("changes.lineElided", { size })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
|
||||
if (emph) {
|
||||
const [from, to] = emph;
|
||||
@@ -544,6 +556,7 @@ function UnifiedLine({
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph} />
|
||||
<Elided n={line.elided} />
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
@@ -621,6 +634,7 @@ function SplitLine({
|
||||
/>
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
|
||||
{line ? <Elided n={line.elided} /> : null}
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @file The lane's git action row: ahead/behind against the upstream, and the
|
||||
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
|
||||
* fetch, pull, push, switch branch, create/delete a branch, publish a repo
|
||||
* fetch, pull, push, switch branch (a local one, or one only a remote has —
|
||||
* listed under "On the remote" and checked out as a new tracking branch; the
|
||||
* list is re-read after every fetch and pull), create/delete a branch, publish a repo
|
||||
* that has no remote yet (a URL, then `origin` + `push -u`), and start a merge
|
||||
* (the picked branch, or the upstream once diverged; resolving it is the
|
||||
* Changes panel's job). Committing is
|
||||
@@ -13,7 +15,16 @@
|
||||
* icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
|
||||
* worktree`), and these are the ordinary moves a lane needs several times an hour.
|
||||
* Nothing here decides what an action may do; the server does (pull is always
|
||||
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
|
||||
* --ff-only, switch refuses a dirty tree unless told what to do with the
|
||||
* changes), and this only reports what it said. With uncommitted changes,
|
||||
* Switch branch asks first — **Bring my changes** (`changes: "carry"`; a
|
||||
* file git would overwrite comes back as ECARRYCONFLICT, listed, with Shelve
|
||||
* and switch offered instead) or **Shelve and switch** (`changes: "shelve"`)
|
||||
* — and the new-branch row offers **Create & switch** (`switch: true`, the
|
||||
* changes come along; the default on Enter while the tree is dirty) beside
|
||||
* plain Create. A switch onto a branch that has shelves asks whether to
|
||||
* restore the newest (never automatically), and a write the server answers
|
||||
* EAGENTRUNNING is confirmed and resent (`withAgentConfirm`'s dialog).
|
||||
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
|
||||
* the user more than any sentence this file could write. The one exception
|
||||
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
|
||||
@@ -21,6 +32,7 @@
|
||||
* resent with the typed credential — the token is never rendered back.
|
||||
* Fetch, pull, push and publish say so in a toast when they succeed; a merge,
|
||||
* a push, a publish and a branch delete ask through the themed confirm first; the rows wrap in a narrow panel.
|
||||
* The tags icon opens the Branches panel (`git/BranchManager`: every branch and tag, rename, upstream, tags).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -38,11 +50,14 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api";
|
||||
import { api, gitTarget, type GitSource, type GitAuthNeeded } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||
import { restoreShelf } from "./LaneShelves";
|
||||
import { BranchesButton } from "./git/BranchesButton";
|
||||
import { Select } from "../Select";
|
||||
|
||||
type Action =
|
||||
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
|
||||
@@ -98,7 +113,7 @@ export function LaneGitBar({
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
git: LaneGitFacts;
|
||||
/** Rendered at the start of the action row: the header's branch line, so
|
||||
* the facts and the buttons that act on them read as one row. */
|
||||
@@ -114,6 +129,8 @@ export function LaneGitBar({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
// Fetched remote branches with no local one yet (`origin/feature`).
|
||||
const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
const [picked, setPicked] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
@@ -124,6 +141,9 @@ export function LaneGitBar({
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [url, setUrl] = useState("");
|
||||
// A switch with uncommitted changes asks how to take them: set to the
|
||||
// branch picked (and the files git refused to carry, after ECARRYCONFLICT).
|
||||
const [choice, setChoice] = useState<{ branch: string; conflict?: string[] } | null>(null);
|
||||
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
|
||||
// All of the state above describes ONE lane — a branch picked for lane A,
|
||||
// applied after switching, would move B's HEAD — so it is reset during
|
||||
@@ -138,6 +158,7 @@ export function LaneGitBar({
|
||||
setError(null);
|
||||
setSwitching(false);
|
||||
setBranches(null);
|
||||
setRemoteBranches([]);
|
||||
setWorktrees([]);
|
||||
setPicked("");
|
||||
setNewBranch("");
|
||||
@@ -145,6 +166,7 @@ export function LaneGitBar({
|
||||
setPassword("");
|
||||
setPublishing(false);
|
||||
setUrl("");
|
||||
setChoice(null);
|
||||
inFlight.current = false;
|
||||
}
|
||||
const laneNow = useRef(laneId);
|
||||
@@ -152,6 +174,9 @@ export function LaneGitBar({
|
||||
|
||||
if (!git.available) return null;
|
||||
const dirty = git.dirty + git.untracked;
|
||||
// A remote branch: switching creates its local tracking branch; it cannot
|
||||
// be deleted here or given a sibling worktree before that.
|
||||
const pickedRemote = remoteBranches.includes(picked);
|
||||
const merging = !!git.merging;
|
||||
|
||||
const run = async (action: Action, body?: RunBody) => {
|
||||
@@ -160,6 +185,9 @@ export function LaneGitBar({
|
||||
const lane = laneId;
|
||||
setBusy(action);
|
||||
setError(null);
|
||||
// EAGENTRUNNING: asked AFTER the request settles, so the resend is not
|
||||
// refused as a second action in flight.
|
||||
let askAgent = false;
|
||||
try {
|
||||
const res = await api.lanes.gitAction(gitTarget(lane, session, source), action, body || {});
|
||||
// Landed after a lane switch: every write below belongs to the lane
|
||||
@@ -182,17 +210,50 @@ export function LaneGitBar({
|
||||
if (action === "switch") setSwitching(false);
|
||||
else setNewBranch("");
|
||||
setPicked("");
|
||||
setChoice(null);
|
||||
setBranches(null);
|
||||
await refreshBranches();
|
||||
} else if ((action === "fetch" || action === "pull") && branches !== null) {
|
||||
// A fetch may have brought branches the list does not show yet.
|
||||
await refreshBranches();
|
||||
}
|
||||
if (res?.shelved) toast.success(t("gitBar.shelvedOn", { branch: res.shelved.branch }));
|
||||
onChanged();
|
||||
// The branch switched to has changes shelved on it: offer them back —
|
||||
// asking, never restoring on its own.
|
||||
const offer = action === "switch" ? res?.shelves?.[0] : undefined;
|
||||
if (offer) {
|
||||
const yes = await confirmDialog({
|
||||
title: t("gitBar.restoreOfferTitle"),
|
||||
message: t("gitBar.restoreOffer", {
|
||||
branch: offer.branch,
|
||||
count: res?.shelves?.length ?? 1,
|
||||
files: offer.files.length,
|
||||
}),
|
||||
confirmLabel: t("shelves.restore"),
|
||||
destructive: false,
|
||||
});
|
||||
if (yes && laneNow.current === lane) {
|
||||
await restoreShelf(gitTarget(lane, session, source), offer);
|
||||
if (laneNow.current === lane) onChanged();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// The server appends git's own stderr to the message, so this is the
|
||||
// rejection text verbatim — hence the <pre> below rather than a line.
|
||||
// ENOREMOTE is the one refusal with no git text to show: the server's
|
||||
// sentence is English and says less than a translated one.
|
||||
const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
|
||||
if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
|
||||
const code = (err as { code?: string })?.code;
|
||||
if (laneNow.current === lane && code === "EAGENTRUNNING" && body?.confirm !== true) {
|
||||
askAgent = true;
|
||||
} else if (laneNow.current === lane && code === "ECARRYCONFLICT" && body?.branch) {
|
||||
// git would overwrite these files: show them and offer the shelf.
|
||||
setChoice({
|
||||
branch: body.branch,
|
||||
conflict: (err as { files?: string[] }).files ?? [],
|
||||
});
|
||||
} else if (laneNow.current === lane && code === "EGITAUTH" && auth) {
|
||||
setLogin({ action, auth });
|
||||
setUsername((u) => u || auth.username || "");
|
||||
setPassword("");
|
||||
@@ -210,6 +271,15 @@ export function LaneGitBar({
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
if (askAgent) {
|
||||
const ok = await confirmDialog({
|
||||
title: t("shelves.agentTitle"),
|
||||
message: t("shelves.agentMessage"),
|
||||
confirmLabel: t("shelves.agentConfirm"),
|
||||
destructive: false,
|
||||
});
|
||||
if (ok && laneNow.current === lane) void run(action, { ...body, confirm: true });
|
||||
}
|
||||
};
|
||||
|
||||
const refreshBranches = async () => {
|
||||
@@ -217,6 +287,7 @@ export function LaneGitBar({
|
||||
const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
|
||||
if (laneNow.current !== laneId) return;
|
||||
setBranches(r.branches);
|
||||
setRemoteBranches(r.remotes || []);
|
||||
setWorktrees(r.worktrees || []);
|
||||
} catch {
|
||||
if (laneNow.current === laneId) setBranches([]);
|
||||
@@ -294,6 +365,20 @@ export function LaneGitBar({
|
||||
if (ok) void run("merge", { branch });
|
||||
};
|
||||
|
||||
// Switch branch: straight away on a clean tree; with changes, ask how.
|
||||
const switchTo = (branch: string) => {
|
||||
if (dirty > 0) {
|
||||
setChoice({ branch });
|
||||
setError(null);
|
||||
} else void run("switch", { branch });
|
||||
};
|
||||
// Enter in the new-branch box: with changes, Create & switch (they come
|
||||
// along); on a clean tree, plain Create as before.
|
||||
const createBranch = (andSwitch: boolean) => {
|
||||
const branch = newBranch.trim();
|
||||
if (branch) void run("branch-create", andSwitch ? { branch, switch: true } : { branch });
|
||||
};
|
||||
|
||||
const heldBy = new Map(
|
||||
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
|
||||
);
|
||||
@@ -412,49 +497,72 @@ export function LaneGitBar({
|
||||
label={t("gitBar.switch")}
|
||||
icon={<GitBranch className="h-3.5 w-3.5" />}
|
||||
disabled={!!busy || merging}
|
||||
// A dirty tree is refused server-side; saying so before the click
|
||||
// beats a red box after it.
|
||||
// With changes, Switch branch asks whether to bring or shelve
|
||||
// them; the hint says so before the click.
|
||||
title={
|
||||
merging
|
||||
? t("gitBar.merging", { op: git.operation ?? "merge" })
|
||||
: dirty > 0
|
||||
? t("gitBar.switchDirty")
|
||||
? t("gitBar.switchDirtyHint", { count: dirty })
|
||||
: t("gitBar.switchHint")
|
||||
}
|
||||
onClick={openSwitch}
|
||||
/>
|
||||
<BranchesButton
|
||||
laneId={laneId}
|
||||
session={session}
|
||||
source={source}
|
||||
dirty={dirty}
|
||||
merging={merging}
|
||||
disabled={!!busy}
|
||||
onChanged={() => {
|
||||
// A rename, delete or new branch makes the switcher's list stale.
|
||||
if (branches !== null) void refreshBranches();
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{switching && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<select
|
||||
aria-label={t("gitBar.switchLabel")}
|
||||
value={picked}
|
||||
disabled={!!busy}
|
||||
onChange={(e) => setPicked(e.target.value)}
|
||||
className="select min-w-[8rem] flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
|
||||
>
|
||||
<option value="">{t("gitBar.switchLabel")}</option>
|
||||
{(branches || []).map((b) => {
|
||||
const held = heldBy.get(b);
|
||||
return (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
{b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{/* A combobox once the list is long (Select's search box, #88):
|
||||
type to narrow the local and remote branches. */}
|
||||
<div className="min-w-[8rem] flex-1 font-mono">
|
||||
<Select
|
||||
label={t("gitBar.switchLabel")}
|
||||
searchLabel={t("gitBar.branchSearch")}
|
||||
searchable
|
||||
value={picked}
|
||||
disabled={!!busy}
|
||||
onChange={setPicked}
|
||||
options={[
|
||||
{ value: "", label: t("gitBar.switchLabel") },
|
||||
...(branches || []).map((b) => {
|
||||
const held = heldBy.get(b);
|
||||
const note =
|
||||
b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : "";
|
||||
return { value: b, label: `${b}${note}` };
|
||||
}),
|
||||
...remoteBranches.map((b) => ({
|
||||
value: b,
|
||||
label: b,
|
||||
group: t("gitBar.remoteBranches"),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Btn
|
||||
label={t("gitBar.switchApply")}
|
||||
busy={busy === "switch"}
|
||||
// A branch git already has checked out somewhere else cannot be
|
||||
// switched to — git allows one worktree per branch.
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || dirty > 0}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
||||
onClick={() => void run("switch", { branch: picked })}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
|
||||
title={
|
||||
dirty > 0 ? t("gitBar.switchDirtyHint", { count: dirty }) : t("gitBar.switchHint")
|
||||
}
|
||||
onClick={() => switchTo(picked)}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.merge")}
|
||||
@@ -476,7 +584,7 @@ export function LaneGitBar({
|
||||
})
|
||||
: t("gitBar.newLaneHint")
|
||||
}
|
||||
disabled={!picked || heldBy.has(picked) || picked === git.branch}
|
||||
disabled={!picked || heldBy.has(picked) || picked === git.branch || pickedRemote}
|
||||
// The sibling lane is created through the normal Add-lane flow,
|
||||
// prefilled — one provisioning path, not a second one here.
|
||||
onClick={() => onNewLane(picked)}
|
||||
@@ -486,7 +594,7 @@ export function LaneGitBar({
|
||||
label={t("gitBar.branchDelete")}
|
||||
icon={<Trash2 className="h-3 w-3" />}
|
||||
busy={busy === "branch-delete"}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || pickedRemote}
|
||||
title={t("gitBar.branchDeleteHint")}
|
||||
onClick={() =>
|
||||
void confirmRun(
|
||||
@@ -500,34 +608,83 @@ export function LaneGitBar({
|
||||
)
|
||||
}
|
||||
/>
|
||||
{dirty > 0 && (
|
||||
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{switching && choice && (
|
||||
<div
|
||||
data-testid="lane-git-switch-choice"
|
||||
role="group"
|
||||
aria-label={t("gitBar.choiceTitle", { branch: choice.branch })}
|
||||
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
|
||||
>
|
||||
<p className="break-words text-fg-primary">
|
||||
{choice.conflict
|
||||
? t("gitBar.carryConflict", { branch: choice.branch })
|
||||
: t("gitBar.choiceMessage", { count: dirty, branch: choice.branch })}
|
||||
</p>
|
||||
{choice.conflict && choice.conflict.length > 0 && (
|
||||
<ul
|
||||
data-testid="lane-git-carry-conflicts"
|
||||
className="max-h-24 overflow-auto font-mono text-[10px] text-status-warning"
|
||||
>
|
||||
{choice.conflict.map((f) => (
|
||||
<li key={f} className="truncate" title={f}>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{!choice.conflict && (
|
||||
<Btn
|
||||
label={t("gitBar.choiceCarry")}
|
||||
busy={busy === "switch"}
|
||||
title={t("gitBar.choiceCarryHint")}
|
||||
onClick={() => void run("switch", { branch: choice.branch, changes: "carry" })}
|
||||
/>
|
||||
)}
|
||||
<Btn
|
||||
label={t("gitBar.choiceShelve")}
|
||||
busy={busy === "switch"}
|
||||
title={t("gitBar.choiceShelveHint")}
|
||||
onClick={() => void run("switch", { branch: choice.branch, changes: "shelve" })}
|
||||
/>
|
||||
<Btn label={t("gitBar.loginCancel")} onClick={() => setChoice(null)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{switching && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<input
|
||||
value={newBranch}
|
||||
onChange={(e) => setNewBranch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && newBranch.trim())
|
||||
void run("branch-create", { branch: newBranch.trim() });
|
||||
if (isSubmitEnter(e)) createBranch(dirty > 0);
|
||||
}}
|
||||
placeholder={t("gitBar.branchNewPlaceholder")}
|
||||
aria-label={t("gitBar.branchNewPlaceholder")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
className="min-w-[8rem] flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.branchCreateSwitch")}
|
||||
icon={<GitBranch className="h-3 w-3" />}
|
||||
busy={busy === "branch-create"}
|
||||
disabled={!newBranch.trim()}
|
||||
// The working tree stays as it is: uncommitted changes come along.
|
||||
title={t("gitBar.branchCreateSwitchHint")}
|
||||
onClick={() => createBranch(true)}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.branchCreate")}
|
||||
icon={<Plus className="h-3 w-3" />}
|
||||
busy={busy === "branch-create"}
|
||||
disabled={!newBranch.trim()}
|
||||
// Creating does not switch: the point is usually a sibling lane,
|
||||
// and moving this pane's HEAD under a working agent is not.
|
||||
// Plain Create does not switch: the point is usually a sibling
|
||||
// lane, and moving this pane's HEAD under a working agent is not.
|
||||
title={t("gitBar.branchCreateHint")}
|
||||
onClick={() => void run("branch-create", { branch: newBranch.trim() })}
|
||||
onClick={() => createBranch(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
* It used to sit in the lane card, a column away from the diff it describes —
|
||||
* a pull there changed files the Changes panel only noticed on its next poll.
|
||||
* Here the two share one read of `GET /api/lanes/:id/git` (`useLaneGitFacts`,
|
||||
* owned by the panel) and refresh each other after every write.
|
||||
* owned by the panel) and refresh each other after every write. Every other
|
||||
* reader of the facts (the rail counts, a second panel) hears a write through
|
||||
* `agenthub:git-changed` (lib/gitChanged.ts), which `useLaneGitFacts` listens to.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -19,8 +21,9 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { LaneGitBar } from "./LaneGitBar";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import { useGitChanged } from "../../lib/gitChanged";
|
||||
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
||||
|
||||
/** How often the mounted panel re-reads its working-copy facts. Slow on
|
||||
@@ -37,8 +40,8 @@ export function useLaneGitFacts(
|
||||
laneId: number,
|
||||
/** Read that session's own worktree instead (`?session=`). */
|
||||
session?: string,
|
||||
/** Read that extra folder's repo instead (`?source=`). */
|
||||
source?: number
|
||||
/** Read that extra folder's repo (`?source=`, a number) or that inner repo (`?repo=`, a string) instead. */
|
||||
source?: GitSource
|
||||
): [LaneGitFacts | null, () => void] {
|
||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||
// Bumped by the git action bar: a commit or a pull changes every fact on
|
||||
@@ -76,6 +79,9 @@ export function useLaneGitFacts(
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneId, session, source, nonce]);
|
||||
// A git write from ANY view of this workspace (another panel, the session
|
||||
// view, a second tab of the same page) moves these facts too.
|
||||
useGitChanged(laneId, () => setNonce((n) => n + 1));
|
||||
|
||||
return [facts, () => setNonce((n) => n + 1)];
|
||||
}
|
||||
@@ -329,7 +335,7 @@ export function LaneGitRailCounts({
|
||||
}: {
|
||||
laneId: number;
|
||||
session?: string;
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
/** Opens the Changes panel from the file list's "… more files". */
|
||||
onShowAll?: () => void;
|
||||
}) {
|
||||
@@ -349,7 +355,7 @@ export function LaneGitHeader({
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
git: LaneGitFacts | null;
|
||||
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
||||
onChanged: () => void;
|
||||
@@ -367,12 +373,17 @@ export function LaneGitHeader({
|
||||
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
|
||||
title={`${git.head} ${git.subject}`}
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
|
||||
<GitBranch className="h-3 w-3 text-fg-muted" />
|
||||
{git.branch}
|
||||
{/* A long branch name truncates too (after the subject has gone),
|
||||
or it overflowed onto the head and the action icons. */}
|
||||
<span
|
||||
className="flex min-w-[5rem] items-center gap-1 self-center font-semibold text-fg-primary"
|
||||
title={git.branch}
|
||||
>
|
||||
<GitBranch className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
<span className="truncate">{git.branch}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
|
||||
<span className="min-w-0 shrink-[999] truncate text-fg-muted">{git.subject}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @file The Changes panel's Shelves list: changes set aside by AgentHub (git
|
||||
* stash entries it made — `GET /api/lanes/:id/git/shelves`), each with its
|
||||
* branch, note, time and files, and the two buttons that make a shelf safe to
|
||||
* offer from a dashboard: **Restore** (`unshelve`, which drops the shelf only
|
||||
* when it applied cleanly; a conflicted restore keeps it and leaves the files
|
||||
* to the conflict list) and **Delete** (`shelf-drop`, two clicks through
|
||||
* `useArmed`, sent with `confirm: true`). Collapsed to one line until opened,
|
||||
* and absent while there are none. Also exports `withAgentConfirm`, the
|
||||
* one place a git write answered `EAGENTRUNNING` asks the user and is resent
|
||||
* with `confirm: true`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Archive, ChevronDown, ChevronRight, RotateCcw, Trash2 } from "lucide-react";
|
||||
import i18n from "../../i18n";
|
||||
import { api, type GitShelf, type GitTarget } from "../../lib/api";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { useArmed } from "../../hooks/useArmed";
|
||||
|
||||
/**
|
||||
* Run a git write; when the server answers `EAGENTRUNNING` (an agent of the
|
||||
* workspace is working and the write would move files under it), ask, and
|
||||
* resend it with `confirm: true`. Resolves null when the user declined.
|
||||
*/
|
||||
export async function withAgentConfirm<T>(
|
||||
send: (confirm: boolean) => Promise<T>
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return await send(false);
|
||||
} catch (err) {
|
||||
if ((err as { code?: string })?.code !== "EAGENTRUNNING") throw err;
|
||||
const ok = await confirmDialog({
|
||||
title: i18n.t("lanes:shelves.agentTitle"),
|
||||
message: i18n.t("lanes:shelves.agentMessage"),
|
||||
confirmLabel: i18n.t("lanes:shelves.agentConfirm"),
|
||||
destructive: false,
|
||||
});
|
||||
return ok ? send(true) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore one shelf (`unshelve`), reporting the outcome in a toast. Shared by
|
||||
* the list below and the git bar's "restore what you shelved here" prompt.
|
||||
* @returns true when anything changed in the working tree.
|
||||
*/
|
||||
export async function restoreShelf(target: GitTarget, shelf: GitShelf): Promise<boolean> {
|
||||
try {
|
||||
const res = await withAgentConfirm((confirm) =>
|
||||
api.lanes.gitAction(target, "unshelve", { sha: shelf.sha, ...(confirm ? { confirm } : {}) })
|
||||
);
|
||||
if (!res) return false;
|
||||
if (res.conflicts?.length) {
|
||||
toast.info(i18n.t("lanes:shelves.restoredConflicts", { count: res.conflicts.length }));
|
||||
} else {
|
||||
toast.success(i18n.t("lanes:shelves.restored"));
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
(err as { code?: string })?.code === "EDIRTY"
|
||||
? i18n.t("lanes:shelves.restoreDirty")
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** "M", "A", "D", "??" → a colour, matching the Changes list's letters. */
|
||||
function statusTone(status: string): string {
|
||||
if (status === "??" || status.startsWith("A")) return "text-status-success";
|
||||
if (status.startsWith("D")) return "text-status-danger";
|
||||
return "text-status-warning";
|
||||
}
|
||||
|
||||
export function LaneShelves({
|
||||
target,
|
||||
reread,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
/** Bumped by the panel after every git write: the list is read again. */
|
||||
reread: number;
|
||||
/** Called after a restore or a delete changed the tree or the list. */
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [shelves, setShelves] = useState<GitShelf[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filesOf, setFilesOf] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const del = useArmed();
|
||||
const key = JSON.stringify(target);
|
||||
const keyNow = useRef(key);
|
||||
keyNow.current = key;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const asked = key;
|
||||
try {
|
||||
const r = await api.lanes.gitShelves(target);
|
||||
if (keyNow.current === asked) setShelves(r.available ? r.shelves : []);
|
||||
} catch {
|
||||
if (keyNow.current === asked) setShelves([]);
|
||||
}
|
||||
// `target` is described by `key`; a new object with the same content is
|
||||
// the same tree.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load, reread]);
|
||||
|
||||
if (!shelves.length) return null;
|
||||
|
||||
const restore = async (s: GitShelf) => {
|
||||
setBusy(s.sha);
|
||||
try {
|
||||
if (await restoreShelf(target, s)) onChanged();
|
||||
await load();
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const drop = async (s: GitShelf) => {
|
||||
if (!del.isArmed(s.sha)) {
|
||||
del.arm(s.sha);
|
||||
return;
|
||||
}
|
||||
del.disarm();
|
||||
setBusy(s.sha);
|
||||
try {
|
||||
await api.lanes.gitAction(target, "shelf-drop", { sha: s.sha, confirm: true });
|
||||
toast.success(t("shelves.deleted"));
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
await load();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section data-testid="lane-shelves" className="mb-2 rounded border border-border-light">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-1 px-2 py-1 text-left text-[11px] font-medium text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
<Archive className="h-3 w-3 text-fg-muted" aria-hidden />
|
||||
{t("shelves.title", { count: shelves.length })}
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="divide-y divide-border-light border-t border-border-light">
|
||||
{shelves.map((s) => {
|
||||
const when = s.createdAt ? new Date(s.createdAt) : null;
|
||||
const showFiles = filesOf === s.sha;
|
||||
return (
|
||||
<li key={s.sha} data-testid="lane-shelf" className="px-2 py-1 text-[11px]">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span
|
||||
className="min-w-0 max-w-full truncate font-mono font-semibold text-fg-primary"
|
||||
title={s.branch}
|
||||
>
|
||||
{s.branch}
|
||||
</span>
|
||||
{when && !Number.isNaN(when.getTime()) && (
|
||||
<span className="shrink-0 text-fg-muted" title={s.createdAt ?? undefined}>
|
||||
{when.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showFiles}
|
||||
onClick={() => setFilesOf(showFiles ? null : s.sha)}
|
||||
className="shrink-0 text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
|
||||
>
|
||||
{t("shelves.files", { count: s.files.length })}
|
||||
</button>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => void restore(s)}
|
||||
title={t("shelves.restoreHint")}
|
||||
className="flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" aria-hidden />
|
||||
{t("shelves.restore")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => void drop(s)}
|
||||
title={t("shelves.deleteHint")}
|
||||
className="flex items-center gap-1 rounded border border-status-danger/40 px-1.5 py-0.5 text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
{del.isArmed(s.sha) ? t("shelves.deleteConfirm") : t("shelves.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{s.note && (
|
||||
<p className="mt-0.5 break-words text-fg-secondary" title={s.note}>
|
||||
{s.note}
|
||||
</p>
|
||||
)}
|
||||
{showFiles && (
|
||||
<ul data-testid="lane-shelf-files" className="mt-1 space-y-0.5 pl-2">
|
||||
{s.files.map((f) => (
|
||||
<li key={f.path} className="flex min-w-0 items-center gap-1.5 font-mono">
|
||||
<span className={`w-4 shrink-0 text-center ${statusTone(f.status)}`}>
|
||||
{f.status === "??" ? "U" : f.status.slice(0, 1)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-fg-secondary" title={f.path}>
|
||||
{f.path}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default LaneShelves;
|
||||
@@ -31,6 +31,7 @@ export function SessionMenu({
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
onPin,
|
||||
onShare,
|
||||
}: MenuPoint & {
|
||||
laneId: number;
|
||||
session: Session | null;
|
||||
@@ -42,6 +43,8 @@ export function SessionMenu({
|
||||
onUnarchive?: () => void;
|
||||
/** Pin or unpin (by `session.pinned_at`), where the list offers it. */
|
||||
onPin?: () => void;
|
||||
/** Share this session on its own (server mode, the workspace's owner). */
|
||||
onShare?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
@@ -81,6 +84,11 @@ export function SessionMenu({
|
||||
{t(session.pinned_at ? "sessionMenu.unpin" : "sessionMenu.pin")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onShare && session && (
|
||||
<MenuItem testId="session-menu-share" onSelect={pick(onShare)}>
|
||||
{t("sessionMenu.share")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{session && (
|
||||
<>
|
||||
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
* first, and a session some tab already holds opens THAT tab instead of a
|
||||
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
|
||||
* message is pasted there (never sent: Enter is the user's), with a toast; while
|
||||
* it is live the composer is off. Only Claude and Codex can resume a session by id —
|
||||
* any other tool's session offers a new session from the workspace home
|
||||
* it is live the composer is off. Only Claude, Codex and a catalog tool with
|
||||
* `resumeById` (omp, Cursor, Antigravity) can resume a session by id — any other tool's session
|
||||
* offers a new session from the workspace home
|
||||
* instead. The header carries the transcript page's Resume/Fork
|
||||
* (`SessionResumeButton`, behind its warning). The session's tool and folder
|
||||
* come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the
|
||||
@@ -32,6 +33,7 @@ import { toast } from "../../lib/toast";
|
||||
import i18n from "../../i18n";
|
||||
import { liveRunFor } from "./WorkspaceSessionList";
|
||||
import { SessionResumeButton } from "./SessionResumeButton";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
|
||||
/** A message for a session that a tab already holds goes into THAT tab as a
|
||||
* paste, once its terminal is on screen (it mounts after the navigation);
|
||||
@@ -47,6 +49,7 @@ function pasteWhenShown(runId: string, text: string) {
|
||||
}
|
||||
|
||||
/** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */
|
||||
// Before the catalog loads: the two hand-written launchers resume by id.
|
||||
const RESUMABLE = new Set(["claude", "codex"]);
|
||||
|
||||
export function SessionReplay({
|
||||
@@ -65,6 +68,7 @@ export function SessionReplay({
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const { byId } = useAiTools();
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
@@ -88,9 +92,9 @@ export function SessionReplay({
|
||||
}, [sessionId]);
|
||||
|
||||
const tool = session?.tool || "claude";
|
||||
const resumable = RESUMABLE.has(tool);
|
||||
const resumable = byId(tool)?.resumeById ?? RESUMABLE.has(tool);
|
||||
const livePane = liveRunFor(lane, liveRuns, sessionId);
|
||||
const text = promptWithImages(prompt, images);
|
||||
const text = promptWithImages(prompt, images, tool === "claude");
|
||||
|
||||
const send = async () => {
|
||||
if (!text || busy || uploading || livePane || !resumable) return;
|
||||
@@ -141,7 +145,7 @@ export function SessionReplay({
|
||||
{livePane ? (
|
||||
<div
|
||||
data-testid="session-replay-live"
|
||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("replay.live")}</span>
|
||||
<button
|
||||
@@ -156,7 +160,7 @@ export function SessionReplay({
|
||||
) : session && !resumable ? (
|
||||
<div
|
||||
data-testid="session-replay-no-resume"
|
||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
|
||||
<button
|
||||
@@ -168,7 +172,7 @@ export function SessionReplay({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<div className="mx-auto w-full max-w-5xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file SessionResumeButton.tsx
|
||||
* @description The transcript page's explicit Resume and Fork for a Claude
|
||||
* session inside a workspace. Opening a session never resumes it; this is the
|
||||
* session inside a workspace (Resume only for a catalog tool that resumes by
|
||||
* id — omp's `--resume <id>`). Opening a session never resumes it; this is the
|
||||
* one place that does, behind a warning, because a resume re-reads the whole
|
||||
* transcript into context. Fork resumes it into a NEW session id
|
||||
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
|
||||
@@ -26,6 +27,7 @@ import { ConfirmModal } from "../ConfirmModal";
|
||||
import { startInFreeTabDetailed } from "./startSession";
|
||||
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
|
||||
/** A path without its trailing separators — `/` or a Windows `\`. */
|
||||
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||
@@ -54,6 +56,7 @@ export function SessionResumeButton({
|
||||
const navigate = useNavigate();
|
||||
const { lanes } = useLanes();
|
||||
const { canEditLane } = usePermissions();
|
||||
const { byId } = useAiTools();
|
||||
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
||||
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -70,12 +73,13 @@ export function SessionResumeButton({
|
||||
};
|
||||
}, [session.id]);
|
||||
|
||||
// Claude only: a Codex session id is not what
|
||||
// `codex resume` takes for every kind of thread.
|
||||
// Claude, or a catalog tool that resumes by id (no launcher of its own): a
|
||||
// Codex session id is not what `codex resume` takes for every kind of thread.
|
||||
const tool = session.tool || "claude";
|
||||
const catalogResume = !!byId(tool)?.resumeById && !byId(tool)?.launcher;
|
||||
// The workspace that owns it, else the one whose folder holds it.
|
||||
const lane =
|
||||
tool !== "claude" || !session.cwd
|
||||
(tool !== "claude" && !catalogResume) || !session.cwd
|
||||
? null
|
||||
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
|
||||
owningLane(session.cwd, tool, lanes));
|
||||
@@ -140,7 +144,7 @@ export function SessionResumeButton({
|
||||
{t("sessions.resume")}
|
||||
</button>
|
||||
)}
|
||||
{resumable && (
|
||||
{resumable && tool === "claude" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming("fork")}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @file UrlPreview.tsx
|
||||
* @description The session panel's Preview tab: a web page (a lane's dev
|
||||
* server, mostly) shown beside the console. One bar — address, Reload, Open
|
||||
* in a browser tab — and, before anything is open, the workspace's own ports
|
||||
* as one-click chips. The page runs in an `<iframe src>` on ITS OWN origin:
|
||||
* `normalizePreviewUrl` refuses the dashboard's origin and anything but
|
||||
* http(s), and the sandbox leaves out top navigation so a page cannot move
|
||||
* the dashboard away. A site that forbids framing (most login pages) shows
|
||||
* blank or as the browser's broken-page icon; that is the browser's rule and
|
||||
* cannot be read from here, so any page not on this machine gets a strip
|
||||
* saying so with the Open button beside it. The
|
||||
* last address is remembered per workspace in localStorage (a viewer
|
||||
* convenience, never the server's).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, Globe, RotateCw } from "lucide-react";
|
||||
import { isLoopbackUrl, normalizePreviewUrl } from "../../lib/openInPreview";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
const KEY = (laneId: number) => `agenthub.preview.url.${laneId}`;
|
||||
|
||||
function readSaved(laneId: number): string {
|
||||
try {
|
||||
return window.localStorage.getItem(KEY(laneId)) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** The frame's sandbox: everything a web app needs, minus moving the
|
||||
* dashboard (`allow-top-navigation*`). `allow-same-origin` is the PAGE's
|
||||
* own origin — never the dashboard's, which `normalizePreviewUrl` refuses. */
|
||||
const FRAME_SANDBOX =
|
||||
"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads";
|
||||
|
||||
export function UrlPreview({
|
||||
laneId,
|
||||
ports,
|
||||
request,
|
||||
}: {
|
||||
laneId: number;
|
||||
/** The lane's bound ports, by name — the chips of the empty state. */
|
||||
ports: Record<string, number>;
|
||||
/** A link asked for this page (`openInPreview`); a new nonce re-opens it. */
|
||||
request: { url: string; nonce: number } | null;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [url, setUrl] = useState(() => readSaved(laneId));
|
||||
const [draft, setDraft] = useState(url);
|
||||
const [reload, setReload] = useState(0);
|
||||
|
||||
// Another workspace: its own last page.
|
||||
useEffect(() => {
|
||||
const saved = readSaved(laneId);
|
||||
setUrl(saved);
|
||||
setDraft(saved);
|
||||
}, [laneId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (request) go(request.url);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the nonce
|
||||
}, [request?.nonce]);
|
||||
|
||||
function go(raw: string) {
|
||||
const next = normalizePreviewUrl(raw);
|
||||
if (!next) {
|
||||
toast.error(t("preview.badUrl"));
|
||||
return;
|
||||
}
|
||||
setUrl(next);
|
||||
setDraft(next);
|
||||
setReload((n) => n + 1);
|
||||
try {
|
||||
window.localStorage.setItem(KEY(laneId), next);
|
||||
} catch {
|
||||
/* disabled storage: the tab just forgets between visits */
|
||||
}
|
||||
}
|
||||
|
||||
const openTab = () => url && window.open(url, "_blank", "noopener,noreferrer");
|
||||
const portList = Object.entries(ports ?? {}).filter(([, p]) => Number.isInteger(p) && p > 0);
|
||||
const btn =
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="url-preview">
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
go(draft);
|
||||
}
|
||||
}}
|
||||
placeholder="localhost:3000"
|
||||
aria-label={t("preview.address")}
|
||||
spellCheck={false}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
disabled={!url}
|
||||
onClick={() => setReload((n) => n + 1)}
|
||||
title={t("preview.reload")}
|
||||
aria-label={t("preview.reload")}
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
disabled={!url}
|
||||
onClick={openTab}
|
||||
title={t("preview.openTab")}
|
||||
aria-label={t("preview.openTab")}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{url && !isLoopbackUrl(url) && (
|
||||
// An outside site: most refuse to be framed, and a refusal cannot be
|
||||
// detected from here — so say it, with the way out beside it.
|
||||
<div
|
||||
data-testid="url-preview-blocked-hint"
|
||||
className="flex shrink-0 items-center gap-2 border-b border-border bg-surface-2 px-2 py-1.5 text-[11px] text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("preview.blockedHint")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openTab}
|
||||
className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-accent hover:bg-surface-4"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t("preview.openTabShort")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{url ? (
|
||||
<iframe
|
||||
key={`${url}#${reload}`}
|
||||
src={url}
|
||||
title={t("preview.title")}
|
||||
sandbox={FRAME_SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
className="min-h-0 w-full flex-1 border-0 bg-white"
|
||||
data-testid="url-preview-frame"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<Globe className="h-6 w-6 text-fg-muted" />
|
||||
<p className="text-xs text-fg-secondary">{t("preview.empty")}</p>
|
||||
{portList.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{portList.map(([name, port]) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => go(`localhost:${port}`)}
|
||||
title={name}
|
||||
className="rounded-full border border-border px-2.5 py-0.5 font-mono text-[11px] text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
localhost:{port}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-fg-muted">{t("preview.loginHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file WorkspaceContext — a workspace's context: Instructions, Memory
|
||||
* (read-only), Context files and Folders (`GET/PUT/POST/DELETE
|
||||
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
|
||||
* workspace home's right column and, `compact`, folded at the bottom of the
|
||||
* workspace home's right column (sticky: it follows the home's scroll) and,
|
||||
* `compact`, folded at the bottom of the
|
||||
* session view's Changes panel. Folder suggestions are the repo's untracked
|
||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees; the
|
||||
* same form clones related repos beside the workspace (`CloneSourcesForm`).
|
||||
@@ -157,7 +158,13 @@ function ContextBody({
|
||||
<aside
|
||||
data-testid="workspace-context"
|
||||
aria-label={t("home.contextPanel")}
|
||||
className={compact ? "" : "card w-80 shrink-0 self-start p-0"}
|
||||
// On the home it follows the scroll (sticky in the home's scroller),
|
||||
// scrolling itself — without a bar — when taller than the window.
|
||||
className={
|
||||
compact
|
||||
? ""
|
||||
: "card no-scrollbar sticky top-2 max-h-[calc(100vh-7rem)] w-80 shrink-0 self-start overflow-y-auto p-0"
|
||||
}
|
||||
>
|
||||
{view && !view.supported && (
|
||||
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
||||
|
||||
@@ -11,18 +11,19 @@
|
||||
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
|
||||
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
|
||||
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
|
||||
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
|
||||
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
|
||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
||||
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
||||
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
* its right-click menu — Archive on a live session stops its pane first, `endRuns`), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||||
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { useGitChanged } from "../../lib/gitChanged";
|
||||
import { endRuns } from "../../lib/stopSession";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
MoreHorizontal,
|
||||
ArrowUp,
|
||||
SquareTerminal,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
@@ -55,6 +57,8 @@ import { AiToolIcon } from "../AiToolIcon";
|
||||
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
||||
import { Select } from "../Select";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||
import { WorkspaceShareModal } from "./WorkspaceShareModal";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { workspaceName } from "./WorkspaceRow";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
import { menuPoint, useContextMenu } from "../ContextMenu";
|
||||
@@ -76,7 +80,7 @@ export {
|
||||
} from "./startSession";
|
||||
import { startInFreeTab } from "./startSession";
|
||||
import { WorkspaceContext } from "./WorkspaceContext";
|
||||
import { readDefaultMode } from "../../lib/sessionDefaults";
|
||||
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
|
||||
|
||||
/** Recents' page size; Show more adds another. */
|
||||
const RECENT_PAGE = 30;
|
||||
@@ -116,6 +120,7 @@ function HomeBody({
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const aiTools = useAiTools();
|
||||
const { ownsLane } = usePermissions();
|
||||
// The NEW session's AI tool — its own choice, the workspace's by default.
|
||||
const [tool, setTool] = useState(lane.tool);
|
||||
const entry = aiTools.byId(tool);
|
||||
@@ -146,7 +151,7 @@ function HomeBody({
|
||||
const proxyCapable = entry?.proxyCapable ?? (claude || codex);
|
||||
// A profile picked for a tool that can use one; its models replace the tool's own.
|
||||
const profile = proxyCapable
|
||||
? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null)
|
||||
? (proxies?.find((r) => r.name === proxy && !r.builtin && !r.company) ?? null)
|
||||
: null;
|
||||
const proxyModels: ModelChoice[] | undefined = profile
|
||||
? [
|
||||
@@ -165,12 +170,14 @@ function HomeBody({
|
||||
// Claude and Codex always take a model and an effort; a catalog tool a
|
||||
// model only when its file names the flag, an effort never.
|
||||
const hasModel = claude || codex || !!entry?.modelFlag;
|
||||
// The account's own default (first-run setup) — never bypass.
|
||||
// The account's own defaults (first-run setup, Settings → AI tools →
|
||||
// Session defaults); a pick here is for this session only.
|
||||
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
|
||||
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
|
||||
const [approval, setApproval] = useState<CodexApproval>("on-request");
|
||||
// Per session, never remembered.
|
||||
const [sandbox, setSandbox] = useState<CodexSandbox>(() => readCodexDefaults().sandbox);
|
||||
const [approval, setApproval] = useState<CodexApproval>(() => readCodexDefaults().approval);
|
||||
// Off for every tool but Codex, which starts from the account's default.
|
||||
const [bypass, setBypass] = useState(false);
|
||||
useEffect(() => setBypass(codex && readCodexDefaults().bypass), [codex]);
|
||||
const canBypass = !claude && !!entry?.bypass;
|
||||
const [effort, setEffort] = useState<EffortLevel>("");
|
||||
const hasEffort = claude || codex;
|
||||
@@ -191,6 +198,7 @@ function HomeBody({
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||||
const [title, setTitle] = useState<string | null>(null);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const recentCtx = useContextMenu<Session>();
|
||||
|
||||
// A new pane, a pane gone, a session id learned — all move these lane fields.
|
||||
@@ -235,6 +243,17 @@ function HomeBody({
|
||||
live = false;
|
||||
};
|
||||
}, [lane.id]);
|
||||
// A branch moved from the git bar: the main worktree's branch shown here is
|
||||
// the server's cached list, so it is refreshed rather than re-read.
|
||||
useGitChanged(lane.id, (action) => {
|
||||
if (!["switch", "branch-create", "branch-delete"].includes(action)) return;
|
||||
api.projects
|
||||
.refresh()
|
||||
.then(({ projects }) =>
|
||||
setProject(projects.find((p) => p.workspaces.includes(lane.id)) ?? null)
|
||||
)
|
||||
.catch(() => undefined);
|
||||
});
|
||||
|
||||
// The AI menu's Open terminal: a plain shell in the first free slot, opened
|
||||
// at once — the same start the session view's tab bar `+` makes.
|
||||
@@ -254,11 +273,18 @@ function HomeBody({
|
||||
}
|
||||
};
|
||||
|
||||
const text = promptWithImages(prompt, images);
|
||||
const text = promptWithImages(prompt, images, claude);
|
||||
// A workspace shared with this user: a session they start runs on their
|
||||
// own account and is billed to them (the server answers ENOCOLLABACCOUNT
|
||||
// when they have none).
|
||||
const collab = !ownsLane(lane);
|
||||
// Only a session of it is shared with them (phase 3): no new session here —
|
||||
// they open that session and send it messages (the server answers ESESSIONSHARE).
|
||||
const sessionOnly = lane.access === "session";
|
||||
const start = async () => {
|
||||
// Enter reaches here past the disabled button: the same guards again.
|
||||
// A ref, not only the state: two Enters in one tick both read `starting` false.
|
||||
if (startingRef.current || uploading || !text) return;
|
||||
if (sessionOnly || startingRef.current || uploading || !text) return;
|
||||
startingRef.current = true;
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
@@ -305,30 +331,30 @@ function HomeBody({
|
||||
}
|
||||
};
|
||||
const archiveSession = async (s: Session) => {
|
||||
const live = !!liveRunFor(lane, liveRuns, s.id);
|
||||
const run = liveRunFor(lane, liveRuns, s.id);
|
||||
// A live one is stopped first, as the tab's X does (its own confirm):
|
||||
// archiving only its row left a pane running that no list showed.
|
||||
if (run) {
|
||||
if ((await endRuns([run], { name: s.name || t("tree.newSession"), t })).length)
|
||||
await patchSession(s, { archived: true });
|
||||
return;
|
||||
}
|
||||
const wt = sessionWorktreeName(lane, s);
|
||||
const ok = await confirmDialog(
|
||||
wt && !live
|
||||
wt
|
||||
? {
|
||||
title: t("tree.archiveWorktreeTitle"),
|
||||
message: t("tree.archiveWorktreeBody", { name: wt }),
|
||||
confirmLabel: t("tree.archiveWorktreeConfirm"),
|
||||
}
|
||||
: live
|
||||
? {
|
||||
title: t("tree.archiveLiveTitle"),
|
||||
message: t("tree.archiveLiveBody"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}
|
||||
: {
|
||||
title: t("tree.archiveSession"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}
|
||||
: {
|
||||
title: t("tree.archiveSession"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}
|
||||
);
|
||||
if (!ok) return;
|
||||
if (wt && !live) {
|
||||
if (wt) {
|
||||
try {
|
||||
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
|
||||
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
|
||||
@@ -514,7 +540,7 @@ function HomeBody({
|
||||
type="button"
|
||||
data-testid="workspace-home-start"
|
||||
onClick={() => void start()}
|
||||
disabled={starting || uploading || !text}
|
||||
disabled={sessionOnly || starting || uploading || !text}
|
||||
aria-label={t("home.start")}
|
||||
title={t("home.start")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
@@ -524,6 +550,11 @@ function HomeBody({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{collab && (
|
||||
<p data-testid="workspace-home-collab" className="mt-2 text-xs text-fg-muted">
|
||||
{t(sessionOnly ? "shared.sessionOnly" : "shared.ownAccount")}
|
||||
</p>
|
||||
)}
|
||||
{/* The session's quieter choices, under the box like Claude Desktop's. */}
|
||||
<div
|
||||
data-testid="workspace-home-options"
|
||||
@@ -607,15 +638,25 @@ function HomeBody({
|
||||
</div>
|
||||
)}
|
||||
{canBypass && (
|
||||
<span className="px-2">
|
||||
<Checkbox
|
||||
tone="danger"
|
||||
checked={bypass}
|
||||
onChange={setBypass}
|
||||
label={t("run:fields.bypass")}
|
||||
labelClassName="text-xs text-status-danger"
|
||||
// A chip like the selects beside it: quiet when off, red when on.
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={bypass}
|
||||
data-testid="workspace-home-bypass"
|
||||
title={t(codex ? "run:fields.yoloHint" : "run:fields.bypass")}
|
||||
onClick={() => setBypass(!bypass)}
|
||||
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-status-danger/60 ${
|
||||
bypass
|
||||
? "bg-status-danger/10 font-medium text-status-danger ring-1 ring-inset ring-status-danger/30 hover:bg-status-danger/20"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Zap
|
||||
className={`h-3.5 w-3.5 ${bypass ? "fill-current" : "text-fg-muted"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
{t(codex ? "run:fields.yolo" : "run:fields.bypassShort")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{danger && (
|
||||
@@ -762,9 +803,16 @@ function HomeBody({
|
||||
x={menuAt.x}
|
||||
y={menuAt.y}
|
||||
onClose={() => setMenuAt(null)}
|
||||
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
|
||||
onAction={(a) =>
|
||||
a === "rename"
|
||||
? setTitle(lane.title || "")
|
||||
: a === "share"
|
||||
? setSharing(true)
|
||||
: onMenuAction(a)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{sharing && <WorkspaceShareModal lane={lane} onClose={() => setSharing(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,12 +161,19 @@ export function WorkspaceRow({
|
||||
e.preventDefault();
|
||||
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
|
||||
}}
|
||||
title={`${lane.cwd}\n${readOnly ?? t("row.hint")}`}
|
||||
title={`${lane.cwd}\n${lane.folder_missing ? t("row.folderMissing") : (readOnly ?? t("row.hint"))}`}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
|
||||
>
|
||||
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
|
||||
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
|
||||
<span
|
||||
data-folder-missing={lane.folder_missing ? "true" : undefined}
|
||||
className={`truncate text-[13px] font-medium ${
|
||||
lane.folder_missing ? "text-fg-muted line-through" : "text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{readOnly && <Eye aria-label={readOnly} className="mr-1 h-3 w-3 shrink-0 text-fg-muted" />}
|
||||
|
||||
@@ -2,19 +2,26 @@
|
||||
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
|
||||
* the pointer (right-click) or under the row's `⋯` button. Every item is an
|
||||
* existing call: Rename (the row's inline field), New workspace here (the
|
||||
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
||||
* managed lane. The server's guards are the real ones; the menu only hides
|
||||
* what the lane's kind or tool makes meaningless. The shell (position,
|
||||
* Add modal prefilled), Copy path, Share (server mode: `WorkspaceShareModal`),
|
||||
* and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
||||
* managed lane — plus Move to group (the sidebar's own groups,
|
||||
* `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, and the owner-only items
|
||||
* (Rename, Share, Reset, Remove) on a workspace shared with this user. The shell (position,
|
||||
* Escape / outside-click close) is `ContextMenu`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { Check } from "lucide-react";
|
||||
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
||||
import type { WorkspaceGroup } from "../../lib/workspaceGroups";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
|
||||
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
|
||||
export type WorkspaceMenuAction = "rename" | "new-here" | "share" | "reset" | "remove";
|
||||
|
||||
export function WorkspaceRowMenu({
|
||||
lane,
|
||||
@@ -22,6 +29,9 @@ export function WorkspaceRowMenu({
|
||||
y,
|
||||
onClose,
|
||||
onAction,
|
||||
groups = [],
|
||||
groupId = null,
|
||||
onGroup,
|
||||
}: {
|
||||
lane: Lane;
|
||||
x: number;
|
||||
@@ -29,11 +39,18 @@ export function WorkspaceRowMenu({
|
||||
onClose: () => void;
|
||||
/** Items the tree itself carries out (rename field, modals). */
|
||||
onAction: (action: WorkspaceMenuAction) => void;
|
||||
/** The sidebar's groups and the one this workspace is in. */
|
||||
groups?: WorkspaceGroup[];
|
||||
groupId?: string | null;
|
||||
/** Move it: to a group's id, out of its group (null), or into a new group ("new"). */
|
||||
onGroup?: (target: string | null | "new") => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const { local, ownsLane } = usePermissions();
|
||||
const owner = ownsLane(lane);
|
||||
return (
|
||||
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
|
||||
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
|
||||
{owner && <MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>}
|
||||
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
@@ -43,15 +60,53 @@ export function WorkspaceRowMenu({
|
||||
>
|
||||
{t("menu.copyPath")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
{lane.kind === "managed" && (
|
||||
<MenuItem danger onSelect={() => onAction("reset")}>
|
||||
{t("menu.reset")}
|
||||
{owner && !local && (
|
||||
<MenuItem testId="workspace-menu-share" onSelect={() => onAction("share")}>
|
||||
{t("menu.share")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem danger onSelect={() => onAction("remove")}>
|
||||
{t("menu.remove")}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{owner && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
{lane.kind === "managed" && (
|
||||
<MenuItem danger onSelect={() => onAction("reset")}>
|
||||
{t("menu.reset")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem danger onSelect={() => onAction("remove")}>
|
||||
{t("menu.remove")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,15 +24,15 @@
|
||||
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
|
||||
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
|
||||
* live tab whose session was archived is not brought back as a New session,
|
||||
* and archiving a live one asks first. F2 renames the focused row too; each
|
||||
* and archiving a live one STOPS it first (`endRuns`, the tab X's confirm). F2 renames the focused row too; each
|
||||
* dot carries its meaning as screen-reader text. `readOnly` (another user's
|
||||
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
|
||||
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { lazy, Suspense, useEffect, useState, type ReactNode } from "react";
|
||||
import { endRuns } from "../../lib/stopSession";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -46,6 +46,11 @@ import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useThrottledValue } from "../../hooks/useThrottledValue";
|
||||
import { useContextMenu } from "../ContextMenu";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
|
||||
const WorkspaceShareModal = lazy(() =>
|
||||
import("./WorkspaceShareModal").then((m) => ({ default: m.WorkspaceShareModal }))
|
||||
);
|
||||
|
||||
export const SESSION_LIMIT = 6;
|
||||
|
||||
@@ -256,6 +261,9 @@ export function WorkspaceSessionList({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ctx = useContextMenu<RunHandle>();
|
||||
const sessionCtx = useContextMenu<Session>();
|
||||
// Share one session (server mode, the workspace's owner or an admin).
|
||||
const { local, ownsLane } = usePermissions();
|
||||
const [sharing, setSharing] = useState<Session | null>(null);
|
||||
// Rows shown; See all raises it to the whole list.
|
||||
const [limit, setLimit] = useState(SESSION_LIMIT);
|
||||
|
||||
@@ -363,17 +371,10 @@ export function WorkspaceSessionList({
|
||||
};
|
||||
const archive = async (s: Session) => {
|
||||
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
|
||||
// A live one keeps running in its tab; archiving only hides its row.
|
||||
if (
|
||||
runFor(s) &&
|
||||
!(await confirmDialog({
|
||||
title: t("tree.archiveLiveTitle"),
|
||||
message: t("tree.archiveLiveBody"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
// A live one is stopped first, as the tab's X does: archiving only its row
|
||||
// left a pane running that no list showed any more.
|
||||
const run = runFor(s);
|
||||
if (run && !(await endRuns([run], { name: s.name || t("tree.newSession"), t })).length) return;
|
||||
await patch(s, { archived: true });
|
||||
};
|
||||
const archiveWithWorktree = async (s: Session) => {
|
||||
@@ -644,8 +645,18 @@ export function WorkspaceSessionList({
|
||||
onPin={() =>
|
||||
void patch(sessionCtx.menu!.item, { pinned: !sessionCtx.menu!.item.pinned_at })
|
||||
}
|
||||
onShare={!local && ownsLane(lane) ? () => setSharing(sessionCtx.menu!.item) : undefined}
|
||||
/>
|
||||
)}
|
||||
{sharing && (
|
||||
<Suspense fallback={null}>
|
||||
<WorkspaceShareModal
|
||||
lane={lane}
|
||||
session={{ id: sharing.id, name: sharing.name }}
|
||||
onClose={() => setSharing(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* @file WorkspaceShareModal — share a workspace, or one of its sessions
|
||||
* (`session`), with other users (server mode, docs/plans/workspace-sharing/
|
||||
* phases 1 and 3). Ticks people from `GET /api/artifacts/people` (names only)
|
||||
* and saves the whole list through `PUT /api/lanes/:id/shares` or
|
||||
* `PUT /api/lanes/:id/sessions/:sid/shares`. It says plainly what sharing means on this
|
||||
* server: the people it is shared with work as the owner's account and can
|
||||
* read everything in the owner's home. Only the owner or an admin opens it —
|
||||
* the server refuses anyone else (`ENOTOWNER`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShieldAlert, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
|
||||
type Person = { id: number; username: string; name: string; disabled?: boolean };
|
||||
|
||||
export function WorkspaceShareModal({
|
||||
lane,
|
||||
session,
|
||||
onClose,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Share this one session instead of the whole workspace. */
|
||||
session?: { id: string; name?: string | null };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef, onClose);
|
||||
const [people, setPeople] = useState<Person[] | null>(null);
|
||||
const [initial, setInitial] = useState<Set<number>>(new Set());
|
||||
const [picked, setPicked] = useState<Set<number>>(new Set());
|
||||
const [query, setQuery] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const sid = session?.id;
|
||||
Promise.all([
|
||||
api.artifacts.people(),
|
||||
sid ? api.lanes.sessionShares(lane.id, sid) : api.lanes.shares(lane.id),
|
||||
])
|
||||
.then(([p, s]) => {
|
||||
if (!alive) return;
|
||||
// Never the owner: a workspace is not shared with them. Someone it is
|
||||
// already shared with but who was disabled since is not in `people`:
|
||||
// listed anyway, so they can be taken off.
|
||||
const known = new Set(p.people.map((x) => x.id));
|
||||
setPeople([
|
||||
...p.people.filter((x) => x.id !== lane.owner_id),
|
||||
...s.shares.filter((x) => !known.has(x.id)).map((x) => ({ ...x, disabled: true })),
|
||||
]);
|
||||
const ids = new Set(s.shares.map((x) => x.id));
|
||||
setInitial(ids);
|
||||
setPicked(new Set(ids));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!alive) return;
|
||||
toast.error(err);
|
||||
setPeople([]);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [lane.id, lane.owner_id, session?.id]);
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return (people ?? []).filter(
|
||||
(p) => !q || p.name.toLowerCase().includes(q) || p.username.toLowerCase().includes(q)
|
||||
);
|
||||
}, [people, query]);
|
||||
const added = [...picked].filter((id) => !initial.has(id));
|
||||
const changed = added.length > 0 || [...initial].some((id) => !picked.has(id));
|
||||
const name = session ? session.name || session.id : lane.title || lane.cwd;
|
||||
|
||||
const save = async () => {
|
||||
if (added.length > 0) {
|
||||
const names = (people ?? []).filter((p) => added.includes(p.id)).map((p) => p.name);
|
||||
const ok = await confirmDialog({
|
||||
title: t("share.confirmTitle", { name }),
|
||||
message: t("share.confirmBody", { people: names.join(", ") }),
|
||||
confirmLabel: t("share.confirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = session
|
||||
? await api.lanes.setSessionShares(lane.id, session.id, [...picked])
|
||||
: await api.lanes.setShares(lane.id, [...picked]);
|
||||
toast.success(
|
||||
r.shares.length ? t("share.saved", { count: r.shares.length }) : t("share.savedNone")
|
||||
);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (id: number, on: boolean) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="workspace-share-title"
|
||||
data-testid="workspace-share-modal"
|
||||
className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
|
||||
>
|
||||
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id="workspace-share-title" className="text-base font-semibold text-fg-primary">
|
||||
{t(session ? "share.sessionTitle" : "share.title")}
|
||||
</h2>
|
||||
<p className="mt-0.5 truncate text-xs text-fg-muted" title={lane.cwd}>
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("share.close")}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-5 py-4">
|
||||
<p className="text-xs text-fg-secondary">
|
||||
{t(session ? "share.sessionWhat" : "share.what")}
|
||||
</p>
|
||||
<div
|
||||
data-testid="workspace-share-warning"
|
||||
className="flex gap-2 rounded-lg border border-status-warning/30 bg-status-warning/10 px-3 py-2 text-xs text-status-warning"
|
||||
>
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{t("share.warning")}</span>
|
||||
</div>
|
||||
{people === null ? (
|
||||
<p className="text-xs text-fg-muted">{t("share.loading")}</p>
|
||||
) : people.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("share.nobody")}</p>
|
||||
) : (
|
||||
<>
|
||||
{people.length > 8 && (
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("share.search")}
|
||||
aria-label={t("share.search")}
|
||||
className="input w-full text-sm"
|
||||
/>
|
||||
)}
|
||||
<ul className="space-y-1" data-testid="workspace-share-people">
|
||||
{shown.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Checkbox
|
||||
checked={picked.has(p.id)}
|
||||
onChange={(v) => toggle(p.id, v)}
|
||||
disabled={busy}
|
||||
label={
|
||||
<span>
|
||||
{p.name}
|
||||
{p.name !== p.username && (
|
||||
<span className="ml-1.5 text-fg-muted">@{p.username}</span>
|
||||
)}
|
||||
{p.disabled && (
|
||||
<span className="ml-1.5 text-fg-muted">{t("share.disabled")}</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||
{t("share.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-sm"
|
||||
data-testid="workspace-share-save"
|
||||
onClick={() => void save()}
|
||||
disabled={busy || !changed || people === null}
|
||||
>
|
||||
{busy ? t("share.saving") : t("share.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { AddLaneModal } from "../AddLaneModal";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { AiTool, AiToolsView, CwdSuggestion } from "../../../lib/api";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
import { resetCloneJobsForTests } from "../../../lib/cloneJobs";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
@@ -26,6 +27,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
worktree: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
cloneJobs: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
pickPath: vi.fn(),
|
||||
// The folder browser's Places list the user's workspaces.
|
||||
@@ -69,7 +71,7 @@ const TOOLS: AiToolsView = {
|
||||
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("aider", "Aider", { installed: false, path: null }),
|
||||
tool("grok", "Grok", { enabled: false }),
|
||||
tool("goose", "Goose", { enabled: false }),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -158,6 +160,8 @@ beforeEach(() => {
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
vi.mocked(api.lanes.clone).mockReset();
|
||||
vi.mocked(api.lanes.cloneJobs).mockReset().mockResolvedValue({ jobs: [] });
|
||||
resetCloneJobsForTests();
|
||||
vi.mocked(api.lanes.browse).mockReset();
|
||||
vi.mocked(api.lanes.pickPath).mockReset();
|
||||
// Provisioning finishes instantly by default - tests that care about the
|
||||
@@ -786,9 +790,73 @@ describe("AddLaneModal — Clone", () => {
|
||||
)
|
||||
);
|
||||
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
|
||||
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].partial).toBeUndefined();
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("hands a background clone to the dock and selects the workspace when it ends", async () => {
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const job = {
|
||||
id: "j1",
|
||||
kind: "workspace",
|
||||
state: "queued",
|
||||
url: "git@github.com:owner/repo.git",
|
||||
name: "repo",
|
||||
parent: "/home/an/code",
|
||||
target: "/home/an/code/repo",
|
||||
laneId: null,
|
||||
position: 1,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "git@github.com:owner/repo.git", parent: "/home/an/code", name: "repo" },
|
||||
} as const;
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({ job: { ...job } });
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [{ ...job, state: "done", result: { laneId: 5, path: "/home/an/code/repo" } }],
|
||||
});
|
||||
vi.mocked(api.lanes.get).mockResolvedValue({ lane: laneFixture({ id: 5 }) });
|
||||
const user = userEvent.setup();
|
||||
renderModal({ onAdded, onClose });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
|
||||
const urlField = screen.getByLabelText("Repository URL");
|
||||
await focusField(user, urlField);
|
||||
await user.type(urlField, "git@github.com:owner/repo.git");
|
||||
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }))
|
||||
);
|
||||
// Accepted: the modal closes at once, before the clone is done.
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(onAdded).not.toHaveBeenCalled();
|
||||
// The store's next read sees it done: the new workspace is selected.
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 5 })), {
|
||||
timeout: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends partial only when the box is ticked", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: laneFixture({ id: 7 }),
|
||||
created: true,
|
||||
path: "/home/an/code/big",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
await fillClone(user, "https://git.example.com/o/big.git");
|
||||
await user.click(screen.getByTestId("add-lane-clone-partial"));
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ partial: true }))
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
|
||||
vi.mocked(api.lanes.clone)
|
||||
.mockRejectedValueOnce(
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* @file BranchManager.test.tsx
|
||||
* @description Pins the git bar's Branches panel: local branches with the
|
||||
* current one marked and ahead/behind shown, remote branches grouped by
|
||||
* remote, the search box, arrow-key walking, and each action's request —
|
||||
* rename, new branch from a remote branch (switch only offered on a clean
|
||||
* tree), set/unset upstream, merge into current, delete refused with its
|
||||
* reason — plus the Tags tab: annotated create, push one tag, delete with
|
||||
* `confirm: true` after the themed confirm, and git's error shown verbatim.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { BranchManager } from "../git/BranchManager";
|
||||
import { api, type GitRefs } from "../../../lib/api";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
||||
vi.mock("../../../lib/toast", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number) => lane,
|
||||
api: { lanes: { gitRefs: vi.fn(), gitRefAction: vi.fn() } },
|
||||
}));
|
||||
|
||||
const REFS: GitRefs = {
|
||||
available: true,
|
||||
current: "main",
|
||||
head: "aaaaaaa",
|
||||
truncated: false,
|
||||
local: [
|
||||
{
|
||||
name: "main",
|
||||
sha: "a1",
|
||||
current: true,
|
||||
upstream: "origin/main",
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
gone: false,
|
||||
subject: "latest work",
|
||||
date: null,
|
||||
merged: true,
|
||||
worktree: null,
|
||||
protected: true,
|
||||
},
|
||||
{
|
||||
name: "feature/very-long-branch-name",
|
||||
sha: "b2",
|
||||
current: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
gone: false,
|
||||
subject: "feature commit",
|
||||
date: null,
|
||||
merged: false,
|
||||
worktree: null,
|
||||
protected: false,
|
||||
},
|
||||
{
|
||||
name: "held",
|
||||
sha: "c3",
|
||||
current: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
gone: false,
|
||||
subject: "x",
|
||||
date: null,
|
||||
merged: true,
|
||||
worktree: "/tmp/other",
|
||||
protected: false,
|
||||
},
|
||||
],
|
||||
remotes: [
|
||||
{
|
||||
remote: "origin",
|
||||
branches: [
|
||||
{ name: "origin/main", branch: "main", sha: "a1", subject: "s", date: null },
|
||||
{ name: "origin/remote-only", branch: "remote-only", sha: "d4", subject: "r", date: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: "v1",
|
||||
sha: "a1b2c3d4",
|
||||
annotated: true,
|
||||
message: "release one",
|
||||
tagger: "T",
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function setup(over: { dirty?: number } = {}) {
|
||||
const onChanged = vi.fn();
|
||||
render(
|
||||
<BranchManager
|
||||
laneId={3}
|
||||
dirty={over.dirty ?? 0}
|
||||
merging={false}
|
||||
onClose={vi.fn()}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
);
|
||||
return { onChanged };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitRefs).mockReset().mockResolvedValue(REFS);
|
||||
vi.mocked(api.lanes.gitRefAction)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ action: "x", ok: true, output: "" });
|
||||
vi.mocked(confirmDialog).mockClear();
|
||||
});
|
||||
|
||||
const row = (name: RegExp) => screen.getByRole("button", { name });
|
||||
|
||||
describe("BranchManager", () => {
|
||||
it("lists local branches with the current marked and remote ones grouped", async () => {
|
||||
setup();
|
||||
await screen.findByText("feature/very-long-branch-name");
|
||||
const main = row(/^main/);
|
||||
expect(within(main).getByText("current")).toBeInTheDocument();
|
||||
expect(main).toHaveTextContent("2");
|
||||
expect(main).toHaveTextContent("1");
|
||||
expect(screen.getByText("Remote · origin")).toBeInTheDocument();
|
||||
expect(screen.getByText("origin/remote-only")).toBeInTheDocument();
|
||||
// A long name truncates with its full text as the tooltip.
|
||||
expect(screen.getByText("feature/very-long-branch-name")).toHaveAttribute(
|
||||
"title",
|
||||
"feature/very-long-branch-name"
|
||||
);
|
||||
});
|
||||
|
||||
it("filters by the search box and walks rows with the arrow keys", async () => {
|
||||
setup();
|
||||
await screen.findByText("held");
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "Search branches" }), "remote");
|
||||
expect(screen.queryByText("held")).toBeNull();
|
||||
expect(screen.getByText("origin/remote-only")).toBeInTheDocument();
|
||||
await userEvent.clear(screen.getByRole("textbox", { name: "Search branches" }));
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
expect(row(/^main/)).toHaveFocus();
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
expect(row(/^feature\/very-long/)).toHaveFocus();
|
||||
});
|
||||
|
||||
it("renames a branch and re-reads the list", async () => {
|
||||
const { onChanged } = setup();
|
||||
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /^Rename$/ }));
|
||||
const input = screen.getByRole("textbox", { name: "New name" });
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "feature/short{Enter}");
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-rename", {
|
||||
branch: "feature/very-long-branch-name",
|
||||
name: "feature/short",
|
||||
});
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalled());
|
||||
expect(api.lanes.gitRefs).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("refuses rename and delete of a protected current branch, with the reason", async () => {
|
||||
setup();
|
||||
await userEvent.click(await screen.findByText("main"));
|
||||
expect(screen.getByRole("button", { name: /^Rename$/ })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled();
|
||||
expect(screen.getByText(/You are on this branch/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains a branch another worktree holds", async () => {
|
||||
setup();
|
||||
await userEvent.click(await screen.findByText("held"));
|
||||
expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled();
|
||||
expect(screen.getByText(/another worktree: \/tmp\/other/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deletes an unmerged branch only after the confirm warns", async () => {
|
||||
setup();
|
||||
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /^Delete$/ }));
|
||||
expect(vi.mocked(confirmDialog).mock.calls[0]?.[0].message).toMatch(/not merged/);
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-delete", {
|
||||
branch: "feature/very-long-branch-name",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a branch from a remote branch, switch offered only on a clean tree", async () => {
|
||||
setup({ dirty: 2 });
|
||||
await userEvent.click(await screen.findByText("origin/remote-only"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /New branch from here/ }));
|
||||
expect(screen.getByRole("checkbox", { name: "Switch to it" })).toBeDisabled();
|
||||
const input = screen.getByRole("textbox", { name: "New branch name" });
|
||||
expect(input).toHaveValue("remote-only");
|
||||
await userEvent.type(input, "{Enter}");
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-create", {
|
||||
branch: "remote-only",
|
||||
from: "origin/remote-only",
|
||||
});
|
||||
});
|
||||
|
||||
it("sets and unsets an upstream, and merges into the current branch", async () => {
|
||||
setup();
|
||||
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Set upstream/ }));
|
||||
await userEvent.selectOptions(screen.getByRole("combobox"), "origin/remote-only");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Apply/ }));
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", {
|
||||
branch: "feature/very-long-branch-name",
|
||||
upstream: "origin/remote-only",
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: /Merge into main/ }));
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "merge", {
|
||||
branch: "feature/very-long-branch-name",
|
||||
});
|
||||
await userEvent.click(screen.getByText("main"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Unset upstream/ }));
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", { branch: "main" });
|
||||
});
|
||||
|
||||
it("shows git's own words when a write is refused", async () => {
|
||||
vi.mocked(api.lanes.gitRefAction).mockRejectedValueOnce(
|
||||
Object.assign(new Error("branch x already exists"), { code: "EBRANCHEXISTS" })
|
||||
);
|
||||
setup();
|
||||
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /^Rename$/ }));
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "New name" }), "x{Enter}");
|
||||
expect(await screen.findByTestId("branch-manager-error")).toHaveTextContent(
|
||||
"branch x already exists"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TagManager (Tags tab)", () => {
|
||||
const openTags = async () => {
|
||||
setup();
|
||||
await screen.findByText("main");
|
||||
await userEvent.click(screen.getByRole("tab", { name: /Tags/ }));
|
||||
};
|
||||
|
||||
it("lists tags with their message and creates an annotated one", async () => {
|
||||
await openTags();
|
||||
expect(screen.getByText("v1")).toBeInTheDocument();
|
||||
expect(screen.getByText(/release one/)).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: /New tag/ }));
|
||||
await userEvent.type(screen.getByRole("textbox", { name: /Tag name/ }), "v2");
|
||||
await userEvent.type(
|
||||
screen.getByRole("textbox", { name: /^On / }),
|
||||
"feature/very-long-branch-name"
|
||||
);
|
||||
await userEvent.type(screen.getByRole("textbox", { name: /Message/ }), "notes");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Create annotated tag" }));
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-create", {
|
||||
tag: "v2",
|
||||
message: "notes",
|
||||
from: "feature/very-long-branch-name",
|
||||
});
|
||||
});
|
||||
|
||||
it("pushes one tag and deletes one only with confirm: true", async () => {
|
||||
await openTags();
|
||||
await userEvent.click(screen.getByText("v1"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Push to origin/ }));
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-push", { tag: "v1" });
|
||||
await userEvent.click(screen.getByRole("button", { name: /Delete tag/ }));
|
||||
expect(vi.mocked(confirmDialog).mock.lastCall?.[0].destructive).toBe(true);
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-delete", {
|
||||
tag: "v1",
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens from the git bar's button and closes on Escape", async () => {
|
||||
const { BranchesButton } = await import("../git/BranchesButton");
|
||||
render(<BranchesButton laneId={3} dirty={0} merging={false} onChanged={vi.fn()} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Manage branches & tags" }));
|
||||
expect(await screen.findByTestId("branch-manager")).toBeInTheDocument();
|
||||
await screen.findByText("main");
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.queryByTestId("branch-manager")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not delete when the confirm is cancelled", async () => {
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await openTags();
|
||||
await userEvent.click(screen.getByText("v1"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Delete tag/ }));
|
||||
expect(api.lanes.gitRefAction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @file Tests for CloneProgress: a queued clone shows its place in the
|
||||
* server's clone queue (with the machine-wide counts when it has them), a
|
||||
* running one git's phase, percent and speed with a bar, and a finished one
|
||||
* nothing — all rendered from a clone job's fields, no fetching.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import { CloneProgress, type CloneProgressFields } from "../CloneProgress";
|
||||
|
||||
const base: CloneProgressFields = {
|
||||
state: "running",
|
||||
position: null,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
};
|
||||
|
||||
describe("CloneProgress", () => {
|
||||
it("shows the clone's place in the queue while it waits", () => {
|
||||
render(<CloneProgress view={{ ...base, state: "queued", position: 2 }} />);
|
||||
expect(screen.getByTestId("clone-progress")).toHaveTextContent(
|
||||
"Waiting in the clone queue — #2"
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the machine-wide counts when the view carries them", () => {
|
||||
render(
|
||||
<CloneProgress
|
||||
view={{ ...base, state: "queued", position: 1, waiting: 1, running: 2, limit: 2 }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("clone-progress")).toHaveTextContent(
|
||||
"Waiting in the clone queue — #1 of 1 (2/2 clones running)"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows git's phase, percent and speed with a bar while it runs", () => {
|
||||
render(
|
||||
<CloneProgress
|
||||
view={{
|
||||
...base,
|
||||
phase: "receiving",
|
||||
label: "Receiving objects",
|
||||
percent: 45,
|
||||
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const line = screen.getByTestId("clone-progress");
|
||||
expect(line).toHaveTextContent("Downloading 45%");
|
||||
expect(line).toHaveTextContent("120.50 MiB | 5.20 MiB/s");
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||
});
|
||||
|
||||
it("shows nothing once the clone finished", () => {
|
||||
render(<CloneProgress view={{ ...base, state: "done" }} />);
|
||||
expect(screen.queryByTestId("clone-progress")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
|
||||
* an extra folder, a host that wants a login opens the login row, and Retry
|
||||
* re-runs only the failed URLs with that login.
|
||||
* re-runs only the failed URLs with that login. An SSH link while the
|
||||
* account has no key is never sent — the user is pointed at Settings → Git —
|
||||
* and an HTTPS host with no saved login is flagged before Clone. The
|
||||
* partial-clone box adds `partial: true` to every clone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -11,16 +14,26 @@ import userEvent from "@testing-library/user-event";
|
||||
import "../../../i18n";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneFolder: vi.fn() } },
|
||||
api: { lanes: { cloneFolder: vi.fn(), sources: vi.fn(), cloneJobs: vi.fn() }, gitIdentity: { get: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type LaneSourcesView } from "../../../lib/api";
|
||||
import { CloneSourcesForm } from "../CloneSourcesForm";
|
||||
import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
|
||||
import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
|
||||
import { CLONE_JOB_EVENT, resetCloneJobsForTests } from "../../../lib/cloneJobs";
|
||||
|
||||
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
||||
|
||||
describe("CloneSourcesForm", () => {
|
||||
beforeEach(() => vi.mocked(api.lanes.cloneFolder).mockReset());
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.cloneFolder).mockReset();
|
||||
// A finished clone (an older server's answer, or a job the store saw end)
|
||||
// makes the form read the folder list again.
|
||||
vi.mocked(api.lanes.sources).mockReset().mockResolvedValue(view);
|
||||
vi.mocked(api.lanes.cloneJobs).mockReset().mockResolvedValue({ jobs: [] });
|
||||
resetCloneJobsForTests();
|
||||
// Unknown identity: nothing is checked up front.
|
||||
vi.mocked(api.gitIdentity.get).mockReset().mockRejectedValue(new Error("old server"));
|
||||
});
|
||||
|
||||
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -42,7 +55,7 @@ describe("CloneSourcesForm", () => {
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
|
||||
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(2));
|
||||
expect(onAdded).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText(/private\.test needs a login/)).toBeInTheDocument();
|
||||
// Only what is left stays in the box.
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("https://private.test/b.git");
|
||||
@@ -53,9 +66,102 @@ describe("CloneSourcesForm", () => {
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
||||
background: true,
|
||||
url: "https://private.test/b.git",
|
||||
credentials: { username: "dev", password: "tok", remember: true },
|
||||
});
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("hands a background clone to the dock and re-reads the folders when it ends", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAdded = vi.fn();
|
||||
const job = {
|
||||
id: "s1",
|
||||
kind: "source",
|
||||
state: "queued",
|
||||
url: "https://h/a.git",
|
||||
name: "a",
|
||||
parent: "/",
|
||||
target: "/a",
|
||||
laneId: 3,
|
||||
position: 1,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "https://h/a.git", parent: "/", name: "a" },
|
||||
} as const;
|
||||
vi.mocked(api.lanes.cloneFolder).mockResolvedValue({ job: { ...job } });
|
||||
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={onAdded} />);
|
||||
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/a.git");
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
expect(await screen.findByText(/progress is in the corner/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
expect(onAdded).not.toHaveBeenCalled();
|
||||
// Another workspace's clone is not ours.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, laneId: 4, state: "done" } })
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, state: "done" } }));
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(1));
|
||||
expect(api.lanes.sources).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it("asks for partial clones when the box is ticked", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
|
||||
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
|
||||
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/big.git");
|
||||
await user.click(screen.getByTestId("workspace-clone-partial"));
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
||||
background: true,
|
||||
url: "https://h/big.git",
|
||||
partial: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("tells an SSH link from an HTTPS one", () => {
|
||||
expect(cloneTarget("git@github.com:o/r.git")).toEqual({ kind: "ssh", host: "github.com" });
|
||||
expect(cloneTarget("ssh://git@h.test:2222/o/r")).toEqual({ kind: "ssh", host: "h.test" });
|
||||
expect(cloneTarget("https://h.test/o/r.git")).toEqual({ kind: "https", host: "h.test" });
|
||||
expect(cloneTarget("not a url")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not clone an SSH link before the account has a key, and flags an HTTPS host with no login", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue({
|
||||
ssh: null,
|
||||
sshKeys: [],
|
||||
https: [],
|
||||
} as unknown as GitIdentity);
|
||||
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
|
||||
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
|
||||
await waitFor(() => expect(api.gitIdentity.get).toHaveBeenCalled());
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId("workspace-clone-input"),
|
||||
"git@github.com:o/r.git\nhttps://h.test/a.git"
|
||||
);
|
||||
expect(screen.getByTestId("workspace-clone-need-ssh")).toHaveTextContent("github.com");
|
||||
expect(screen.getByTestId("workspace-clone-need-login")).toHaveTextContent("h.test");
|
||||
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
||||
url: "https://h.test/a.git",
|
||||
background: true,
|
||||
});
|
||||
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,29 +3,43 @@
|
||||
* opens sub-folders, makes a folder inline, deletes into the Trash without a
|
||||
* confirm, copies in one workspace and pastes into another (`fromLane`),
|
||||
* asks Replace / Keep both / Skip when an upload's name is taken, refuses a
|
||||
* batch over 500 MB before sending anything, restores from the user's own
|
||||
* batch over 500 MB before sending anything, leaves a repo's .git out of a
|
||||
* folder upload with one notice, restores from the user's own
|
||||
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
|
||||
* one saying why), opens each user's Trash from its folder (asking before a
|
||||
* purge there, sending the item's store) and
|
||||
* shows a Trash that could not be read as an error, never as empty.
|
||||
* shows a Trash that could not be read as an error, never as empty; nests a
|
||||
* user's workspaces under their home in the sidebar (under a fold-only row
|
||||
* when the viewer may not open that home); opens an HTML file as the page
|
||||
* instead of downloading it; New → Clone repository starts a background
|
||||
* clone (`background: true`), keeps a pre-start refusal in the form, and a
|
||||
* finished clone (the `agenthub:clone-job` event) re-reads the folder.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FileExplorer } from "../FileExplorer";
|
||||
import { api, type FsListing } from "../../../lib/api";
|
||||
import { api, type CloneJob, type FsListing } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { CLONE_JOB_EVENT, resetCloneJobsForTests } from "../../../lib/cloneJobs";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
gitTarget: (lane: number, session?: string | null, source?: number | null) =>
|
||||
source ? { lane, source } : session ? { lane, session } : lane,
|
||||
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
|
||||
newUploadId: (() => {
|
||||
let n = 0;
|
||||
return () => `upload-id-${String(++n).padStart(8, "0")}`;
|
||||
})(),
|
||||
api: {
|
||||
lanes: {
|
||||
list: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
cloneJobs: vi.fn(async () => ({ jobs: [] })),
|
||||
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
||||
},
|
||||
files: {
|
||||
@@ -42,7 +56,10 @@ vi.mock("../../../lib/api", () => ({
|
||||
allTrash: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
purge: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
uploadStaged: vi.fn(),
|
||||
uploadBatchStaged: vi.fn(),
|
||||
dropUpload: vi.fn(async () => null),
|
||||
uploadCheck: vi.fn(),
|
||||
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
|
||||
},
|
||||
},
|
||||
@@ -66,9 +83,30 @@ const src: FsListing = {
|
||||
truncated: false,
|
||||
entries: [{ name: "x.js", path: "src/x.js", type: "file", size: 2, mtime: 0 }],
|
||||
};
|
||||
const job: CloneJob = {
|
||||
id: "j1",
|
||||
kind: "workspace",
|
||||
state: "queued",
|
||||
url: "git@host:o/repo.git",
|
||||
name: "repo",
|
||||
parent: "/w/one",
|
||||
target: "/w/one/repo",
|
||||
laneId: null,
|
||||
position: 1,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: { laneId: 9, path: "/w/one/repo" },
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "git@host:o/repo.git", parent: "/w/one", name: "repo" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetCloneJobsForTests();
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
@@ -129,6 +167,38 @@ describe("FileExplorer", () => {
|
||||
expect(api.files.list).toHaveBeenLastCalledWith(1, "src");
|
||||
});
|
||||
|
||||
it("opens an HTML file as the page, its stylesheet read through the download", async () => {
|
||||
vi.mocked(api.files.list).mockResolvedValue({
|
||||
...root,
|
||||
entries: [
|
||||
...root.entries,
|
||||
{ name: "deck.html", path: "deck.html", type: "file", size: 9, mtime: 0 },
|
||||
],
|
||||
});
|
||||
const body: Record<string, string> = {
|
||||
"/dl/1/deck.html": '<link rel="stylesheet" href="s.css"><p>deck</p>',
|
||||
"/dl/1/s.css": "p{color:red}",
|
||||
};
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockImplementation(async (u) => new Response(body[String(u)] ?? "", { status: 200 }));
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
open();
|
||||
await screen.findByText("deck.html");
|
||||
fireEvent.doubleClick(row("deck.html"));
|
||||
const viewer = await screen.findByTestId("fx-html-viewer");
|
||||
await waitFor(() =>
|
||||
expect(within(viewer).getByTestId("html-frame").getAttribute("srcdoc")).toContain(
|
||||
"<style>p{color:red}</style>"
|
||||
)
|
||||
);
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(viewer).getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByTestId("fx-html-viewer")).toBeNull();
|
||||
fetchSpy.mockRestore();
|
||||
click.mockRestore();
|
||||
});
|
||||
|
||||
it("makes a folder inline", async () => {
|
||||
vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" });
|
||||
const user = userEvent.setup();
|
||||
@@ -171,7 +241,7 @@ describe("FileExplorer", () => {
|
||||
});
|
||||
|
||||
it("asks about a taken name on upload, and sends the choice", async () => {
|
||||
vi.mocked(api.files.upload)
|
||||
vi.mocked(api.files.uploadStaged)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("taken"), { code: "EEXISTS", names: ["a.txt"] })
|
||||
)
|
||||
@@ -184,14 +254,71 @@ describe("FileExplorer", () => {
|
||||
const ask = await screen.findByTestId("fx-conflict");
|
||||
await user.click(within(ask).getByRole("button", { name: "Keep both" }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.upload).toHaveBeenLastCalledWith(
|
||||
expect(api.files.uploadStaged).toHaveBeenLastCalledWith(
|
||||
1,
|
||||
"a.txt",
|
||||
file,
|
||||
"keep",
|
||||
expect.any(Function)
|
||||
expect.objectContaining({ upload: expect.any(String), signal: expect.any(AbortSignal) })
|
||||
)
|
||||
);
|
||||
// The retry names the SAME upload, so the staged bytes are not sent again,
|
||||
// and the staged upload is dropped once answered.
|
||||
const [first, second] = vi.mocked(api.files.uploadStaged).mock.calls.map((c) => c[4].upload);
|
||||
expect(second).toBe(first);
|
||||
await waitFor(() => expect(api.files.dropUpload).toHaveBeenCalledWith(1, first));
|
||||
});
|
||||
|
||||
it("sends many small files as one batch, asking once about every taken name", async () => {
|
||||
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true });
|
||||
vi.mocked(api.files.uploadBatchStaged).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const files = ["a.txt", "b.txt", "c.txt"].map((n) => new File(["hi"], n));
|
||||
await user.upload(screen.getByTestId("fx-upload-files"), files);
|
||||
const ask = await screen.findByTestId("fx-conflict");
|
||||
await user.click(within(ask).getByRole("button", { name: "Replace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.uploadBatchStaged).toHaveBeenCalledWith(
|
||||
1,
|
||||
"",
|
||||
files.map((file) => ({ file, rel: file.name })),
|
||||
"replace",
|
||||
expect.objectContaining({ upload: expect.any(String), signal: expect.any(AbortSignal) })
|
||||
)
|
||||
);
|
||||
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
|
||||
expect(api.files.uploadStaged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to one request per file when the server cannot take a batch", async () => {
|
||||
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false });
|
||||
vi.mocked(api.files.uploadStaged).mockResolvedValue({ path: "x" });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
|
||||
await user.upload(screen.getByTestId("fx-upload-files"), files);
|
||||
await waitFor(() => expect(api.files.uploadStaged).toHaveBeenCalledTimes(2));
|
||||
expect(api.files.uploadBatchStaged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a picked repo's .git out of a folder upload, with one notice", async () => {
|
||||
vi.mocked(api.files.uploadStaged).mockResolvedValue({ path: "x" });
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const mk = (rel: string) => {
|
||||
const f = new File(["hi"], rel.split("/").pop()!);
|
||||
Object.defineProperty(f, "webkitRelativePath", { value: rel });
|
||||
return f;
|
||||
};
|
||||
const files = ["repo/.git/HEAD", "repo/.git/objects/d4/ab", "repo/src/a.js"].map(mk);
|
||||
fireEvent.change(screen.getByTestId("fx-upload-folder"), { target: { files } });
|
||||
await waitFor(() => expect(api.files.uploadStaged).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(api.files.uploadStaged).mock.calls[0]![1]).toBe("repo/src/a.js");
|
||||
expect(toast.info).toHaveBeenCalledTimes(1);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a batch over 500 MB before sending anything", async () => {
|
||||
@@ -201,7 +328,7 @@ describe("FileExplorer", () => {
|
||||
Object.defineProperty(big, "size", { value: 600 * 1024 * 1024 });
|
||||
fireEvent.change(screen.getByTestId("fx-upload-files"), { target: { files: [big] } });
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(api.files.upload).not.toHaveBeenCalled();
|
||||
expect(api.files.uploadStaged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores from the Trash", async () => {
|
||||
@@ -366,6 +493,33 @@ describe("FileExplorer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a workspace whose folder was deleted out of the places", async () => {
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
{ id: 2, title: "two", cwd: "/w/two", folder_missing: true },
|
||||
],
|
||||
counts: {},
|
||||
} as never);
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await waitFor(() => expect(api.lanes.list).toHaveBeenCalled());
|
||||
await screen.findByText("one", { selector: "aside span" });
|
||||
expect(screen.queryByRole("button", { name: "two" })).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the last folder's entries when the next one cannot be read", async () => {
|
||||
vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => {
|
||||
if (p === "src") throw new Error("ENOENT: no such file or directory");
|
||||
return root;
|
||||
});
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
fireEvent.doubleClick(row("src"));
|
||||
await screen.findByText(/ENOENT/);
|
||||
expect(screen.queryByText("a.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("right-clicks a sidebar workspace and a path crumb", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
@@ -431,4 +585,163 @@ describe("FileExplorer", () => {
|
||||
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" });
|
||||
expect(api.files.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops a user's workspaces down under their home; an unowned one stays in the list", async () => {
|
||||
localStorage.removeItem("agenthub.files.homesOpen");
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
{ id: 2, title: "two", cwd: "/w/two", owner_id: 7, owner_name: "bob" },
|
||||
],
|
||||
counts: {},
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const toggle = await screen.findByRole("button", { name: "Show or hide Bob’s workspaces" });
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
const places = screen.getByRole("navigation", { name: "Workspaces" });
|
||||
expect(within(places).queryByRole("button", { name: "two" })).toBeNull();
|
||||
expect(within(places).getByRole("button", { name: "one" })).toBeInTheDocument();
|
||||
await user.click(toggle);
|
||||
await user.click(within(places).getByRole("button", { name: "two" }));
|
||||
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
|
||||
expect(localStorage.getItem("agenthub.files.homesOpen")).toContain("bob");
|
||||
});
|
||||
|
||||
it("groups an owner whose home the viewer cannot open (an analyst) under a row that only folds", async () => {
|
||||
localStorage.removeItem("agenthub.files.homesOpen");
|
||||
vi.mocked(api.files.homes).mockResolvedValue({
|
||||
homes: [{ id: "me", label: "An", readOnly: false }],
|
||||
});
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
{
|
||||
id: 3,
|
||||
title: "three",
|
||||
cwd: "/w/three",
|
||||
owner_id: 9,
|
||||
owner_name: "cara",
|
||||
owner_display_name: "Cara",
|
||||
},
|
||||
],
|
||||
counts: {},
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const places = screen.getByRole("navigation", { name: "Workspaces" });
|
||||
expect(within(places).getByText("Users")).toBeInTheDocument();
|
||||
expect(within(places).queryByRole("button", { name: "three" })).toBeNull();
|
||||
await user.click(within(places).getByRole("button", { name: /^Cara/ }));
|
||||
expect(api.files.list).not.toHaveBeenCalledWith({ home: "cara" }, "");
|
||||
expect(within(places).getByRole("button", { name: "three" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileExplorer — New → Clone repository", () => {
|
||||
const openClone = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByRole("button", { name: "New" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Clone repository" }));
|
||||
return screen.getByTestId("fx-clone-dialog");
|
||||
};
|
||||
|
||||
it("clones into the open folder of the workspace in the background", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({ job: { ...job, state: "queued" } } as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
fireEvent.doubleClick(await screen.findByText("src"));
|
||||
await screen.findByText("x.js");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
|
||||
expect(within(dialog).getByText(/\/w\/one\/src\/repo/)).toBeInTheDocument();
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith({
|
||||
url: "git@host:o/repo.git",
|
||||
parent: "/w/one/src",
|
||||
name: undefined,
|
||||
background: true,
|
||||
})
|
||||
);
|
||||
// Accepted: the dialog steps aside, the app-wide dock takes over.
|
||||
await waitFor(() => expect(screen.queryByTestId("fx-clone-dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("re-reads the folder and workspaces when a background clone ends", async () => {
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const lists = vi.mocked(api.files.list).mock.calls.length;
|
||||
const laneReads = vi.mocked(api.lanes.list).mock.calls.length;
|
||||
window.dispatchEvent(new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, state: "done" } }));
|
||||
await waitFor(() => expect(vi.mocked(api.files.list).mock.calls.length).toBeGreaterThan(lists));
|
||||
expect(vi.mocked(api.lanes.list).mock.calls.length).toBeGreaterThan(laneReads);
|
||||
});
|
||||
|
||||
it("treats an older server's synchronous answer as a finished clone", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
|
||||
created: true,
|
||||
path: "/w/one/repo",
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and sends it once", async () => {
|
||||
vi.mocked(api.lanes.clone)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
|
||||
)
|
||||
.mockResolvedValueOnce({ job } as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
// A refusal before the clone starts stays in the form, with a login row.
|
||||
const login = await within(dialog).findByTestId("fx-clone-login");
|
||||
expect(within(dialog).getByRole("alert")).toHaveTextContent("git.example needs a login");
|
||||
await user.type(within(login).getByLabelText("Username"), "ann");
|
||||
await user.type(within(login).getByLabelText("Password or access token"), "tok");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
parent: "/w/one",
|
||||
background: true,
|
||||
credentials: { username: "ann", password: "tok", remember: true },
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps typing in the dialog away from the explorer's shortcuts", async () => {
|
||||
vi.mocked(api.files.remove).mockResolvedValue({ trashed: [] });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
fireEvent.click(row("a.txt"));
|
||||
const dialog = await openClone(user);
|
||||
const url = within(dialog).getByLabelText("Repository URL");
|
||||
await user.type(url, "abc{Backspace}{Delete}");
|
||||
expect(api.files.remove).not.toHaveBeenCalled();
|
||||
expect(url).toHaveValue("ab");
|
||||
});
|
||||
|
||||
it("is not offered in an extra folder", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileExplorer open laneId={1} source={3} onClose={vi.fn()} onChanged={vi.fn()} />);
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(screen.getByRole("button", { name: "New" }));
|
||||
expect(screen.queryByRole("menuitem", { name: "Clone repository" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
|
||||
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
|
||||
import { api } from "../../../lib/api";
|
||||
import { emitGitChanged } from "../../../lib/gitChanged";
|
||||
import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
// The themed confirm needs its host (App); answer it here.
|
||||
@@ -84,6 +85,19 @@ vi.mock("../../../lib/api", () => ({
|
||||
gitDownloadUrl: (id: number, path: string) =>
|
||||
`/api/lanes/${id}/git/download?path=${encodeURIComponent(path)}`,
|
||||
gitUpload: vi.fn(),
|
||||
gitHistory: {
|
||||
commit: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
fileHistory: vi.fn(),
|
||||
blameAt: vi.fn(),
|
||||
treeAt: vi.fn(),
|
||||
compare: vi.fn(),
|
||||
compareFile: vi.fn(),
|
||||
pickPlan: vi.fn(),
|
||||
sequence: vi.fn(),
|
||||
pick: vi.fn(),
|
||||
act: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -153,6 +167,46 @@ function discardOf(path: string) {
|
||||
|
||||
beforeEach(() => {
|
||||
perms.local = true;
|
||||
const h = api.lanes.gitHistory;
|
||||
vi.mocked(h.commit).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(h.sequence).mockReset().mockResolvedValue({ available: true, active: false });
|
||||
vi.mocked(h.pick).mockReset();
|
||||
vi.mocked(h.pickPlan)
|
||||
.mockReset()
|
||||
.mockImplementation(async (_t, op, shas) => ({
|
||||
available: true,
|
||||
op,
|
||||
commits: shas.map((sha) => ({
|
||||
sha,
|
||||
short: sha.slice(0, 7),
|
||||
subject: "feat: the commit",
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
})),
|
||||
branch: "feat/thing",
|
||||
clean: true,
|
||||
repoRoot: true,
|
||||
busy: false,
|
||||
}));
|
||||
vi.mocked(h.fileHistory)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
hasMore: false,
|
||||
commits: [
|
||||
{
|
||||
sha: "a".repeat(40),
|
||||
short: "aaaaaaa",
|
||||
subject: "feat: the commit",
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
parents: [],
|
||||
status: "M",
|
||||
path: "server/lib/git-ops.js",
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
@@ -382,7 +436,8 @@ describe("LaneChangesPanel", () => {
|
||||
const box = within(folder.parentElement!).getByRole("checkbox", {
|
||||
name: "Tick every file in server/lib",
|
||||
});
|
||||
expect(box).toBeChecked();
|
||||
// The default ticks land in an effect after the list renders.
|
||||
await waitFor(() => expect(box).toBeChecked());
|
||||
await user.click(box);
|
||||
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||
await user.click(folder);
|
||||
@@ -444,6 +499,85 @@ describe("LaneChangesPanel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the NEW branch everywhere after a switch from the git bar", async () => {
|
||||
// The repo as the server sees it: which branch, which changes.
|
||||
let branch = "feat/thing";
|
||||
vi.mocked(api.lanes.git).mockImplementation(async () => ({
|
||||
available: true,
|
||||
branch,
|
||||
head: "9b3e74a",
|
||||
subject: "wip",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
files: [],
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
}));
|
||||
vi.mocked(api.lanes.changes).mockImplementation(async () =>
|
||||
changes({
|
||||
files: [
|
||||
{
|
||||
path: branch === "other" ? "only-on-other.ts" : "only-on-thing.ts",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
unstaged: true,
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
vi.mocked(api.lanes.gitAction).mockImplementation(async (_t, action, body) => {
|
||||
if (action === "switch") branch = (body as { branch: string }).branch;
|
||||
return { action, ok: true, output: "", shelves: [] };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
|
||||
await screen.findByTitle("only-on-thing.ts");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Branch$/ }));
|
||||
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
|
||||
await user.click(screen.getByRole("option", { name: "other" }));
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
|
||||
expect(await screen.findByTitle("only-on-other.ts")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("only-on-thing.ts")).toBeNull();
|
||||
});
|
||||
|
||||
it("re-reads when ANOTHER view of the lane changed its git state", async () => {
|
||||
let branch = "feat/thing";
|
||||
vi.mocked(api.lanes.git).mockImplementation(async () => ({
|
||||
available: true,
|
||||
branch,
|
||||
head: "9b3e74a",
|
||||
subject: "wip",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
files: [],
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
}));
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
|
||||
const before = vi.mocked(api.lanes.changes).mock.calls.length;
|
||||
branch = "other";
|
||||
act(() => emitGitChanged(7, "switch"));
|
||||
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
|
||||
expect(vi.mocked(api.lanes.changes).mock.calls.length).toBeGreaterThan(before);
|
||||
// Another workspace's write is not this panel's business.
|
||||
const now = vi.mocked(api.lanes.git).mock.calls.length;
|
||||
act(() => emitGitChanged(8, "switch"));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(vi.mocked(api.lanes.git).mock.calls.length).toBe(now);
|
||||
});
|
||||
|
||||
it("re-reads history too when Refresh is pressed there", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
@@ -514,7 +648,7 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByText("Unversioned Files")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
|
||||
it("opens a whole markdown file rendered, as source after Raw, and rendered again after Preview", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
@@ -534,12 +668,78 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("README.md"));
|
||||
await screen.findByTestId("lane-file-editor");
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one");
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens a whole HTML file as its source, the page in a modal after Preview", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [{ name: "deck.html", path: "deck.html", dir: false, status: "??" }],
|
||||
truncated: false,
|
||||
});
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "deck.html",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["<p>deck</p>"],
|
||||
sha: "c".repeat(40),
|
||||
});
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("deck.html"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
|
||||
expect(screen.queryByTestId("html-frame")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
const modal = await screen.findByTestId("html-viewer");
|
||||
const frame = await within(modal).findByTestId("html-frame");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
|
||||
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(screen.queryByTestId("html-viewer")).toBeNull());
|
||||
// The source stayed where it was, under the modal.
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers Preview on a changed HTML file's Changes, opening the page in a modal", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||
changes({
|
||||
files: [
|
||||
{ path: "deck.html", status: "??", untracked: true, added: 1, deleted: 0, binary: false },
|
||||
],
|
||||
})
|
||||
);
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await user.click(await screen.findByTitle("deck.html"));
|
||||
await screen.findByRole("button", { name: "Changes", pressed: true });
|
||||
await user.click(await screen.findByTestId("lane-file-preview"));
|
||||
const modal = await screen.findByTestId("html-viewer");
|
||||
expect(await within(modal).findByTestId("html-frame")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Changes", pressed: true })).toBeInTheDocument();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows an image file as the picture, with no edit control", async () => {
|
||||
@@ -770,6 +970,39 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("discards only the ticked files, and only after the dialog is confirmed (#87)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
// notes.txt (unversioned) starts unticked; untick docs/API.md too.
|
||||
await user.click(tickOf("docs/API.md"));
|
||||
|
||||
vi.mocked(confirmDialog).mockClear();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
// The confirm button carries its count, and a tracked-only selection
|
||||
// says nothing about new files to delete.
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
confirmLabel: "Discard (1)",
|
||||
message: expect.not.stringMatching(/new file|\{\{/),
|
||||
})
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Nothing ticked: nothing to discard.
|
||||
await user.click(tickOf("server/lib/git-ops.js"));
|
||||
expect(screen.getByTestId("lane-changes-discard-all")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
@@ -862,10 +1095,11 @@ describe("LaneChangesPanel", () => {
|
||||
within(screen.getByTestId("lane-changes-file-menu")).getByText("Show History")
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
|
||||
limit: 50,
|
||||
file: "server/lib/git-ops.js",
|
||||
})
|
||||
expect(api.lanes.gitHistory.fileHistory).toHaveBeenLastCalledWith(
|
||||
7,
|
||||
"server/lib/git-ops.js",
|
||||
{ limit: 100 }
|
||||
)
|
||||
);
|
||||
expect(screen.getByTestId("lane-changes-history-file")).toHaveTextContent(
|
||||
"server/lib/git-ops.js"
|
||||
@@ -992,7 +1226,7 @@ describe("LaneChangesPanel", () => {
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
expect(await screen.findAllByTestId("lane-changes-graph")).toHaveLength(1);
|
||||
await user.click(await screen.findByText("feat: the commit"));
|
||||
await user.dblClick(await screen.findByText("feat: the commit"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
@@ -1050,8 +1284,21 @@ describe("LaneChangesPanel", () => {
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
|
||||
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
|
||||
action: "revert",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
paused: false,
|
||||
from: "b".repeat(40),
|
||||
to: "c".repeat(40),
|
||||
commits: [{ sha: "c".repeat(40), short: "ccccccc", subject: 'Revert "x"' }],
|
||||
});
|
||||
await user.click(within(await commitMenu(user)).getByText("Revert this commit"));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "revert", { sha: "a".repeat(40) });
|
||||
await user.click(await screen.findByTestId("pick-confirm"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "revert", ["a".repeat(40)])
|
||||
);
|
||||
|
||||
await user.click(within(await commitMenu(user)).getByText("Undo commit (keep changes)"));
|
||||
await waitFor(() =>
|
||||
@@ -1062,6 +1309,20 @@ describe("LaneChangesPanel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not offer Undo for a top commit its upstream already holds", async () => {
|
||||
withParent();
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(
|
||||
facts({ hasRemote: true, upstream: "origin/feat/thing", ahead: 0, behind: 0 })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
const undo = within(await commitMenu(user)).getByTestId("lane-changes-menu-undo-commit");
|
||||
await waitFor(() => expect(undo).toBeDisabled());
|
||||
expect(undo).toHaveAttribute("title", expect.stringMatching(/already on the remote/));
|
||||
});
|
||||
|
||||
it("cherry-picks only from another branch's history", async () => {
|
||||
withParent();
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(facts());
|
||||
@@ -1074,10 +1335,20 @@ describe("LaneChangesPanel", () => {
|
||||
|
||||
await user.selectOptions(await screen.findByTestId("lane-changes-history-ref"), "other");
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, { limit: 50, ref: "other" })
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, { limit: 100, ref: "other" })
|
||||
);
|
||||
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
|
||||
action: "cherry-pick",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
paused: false,
|
||||
});
|
||||
await user.click(within(await commitMenu(user)).getByText("Cherry-pick onto feat/thing"));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "cherry-pick", { sha: "a".repeat(40) });
|
||||
await user.click(await screen.findByTestId("pick-confirm"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", ["a".repeat(40)])
|
||||
);
|
||||
});
|
||||
|
||||
it("asks before a revert and an amend, and a No sends nothing", async () => {
|
||||
@@ -1093,9 +1364,11 @@ describe("LaneChangesPanel", () => {
|
||||
expect(confirmDialog).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
// A revert previews what it will do; Cancel sends nothing.
|
||||
await user.click(within(await commitMenu(user)).getByText("Revert this commit"));
|
||||
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(2));
|
||||
await screen.findByTestId("pick-plan");
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(api.lanes.gitHistory.pick).not.toHaveBeenCalled();
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1574,6 +1847,25 @@ describe("LaneChangesPanel", () => {
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards only the ticked change blocks of a partly ticked file (#87)", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
await user.click(
|
||||
screen.getAllByRole("checkbox", { name: "Include this change in the commit" })[1]!
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Changes" }));
|
||||
await screen.findByTestId("lane-changes-commit");
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||
paths: [],
|
||||
hunks: { "server/lib/git-ops.js": [{ hunk: "@@ -1,2 +1,3 @@", blocks: [0] }] },
|
||||
context: 3,
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("commits only the ticked change blocks of a partly ticked file (hunks)", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
|
||||
@@ -609,3 +609,26 @@ describe("LaneDiffView — line comments", () => {
|
||||
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneDiffView — a row the server cut down", () => {
|
||||
const CUT: LaneChangeFile = {
|
||||
...FILE,
|
||||
hunks: [
|
||||
{
|
||||
...FILE.hunks![0]!,
|
||||
lines: [
|
||||
{ type: "del", oldLine: 1, newLine: null, text: '<video src="AAAA', elided: 3_000_000 },
|
||||
{ type: "add", oldLine: null, newLine: 1, text: '<video src="BBBB', elided: 3_000_000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("says how much of the row it hides, in both layouts", () => {
|
||||
const { unmount } = render(<LaneDiffView file={CUT} layout="unified" />);
|
||||
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
|
||||
unmount();
|
||||
render(<LaneDiffView file={CUT} layout="split" />);
|
||||
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user