perf: faster plugin updates, half-size first bundle, images from the chat attach on Windows

- Chat view: an attached image's path is pasted on its own after the text
  (promptForPane). claude turns a paste that is only an image path into
  [Image #n], but left a Windows path inside a paste of text as a plain path.
  Other files and @-mentions stay in the text as before.
- Plugin bootstrap: depsHash covers only what npm installs (no release
  version), so a version bump no longer reinstalls runtime deps; the client's
  installed tree is kept while its manifests are unchanged (no npm ci per
  update); the user-side build runs vite only (tsc -b cost ~25 s / ~900 MB);
  clientBuild is recorded only after a build that did not fail, so a failed
  one is retried; npm output goes to runtime/npm-install.log.
- Server: a port held by another program ends with a clear message and exit 1
  instead of an uncaught 'error' stack trace.
- Client: the onboarding wizard is lazy and rendered only before onboarding;
  usePrefersReducedMotion moved to hooks/ so PetSettings no longer pulls the
  Sagi chat/pane stack into the entry chunk (884 KB -> 431 KB, gzip 268 -> 133 KB).
This commit is contained in:
2026-10-16 10:05:03 +07:00
parent a58e95a957
commit ecbbb4dd8f
17 changed files with 313 additions and 89 deletions
+60 -5
View File
@@ -91,6 +91,34 @@ describe("node version gate", () => {
});
});
describe("depsHash — only what npm installs", () => {
function root(pkg, lock) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-deps-"));
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkg));
fs.writeFileSync(path.join(dir, "package-lock.json"), JSON.stringify(lock));
return dir;
}
const pkg = (version, deps) => ({ name: "agenthub", version, dependencies: deps });
const lock = (version, deps) => ({
name: "agenthub",
version,
packages: { "": { name: "agenthub", version, dependencies: deps }, "node_modules/a": {} },
});
it("ignores a release's version bump", () => {
// Every update reinstalled all runtime dependencies when only the version moved.
const a = root(pkg("1.0.0", { a: "^1" }), lock("1.0.0", { a: "^1" }));
const b = root(pkg("1.0.1", { a: "^1" }), lock("1.0.1", { a: "^1" }));
assert.equal(boot.depsHash(a), boot.depsHash(b));
});
it("changes when a dependency does", () => {
const a = root(pkg("1.0.0", { a: "^1" }), lock("1.0.0", { a: "^1" }));
const b = root(pkg("1.0.0", { a: "^2" }), lock("1.0.0", { a: "^2" }));
assert.notEqual(boot.depsHash(a), boot.depsHash(b));
});
});
describe("fast path", () => {
beforeEach(resetRuntime);
@@ -527,7 +555,6 @@ describe("buildClient dependency install", () => {
assert.deepEqual(calls, [
"ci --no-audit --no-fund --include=dev --ignore-scripts",
"install --no-audit --no-fund --include=dev --ignore-scripts",
"node tsc -b",
"node vite.js build",
]);
});
@@ -567,8 +594,9 @@ describe("buildClient dependency install", () => {
it("builds with the running node, no lifecycle script and no PATH lookup (Windows)", () => {
// On Windows a script or a .bin shim runs through cmd.exe and needs `node`
// on PATH; the server's PATH lacked it, so esbuild's postinstall failed and
// every update kept the previous UI. The build here must equal the
// client's own "build" script, run without npm.
// every update kept the previous UI. The build here is the vite half of
// the client's own "build" script, run without npm; the type check is the
// release's job, not every user's.
assert.equal(
JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "client", "package.json")))
.scripts.build,
@@ -577,8 +605,35 @@ describe("buildClient dependency install", () => {
resetRuntime();
const calls = [];
buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls) });
assert.deepEqual(calls.slice(-2), ["node tsc -b", "node vite.js build"]);
assert.ok(calls.every((c) => !c.startsWith("run")));
assert.deepEqual(calls.slice(-1), ["node vite.js build"]);
assert.ok(calls.every((c) => !c.startsWith("run") && !c.includes("tsc")));
});
it("keeps the installed tree while the client's manifests are unchanged", () => {
resetRuntime();
const root = fakeRoot();
const first = [];
buildClient({ rt: RT, root, exec: fakeExec(first) });
assert.ok(first[0].startsWith("ci"));
const again = [];
assert.equal(buildClient({ rt: RT, root, force: true, exec: fakeExec(again) }), "built");
assert.deepEqual(again, ["node vite.js build"]);
fs.writeFileSync(path.join(root, "client", "package.json"), '{"dependencies":{"x":"1"}}');
const changed = [];
buildClient({ rt: RT, root, force: true, exec: fakeExec(changed) });
assert.ok(changed[0].startsWith("ci"), "a changed manifest installs from clean");
});
it("installs from clean again after a build on the kept tree failed", () => {
resetRuntime();
const root = fakeRoot();
buildClient({ rt: RT, root, exec: fakeExec([]) });
const failing = (cmd, args, cwd) => (cmd === "node" ? false : fakeExec([])(cmd, args, cwd));
assert.equal(buildClient({ rt: RT, root, force: true, exec: failing }), "failed");
const next = [];
buildClient({ rt: RT, root, force: true, exec: fakeExec(next) });
assert.ok(next[0].startsWith("ci"));
});
});
+30 -7
View File
@@ -33,6 +33,20 @@ if (process.env.GIT_EDITOR === "true") delete process.env.GIT_EDITOR;
// server/lib/settings-env.js. Only when this
// file IS the process: the test suite requires it dozens of times, and a
// developer's real settings file pins DASHBOARD_DATA_DIR at their live database.
/** What to tell the user when the dashboard cannot bind its port. The health
* probe above already turned away a port held by a running dashboard, so a
* port in use here belongs to some other program. */
function listenFailure(err, port) {
if (err?.code === "EADDRINUSE")
return (
`Port ${port} is already in use by another program, so AgentHub cannot start there. ` +
`Stop that program, or run AgentHub on another port with DASHBOARD_PORT (for example DASHBOARD_PORT=4821).`
);
if (err?.code === "EACCES")
return `Not allowed to listen on port ${port}. Pick a port above 1024 with DASHBOARD_PORT.`;
return `AgentHub could not start its server: ${err?.stack || err}`;
}
if (require.main === module) {
const settingsEnv = require("./lib/settings-env");
settingsEnv.loadEnvFiles({
@@ -337,8 +351,12 @@ function startServer(app, port) {
const host = resolveHost();
const boundLoopback = isLoopbackHostname(host);
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
// A port another program holds fails here, not as an uncaught 'error'
// event: the caller says what happened instead of a bare stack trace.
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
// Publish the live port so the Claude Code hook handler can find this
// server even when it bound a non-default port (the desktop app falls
// back off 4820 when that port is already taken).
@@ -1210,12 +1228,17 @@ if (require.main === module) {
return;
}
const app = createApp();
startServer(app, PORT).then((server) => {
httpServer = server;
startBackgroundServices();
// The status-area icon (server/lib/tray.js) — production servers only.
require("./lib/tray").startTrayWatch();
});
startServer(app, PORT)
.then((server) => {
httpServer = server;
startBackgroundServices();
// The status-area icon (server/lib/tray.js) — production servers only.
require("./lib/tray").startTrayWatch();
})
.catch((err) => {
console.error(listenFailure(err, PORT));
process.exit(1);
});
});
// Graceful shutdown — close connections and DB cleanly