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
+55 -1
View File
@@ -19,8 +19,11 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const express = require("express");
const { Router } = express;
const fs = require("node:fs");
const os = require("node:os");
const crypto = require("node:crypto");
const path = require("node:path");
const runs = require("../lib/pty-run");
const tmux = require("../lib/tmux");
@@ -81,6 +84,57 @@ router.get("/", (_req, res) => {
* runs are first. Optional ?laneId=<n> narrows to the runs started through one
* lane, which is what the Workspace page's per-lane history shows.
*/
// ── Drag & drop into the terminal ─────────────────────────────────────────
// A local terminal answers a dropped image by typing its PATH, which is how
// `claude` attaches it. A browser has no path to give, so the image is saved
// here — on the machine the pane runs on — and the client pastes this path.
// Only the bytes come from the request: the name is random and the type picks
// the extension from a fixed map, so a request never names a file.
const DROP_TYPES = {
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
};
const DROP_MAX_BYTES = 20 * 1024 * 1024;
const DROP_TTL_MS = 24 * 60 * 60_000;
const dropDir = () => path.join(os.tmpdir(), `agenthub-drops-${os.userInfo().uid}`);
router.post(
"/drop-image",
express.raw({ type: Object.keys(DROP_TYPES), limit: DROP_MAX_BYTES }),
(req, res) => {
const ext =
DROP_TYPES[
String(req.headers["content-type"] || "")
.split(";")[0]
.trim()
];
if (!ext || !Buffer.isBuffer(req.body) || req.body.length === 0) {
return res.status(400).json({ error: "png, jpeg, gif or webp image body required" });
}
try {
const dir = dropDir();
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
const cutoff = Date.now() - DROP_TTL_MS;
for (const name of fs.readdirSync(dir)) {
const f = path.join(dir, name);
try {
if (fs.statSync(f).mtimeMs < cutoff) fs.unlinkSync(f);
} catch {
/* raced with another prune — fine */
}
}
const file = path.join(dir, `${crypto.randomBytes(8).toString("hex")}.${ext}`);
fs.writeFileSync(file, req.body, { mode: 0o600, flag: "wx" });
res.json({ path: file });
} catch (err) {
res.status(500).json({ error: err.message });
}
}
);
router.get("/history", (req, res) => {
let dr = null;
try {