feat(lanes,run,pricing): Changes sidebar editor, staged groups, image drop, auto pricing

- Changes panel at full screen: the whole Files view (finder, expand-all,
  tree) becomes a left sidebar and the Files tab is dropped; a working-copy
  file opens straight into the editor (Save/Cancel live once the text
  differs, Cancel restores it, Whole file toggles back to the diff), and the
  page is height-locked so the editor no longer collapses with no live run.
- Overview splits into Staged Changes / Changes groups in the work range.
- A changed file's whole tree row takes its status tint.
- Drag an image onto the lane terminal: POST /api/run/drop-image saves it
  (png/jpeg/gif/webp, random name, 0600, pruned after a day) and the path
  is pasted into the pane, which is how claude attaches it.
- Claude Opus 5.5 gets its own pricing row (4/20/0.2/5/8, fast 8/40);
  claude-opus-5% had been pricing it as Opus 5.
- server/lib/pricing-sync.js: a model family used in token_usage with no
  row of its own is inserted from LiteLLM's public price list, never
  updated; off with AGENTHUB_PRICING_SYNC=0. repo-links allows exactly
  that one third-party GitHub raw URL.
This commit is contained in:
2026-09-24 17:05:15 +07:00
parent 804b193ac7
commit 158783d5d5
25 changed files with 1266 additions and 540 deletions
+47 -1
View File
@@ -26,7 +26,11 @@ function fetchJson(p, opts = {}) {
const url = new URL(p, BASE);
const headers = { ...(opts.headers || {}) };
let body;
if (opts.body !== undefined) {
if (opts.raw) {
body = opts.raw.buf;
headers["Content-Type"] = opts.raw.type;
headers["Content-Length"] = body.length;
} else if (opts.body !== undefined) {
body = Buffer.from(JSON.stringify(opts.body));
headers["Content-Type"] = "application/json";
headers["Content-Length"] = body.length;
@@ -203,3 +207,45 @@ describe("/api/run", () => {
assert.equal(res.status, 400);
});
});
describe("POST /api/run/drop-image", () => {
let srv;
before(async () => {
srv = http.createServer(createApp());
await new Promise((r) => srv.listen(0, r));
BASE = `http://127.0.0.1:${srv.address().port}`;
});
after(() => new Promise((r) => srv.close(r)));
const PNG = Buffer.from("89504e470d0a1a0a0000", "hex");
it("saves the image under a random name, owner-only, and returns its path", async () => {
const r = await fetchJson("/api/run/drop-image", {
method: "POST",
raw: { type: "image/png", buf: PNG },
});
assert.equal(r.status, 200);
assert.match(path.basename(r.body.path), /^[0-9a-f]{16}\.png$/);
assert.ok(r.body.path.startsWith(os.tmpdir()));
assert.deepEqual(fs.readFileSync(r.body.path), PNG);
assert.equal(fs.statSync(r.body.path).mode & 0o777, 0o600);
fs.unlinkSync(r.body.path);
});
it("refuses anything that is not one of the image types", async () => {
const r = await fetchJson("/api/run/drop-image", {
method: "POST",
raw: { type: "text/x-shellscript", buf: Buffer.from("rm -rf ~") },
});
assert.equal(r.status, 400);
});
it("refuses a cross-origin browser request", async () => {
const r = await fetchJson("/api/run/drop-image", {
method: "POST",
headers: { Origin: "https://evil.example" },
raw: { type: "image/png", buf: PNG },
});
assert.equal(r.status, 403);
});
});