Compare commits

..

5 Commits

Author SHA1 Message Date
nntrivi2001 605ca9d65e chore(release): bump version to 1.8.8 2026-09-24 17:08:18 +07:00
nntrivi2001 158783d5d5 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.
2026-09-24 17:05:15 +07:00
nntrivi2001 804b193ac7 chore(release): bump version to 1.8.7 2026-09-24 15:54:09 +07:00
nntrivi2001 d397d3a1f6 feat(lanes): split panes on one lane, in-place file editing, hunk review bar
- Split view: every pane shows the selected lane, each in its own tmux
  session (`slot` 0-3 on POST /api/lanes/:id/start; slot 0 stays the
  lane's `run_id`, slots 1-3 are `agenthub-lane-<id>-<slot>`). A pane's
  Active runs hides what the other panes show; stop/reset/remove kill
  every slot. No per-pane lane picker any more.
- Whole-file editor: PUT /api/lanes/:id/git/file (lane-file-write.js)
  with the read path's guards, existing text files only, and a baseSha
  stale check (409 ESTALE). A file it cannot take says why.
- Per-hunk review bar under each hunk's last changed line: prev/next,
  "n of N", Undo, Keep. Review diffs no longer nest their own scroll box
  (unless windowed), so navigation moves the one real scroller.
- Files tree lists untracked-not-ignored files and colours entries by git
  status (M/U/A/D/R), folders holding changes get a dot.
- Changes panel: Unstage all on the single commit/Stage-all row; sizes
  Narrow (30%) / Medium / Full screen, no 70/30.
- Lane card: the needs-you banner is gone (the strip tile keeps it).
2026-09-24 15:49:39 +07:00
nntrivi2001 67210bcd61 fix(test): run the server suite on a private tmux server
Several suites start real `agenthub-lane-<id>` tmux sessions and then kill
or reset them, with lane ids from their own throwaway database. On the
user's default tmux server those names collide with the dashboard's real
lane panes, so a test run — including the one the pre-commit hook runs on
every commit — killed whichever live session shared an id, the pane the
commit was made from included.

`npm run test:server` now goes through scripts/test-server.js, which drops
TMUX, points TMUX_TMPDIR at a fresh directory and kills that server after.
Also fixes the ERUNTIMEOUT test's tmux mock, which had the wrong (async,
cmd+args) signature and only passed by accident.
2026-09-24 15:48:19 +07:00
49 changed files with 2519 additions and 721 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+4 -1
View File
File diff suppressed because one or more lines are too long
+2 -5
View File
@@ -499,11 +499,8 @@ export default function LaneCard({
</div>
)}
{lane.needs_action && (
<div className="mb-2 rounded border border-status-warning/50 bg-status-warning/10 px-2 py-1 text-xs text-status-warning">
⚠ {lane.needs_action}
</div>
)}
{/* No needs-you banner here: the strip card already flags it, and the
console directly below is where the prompt actually waits. */}
{/* Runtime strip: the lane's own app stack, shown only for lanes that
declare a profile. A port that drifted from `base + slot` is called
File diff suppressed because it is too large Load Diff
+159 -30
View File
@@ -31,6 +31,7 @@ import {
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp } from "lucide-react";
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
@@ -38,11 +39,31 @@ export type DiffLayout = "unified" | "split";
/**
* How tall a diff box may be. `fill` (the single-file view) takes the height
* its parent gives it; otherwise — the review list, which stacks many files —
* each file is capped so its own horizontal scrollbar stays on screen.
* its parent gives it. In the review list, which stacks many files, a diff
* grows to its full height so the PANEL is the one vertical scroller — a
* 60vh box per file nested inside it made hunk navigation scroll two boxes
* at once and land nowhere predictable. Only a windowed diff (above
* WINDOW_THRESHOLD rows) keeps the cap, because windowing needs its own
* scroller.
*/
function boxHeight(fill: boolean) {
return fill ? "min-h-0 flex-1" : "max-h-[60vh]";
function boxHeight(fill: boolean, windowed: boolean) {
return fill ? "min-h-0 flex-1" : windowed ? "max-h-[60vh]" : "";
}
/**
* Bring `el` to the top of the scroller that actually scrolls it, below
* `pad` px of sticky header. Not `scrollIntoView`: that moves EVERY scrolling
* ancestor, the page included.
*/
function scrollToTop(el: HTMLElement, pad: number) {
let s = el.parentElement;
while (
s &&
!(s.scrollHeight > s.clientHeight && /auto|scroll/.test(getComputedStyle(s).overflowY))
)
s = s.parentElement;
if (!s) return;
s.scrollTop += el.getBoundingClientRect().top - s.getBoundingClientRect().top - pad;
}
/** Rows above which a view windows; at or below it renders exactly as before. */
@@ -284,21 +305,79 @@ function markerClass(type: LaneDiffLine["type"]) {
return "text-fg-muted";
}
/** Per-hunk controls (stage / undo) beside each `@@` line. A context rather
* than a prop because the header renders from four places. */
const HunkActions = createContext<((hunk: LaneDiffHunk) => ReactNode) | null>(null);
/** Per-hunk review controls (Undo / Keep), drawn in a bar under each hunk's
* last changed line. A context rather than a prop because lines render from
* four places; `hunks` is what the bar's "n of N" and its arrows walk. */
const HunkActions = createContext<{
actions: (hunk: LaneDiffHunk) => ReactNode;
hunks: LaneDiffHunk[];
go: (index: number) => void;
} | null>(null);
function HunkHeader({
hunk,
fixed,
noActions,
}: {
hunk: LaneDiffHunk;
fixed?: boolean;
/** The split view's left column: one set of controls per hunk, not two. */
noActions?: boolean;
}) {
const actions = useContext(HunkActions);
/** Index of a hunk's last +/- line (unified), or last row with one (split). */
function lastChangedLine(hunk: LaneDiffHunk) {
for (let i = hunk.lines.length - 1; i >= 0; i -= 1) {
if (hunk.lines[i]!.type !== "ctx") return i;
}
return -1;
}
function lastChangedRow(hunk: LaneDiffHunk) {
const rows = analyseHunk(hunk).rows;
for (let i = rows.length - 1; i >= 0; i -= 1) {
const r = rows[i]!;
if ((r.left && r.left.type !== "ctx") || (r.right && r.right.type !== "ctx")) return i;
}
return -1;
}
/** The review bar: hangs just BELOW the last changed line, pinned to the
* visible right edge, so it sits on the change it acts on — the editor-style
* "^ v 2 of 3 · Undo · Keep" instead of glyphs at the end of the `@@` line. */
function HunkBar({ hunk }: { hunk: LaneDiffHunk }) {
const { t } = useTranslation(["lanes"]);
const ctx = useContext(HunkActions);
if (!ctx) return null;
const index = ctx.hunks.indexOf(hunk);
const total = ctx.hunks.length;
const nav =
"rounded px-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-30";
return (
<span className="sticky right-2 z-10 ml-auto h-0 w-0 self-start">
<span
data-testid="lane-hunk-bar"
className="absolute right-0 top-[18px] flex items-center gap-1 whitespace-nowrap rounded border border-border-light bg-surface-1 px-1 py-0.5 font-sans text-[10px] leading-[14px] text-fg-secondary shadow-card"
>
<button
type="button"
aria-label={t("changes.prevHunk")}
title={t("changes.prevHunk")}
disabled={index <= 0}
onClick={() => ctx.go(index - 1)}
className={nav}
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
aria-label={t("changes.nextHunk")}
title={t("changes.nextHunk")}
disabled={index >= total - 1}
onClick={() => ctx.go(index + 1)}
className={nav}
>
<ChevronDown className="h-3 w-3" />
</button>
<span className="px-0.5 tabular-nums text-fg-muted">
{t("changes.hunkOf", { n: index + 1, total })}
</span>
{ctx.actions(hunk)}
</span>
</span>
);
}
function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) {
const ctx = useContext(HunkActions);
const at = hunk.header.split("@@")[1];
// Windowed, the header is one ROW_PX row like the lines around it.
return (
@@ -308,14 +387,12 @@ function HunkHeader({
}`}
style={fixed ? FIXED : undefined}
data-diff-item={fixed ? "" : undefined}
data-hunk-index={ctx ? ctx.hunks.indexOf(hunk) : undefined}
>
<span className="min-w-0 flex-1 truncate">
{at ? `@@${at}@@` : hunk.header}
{hunk.context ? ` ${hunk.context}` : ""}
</span>
{actions && !noActions && (
<span className="sticky right-2 flex shrink-0 items-center gap-0.5">{actions(hunk)}</span>
)}
</div>
);
}
@@ -325,11 +402,14 @@ function UnifiedLine({
lang,
emph,
style,
bar,
}: {
line: LaneDiffLine;
lang: string;
emph?: [number, number];
style?: CSSProperties;
/** This is the hunk's last changed line: hang the review bar under it. */
bar?: LaneDiffHunk;
}) {
return (
<Row tint={rowTint(line.type)} style={style}>
@@ -341,17 +421,25 @@ function UnifiedLine({
</span>
<Code text={line.text} lang={lang} emph={emph} />
</span>
{bar && <HunkBar hunk={bar} />}
</Row>
);
}
function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
const { emph } = analyseHunk(hunk);
const last = lastChangedLine(hunk);
return (
<div>
<HunkHeader hunk={hunk} />
{hunk.lines.map((line, i) => (
<UnifiedLine key={i} line={line} lang={lang} emph={emph.get(i)} />
<UnifiedLine
key={i}
line={line}
lang={lang}
emph={emph.get(i)}
bar={i === last ? hunk : undefined}
/>
))}
</div>
);
@@ -384,11 +472,13 @@ function SplitLine({
lang,
side,
style,
bar,
}: {
row: PairRow;
lang: string;
side: "left" | "right";
style?: CSSProperties;
bar?: LaneDiffHunk;
}) {
const line = side === "left" ? row.left : row.right;
return (
@@ -403,6 +493,7 @@ function SplitLine({
/>
) : null}
</span>
{bar && <HunkBar hunk={bar} />}
</Row>
);
}
@@ -421,9 +512,16 @@ function SplitColumn({
<>
{hunks.map((hunk, h) => (
<div key={`${hunk.header}:${h}`}>
<HunkHeader hunk={hunk} noActions={side === "left"} />
<HunkHeader hunk={hunk} />
{analyseHunk(hunk).rows.map((row, i) => (
<SplitLine key={i} row={row} lang={lang} side={side} />
<SplitLine
key={i}
row={row}
lang={lang}
side={side}
// One bar per hunk: on the right (new) column only.
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
/>
))}
</div>
))}
@@ -449,7 +547,7 @@ function SplitSlice({
.slice(range.start, range.end)
.map(({ hunk, i }, k) =>
i < 0 ? (
<HunkHeader key={range.start + k} hunk={hunk} fixed noActions={side === "left"} />
<HunkHeader key={range.start + k} hunk={hunk} fixed />
) : (
<SplitLine
key={range.start + k}
@@ -457,6 +555,7 @@ function SplitSlice({
lang={lang}
side={side}
style={FIXED}
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
/>
)
)}
@@ -521,7 +620,7 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
);
return (
<div className={`flex ${boxHeight(fill)}`}>
<div className={`flex ${boxHeight(fill, win.on)}`}>
<div
ref={left}
data-testid="lane-diff-split-left"
@@ -553,9 +652,10 @@ export function LaneDiffView({
layout: DiffLayout;
/** Take the parent's height (single-file view) instead of the 60vh cap. */
fill?: boolean;
/** Controls drawn on each hunk's header row. */
/** Controls drawn in each hunk's review bar. */
hunkActions?: (hunk: LaneDiffHunk) => ReactNode;
}) {
const root = useRef<HTMLDivElement>(null);
const { t } = useTranslation(["lanes"]);
const lang = langFor(file.path);
const hunks = file.hunks;
@@ -567,6 +667,31 @@ export function LaneDiffView({
);
// Unified only; split windows inside its own two scrollers.
const win = useWindow(layout === "unified" ? items.length : 0);
const splitWindowed = useMemo(
() => layout === "split" && !!hunks && flatten(hunks, "split").length > WINDOW_THRESHOLD,
[layout, hunks]
);
// The bar's arrows: bring hunk `index`'s header into view. A windowed
// unified diff may not have it in the DOM, so there it is found by offset.
const go = (index: number) => {
const el = root.current;
if (!el || !hunks) return;
const header = el.querySelector<HTMLElement>(`[data-hunk-index="${index}"]`);
// The review list pins each file's header over its own diff.
const sticky = el
.closest("[data-review-file]")
?.querySelector<HTMLElement>("[data-sticky-top]");
const pad = sticky?.offsetHeight ?? 0;
if (header) {
scrollToTop(header, pad);
return;
}
// Windowed: the header is not in the DOM, but its offset is known.
const at = items.findIndex((it) => it.i < 0 && it.hunk === hunks[index]);
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
};
const review = hunkActions && hunks ? { actions: hunkActions, hunks, go } : null;
if (file.binary)
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
@@ -574,13 +699,16 @@ export function LaneDiffView({
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
return (
<HunkActions.Provider value={hunkActions ?? null}>
<HunkActions.Provider value={review}>
<div
ref={root}
data-testid="lane-diff-view"
data-layout={layout}
onScroll={(e) => win.onScroll(e.currentTarget)}
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
layout === "split" ? boxHeight(fill) : `overflow-auto ${boxHeight(fill)}`
layout === "split"
? boxHeight(fill, splitWindowed)
: `overflow-auto ${boxHeight(fill, win.on)}`
}`}
>
{layout === "split" ? (
@@ -599,6 +727,7 @@ export function LaneDiffView({
lang={lang}
emph={analyseHunk(hunk).emph.get(i)}
style={FIXED}
bar={i === lastChangedLine(hunk) ? hunk : undefined}
/>
)
)}
@@ -646,7 +775,7 @@ export function LaneFileView({
<div
data-testid="lane-file-view"
onScroll={(e) => win.onScroll(e.currentTarget)}
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill)}`}
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
>
{win.on ? (
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
@@ -167,9 +167,9 @@ describe("LaneCard rebuilt layout", () => {
expect(screen.queryByText("48%")).toBeNull();
});
it("surfaces a needs-you message", () => {
it("leaves the needs-you message to the strip card", () => {
render(<LaneCard lane={full({ needs_action: "waiting on approval" })} onAction={vi.fn()} />);
expect(screen.getByText(/waiting on approval/)).toBeInTheDocument();
expect(screen.queryByText(/waiting on approval/)).toBeNull();
});
it("keeps reset behind the ⋯ menu rather than showing it directly", async () => {
@@ -11,7 +11,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act } from "@testing-library/react";
import { render, screen, waitFor, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneChangesPanel } from "../LaneChangesPanel";
import { api } from "../../../lib/api";
@@ -26,6 +26,7 @@ vi.mock("../../../lib/api", () => ({
gitAction: vi.fn(),
gitLog: vi.fn(),
gitFile: vi.fn(),
gitSaveFile: vi.fn(),
gitLs: vi.fn(),
gitTree: vi.fn(),
gitTreeAll: vi.fn(),
@@ -79,6 +80,13 @@ function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}):
};
}
/** The overview row's discard button for one path. */
function discardOf(path: string) {
return within(screen.getByText(path).closest("li")!).getByRole("button", {
name: "Discard this file's changes",
});
}
beforeEach(() => {
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.changes).mockReset();
@@ -300,13 +308,82 @@ describe("LaneChangesPanel", () => {
});
});
it("lists staged files apart from the changes the next commit leaves behind", async () => {
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
expect(within(staged).getByText("docs/API.md")).toBeInTheDocument();
expect(within(staged).queryByText("notes.txt")).toBeNull();
expect(within(unstaged).getByText("server/lib/git-ops.js")).toBeInTheDocument();
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
expect(within(unstaged).queryByText("docs/API.md")).toBeNull();
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
});
it("at full width shows the tree as a sidebar and opens a file straight into the editor", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "main.ts", path: "src/main.ts", dir: false, status: " M" }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 1;"],
sha: "b".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
// The sidebar IS the Files view: its finder lives there, and the tab is gone.
expect(within(sidebar).getByLabelText("Find a file by path…")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Files" })).toBeNull();
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
await user.click(await within(sidebar).findByText("main.ts"));
// No Edit click: the editor is simply there, Save/Cancel idle until typed.
const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("const whole = 1;");
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
await user.type(editor, "x");
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
// Cancel reverts to the saved text and stays in the editor.
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
});
it("at full width opens a changed file from the overview straight into the editor", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "server/lib/git-ops.js",
rev: "work",
binary: false,
truncated: false,
lines: ["a"],
sha: "b".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByText("server/lib/git-ops.js"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("a");
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
});
it("needs two clicks to discard, and only the second one reaches the server", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
await user.click(discard!);
await user.click(discardOf("server/lib/git-ops.js"));
expect(api.lanes.gitAction).not.toHaveBeenCalled();
await user.click(
@@ -340,8 +417,7 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
await user.click(discard!);
await user.click(discardOf("server/lib/git-ops.js"));
// The file was committed/reverted elsewhere; a later reappearance must not be pre-armed.
const base = changes();
@@ -473,6 +549,34 @@ describe("LaneChangesPanel", () => {
expect(screen.getByText("src")).toBeInTheDocument();
});
it("highlights modified and untracked files in the Files tree, and folders holding them", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [
{ name: "src", path: "src", dir: true, changed: true },
{ name: "docs", path: "docs", dir: true },
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
{ name: "new.txt", path: "new.txt", dir: false, status: "??" },
{ name: "keep.txt", path: "keep.txt", dir: false },
],
truncated: false,
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
expect(await row("a.txt")).toHaveAttribute("data-tree-status", "M");
expect(await row("a.txt")).toHaveClass("text-status-warning");
expect(await row("new.txt")).toHaveAttribute("data-tree-status", "U");
expect(await row("new.txt")).toHaveClass("text-status-success");
expect(await row("src")).toHaveAttribute("data-tree-status", "dir");
expect(await row("keep.txt")).not.toHaveAttribute("data-tree-status");
expect(await row("docs")).not.toHaveAttribute("data-tree-status");
});
it("quick-opens a tracked file as its whole content, not a diff", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -486,6 +590,113 @@ describe("LaneChangesPanel", () => {
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
});
it("edits a whole file in place and saves over the exact version it opened", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 1;"],
sha: "b".repeat(40),
});
vi.mocked(api.lanes.gitSaveFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 2;"],
sha: "c".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await screen.findByTestId("lane-file-view");
await user.click(screen.getByTestId("lane-file-edit"));
const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("const whole = 1;");
await user.clear(editor);
await user.type(editor, "const whole = 2;");
await user.click(screen.getByTestId("lane-file-save"));
expect(api.lanes.gitSaveFile).toHaveBeenCalledWith(
7,
"src/main.ts",
"const whole = 2;",
"b".repeat(40)
);
// Back to reading, showing what was saved.
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 2;");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: true, // over the 1 MB view cap: no `sha`, nothing safe to save
lines: ["partial"],
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
expect(await screen.findByTestId("lane-file-noedit")).toHaveTextContent(
"Too large to edit here (over 1 MB)"
);
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
});
it("keeps the draft and says why when the file changed on disk meanwhile", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["x"],
sha: "b".repeat(40),
});
vi.mocked(api.lanes.gitSaveFile).mockRejectedValue(
Object.assign(new Error("the file changed on disk since it was opened"), { code: "ESTALE" })
);
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await screen.findByTestId("lane-file-view");
await user.click(screen.getByTestId("lane-file-edit"));
await user.type(await screen.findByTestId("lane-file-editor"), "y");
await user.click(screen.getByTestId("lane-file-save"));
expect(await screen.findByRole("alert")).toHaveTextContent(/changed on disk/);
expect(screen.getByTestId("lane-file-editor")).toHaveValue("xy");
});
it("unstages every staged file in one call, on the same row as Stage all and commit", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const row = screen.getByTestId("lane-changes-actions");
expect(row).toContainElement(screen.getByTestId("lane-changes-commit"));
expect(row).toContainElement(screen.getByRole("button", { name: "Stage all (2)" }));
await user.click(screen.getByRole("button", { name: "Unstage all (1)" }));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "unstage", { paths: ["docs/API.md"] });
});
it("switches the diff to side-by-side", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -715,6 +926,23 @@ describe("LaneChangesPanel", () => {
});
});
it("hangs one review bar under each actionable hunk, counting them", async () => {
const user = userEvent.setup();
await openReview(user);
// Two hunks on the tracked file; the untracked file gets no bar.
const bars = screen.getAllByTestId("lane-hunk-bar");
expect(bars.map((b) => b.textContent)).toEqual([
expect.stringContaining("1 of 2"),
expect.stringContaining("2 of 2"),
]);
// Undo sits beside Keep inside the bar — not on the @@ line.
expect(bars[0]).toContainElement(
screen.getAllByRole("button", { name: "Undo this hunk" })[0]!
);
expect(screen.getAllByRole("button", { name: "Previous change" })[0]).toBeDisabled();
expect(screen.getAllByRole("button", { name: "Next change" })[1]).toBeDisabled();
});
it("needs two clicks to undo a hunk, and arms only that hunk", async () => {
const user = userEvent.setup();
await openReview(user);
@@ -59,11 +59,28 @@ describe("LaneDiffView — side by side", () => {
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
});
it("bounds its own height so the horizontal bar stays on screen", () => {
// A stacked review list caps each file; the single-file view takes the
// height its parent gives it. Either way the diff scrolls itself rather
// than growing a 2000-line container whose scrollbar is off the bottom.
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
// Unwindowed, a review-list diff grows so the PANEL is the one vertical
// scroller (nested 60vh boxes made hunk navigation land nowhere); a
// windowed one keeps the cap because windowing needs its own scroller.
const { rerender } = render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
const big: LaneChangeFile = {
...FILE,
hunks: [
{
...FILE.hunks![0]!,
lines: Array.from({ length: WINDOW_THRESHOLD + 1 }, (_, i) => ({
type: "ctx" as const,
oldLine: i + 1,
newLine: i + 1,
text: `line ${i}`,
})),
},
],
};
rerender(<LaneDiffView file={big} layout="unified" />);
expect(screen.getByTestId("lane-diff-view").className).toContain("max-h-[60vh]");
rerender(<LaneDiffView file={FILE} layout="unified" fill />);
@@ -379,3 +396,66 @@ describe("LaneDiffView — windowing", () => {
expect(container.textContent).toContain("row 4000");
});
});
describe("LaneDiffView — hunk review bar navigation", () => {
const hunk = (n: number) => ({
header: `@@ -${n},1 +${n},1 @@`,
context: "",
oldStart: n,
newStart: n,
lines: [
{ type: "del" as const, oldLine: n, newLine: null, text: `old ${n}` },
{ type: "add" as const, oldLine: null, newLine: n, text: `new ${n}` },
],
});
const three: LaneChangeFile = { ...FILE, hunks: [hunk(1), hunk(20), hunk(40)] };
// jsdom lays nothing out: give the ONE real scroller a scrollable box and
// put hunk k's `@@` row k×100px below its top, wherever it has scrolled to.
function renderInScroller() {
const view = render(
<div data-testid="scroller" style={{ overflowY: "auto" }}>
<LaneDiffView file={three} layout="unified" hunkActions={() => <span />} />
</div>
);
const scroller = screen.getByTestId("scroller");
Object.defineProperty(scroller, "scrollHeight", { value: 1000 });
Object.defineProperty(scroller, "clientHeight", { value: 100 });
scroller.getBoundingClientRect = () => ({ top: 0 }) as DOMRect;
for (const el of view.container.querySelectorAll<HTMLElement>("[data-hunk-index]")) {
const k = Number(el.dataset.hunkIndex);
el.getBoundingClientRect = () => ({ top: k * 100 - scroller.scrollTop }) as DOMRect;
}
return scroller;
}
it("steps to the NEXT hunk and back to the PREVIOUS one, not the first", () => {
const scroller = renderInScroller();
const next = screen.getAllByRole("button", { name: "Next change" });
const prev = screen.getAllByRole("button", { name: "Previous change" });
fireEvent.click(next[0]!); // bar 1 → hunk 2
expect(scroller.scrollTop).toBe(100);
fireEvent.click(next[1]!); // bar 2 → hunk 3
expect(scroller.scrollTop).toBe(200);
fireEvent.click(prev[2]!); // bar 3 → hunk 2, not hunk 1
expect(scroller.scrollTop).toBe(100);
});
it("counts the hunks and disables the arrows at either end", () => {
renderInScroller();
const bars = screen.getAllByTestId("lane-hunk-bar");
expect(bars.map((b) => b.textContent)).toEqual([
expect.stringContaining("1 of 3"),
expect.stringContaining("2 of 3"),
expect.stringContaining("3 of 3"),
]);
expect(screen.getAllByRole("button", { name: "Previous change" })[0]).toBeDisabled();
expect(screen.getAllByRole("button", { name: "Next change" })[2]).toBeDisabled();
});
it("leaves an unwindowed review diff uncapped, so the panel is its only scroller", () => {
render(<LaneDiffView file={three} layout="unified" />);
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
});
});
+66 -30
View File
@@ -16,8 +16,9 @@
* and its live tmux session — instead of leaving the previous lane's terminal
* on screen under a new lane's header.
*
* A lane's pane is one tmux session, so starting or resuming into a lane
* already holding a live run answers 409 ERUNLIVE rather than overwriting a
* Each pane is one tmux session on its lane — split panes all show the same
* lane, pane `slot` N owning `agenthub-lane-<id>[-N]` — so starting or resuming
* into a pane already holding a live run answers 409 ERUNLIVE rather than overwriting a
* prompt in flight — `onStartFromSetup` and `onResumeFromHistory` both catch
* that and attach to the still-live session instead of failing outright
* (with a notice explaining what happened), since that session is still
@@ -76,6 +77,19 @@ export interface LaneConsolePaneProps {
wsConnected: boolean;
defaultCwd?: string;
onHasActiveRunChange?: (active: boolean) => void;
/** Which split pane this is. Every pane shows the SAME lane, each in its own
* tmux session: slot 0 is the lane's own, 1–3 are `agenthub-lane-<id>-<slot>`. */
slot?: number;
/** Runs the OTHER panes are showing — left out of this pane's Active runs so
* one terminal is never attached twice side by side. */
hiddenRunIds?: string[];
/** Reports the run this pane shows (null on the setup form). */
onHandleChange?: (runId: string | null) => void;
}
/** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */
function paneRunId(laneId: number, slot: number): string {
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
}
export function LaneConsolePane({
@@ -91,6 +105,9 @@ export function LaneConsolePane({
wsConnected,
defaultCwd,
onHasActiveRunChange,
slot = 0,
hiddenRunIds,
onHandleChange,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes");
@@ -117,8 +134,13 @@ export function LaneConsolePane({
() =>
laneId === null || !activeRuns
? activeRuns
: { ...activeRuns, items: activeRuns.items.filter((r) => r.laneId === laneId) },
[activeRuns, laneId]
: {
...activeRuns,
items: activeRuns.items.filter(
(r) => r.laneId === laneId && !hiddenRunIds?.includes(r.id)
),
},
[activeRuns, laneId, hiddenRunIds]
);
const laneSessions = useMemo(
() =>
@@ -133,6 +155,10 @@ export function LaneConsolePane({
onHasActiveRunChange?.(handle !== null);
}, [handle, onHasActiveRunChange]);
useEffect(() => {
onHandleChange?.(handle?.id ?? null);
}, [handle, onHandleChange]);
useEffect(() => {
if (!currentLane && defaultCwd && cwd === "") {
setCwd(defaultCwd);
@@ -171,10 +197,11 @@ export function LaneConsolePane({
setResumeSession(null);
setError(null);
setBusy(null);
setHandle(runs?.items.find((r) => r.laneId === laneId && r.status === "running") ?? null);
const own = laneId === null ? null : paneRunId(laneId, slot);
setHandle(runs?.items.find((r) => r.id === own && r.status === "running") ?? null);
autoAttachedForLane.current = null;
refreshList();
}, [laneId, refreshList]);
}, [laneId, slot, refreshList]);
// Picking a pipeline template writes NOTHING into the prompt box: a prefilled
// slash command (or the codex stage-checklist prose) is sent as soon as the
@@ -191,12 +218,13 @@ export function LaneConsolePane({
useEffect(() => {
if (handle || laneId === null) return;
if (autoAttachedForLane.current === laneId) return;
const running = activeRuns?.items.find((r) => r.laneId === laneId && r.status === "running");
const own = paneRunId(laneId, slot);
const running = activeRuns?.items.find((r) => r.id === own && r.status === "running");
if (running) {
autoAttachedForLane.current = laneId;
setHandle(running);
}
}, [activeRuns, laneId, handle]);
}, [activeRuns, laneId, slot, handle]);
// The cwd tracks the lane's own folder separately, keyed on the resolved
// path rather than on `laneId` alone: the pane can mount before the lane
@@ -275,6 +303,7 @@ export function LaneConsolePane({
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
slot,
prompt: args.initialPrompt || "",
model: args.model || undefined,
permissionMode: args.permissionMode,
@@ -289,29 +318,27 @@ export function LaneConsolePane({
// already has a live run"), never the literal strings "409" or
// "ERUNLIVE" — matching against `.message` here is dead code that
// always falls through to the raw error below.
// This pane's own session is the live one — land on it.
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
if (updatedLane?.run_id) {
await attachToRun(updatedLane.run_id);
setError(t("errors.attachedToLiveInstead"));
return;
}
await attachToRun(paneRunId(targetLaneId, slot));
setError(t("errors.attachedToLiveInstead"));
return;
}
throw laneErr;
}
if (!laneStartResult.lane?.run_id) {
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
if (!startedId) {
throw new Error(t("errors.noRunIdReturned"));
}
try {
const fetched = await api.run.get(laneStartResult.lane.run_id);
const fetched = await api.run.get(startedId);
setHandle(fetched);
refreshList();
} catch {
try {
await attachToRun(laneStartResult.lane.run_id);
await attachToRun(startedId);
refreshList();
} catch (fallbackErr: unknown) {
const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
@@ -325,7 +352,18 @@ export function LaneConsolePane({
setBusy(null);
}
},
[busy, t, lanes, laneId, laneTool, onLaneIdChange, onLaneCreated, attachToRun, refreshList]
[
busy,
t,
lanes,
laneId,
laneTool,
slot,
onLaneIdChange,
onLaneCreated,
attachToRun,
refreshList,
]
);
const onResumeFromHistory = useCallback(
@@ -353,6 +391,7 @@ export function LaneConsolePane({
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
slot,
prompt: "",
model: item.model || undefined,
permissionMode: item.permission_mode || undefined,
@@ -367,23 +406,20 @@ export function LaneConsolePane({
// — the server's `.message` is prose, never the literal string
// "ERUNLIVE" or "409".
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
if (updatedLane?.run_id) {
onLaneIdChange(targetLaneId);
await attachToRun(updatedLane.run_id);
setError(t("errors.attachedToLiveInstead"));
return;
}
onLaneIdChange(targetLaneId);
await attachToRun(paneRunId(targetLaneId, slot));
setError(t("errors.attachedToLiveInstead"));
return;
}
throw laneErr;
}
if (!laneStartResult.lane?.run_id) {
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
if (!startedId) {
throw new Error("No run_id returned from lane start");
}
fetched = await api.run.get(laneStartResult.lane.run_id);
fetched = await api.run.get(startedId);
onLaneIdChange(targetLaneId);
} else {
fetched = await api.run.start({
@@ -407,7 +443,7 @@ export function LaneConsolePane({
setBusy(null);
}
},
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange, attachToRun]
[busy, refreshList, t, lanes, slot, onLaneCreated, onLaneIdChange, attachToRun]
);
const onViewFromHistory = useCallback(
+84 -6
View File
@@ -11,15 +11,23 @@
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
* Always painted dark, in the palette of the user's chosen DARK theme
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
* and its PATH pasted into the pane — what a local terminal does on a drop,
* and how `claude` attaches an image.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { CanvasAddon } from "@xterm/addon-canvas";
import "@xterm/xterm/css/xterm.css";
import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
interface TerminalViewProps {
runId: string;
@@ -45,6 +53,42 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
const paletteRef = useRef(palette);
paletteRef.current = palette;
const termRef = useRef<Terminal | null>(null);
const { t } = useTranslation("run");
const [drop, setDrop] = useState<DropState | null>(null);
// dragenter/dragleave fire for every child crossed, so count them: the hint
// only goes away when the pointer leaves the terminal itself.
const dragDepth = useRef(0);
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const flash = (state: DropState) => {
setDrop(state);
if (flashTimer.current) clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => setDrop(null), 2500);
};
useEffect(
() => () => {
if (flashTimer.current) clearTimeout(flashTimer.current);
},
[]
);
const onDrop = async (e: DragEvent) => {
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
e.preventDefault();
dragDepth.current = 0;
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
if (images.length === 0) return flash("imagesOnly");
setDrop("uploading");
try {
const saved = await Promise.all(images.map((f) => api.run.dropImage(f)));
// paste(), not a raw send: it honours bracketed-paste mode, so claude
// sees one pasted path rather than typed keystrokes.
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
termRef.current?.focus();
setDrop(null);
} catch {
flash("failed");
}
};
useEffect(() => {
// An explicit fontFamily pins every glyph to one bundled webfont. Left
@@ -249,10 +293,44 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
return (
<div
ref={containerRef}
className="h-full w-full"
style={{ backgroundColor: palette.background }}
data-testid="terminal-view"
/>
className="relative h-full w-full"
onDragEnter={(e) => {
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth.current += 1;
setDrop("hint");
}}
onDragOver={(e) => {
if (!hasFiles(e)) return;
e.preventDefault(); // without it the browser refuses the drop
e.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(e) => {
if (!hasFiles(e)) return;
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
}}
onDrop={(e) => void onDrop(e)}
>
<div
ref={containerRef}
className="h-full w-full"
style={{ backgroundColor: palette.background }}
data-testid="terminal-view"
/>
{drop && (
// pointer-events-none: the drop must land on the terminal, not on this.
<div
data-testid="terminal-drop"
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
drop === "failed" || drop === "imagesOnly"
? "border-red-400 text-red-300"
: "border-sky-400 text-sky-200"
}`}
>
{t(`drop.${drop}`)}
</div>
)}
</div>
);
}
@@ -383,4 +383,74 @@ describe("LaneConsolePane", () => {
expect(screen.queryByText("nested session")).toBeNull();
expect(screen.queryByText("sibling prefix")).toBeNull();
});
describe("split panes on one lane", () => {
const run = (id: string, promptPreview = id) => ({
id,
laneId: 1,
status: "running" as const,
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: "2026-09-24T01:00:00Z",
promptPreview,
tool: "claude" as const,
});
it("attaches a pane only to its own slot's session", async () => {
const activeRuns = { items: [run("agenthub-lane-1"), run("agenthub-lane-1-2")] };
render(<LaneConsolePane {...baseProps()} activeRuns={activeRuns} slot={2} />);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
"data-run-id",
"agenthub-lane-1-2"
)
);
});
it("starts into its own slot and shows the session the server opened", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
lane: { ...LANE, run_id: "agenthub-lane-1" },
run_id: "agenthub-lane-1-1",
});
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue(run("agenthub-lane-1-1"));
render(<LaneConsolePane {...baseProps()} slot={1} />);
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({ slot: 1 })
)
);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
"data-run-id",
"agenthub-lane-1-1"
)
);
});
it("leaves runs other panes show out of Active runs", () => {
render(
<MemoryRouter>
<LaneConsolePane
{...baseProps()}
slot={1}
activeRuns={{
items: [run("agenthub-lane-1", "shown"), run("agenthub-lane-1-3", "free")],
}}
hiddenRunIds={["agenthub-lane-1"]}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByRole("button", { name: /1 active run/ }));
expect(screen.getByText("free")).toBeTruthy();
expect(screen.queryByText("shown")).toBeNull();
});
});
});
@@ -8,7 +8,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup, act } from "@testing-library/react";
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
import { TerminalView } from "../TerminalView";
const writeMock = vi.fn();
@@ -18,6 +18,11 @@ const disposeMock = vi.fn();
const wheelHandlerMock = vi.fn();
const keyHandlerMock = vi.fn();
const focusMock = vi.fn();
const pasteMock = vi.fn();
const dropImageMock = vi.fn();
vi.mock("../../../lib/api", () => ({
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
}));
const loadAddonMock = vi.fn();
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
const termState = {
@@ -61,6 +66,7 @@ vi.mock("@xterm/xterm", () => ({
attachCustomWheelEventHandler: wheelHandlerMock,
attachCustomKeyEventHandler: keyHandlerMock,
focus: focusMock,
paste: pasteMock,
})),
}));
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
@@ -314,4 +320,31 @@ describe("TerminalView", () => {
expect(MockWebSocket.instances).toHaveLength(1);
vi.useRealTimers();
});
it("pastes the server-side path of a dropped image into the pane", async () => {
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.png" });
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
const target = screen.getByTestId("terminal-view");
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop image to attach");
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
expect(dropImageMock).toHaveBeenCalledWith(img);
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
});
it("uploads nothing for a dropped non-image and says why", async () => {
dropImageMock.mockReset();
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { types: ["Files"], files: [txt] },
});
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
expect(dropImageMock).not.toHaveBeenCalled();
expect(pasteMock).not.toHaveBeenCalled();
});
});
+20 -4
View File
@@ -108,10 +108,13 @@
"changes.refresh": "Refresh",
"changes.resize": "Resize the changes panel (next: {{size}})",
"changes.size.compact": "Narrow",
"changes.size.half": "50 / 50",
"changes.size.wide": "70 / 30",
"changes.size.full": "Full width",
"changes.size.half": "Medium",
"changes.size.full": "Full screen",
"changes.stageAll": "Stage all ({{count}})",
"changes.unstageAll": "Unstage all ({{count}})",
"changes.dropEdits": "Discard your unsaved edits to this file?",
"changes.stagedGroup": "Staged Changes",
"changes.unstagedGroup": "Changes",
"changes.stage": "Stage this file",
"changes.title": "Changes",
"changes.truncated": "Some changes were left out to keep this panel responsive.",
@@ -237,5 +240,18 @@
"worktrees.heading_other": "{{count}} worktrees",
"changes.stageHunk": "Stage this hunk",
"changes.undoHunk": "Undo this hunk",
"changes.undoHunkConfirm": "Click again to undo this hunk — this cannot be undone"
"changes.undoHunkConfirm": "Click again to undo this hunk — this cannot be undone",
"changes.stageHunkShort": "Keep",
"changes.undoHunkShort": "Undo",
"changes.undoHunkShortConfirm": "Confirm undo",
"changes.edit": "Edit",
"changes.save": "Save",
"changes.cancelEdit": "Cancel",
"changes.editorLabel": "Edit {{path}}",
"changes.prevHunk": "Previous change",
"changes.nextHunk": "Next change",
"changes.hunkOf": "{{n}} of {{total}}",
"changes.noEditBinary": "Binary file — not editable here",
"changes.noEditTooBig": "Too large to edit here (over 1 MB)",
"changes.noEdit": "Not editable here"
}
+6
View File
@@ -164,5 +164,11 @@
"input": "Input",
"label": "Context",
"output": "Output"
},
"drop": {
"hint": "Drop image to attach",
"uploading": "Uploading image…",
"failed": "Could not attach the image",
"imagesOnly": "Only images can be dropped here"
}
}
+19 -3
View File
@@ -108,10 +108,13 @@
"changes.refresh": "Tải lại",
"changes.resize": "Đổi độ rộng khung changes (kế tiếp: {{size}})",
"changes.size.compact": "Hẹp",
"changes.size.half": "50 / 50",
"changes.size.wide": "70 / 30",
"changes.size.half": "Vừa",
"changes.size.full": "Toàn màn hình",
"changes.stageAll": "Stage tất cả ({{count}})",
"changes.unstageAll": "Bỏ stage tất cả ({{count}})",
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
"changes.stagedGroup": "Đã stage",
"changes.unstagedGroup": "Thay đổi",
"changes.stage": "Đưa file này vào index",
"changes.title": "Thay đổi",
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
@@ -237,5 +240,18 @@
"worktrees.heading_other": "{{count}} worktree",
"changes.stageHunk": "Stage đoạn này",
"changes.undoHunk": "Hoàn tác đoạn này",
"changes.undoHunkConfirm": "Bấm lần nữa để hoàn tác đoạn này — không thể khôi phục"
"changes.undoHunkConfirm": "Bấm lần nữa để hoàn tác đoạn này — không thể khôi phục",
"changes.stageHunkShort": "Giữ",
"changes.undoHunkShort": "Hoàn tác",
"changes.undoHunkShortConfirm": "Xác nhận",
"changes.edit": "Sửa",
"changes.save": "Lưu",
"changes.cancelEdit": "Huỷ",
"changes.editorLabel": "Sửa {{path}}",
"changes.prevHunk": "Thay đổi trước",
"changes.nextHunk": "Thay đổi sau",
"changes.hunkOf": "{{n}} / {{total}}",
"changes.noEditBinary": "File nhị phân — không sửa được ở đây",
"changes.noEditTooBig": "File quá lớn để sửa ở đây (trên 1 MB)",
"changes.noEdit": "Không sửa được ở đây"
}
+6
View File
@@ -163,5 +163,11 @@
"input": "Input",
"label": "Context",
"output": "Output"
},
"drop": {
"hint": "Thả ảnh để đính kèm",
"uploading": "Đang tải ảnh lên…",
"failed": "Không đính kèm được ảnh",
"imagesOnly": "Chỉ thả được ảnh vào đây"
}
}
+23 -1
View File
@@ -1982,6 +1982,14 @@ export const api = {
if (q) qs.set("q", q);
return request<{ items: string[] }>(`/run/files?${qs.toString()}`);
},
/** POST /api/run/drop-image - save an image dropped on the terminal on the
* server's machine; the returned path is what gets pasted into the pane. */
dropImage: (file: Blob) =>
request<{ path: string }>("/run/drop-image", {
method: "POST",
body: file,
headers: { "Content-Type": file.type },
}),
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
start: (args: RunStartArgs) =>
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
@@ -2447,6 +2455,16 @@ export const api = {
request<LaneFileContent>(
`/lanes/${id}/git/file?path=${encodeURIComponent(path)}&rev=${encodeURIComponent(rev)}`
),
/**
* PUT /api/lanes/:id/git/file — save the editor's text over the working
* file. `baseSha` is the `sha` it was opened at; a file that changed on
* disk since answers 409 ESTALE rather than being overwritten.
*/
gitSaveFile: (id: number, path: string, content: string, baseSha: string) =>
request<LaneFileContent>(`/lanes/${id}/git/file`, {
method: "PUT",
body: JSON.stringify({ path, content, baseSha }),
}),
/**
* GET /api/lanes/:id/git/ls — tracked paths matching a substring, for
* quick-open. Ignored files never appear (it reads `git ls-files`).
@@ -2611,7 +2629,9 @@ export const api = {
* @returns `{ lane, ok }` — the updated lane (if applicable) or a boolean result.
*/
action: (id: number, action: string, body: Record<string, unknown> = {}) =>
request<{ lane?: Lane; ok?: boolean }>(`/lanes/${id}/${action}`, {
// `run_id` answers a `start`: the session it opened, which for a split
// pane's slot (`body.slot` 1–3) is not the lane's own `run_id`.
request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/lanes/${id}/${action}`, {
method: "POST",
body: JSON.stringify(body),
}),
@@ -3604,6 +3624,8 @@ export interface RunStartArgs {
export interface RunHandle {
id: string;
laneId: number | null;
/** Which Workspace split pane's session this is: 0 is the lane's own. */
slot?: number | null;
status: RunStatus;
cwd: string | null;
model: string | null;
+7
View File
@@ -2651,6 +2651,9 @@ export type LaneFileContent =
binary: boolean;
truncated: boolean;
lines: string[];
/** Version stamp of a whole, textual `work` file — what a save sends
* back as `baseSha`. Absent at a commit, or when binary/truncated. */
sha?: string;
}
| { available: false };
@@ -2660,6 +2663,10 @@ export interface LaneTreeEntry {
/** Path relative to the repo root. */
path: string;
dir: boolean;
/** A changed file's porcelain code (` M`, `??`, `A `, ` D`…); absent when clean. */
status?: string;
/** A folder with a changed or untracked file anywhere under it. */
changed?: true;
}
/** One row of a rendered diff — both gutters already resolved server-side. */
+44 -28
View File
@@ -40,7 +40,7 @@
*
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
@@ -51,7 +51,6 @@ import {
Pencil,
Layers,
PanelLeftClose,
ChevronsLeftRight,
PanelRightClose,
Maximize2,
} from "lucide-react";
@@ -89,24 +88,22 @@ const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
/** The three widths the Changes panel cycles through, then closed. Reading a
* diff and watching a console want opposite amounts of room, and which one is
* "work" changes several times an hour — hence ONE control that steps
* narrow → 50/50 → 70/30 → full → closed, rather than a width button beside a
* 30/70 → 50/50 → full → closed, rather than a width button beside a
* collapse button that both live in the panel's tightest corner. */
const CHANGES_SIZES = ["compact", "half", "wide", "full"] as const;
const CHANGES_SIZES = ["compact", "half", "full"] as const;
type ChangesSize = (typeof CHANGES_SIZES)[number];
const CHANGES_WIDTH: Record<ChangesSize, string> = {
compact: "w-[28rem] xl:w-[34rem]",
compact: "w-[30%]",
half: "w-1/2",
wide: "w-[70%]",
// The console beside it is HIDDEN, not unmounted: a live run's terminal
// and a half-typed prompt must survive a round trip through full width.
full: "w-full",
};
/** The icon the one resize control shows AT each size: what the next click
* gives you — an even split, a wider panel, then closed. */
* gives you — an even split, the full width, then closed. */
const CHANGES_NEXT_ICON: Record<ChangesSize, typeof Columns2> = {
compact: Columns2,
half: ChevronsLeftRight,
wide: Maximize2,
half: Maximize2,
full: PanelRightClose,
};
@@ -163,7 +160,6 @@ function ConsoleArea({
selectedLaneId,
splitView,
setLayout,
setPaneLaneId,
binaryStatus,
cwdSuggestions,
activeRuns,
@@ -179,7 +175,6 @@ function ConsoleArea({
selectedLaneId: number | null;
splitView: SplitViewState;
setLayout: (layout: SplitLayout) => void;
setPaneLaneId: (index: number, id: number) => void;
binaryStatus: Record<LaneTool, { found: boolean; path: string | null }> | null;
cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null;
@@ -191,6 +186,30 @@ function ConsoleArea({
onLaneIdChange: (id: number | null) => void;
showToggle?: boolean;
}) {
// Every split pane shows the SELECTED lane, each in its own tmux session
// (slot = pane index). Tracking what each pane shows lets the others leave
// that run out of their Active runs, so no terminal is attached twice.
const [paneRunIds, setPaneRunIds] = useState<(string | null)[]>([]);
const onPaneHandle = useCallback((i: number, id: string | null) => {
setPaneRunIds((prev) => {
if (prev[i] === id) return prev;
const next = [...prev];
next[i] = id;
return next;
});
}, []);
const paneHandlers = useMemo(
() => [0, 1, 2, 3].map((i) => (id: string | null) => onPaneHandle(i, id)),
[onPaneHandle]
);
const hiddenFor = useMemo(
() =>
[0, 1, 2, 3].map((i) =>
paneRunIds.filter((id, j): id is string => j !== i && j < splitView.layout && !!id)
),
[paneRunIds, splitView.layout]
);
return (
<>
{showToggle && <SplitLayoutToggle splitView={splitView} setLayout={setLayout} />}
@@ -198,6 +217,7 @@ function ConsoleArea({
<LaneConsolePane
lanes={lanes}
laneId={selectedLaneId}
onHandleChange={paneHandlers[0]}
showLaneSelector={false}
onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated}
@@ -215,13 +235,16 @@ function ConsoleArea({
splitView.layout === 2 ? "grid-cols-2" : "grid-cols-2 grid-rows-2"
}`}
>
{splitView.paneLaneIds.map((id, i) => (
{Array.from({ length: splitView.layout }, (_, i) => (
<LaneConsolePane
key={i}
lanes={lanes}
laneId={id}
showLaneSelector
onLaneIdChange={(newId) => setPaneLaneId(i, newId)}
laneId={selectedLaneId}
slot={i}
hiddenRunIds={hiddenFor[i]}
onHandleChange={paneHandlers[i]}
showLaneSelector={false}
onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
@@ -335,16 +358,6 @@ export function Workspace() {
});
}, []);
const setPaneLaneId = useCallback((index: number, id: number) => {
setSplitView((prev) => {
const paneLaneIds = [...prev.paneLaneIds];
paneLaneIds[index] = id;
const next = { ...prev, paneLaneIds };
writeSplitViewState(next);
return next;
});
}, []);
// Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
const refreshLanes = useCallback(async () => {
try {
@@ -570,8 +583,11 @@ export function Workspace() {
// Viewport locked only when a live run is showing (TerminalView needs locked
// viewport for chat scrolling). The config-card screen needs normal page flow
// so the form is fully reachable on short windows.
const viewportLocked = paneHasActiveRun;
// so the form is fully reachable on short windows. A full-width Changes panel
// hides that form anyway, and without a locked height it shrinks to its
// content — the editor inside it collapsed to a few lines (no live run).
const viewportLocked =
paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full");
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
setLaneActionError(null);
@@ -960,7 +976,6 @@ export function Workspace() {
selectedLaneId={selectedLaneId}
splitView={splitView}
setLayout={setLayout}
setPaneLaneId={setPaneLaneId}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
@@ -1020,6 +1035,7 @@ export function Workspace() {
</div>
<LaneChangesPanel
laneId={currentLane.id}
full={changesSize === "full"}
// A sibling worktree forks from the lane's SOURCE repo when
// it has one (this lane is itself a worktree of it); an
// adopted lane is its own repo.
@@ -1,7 +1,7 @@
/**
* @file Workspace.changesPanel.test.tsx
* @description The Changes panel is ONE control: it steps narrow → 50/50 →
* 70/30 → closed, and the rail reopens it narrow. The width survives a reload.
* @description The Changes panel is ONE control: it steps 30/70 → 50/50 →
* full → closed, and the rail reopens it at 30/70. The width survives a reload.
* All of it lives in localStorage and reaches no server — the same rule as the
* lane strip's order.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -75,6 +75,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
}),
pipelines: r({ pipelines: [] }),
git: r({ available: false }),
gitTree: r({ entries: [] }),
runtime: r({ up: false, ports: {}, lastError: null }),
features: { list: r({ features: [] }), show: r({ feature: null }) },
proof: { list: r({ features: [] }), imageUrl: () => "" },
@@ -142,31 +143,28 @@ describe("Workspace — the Changes panel's width", () => {
}
}
it("steps narrow → 50/50 → 70/30 → full → closed on one control", async () => {
it("steps 30/70 → 50/50 → full → closed on one control, with no 70/30 step", async () => {
await openPanel();
const card = () => screen.getByTestId("lane-changes-card");
expect(card().className).toContain("w-[28rem]");
expect(card().className).toContain("w-[30%]");
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(card().className).toContain("w-1/2");
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(card().className).toContain("w-[70%]");
// Full width HIDES the console beside it — hidden, still mounted.
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(card().className).toContain("w-full");
expect(screen.getByTestId("lane-detail").className).toContain("hidden");
// Past the widest step the panel closes — and reopens narrow, not back at
// 70/30, which is why closing resets the width.
// Past the widest step the panel closes — and reopens at 30/70, not back
// at full width, which is why closing resets the width.
fireEvent.click(screen.getByTestId("lane-changes-resize"));
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
fireEvent.click(screen.getByTestId("lane-changes-expand"));
await settle();
expect(card().className).toContain("w-[28rem]");
expect(card().className).toContain("w-[30%]");
});
it("remembers the width across a reload, and never asks the server", async () => {
+9 -17
View File
@@ -652,46 +652,38 @@ describe("split terminal view", () => {
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
});
it("switching to 2-pane layout renders two independent panes with lane pickers", async () => {
it("switching to 2-pane layout renders two panes bound to the selected lane", async () => {
await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
await settle();
expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(2);
expect(screen.getAllByTestId("pane-lane-select")).toHaveLength(2);
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
});
it("switching to 4-pane layout renders four panes", async () => {
await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /4.*pane/i }));
await settle();
expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(4);
expect(screen.getAllByTestId("console-body")).toHaveLength(4);
});
it("persists the layout and pane selections to localStorage across remounts", async () => {
it("persists the layout to localStorage across remounts", async () => {
const { unmount } = await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
await settle();
const selects = screen.getAllByTestId("pane-lane-select");
const select = selects[1];
expect(select).toBeDefined();
fireEvent.change(select!, { target: { value: String(lanesToReturn[1]!.id) } });
await settle();
unmount();
await renderWorkspace();
const persistedSelects = screen.getAllByTestId("pane-lane-select");
expect(persistedSelects).toHaveLength(2);
expect((persistedSelects[1] as HTMLSelectElement).value).toBe(String(lanesToReturn[1]!.id));
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
});
it("falls back to unselected when a persisted lane id no longer exists", async () => {
it("ignores a persisted per-pane lane id: every pane follows the selected lane", async () => {
localStorage.setItem(
"agenthub.workspace.splitView",
JSON.stringify({ layout: 2, paneLaneIds: [9999, null] })
);
await renderWorkspace();
// Lane 9999 doesn't exist, so it falls back to null (unselected).
// The second pane is already null. Both render as pane-empty.
expect(screen.getAllByTestId("pane-empty")).toHaveLength(2);
expect(screen.queryAllByTestId("pane-empty")).toHaveLength(0);
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
});
});
@@ -10364,7 +10364,7 @@ exports[`screen snapshots > Settings 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v1.8.6 (rebuild client to match)
UI build v1.8.8 (rebuild client to match)
</p>
</div>
<div
+19 -5
View File
@@ -261,6 +261,8 @@ All fields are optional. The tool (CLI invoked) comes from the lane itself, neve
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex lanes specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude lanes, `resumeSessionId` re-attaches a Claude session by id.
- **`slot`** — which Workspace split pane's session to start, `0`–`3` (default `0`, else `400 EBADSLOT`). Slot 0 is the lane's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same lane, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every run the dashboard starts is a tmux session, so this is a missing dependency rather than a bad request — the message carries the install command, and the same code comes back from `POST /api/run`. Nothing else in the dashboard is affected.
Error `409 ERUNLIVE` is returned if the lane already has a live run; stop it first with the `stop` action. The same code and status come back when the lane's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
@@ -496,6 +498,7 @@ that file.
```http
GET /api/lanes/:id/git/log?limit=30[&file=<path>]
GET /api/lanes/:id/git/file?path=<path>[&rev=work|HEAD|<sha>]
PUT /api/lanes/:id/git/file {path, content, baseSha}
GET /api/lanes/:id/git/ls?q=<substring>
GET /api/lanes/:id/git/tree?path=<dir>
GET /api/lanes/:id/git/tree-all
@@ -510,7 +513,15 @@ and file explorer. All four are read-only and unguarded, and all four answer
commits touching that path — relative, no `..` segment, else
`400 EBADPATH`. Reading one commit's diff is
`/git/changes?range=commit&sha=`.
- **`/git/file`** → `{available, path, rev, binary, truncated, lines}`.
- **`PUT /git/file`** saves the whole-file editor (same-origin guarded;
`server/lib/lane-file-write.js`). Same path guards as the read, plus: the
file must already exist, be text and within the 1 MB cap, and `baseSha`
must equal the `sha` it was opened at — otherwise `409 ESTALE`, never an
overwrite. `403 ESECRET` for `settings.local.json`; `400 EBADPATH`,
`ENOFILE`, `EBADCONTENT`, `ETOOBIG`, `EBINARY`. Answers the file re-read,
fresh `sha` included. CRLF endings and the final newline are preserved.
- **`/git/file`** → `{available, path, rev, binary, truncated, lines, sha?}`
(`sha` only for a whole, textual `work` file: what a save sends back).
`rev=work` (default) is the file on disk — the one branch that reads the
filesystem, and it is realpath-confined to the lane directory (a symlink
resolving outside it is `EBADPATH`); anything else goes through
@@ -530,10 +541,12 @@ and file explorer. All four are read-only and unguarded, and all four answer
- **`/git/ls`** → `{available, paths, truncated, total}`, capped at 500. It
reads `git ls-files`, so ignored files never appear and no skip-list has to
be maintained; basename hits rank first.
- **`/git/tree`** → `{available, path, entries:[{name, path, dir}], truncated}`
— one directory level, tracked entries only, directories folded and listed
first. `400 EBADPATH` for a path that escapes the lane.
- **`/git/tree-all`** → `{available, levels: {"<dir>": [{name, path, dir}]}, truncated}` — every level of the tracked tree from ONE `git ls-files`, each level shaped and capped like `/git/tree`'s; the Files tab's **Expand all folders** is this one request instead of one per folder.
- **`/git/tree`** → `{available, path, entries:[{name, path, dir, status?, changed?}], truncated}`
— one directory level: tracked files plus untracked ones git does not
ignore (`ls-files --cached --others --exclude-standard`), directories folded
and listed first. A changed file carries its porcelain `status` (` M`, `??`,
`A `, ` D`…), a folder with anything changed under it `changed: true`. `400 EBADPATH` for a path that escapes the lane.
- **`/git/tree-all`** → `{available, levels: {"<dir>": [{name, path, dir}]}, truncated}` — every level of the same tree (entries decorated the same way) from ONE `git ls-files`, each level shaped and capped like `/git/tree`'s; the Files tab's **Expand all folders** is this one request instead of one per folder.
#### Read a lane's own branches
@@ -1981,6 +1994,7 @@ GET /api/run/binary?tool= { found, path } for the `claude` or `codex
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
GET /api/run/history?limit=&laneId= Persisted run history; laneId narrows to one lane's runs
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <tmpdir>/agenthub-drops-<uid>/ with a random name, pruned after a day; the terminal pastes the path
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt? }
GET /api/run/:id Run handle (returns live run state)
+3 -1
View File
@@ -389,7 +389,9 @@ CREATE TABLE notifications (
### model_pricing
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD).
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD). The longest matching pattern wins.
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes. Any Claude model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` suffix) has no `<family>%` row of its own gets one, INSERTED from LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub). The list is fetched only when something is missing and cached for 24 h. Existing rows are never updated, so a rate edited in Settings always wins. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
```sql
CREATE TABLE model_pricing (
+39 -13
View File
@@ -421,14 +421,14 @@ The dashboard web UI merges lanes and runs into a single **Workspace** page acce
- **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment.
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first, and when the pane's process ends — a double Ctrl+C out of `claude`, `exit`, a crash — the console returns to the run setup form on its own, with no "New run" click.
- **Clipboard in a pane** — the lane's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads.
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row.
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). EVERY pane is bound to the lane strip's selection — there is no per-pane lane picker — and each pane owns its own tmux session on that lane: pane 0 is the lane's `agenthub-lane-<id>` (its `run_id`), panes 1–3 are `agenthub-lane-<id>-<slot>` (`POST /api/lanes/:id/start` with `slot`). A pane's **Active runs** leaves out the runs the other panes are showing, so one terminal is never attached twice side by side. Only the layout persists to `localStorage` (`agenthub.workspace.splitView`); a stored per-pane lane id from an older build is ignored.
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the lane strip tile only — the detail card no longer repeats it as a banner.
Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`.
**A console pane follows the lane it shows.** Selecting another lane — from the lane strip in layout 1, or from a pane's own lane picker in layouts 2 and 4 — swaps that pane's cwd, run history and terminal over to the new lane. If the new lane already has a live run in `GET /api/run`, the pane re-attaches to it immediately, so each lane sticks to its own `agenthub-lane-<id>` tmux session; if it has none, the pane shows that lane's setup form. Nothing of the previous lane (a half-typed prompt, its terminal) carries over.
**A console pane follows the lane it shows.** Selecting another lane on the lane strip swaps every pane's cwd, run history and terminal over to the new lane. If the new lane already has a live session for that pane's slot in `GET /api/run`, the pane re-attaches to it immediately, so each pane sticks to its own `agenthub-lane-<id>[-<slot>]` tmux session; if it has none, the pane shows that lane's setup form. Nothing of the previous lane (a half-typed prompt, its terminal) carries over.
The **working directory** field tracks the selected lane's own `cwd` specifically, and re-syncs as soon as that path is known rather than only when the selection changes — a pane can render before `GET /api/lanes` has answered (split view restores its pane lanes from `localStorage`), and its lane id never changes afterwards. `RunSetup` submits that string verbatim to `POST /api/lanes/:id/start`, so a cwd left over from the previous lane or from the home default would start the run in the wrong folder. The home suggestion is used only while no lane is selected at all.
The **working directory** field tracks the selected lane's own `cwd` specifically, and re-syncs as soon as that path is known rather than only when the selection changes — a pane can render before `GET /api/lanes` has answered (the lane list loads after the page mounts), and its lane id never changes afterwards. `RunSetup` submits that string verbatim to `POST /api/lanes/:id/start`, so a cwd left over from the previous lane or from the home default would start the run in the wrong folder. The home suggestion is used only while no lane is selected at all.
**Picking a session to resume starts it right away.** In the setup form's Fresh/Resume switch, choosing a session from the resume picker fires the start immediately — `POST /api/lanes/:id/start` with that `resumeSessionId` and the session's own `cwd` — and the pane switches straight to the terminal. A resume carries its own transcript, so there is nothing to type first: the prompt box stays optional for resumes (the Run button no longer requires it) and required for fresh runs. A resume also **sends no prompt at all** — whatever is left in that box is dropped rather than fired as an extra turn into the session being continued. Type into the tmux terminal once it's attached.
@@ -575,9 +575,15 @@ says more than any sentence the dashboard could write.
To the right of the lane detail sits a collapsible **Changes** card (its state
persists per viewer in `localStorage`). Its header holds **one** control, which
steps narrow → 50/50 → 70/30 → full width (the console beside it is hidden, never unmounted) → closed (`agenthub.workspace.changesSize`
steps **Narrow** (30%) → **Medium** (50/50) → **Full screen** (the console beside it is hidden, never unmounted; the page is
height-locked so the panel fills the viewport even with no live run; the whole
Files view (finder, expand-all, tree) becomes a left sidebar and the Files tab
is dropped, and every working-copy file opened there, from the sidebar, Overview or Review, IS
the editor (Whole file toggles back to its diff; a commit still opens as a diff) — no
Edit click, **Save**/**Cancel** stay and wake up once the text differs, Cancel
restores the saved text) → closed (`agenthub.workspace.changesSize`
alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it
narrow. Reading a diff and watching a console want opposite amounts of room and
narrow. A stored size from an older build that no longer exists (`wide`) falls back to narrow. Reading a diff and watching a console want opposite amounts of room and
which one is "work" changes several times an hour, so the width is a step, not
a setting — and one button beats a width button wedged beside a collapse button
in the panel's tightest corner. It is icon-only, and the icon it draws is the
@@ -591,7 +597,18 @@ only one that answers "show me the code" rather than "show me the change"):
them — a folder's children render nested under it, siblings stay visible,
and a toolbar button expands or collapses every folder at once (each
unfetched level lazily calls `/git/tree` the first time it's opened).
Opening a file entry shows the whole file (`/git/file`).
Untracked files git does not ignore are listed too, and the tree is
decorated like VS Code's: a modified file is amber with `M`, an untracked
one green with `U` (`A` added, `D` deleted and struck through, `R`
renamed), and a folder holding any of them gets an amber dot. The colours
come with the tree read, so **Refresh** re-reads them.
Opening a file entry shows the whole file (`/git/file`). Its **Edit** button
(any range but a commit) turns that view into a text editor: **Save** (or
Ctrl/Cmd+S) is `PUT /api/lanes/:id/git/file`, which writes only over the
exact version that was opened — a file the agent changed meanwhile answers
`409 ESTALE` and the draft stays for a retry — and keeps the file's CRLF
line endings and final newline. A file the editor cannot take (binary, or
over the 1 MB view cap) says so where the Edit button would be.
- **Overview** — every changed file with its `+N −M`. Polls every 15s, because
it is the cheap shape and the one you leave open while an agent works. Each
row carries its own **stage** / **unstage** / **discard** buttons.
@@ -603,9 +620,14 @@ only one that answers "show me the code" rather than "show me the change"):
a file that appears later is open by default; a lane switch clears it.
Each file's header stays **pinned** at the top while its diff scrolls past,
and carries the same stage / unstage / discard buttons as its overview row.
On a file with unstaged changes every `@@` row adds **Stage** and **Undo**
for that hunk alone (`stage-hunk` / `discard-hunk`); Undo asks twice, like
discard. The single-file view offers the same hunk buttons.
On a file with unstaged changes every hunk gets an editor-style **review
bar**, hung just under its last changed line and pinned to the visible
right edge: `^`/`v` to walk the hunks (each brings the next/previous `@@`
line to the top of the ONE scroller, below the sticky file header — a
review diff has no scroll box of its own unless it is large enough to
window, above 500 rows), `n of N`, **Undo**
(`discard-hunk` — asks twice, like discard) and **Keep** (`stage-hunk`).
The single-file view offers the same bar.
- **One file** — clicking a file's path in any list opens just that file.
**All files** returns to the list it was opened from — the same mode, at the
same scroll offset — because reading a change-set is a walk down a long list
@@ -644,14 +666,18 @@ in every panel narrow enough to sit beside a console.
**Per-file writes are the panel's, per-repo writes are the git bar's.** Staging
one file means nothing unless a commit can take the index as it stands, so the
overview grows a commit box the moment anything is staged — above the list and
pinned while it scrolls, beside a **Stage all** that stages every listed file
the index lacks in one call — it sends
pinned while it scrolls, on ONE row with a **Stage all** that stages every listed file
the index lacks in one call and an **Unstage all** that unstages every staged one — it sends
`only: "staged"`, never `add -A`, and it is absent with a clean index because
there is nothing it could mean. It is the dashboard's ONLY commit: the git bar
above it has none, so no click can sweep the rest of the tree in with `add -A`. **Discard** is the one
irreversible action in the panel: it asks twice in the UI, and the server
refuses it without `confirm: true` and accepts only paths `git status` is
already reporting as changed.
already reporting as changed. In the `work` range the overview splits like VS
Code's Source Control: **Staged Changes** (what the next commit takes, each row
offering Unstage) above **Changes** (unstaged and untracked, each offering
Stage); a partly staged file appears in both. In the tree, a changed file's
whole row takes its status tint, not only its letter.
Review, single-file, history and browse do **not** poll: re-fetching under the
reader's scroll position is worse than a slightly stale view, and those are the
+1 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 1.8.6
version: 1.8.8
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "1.8.6",
"version": "1.8.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "1.8.6",
"version": "1.8.8",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "1.8.6",
"version": "1.8.8",
"bin": {
"agenthub": "bin/agenthub.js"
},
@@ -62,8 +62,8 @@
"import-history": "node scripts/import-history.js",
"reconcile-tokens": "node scripts/import-history.js --reconcile-tokens",
"clear-data": "node scripts/clear-data.js",
"test": "node --test server/__tests__/*.test.js && cd client && npm test",
"test:server": "node --test server/__tests__/*.test.js",
"test": "node scripts/test-server.js && cd client && npm test",
"test:server": "node scripts/test-server.js",
"test:client": "cd client && npm test",
"format": "prettier --write .",
"format:check": "prettier --check .",
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env node
/**
* @file test-server.js
* @description Runs the server test suite against a PRIVATE tmux server.
* Several suites start real `agenthub-lane-<id>` tmux sessions and then kill
* or reset them, with lane ids from their own throwaway database — on the
* user's default tmux server those names collide with the dashboard's real
* lane panes, and a test run kills whichever live session shares an id
* (including the pane the test was launched from). Dropping `TMUX` and
* pointing `TMUX_TMPDIR` at a fresh directory gives every tmux call — the
* app's own and the tests' direct `execFileSync("tmux", …)` — its own socket.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { spawnSync } = require("node:child_process");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const root = path.join(__dirname, "..");
const dir = path.join(root, "server", "__tests__");
const files = fs
.readdirSync(dir)
.filter((f) => f.endsWith(".test.js"))
.map((f) => path.join(dir, f));
const args = process.argv.slice(2);
const tmuxDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-test-tmux-"));
const env = { ...process.env, TMUX_TMPDIR: tmuxDir };
delete env.TMUX;
delete env.TMUX_PANE;
const result = spawnSync(process.execPath, ["--test", ...args, ...(args.length ? [] : files)], {
stdio: "inherit",
env,
cwd: root,
});
// Whatever a test leaked dies with the private server, never a real pane.
spawnSync("tmux", ["kill-server"], { env, stdio: "ignore" });
fs.rmSync(tmuxDir, { recursive: true, force: true });
process.exit(result.status ?? 1);
+3 -1
View File
@@ -528,8 +528,9 @@ Request body shape:
| `GET` | `/api/lanes/:id/git/changes?range=work\|branch\|commit[&sha=][&path=][&full=1][&context=]` | The lane's code changes for the Changes panel: a file summary, one file's hunks (`path`), or every file's hunks (`full=1`); `range=commit&sha=` is one commit, `context=` (default 3, max 5000) widens each hunk. Capped at 300 files / 200 KB per file / 1 MB per full read. `400 ENOBASE`, `ENOFILECHANGE`, `EBADRANGE` or `EBADREV`. |
| `GET` | `/api/lanes/:id/git/log?limit=&file=` | The lane's recent commits (newest first, limit max 200), optionally only those touching one path. |
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the lane), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
| `PUT` | `/api/lanes/:id/git/file` | Same-origin guarded save from the whole-file editor: `{path, content, baseSha}`. An existing text file within the 1 MB cap, over exactly the version opened (`baseSha` = the read's `sha`, else `409 ESTALE`); `403 ESECRET`, `400 EBADPATH`/`ENOFILE`/`EBADCONTENT`/`ETOOBIG`/`EBINARY`. |
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked files, folded. `400 EBADPATH` for a path escaping the lane. |
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. `400 EBADPATH` for a path escaping the lane. |
| `GET` | `/api/lanes/:id/git/branches` | The lane repo's own local branches + worktrees, for the branch switcher. The repo comes from the lane row, never the query. |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) and `branch-delete` (`-d`, refuses protected/current/busy branches). No merge/rebase/reset/stash by design. |
@@ -702,6 +703,7 @@ HTTP surface for the tmux-backed terminal run: one session per lane, named `agen
| `GET` | `/api/run/binary[?tool=]` | Probe whether the CLI is on `PATH`. `?tool=codex` probes Codex; omitted or `?tool=claude` probes Claude; anything else is `400 EBADTOOL`. Returns `{ found, path }` |
| `GET` | `/api/run/tmux` | `{ available }` — whether the `tmux` binary exists |
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<tmpdir>/agenthub-drops-<uid>/` with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
| `POST` | `/api/run` | Create-or-adopt the lane's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the lane row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
| `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
+27
View File
@@ -458,6 +458,33 @@ describe("listCommits / readFileAt / listPaths / listTree", () => {
await assert.rejects(() => listTree(LANE, { subdir: "../.." }), { code: "EBADPATH" });
});
it("lists untracked-but-not-ignored files and marks what changed, folders included", async () => {
write(LANE, "src/main.ts", "m\n");
write(LANE, ".gitignore", "*.log\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "src");
write(LANE, "a.txt", "modified\n");
write(LANE, "new.txt", "fresh\n");
write(LANE, "gen/deep/out.txt", "wholly untracked folder\n");
write(LANE, "noise.log", "ignored\n");
const root = await listTree(LANE, {});
const by = Object.fromEntries(root.entries.map((e) => [e.name, e]));
assert.equal(by["a.txt"].status, " M");
assert.equal(by["new.txt"].status, "??");
assert.equal(by["keep.txt"].status, undefined);
assert.equal(by["noise.log"], undefined, "an ignored file stays out of the tree");
assert.equal(by.gen.changed, true);
assert.equal(by.src.changed, undefined);
// Inside a folder git reports only as "gen/": still untracked, still marked.
const deep = await listTree(LANE, { subdir: "gen/deep" });
assert.equal(deep.entries[0].status, "??");
const all = await listTreeAll(LANE);
assert.equal(all.levels["gen"][0].changed, true);
assert.deepEqual(all.levels[""], root.entries);
});
it("reads every level of the tree in one pass, grouped like listTree", async () => {
write(LANE, "src/api/x.ts", "x\n");
write(LANE, "src/main.ts", "m\n");
+6 -11
View File
@@ -848,17 +848,12 @@ describe("destructive lane lifecycle actions", () => {
const runId = started.body.lane.run_id;
// Mock tmux so has-session always returns 0 (session exists), simulating a stuck session
tmux.__setExecImpl(async (cmd, args) => {
if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
return { code: 0, stdout: "", stderr: "" };
}
if (cmd === "tmux" && args[0] === "list-sessions") {
return { code: 0, stdout: "", stderr: "" };
}
if (cmd === "tmux" && args[0] === "kill-session" && args[1] === "-t" && args[2] === runId) {
return { code: 0, stdout: "", stderr: "" };
}
return { code: 1, stdout: "", stderr: "" };
// Same shape as tmux.js's own exec: sync, argv only, throws on failure.
tmux.__setExecImpl((args) => {
if (args[0] === "has-session" && args[2] === runId) return "";
if (args[0] === "list-sessions") return `${runId}\n`;
if (args[0] === "kill-session" && args[2] === runId) return "";
throw Object.assign(new Error("mocked tmux failure"), { status: 1 });
});
try {
+41
View File
@@ -959,6 +959,47 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
assert.ok(tree.body.entries.some((e) => e.name === "a.txt" && e.dir === false));
});
it("saves a file from the editor over the exact bytes it loaded, and nothing else", async () => {
const opened = await request("GET", `/api/lanes/${laneId}/git/file?path=a.txt&rev=work`);
assert.equal(typeof opened.body.sha, "string");
const saved = await request("PUT", `/api/lanes/${laneId}/git/file`, {
path: "a.txt",
content: "one\nTWO\nthree",
baseSha: opened.body.sha,
});
assert.equal(saved.status, 200, JSON.stringify(saved.body));
// The final newline the file had survives an editor that drops it.
assert.equal(fs.readFileSync(path.join(dir, "a.txt"), "utf8"), "one\nTWO\nthree\n");
assert.notEqual(saved.body.sha, opened.body.sha);
// Saving over the OLD stamp is a conflict, never a silent overwrite.
const stale = await request("PUT", `/api/lanes/${laneId}/git/file`, {
path: "a.txt",
content: "clobbered",
baseSha: opened.body.sha,
});
assert.equal(stale.status, 409);
assert.equal(stale.body.error.code, "ESTALE");
assert.equal(fs.readFileSync(path.join(dir, "a.txt"), "utf8"), "one\nTWO\nthree\n");
const escape = await request("PUT", `/api/lanes/${laneId}/git/file`, {
path: "../../etc/passwd",
content: "x",
baseSha: "0".repeat(40),
});
assert.equal(escape.status, 400);
assert.equal(escape.body.error.code, "EBADPATH");
const secret = await request("PUT", `/api/lanes/${laneId}/git/file`, {
path: ".claude/settings.local.json",
content: "{}",
baseSha: "0".repeat(40),
});
assert.equal(secret.status, 403);
assert.equal(secret.body.error.code, "ESECRET");
});
it("stages one file, commits only the index, and refuses an unchanged path", async () => {
fs.writeFileSync(path.join(dir, "a.txt"), "one\nTHREE\n");
fs.writeFileSync(path.join(dir, "b.txt"), "left behind\n");
+39
View File
@@ -324,3 +324,42 @@ describe("calculateCost — date-effective (intro) pricing", () => {
);
});
});
describe("DEFAULT_PRICING — Opus 5.5", () => {
it("prices claude-opus-5-5 at its own rates, not Opus 5's", () => {
const os = require("os");
const path = require("path");
process.env.DASHBOARD_DB_PATH = path.join(
os.tmpdir(),
`pricing-opus55-${Date.now()}-${process.pid}.db`
);
const { DEFAULT_PRICING } = require("../db");
const rules = DEFAULT_PRICING.map(([p, n, i, o, cr, cw, cw1h, fi, fo]) => ({
model_pattern: p,
display_name: n,
input_per_mtok: i,
output_per_mtok: o,
cache_read_per_mtok: cr,
cache_write_per_mtok: cw,
cache_write_1h_per_mtok: cw1h,
fast_input_per_mtok: fi,
fast_output_per_mtok: fo,
}));
// A real session: Claude Code's /cost reported $2.72 for exactly these
// counts (all cache writes at the 1h tier); Opus 5's rates gave $4.86.
const r = calculateCost(
[
bucket({
model: "claude-opus-5-5",
input_tokens: 102,
output_tokens: 25_408,
cache_read_tokens: 5_866_977,
cache_write_tokens: 129_432,
cache_write_1h_tokens: 129_432,
}),
],
rules
);
assert.equal(r.total_cost.toFixed(2), "2.72");
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* @file Tests for the automatic pricing sync: a model seen in token_usage with
* no row of its own gets one from the price list, an edited row is never
* overwritten, and a bad list or a failed fetch changes nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
process.env.DASHBOARD_DB_PATH = path.join(
os.tmpdir(),
`pricing-sync-${Date.now()}-${process.pid}.db`
);
const { db } = require("../db");
const { syncPricing, familyOf, displayName, _resetCache } = require("../lib/pricing-sync");
// The real LiteLLM entry for Opus 5.5, trimmed.
const LIST = {
"claude-opus-9-9": {
litellm_provider: "anthropic",
input_cost_per_token: 0.000004,
output_cost_per_token: 0.00002,
cache_read_input_token_cost: 2e-7,
cache_creation_input_token_cost: 0.000005,
cache_creation_input_token_cost_above_1hr: 0.000008,
supports_fast_mode: true,
provider_specific_entry: { fast: 2 },
},
};
function useModel(model) {
db.prepare("INSERT OR IGNORE INTO sessions (id, status, started_at) VALUES (?, 'active', ?)").run(
`s-${model}`,
new Date().toISOString()
);
db.prepare("INSERT INTO token_usage (session_id, model) VALUES (?, ?)").run(`s-${model}`, model);
}
const row = (p) => db.prepare("SELECT * FROM model_pricing WHERE model_pattern = ?").get(p);
beforeEach(() => {
_resetCache();
db.prepare("DELETE FROM token_usage").run();
db.prepare("DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-opus-9%'").run();
});
describe("pricing sync", () => {
it("reduces an id to its family and names it", () => {
assert.equal(familyOf("claude-opus-5-5-20260101"), "claude-opus-5-5");
assert.equal(familyOf("gpt-4"), null);
assert.equal(familyOf("claude-x%"), null);
assert.equal(displayName("claude-opus-5-5"), "Claude Opus 5.5");
});
it("adds a row for a used model that only a broader pattern matched", async () => {
db.prepare(
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok) VALUES ('claude-opus-9%', 'Broad', 5)"
).run();
useModel("claude-opus-9-9-20260101");
const added = await syncPricing(db, { fetchList: async () => LIST });
assert.deepEqual(added, ["claude-opus-9-9%"]);
const r = row("claude-opus-9-9%");
assert.equal(r.display_name, "Claude Opus 9.9");
assert.deepEqual([r.input_per_mtok, r.output_per_mtok, r.cache_read_per_mtok], [4, 20, 0.2]);
assert.deepEqual([r.cache_write_per_mtok, r.cache_write_1h_per_mtok], [5, 8]);
assert.deepEqual([r.fast_input_per_mtok, r.fast_output_per_mtok], [8, 40]);
});
it("never overwrites a row that exists, and fetches nothing when none is missing", async () => {
db.prepare(
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok) VALUES ('claude-opus-9-9%', 'Mine', 1)"
).run();
useModel("claude-opus-9-9");
let fetched = false;
await syncPricing(db, {
fetchList: async () => {
fetched = true;
return LIST;
},
});
assert.equal(fetched, false);
assert.equal(row("claude-opus-9-9%").input_per_mtok, 1);
});
it("skips absurd rates and survives a failed fetch", async () => {
useModel("claude-opus-9-9");
const bad = { "claude-opus-9-9": { ...LIST["claude-opus-9-9"], input_cost_per_token: 5 } };
assert.deepEqual(await syncPricing(db, { fetchList: async () => bad }), []);
_resetCache();
const failed = await syncPricing(db, {
fetchList: async () => {
throw new Error("offline");
},
});
assert.deepEqual(failed, []);
assert.equal(row("claude-opus-9-9%"), undefined);
});
});
+2
View File
@@ -55,6 +55,8 @@ describe("pty-attach", () => {
it("accepts a well-formed runId", () => {
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42"));
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42-3"));
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-4"), /EBADRUNID/);
});
it("turns tmux mouse mode on for the session it attaches to", () => {
+20
View File
@@ -58,6 +58,26 @@ describe("pty-run", () => {
assert.ok(newSessionCall.includes("opus"));
});
it("spawnRun names a split pane's session agenthub-lane-<id>-<slot>, and refuses a slot past 3", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("no such session");
e.status = 1;
throw e;
}
return "";
});
const handle = pty.spawnRun({ laneId: 42, slot: 2, cwd: "/tmp/repo" });
assert.equal(handle.id, "agenthub-lane-42-2");
assert.equal(handle.laneId, 42);
assert.equal(handle.slot, 2);
assert.equal(pty.laneIdFromRunId("agenthub-lane-42-2"), 42);
assert.throws(
() => pty.spawnRun({ laneId: 42, slot: 4, cwd: "/tmp/repo" }),
(err) => err.code === "EBADSLOT"
);
});
it("spawnRun refuses with ETMUXMISSING, naming the fix, when tmux is not installed", () => {
// Before this guard the ENOENT escaped from three calls down as
// `spawnSync tmux ENOENT` and the route answered 400 — blaming the request
+3 -1
View File
@@ -79,7 +79,9 @@ const RULES = [
// a Gitea /raw/branch/<ref>/ URL instead.
re: /raw\.githubusercontent\.com\/[\w-]+\/[\w.-]+/g,
fix: "use a same-origin path, or a Gitea /raw/branch/<ref>/ URL",
skip: [],
// A third-party project that really is on GitHub: the public model price
// list server/lib/pricing-sync.js reads.
skip: [/raw\.githubusercontent\.com\/BerriAI\/litellm$/],
},
{
name: "GitHub Pages host for a Smartgift site",
+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);
});
});
+3
View File
@@ -834,6 +834,9 @@ const DEFAULT_PRICING = [
["claude-fable-5%", "Claude Fable 5", 10, 50, 1, 12.5, 20, 0, 0],
["claude-mythos-5%", "Claude Mythos 5", 10, 50, 1, 12.5, 20, 0, 0],
// Opus family (fast mode available on 5 / 4.6 / 4.7 / 4.8)
// 5.5 is cheaper than 5 and "claude-opus-5%" would swallow it — own row
// (longest pattern wins in calculateCost).
["claude-opus-5-5%", "Claude Opus 5.5", 4, 20, 0.2, 5, 8, 8, 40],
["claude-opus-5%", "Claude Opus 5", 5, 25, 0.5, 6.25, 10, 10, 50],
["claude-opus-4-8%", "Claude Opus 4.8", 5, 25, 0.5, 6.25, 10, 10, 50],
["claude-opus-4-7%", "Claude Opus 4.7", 5, 25, 0.5, 6.25, 10, 30, 150],
+3
View File
@@ -325,6 +325,9 @@ function startBackgroundServices() {
// One-time legacy-session backfill (a no-op once its marker file exists).
autoImportLegacySessions();
// Rates for models seen in token_usage with no pricing row of their own.
require("./lib/pricing-sync").startPricingSync(require("./db").db);
// A provisioning job belongs to the previous Node process and cannot survive
// a restart. Recover those rows now so the dashboard never shows a permanent
// provisioning spinner for work that was interrupted by the restart.
+74 -16
View File
@@ -24,6 +24,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
@@ -667,6 +668,7 @@ async function readFileAt(dir, requested, rev = "work") {
let text;
let binary = false;
let sha;
if (rev === "work") {
// Neither `..` nor a symlink may walk out of the lane: resolveInLane
// compares REAL paths, not the string the caller sent.
@@ -695,6 +697,9 @@ async function readFileAt(dir, requested, rev = "work") {
}
binary = looksBinary(buf);
text = binary ? "" : buf.toString("utf8");
// What an editor must send back to save over THIS content and nothing
// newer (lane-file-write.js). Only for a whole, textual file.
if (!binary && buf.length <= MAX_FILE_VIEW_BYTES) sha = contentSha(buf);
} else {
try {
// `./`: resolve from the lane's cwd, not the repo root (a bare
@@ -715,7 +720,12 @@ async function readFileAt(dir, requested, rev = "work") {
}
const lines = text ? text.split("\n") : [];
if (lines[lines.length - 1] === "") lines.pop();
return { path: file, rev, binary, truncated, lines };
return { path: file, rev, binary, truncated, lines, ...(sha ? { sha } : {}) };
}
/** The version stamp of a file's bytes, for a stale-save check. */
function contentSha(buf) {
return crypto.createHash("sha1").update(buf).digest("hex");
}
/**
@@ -753,18 +763,67 @@ async function lsFiles(dir, args) {
return { all: all.filter(Boolean), cut };
}
/** What the explorer lists: tracked files plus untracked ones git does not
* ignore — the new file an agent just wrote is the one you want to open. */
const TREE_LS = ["--cached", "--others", "--exclude-standard"];
/**
* One directory level of the working tree, for the file explorer. Tracked
* entries only (same `.gitignore` reasoning as `listPaths`), folded into
* A lane's working-copy status for decorating the explorer, from ONE
* `laneStatus`: `fileStatus(path)` is the porcelain code of a changed file
* (untracked inside a folded untracked dir included), `dirChanged(path)` is
* whether anything under a folder is.
*/
async function treeStatus(dir) {
const codes = new Map();
const folded = []; // `normal` reports a wholly untracked folder as "dir/"
const dirs = new Set();
for (const { code, path: p } of await laneStatus(dir).catch(() => [])) {
if (p.endsWith("/")) folded.push(p);
else codes.set(p, code);
const parts = p.replace(/\/$/, "").split("/");
for (let i = 1; i <= parts.length - (p.endsWith("/") ? 0 : 1); i++) {
dirs.add(parts.slice(0, i).join("/"));
}
}
const inFolded = (p) => folded.some((f) => p.startsWith(f));
return {
fileStatus: (p) => codes.get(p) ?? (inFolded(p) ? "??" : undefined),
dirChanged: (p) => dirs.has(p) || inFolded(`${p}/`),
};
}
/** A folded level's entries, each carrying its status (see `treeStatus`). */
function treeEntries(prefix, dirNames, files, st) {
return [
...[...dirNames].sort().map((name) => {
const p = `${prefix}${name}`;
return { name, path: p, dir: true, ...(st.dirChanged(p) ? { changed: true } : {}) };
}),
...files
.sort((a, b) => a.name.localeCompare(b.name))
.map((f) => {
const status = st.fileStatus(f.path);
return status ? { ...f, status } : f;
}),
];
}
/**
* One directory level of the working tree, for the file explorer: tracked
* files plus untracked-but-not-ignored ones (`TREE_LS`), folded into
* directories at this level — a tree that lists a repo's every file at once is
* both slow to render and not what "expand a folder" means.
* both slow to render and not what "expand a folder" means. A changed file
* carries its porcelain `status`, a folder holding one `changed: true`.
*/
async function listTree(dir, { subdir = "" } = {}) {
const clean = String(subdir || "")
.replace(/^\/+|\/+$/g, "")
.trim();
if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the lane");
const { all, cut } = await lsFiles(dir, clean ? ["--", `${clean}/`] : []);
const [{ all, cut }, st] = await Promise.all([
lsFiles(dir, clean ? [...TREE_LS, "--", `${clean}/`] : TREE_LS),
treeStatus(dir),
]);
const prefix = clean ? `${clean}/` : "";
const dirs = new Set();
const files = [];
@@ -774,10 +833,7 @@ async function listTree(dir, { subdir = "" } = {}) {
if (slash === -1) files.push({ name: rest, path: p, dir: false });
else dirs.add(rest.slice(0, slash));
}
const entries = [
...[...dirs].sort().map((name) => ({ name, path: `${prefix}${name}`, dir: true })),
...files.sort((a, b) => a.name.localeCompare(b.name)),
];
const entries = treeEntries(prefix, dirs, files, st);
return {
path: clean,
entries: entries.slice(0, MAX_PATHS),
@@ -817,13 +873,13 @@ async function hunkPatch(dir, file, header, context) {
}
/**
* Every level of the tracked tree from ONE `git ls-files`, keyed by directory
* Every level of the explorer tree (tracked + untracked-not-ignored) from ONE `git ls-files`, keyed by directory
* path ("" is the root), each level shaped and capped exactly like
* `listTree`'s. "Expand all folders" used to be one request — one ls-files —
* per folder, which on a large repo is thousands of git processes.
*/
async function listTreeAll(dir) {
const { all, cut } = await lsFiles(dir, []);
const [{ all, cut }, st] = await Promise.all([lsFiles(dir, TREE_LS), treeStatus(dir)]);
const byDir = new Map();
const level = (d) => {
let l = byDir.get(d);
@@ -842,11 +898,7 @@ async function listTreeAll(dir) {
const levels = {};
let truncated = cut;
for (const [d, { dirs, files }] of byDir) {
const prefix = d ? `${d}/` : "";
const entries = [
...[...dirs].sort().map((name) => ({ name, path: `${prefix}${name}`, dir: true })),
...files.sort((a, b) => a.name.localeCompare(b.name)),
];
const entries = treeEntries(d ? `${d}/` : "", dirs, files, st);
if (entries.length > MAX_PATHS) truncated = true;
levels[d] = entries.slice(0, MAX_PATHS);
}
@@ -855,6 +907,12 @@ async function listTreeAll(dir) {
module.exports = {
collectChanges,
contentSha,
isSecretOnDisk,
lanePath,
looksBinary,
resolveInLane,
MAX_FILE_VIEW_BYTES,
hunkPatch,
laneStatus,
listCommits,
+77
View File
@@ -0,0 +1,77 @@
/**
* @file Saves one text file in a lane's working tree, for the Changes panel's
* single-file editor. The one filesystem WRITE of the lane file surface, kept
* out of git-ops.js on purpose: that module only ever touches paths `git
* status` reports as changed, and an editor may save any file.
*
* The guards are the READ path's own (git-diff.js), so the editor can never
* write where the viewer could not read: a lexical `lanePath`, a realpath
* confined to the lane (no `..`, no symlink out), and no `settings.local.json`
* — the lane router's key file — at any depth or through any symlink. On top:
* only an EXISTING, textual file within the viewer's size cap, and only over
* the exact bytes the editor loaded (`baseSha`, from `readFileAt`), so a save
* never silently overwrites what the agent wrote in the meantime (ESTALE).
* The file keeps its CRLF line endings and its final newline.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const {
contentSha,
isSecretOnDisk,
lanePath,
looksBinary,
resolveInLane,
MAX_FILE_VIEW_BYTES,
} = require("./git-diff");
function bad(code, message) {
return Object.assign(new Error(message), { code });
}
/**
* Overwrite `requested` in lane `dir` with `content`. Throws EBADPATH,
* ESECRET, ENOFILE, EBADCONTENT, ETOOBIG, EBINARY or ESTALE.
*/
async function writeLaneFile(dir, requested, content, baseSha) {
const file = lanePath(requested);
if (await isSecretOnDisk(dir, file)) {
throw bad("ESECRET", "that file holds secrets and is not writable here");
}
if (typeof content !== "string") throw bad("EBADCONTENT", "content must be a string");
if (typeof baseSha !== "string" || !/^[0-9a-f]{40}$/.test(baseSha)) {
throw bad("EBADCONTENT", "baseSha is required");
}
if (Buffer.byteLength(content) > MAX_FILE_VIEW_BYTES) {
throw bad("ETOOBIG", "file is too large to edit here");
}
const abs = await resolveInLane(dir, file);
let current;
try {
const stat = await fs.promises.stat(abs);
if (!stat.isFile()) throw bad("ENOFILE", "no such file in the working tree");
if (stat.size > MAX_FILE_VIEW_BYTES) throw bad("ETOOBIG", "file is too large to edit here");
current = await fs.promises.readFile(abs);
} catch (err) {
if (err.code === "ETOOBIG" || err.code === "ENOFILE") throw err;
throw bad("ENOFILE", "no such file in the working tree");
}
if (looksBinary(current)) throw bad("EBINARY", "binary files are not editable here");
if (contentSha(current) !== baseSha) {
throw bad("ESTALE", "the file changed on disk since it was opened — reload it first");
}
// A browser textarea hands back LF only, and the viewer drops the final
// newline: put back what the file had, so a one-word fix is a one-line diff.
const text = current.toString("utf8");
const eol = text.includes("\r\n") ? "\r\n" : "\n";
let out = content.replace(/\r\n/g, "\n");
if (eol === "\r\n") out = out.replace(/\n/g, "\r\n");
if (text.endsWith("\n") && out && !out.endsWith(eol)) out += eol;
await fs.promises.writeFile(abs, out);
return { path: file };
}
module.exports = { writeLaneFile };
+142
View File
@@ -0,0 +1,142 @@
/**
* @file Automatic pricing for Claude models the dashboard has seen but has no
* rate row of its own for. Every model id in `token_usage` is reduced to its
* family (a `-YYYYMMDD` snapshot suffix dropped); a family with no
* `<family>%` row is looked up in LiteLLM's public price list and INSERTED —
* never updated, so a rate the user edited in Settings always wins. "Its own
* row" is the test, not "some row matches": Opus 5.5 was silently priced as
* Opus 5 by the broader `claude-opus-5%` pattern.
*
* The list is fetched only when something is missing, at most once per
* CACHE_MS. Off with AGENTHUB_PRICING_SYNC=0|false|off.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const PRICE_LIST_URL =
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
const CHECK_MS = 15 * 60_000;
const CACHE_MS = 24 * 60 * 60_000;
/** A per-MTok rate above this is a parse error, not a price. */
const MAX_PER_MTOK = 1000;
let cached = null; // { at, list }
/** `claude-opus-5-5-20260101` → `claude-opus-5-5`; null for anything else. */
function familyOf(model) {
if (typeof model !== "string") return null;
const fam = model.replace(/-\d{8}$/, "");
return /^claude-[a-z0-9-]+$/.test(fam) ? fam : null;
}
/** `claude-opus-5-5` → `Claude Opus 5.5`. */
function displayName(fam) {
const [, ...parts] = fam.split("-");
const words = [];
const nums = [];
for (const p of parts) (/^\d+$/.test(p) ? nums : words).push(p);
const name = words.map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
return `Claude ${name}${nums.length ? " " + nums.join(".") : ""}`;
}
/** One LiteLLM entry → a model_pricing row, or null when it is not usable. */
function toRow(fam, e) {
if (!e || e.litellm_provider !== "anthropic") return null;
const per = (v) => (typeof v === "number" ? v * 1e6 : NaN);
const input = per(e.input_cost_per_token);
const output = per(e.output_cost_per_token);
// Anthropic's published multipliers stand in for a field the list omits.
const read = per(e.cache_read_input_token_cost ?? e.input_cost_per_token * 0.1);
const w5m = per(e.cache_creation_input_token_cost ?? e.input_cost_per_token * 1.25);
const w1h = per(e.cache_creation_input_token_cost_above_1hr ?? e.input_cost_per_token * 2);
const rates = [input, output, read, w5m, w1h];
if (!rates.every((r) => Number.isFinite(r) && r >= 0 && r <= MAX_PER_MTOK)) return null;
if (input === 0 && output === 0) return null;
const fast = e.supports_fast_mode ? Number(e.provider_specific_entry?.fast) : 0;
const fastOk = Number.isFinite(fast) && fast > 0 && fast <= 10;
// Rounded: 0.000004 * 1e6 is 3.9999999999999996, which Settings would show.
const r = (n) => Math.round(n * 1e6) / 1e6;
return {
pattern: `${fam}%`,
name: displayName(fam),
input: r(input),
output: r(output),
read: r(read),
w5m: r(w5m),
w1h: r(w1h),
fastIn: fastOk ? r(input * fast) : 0,
fastOut: fastOk ? r(output * fast) : 0,
};
}
/** Families used in token_usage that have no `<family>%` row of their own. */
function missingFamilies(db) {
const have = new Set(
db
.prepare("SELECT model_pattern FROM model_pricing")
.all()
.map((r) => r.model_pattern)
);
const out = new Set();
for (const { model } of db.prepare("SELECT DISTINCT model FROM token_usage").all()) {
const fam = familyOf(model);
if (fam && !have.has(`${fam}%`)) out.add(fam);
}
return [...out];
}
async function defaultFetch() {
const res = await fetch(PRICE_LIST_URL, { signal: AbortSignal.timeout(20_000) });
if (!res.ok) throw new Error(`price list HTTP ${res.status}`);
return res.json();
}
/**
* One pass. Returns the patterns inserted. Never throws: pricing is a display
* concern and a failed fetch must not reach the server's startup path.
*/
async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } = {}) {
try {
const missing = missingFamilies(db);
if (missing.length === 0) return [];
if (!cached || now - cached.at > CACHE_MS) cached = { at: now, list: await fetchList() };
const insert = db.prepare(
`INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok,
output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok,
fast_input_per_mtok, fast_output_per_mtok) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
const added = [];
for (const fam of missing) {
const row = toRow(fam, cached.list?.[fam]);
if (!row) continue;
const { changes } = insert.run(
row.pattern,
row.name,
row.input,
row.output,
row.read,
row.w5m,
row.w1h,
row.fastIn,
row.fastOut
);
if (changes) added.push(row.pattern);
}
if (added.length) console.log(`[pricing] added rates for ${added.join(", ")}`);
return added;
} catch (err) {
console.warn("[pricing] sync failed:", err.message);
return [];
}
}
function startPricingSync(db) {
if (/^(0|false|off)$/i.test(process.env.AGENTHUB_PRICING_SYNC || "")) return;
void syncPricing(db);
setInterval(() => void syncPricing(db), CHECK_MS).unref();
}
function _resetCache() {
cached = null;
}
module.exports = { syncPricing, startPricingSync, familyOf, displayName, toRow, _resetCache };
+2 -2
View File
@@ -12,10 +12,10 @@
const tmux = require("./tmux");
const RUN_ID_RE = /^agenthub-lane-\d+$/;
const RUN_ID_RE = /^agenthub-lane-\d+(?:-[1-3])?$/;
/**
* Reject anything that isn't exactly `agenthub-lane-<digits>` before it can ever
* Reject anything that isn't exactly `agenthub-lane-<digits>[-<1-3>]` before it can ever
* reach a tmux/PTY command — the trust boundary for this WS path, since a
* validated runId is the only thing standing between an authenticated WS
* client and naming an arbitrary session on the host.
+21 -4
View File
@@ -21,6 +21,10 @@
*
* Spawns `claude` or `codex` in the tmux pane based on the tool parameter.
*
* A lane can hold up to four sessions, one per Workspace split pane: pane 0 is
* `agenthub-lane-<laneId>` (the lane's own `run_id`), panes 1–3 are
* `agenthub-lane-<laneId>-<slot>`.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -35,7 +39,9 @@ try {
/* db-less environment, skip persistence */
}
const RUN_ID_RE = /^agenthub-lane-(\d+)$/;
const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-3]))?$/;
/** Highest pane slot a lane may use (the Workspace's 4-pane grid is 0–3). */
const MAX_PANE_SLOT = 3;
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
// `auto` is the CLI's own default-ish mode (`claude --permission-mode auto`):
// it asks only for what its classifier reads as risky. `default` is kept for
@@ -55,8 +61,13 @@ const CODEX_APPROVALS = new Set(["on-request", "never"]);
// Codex has no `--effort` flag; reasoning effort is a config override.
const CODEX_EFFORTS = new Set(["low", "medium", "high", "xhigh"]);
function runIdForLane(laneId) {
return `agenthub-lane-${laneId}`;
function runIdForLane(laneId, slot = 0) {
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
}
function slotFromRunId(id) {
const m = typeof id === "string" ? id.match(RUN_ID_RE) : null;
return m ? Number(m[2] || 0) : null;
}
function laneIdFromRunId(id) {
@@ -149,6 +160,7 @@ function buildArgv(args) {
function spawnRun(args) {
const {
laneId,
slot = 0,
cwd,
tool = "claude",
model,
@@ -164,6 +176,9 @@ function spawnRun(args) {
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
throw makeErr("EBADLANE", "laneId must be an integer");
}
if (!Number.isInteger(slot) || slot < 0 || slot > MAX_PANE_SLOT) {
throw makeErr("EBADSLOT", `slot must be an integer 0–${MAX_PANE_SLOT}`);
}
if (typeof cwd !== "string" || !cwd) {
throw makeErr("EBADCWD", "cwd is required");
}
@@ -215,7 +230,7 @@ function spawnRun(args) {
);
}
const id = runIdForLane(laneId);
const id = runIdForLane(laneId, slot);
const startedAt = Date.now();
const argv = buildArgv({
tool,
@@ -323,6 +338,7 @@ function publicRun(id) {
return {
id,
laneId,
slot: slotFromRunId(id),
status: live ? "running" : "gone",
tool: row?.tool || "claude",
cwd: row?.cwd || null,
@@ -353,5 +369,6 @@ module.exports = {
listRuns,
laneIdFromRunId,
runIdForLane,
MAX_PANE_SLOT,
SHELL_COMMANDS,
};
+77 -21
View File
@@ -27,6 +27,7 @@ const { broadcast } = require("../websocket");
const runs = require("../lib/pty-run");
const { applyRouterToLane } = require("../lib/router-apply");
const { sameOriginGuard } = require("./run");
const { writeLaneFile } = require("../lib/lane-file-write");
const { preflight } = require("../lib/lane-preflight");
const {
LANES_ROOT,
@@ -855,6 +856,26 @@ router.get("/:id/git/file", async (req, res) => {
}
});
/**
* Save one text file in the lane from the viewer's editor. Guards live in
* lane-file-write.js (the read path's own, plus existing-textual-file-only and
* a `baseSha` stale check). Answers the file re-read, fresh `sha` included.
*/
router.put("/:id/git/file", sameOriginGuard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const body = req.body || {};
try {
await writeLaneFile(lane.cwd, body.path, body.content, body.baseSha);
res.json({ available: true, ...(await readFileAt(lane.cwd, body.path, "work")) });
} catch (err) {
const status =
{ ESECRET: 403, ESTALE: 409 }[err.code] ??
(["EBADPATH", "ENOFILE", "EBADCONTENT", "ETOOBIG", "EBINARY"].includes(err.code) ? 400 : 500);
res.status(status).json({ error: { code: err.code || "EWRITE", message: err.message } });
}
});
/** Tracked paths matching `?q=`, for quick-open. */
router.get("/:id/git/ls", async (req, res) => {
const lane = laneOr404(req, res);
@@ -877,7 +898,7 @@ router.get("/:id/git/tree-all", async (req, res) => {
}
});
/** One directory level of tracked files, for the file explorer. */
/** One directory level of the explorer tree (tracked + untracked-not-ignored), status-decorated. */
router.get("/:id/git/tree", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
@@ -1165,26 +1186,44 @@ function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Kill a lane run and wait for the tmux session to exit before touching its cwd. */
/** Every tmux session a lane holds: its recorded `run_id` plus the extra
* split-pane sessions (`agenthub-lane-<id>-<slot>`), which never touch it. */
function laneRunIds(lane) {
const ids = new Set(
runs
.listRuns()
.filter((r) => r.laneId === lane.id)
.map((r) => r.id)
);
if (lane.run_id) ids.add(lane.run_id);
return [...ids];
}
/** Kill a lane's runs and wait for their tmux sessions to exit before touching its cwd. */
async function stopLaneRun(lane) {
if (!lane.run_id) return;
try {
runs.killRun(lane.run_id);
} catch {
/* a concurrently completed run is already safe */
const ids = laneRunIds(lane);
if (ids.length === 0) return;
for (const id of ids) {
try {
runs.killRun(id);
} catch {
/* a concurrently completed run is already safe */
}
}
const deadline = Date.now() + RUN_EXIT_TIMEOUT_MS;
let run = runs.getRun(lane.run_id);
while (run && run.status !== "gone") {
if (Date.now() >= deadline) {
throw lifecycleError(
"ERUNTIMEOUT",
`lane run ${lane.run_id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds`
);
for (const id of ids) {
let run = runs.getRun(id);
while (run && run.status !== "gone") {
if (Date.now() >= deadline) {
throw lifecycleError(
"ERUNTIMEOUT",
`lane run ${id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds`
);
}
await wait(RUN_EXIT_POLL_MS);
run = runs.getRun(id);
}
await wait(RUN_EXIT_POLL_MS);
run = runs.getRun(lane.run_id);
}
lanesLib.updateLane(lane.id, { run_id: null });
}
@@ -1587,9 +1626,18 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
}
}
// The pane a `start` targets: 0 is the lane's own session (`run_id`), 1–3
// are the Workspace split panes' extra sessions on the same lane.
let startedRunId = null;
try {
switch (action) {
case "start": {
const slot = body.slot === undefined ? 0 : body.slot;
if (!Number.isInteger(slot) || slot < 0 || slot > runs.MAX_PANE_SLOT) {
return res.status(400).json({
error: { code: "EBADSLOT", message: `slot must be an integer 0–${runs.MAX_PANE_SLOT}` },
});
}
// Overwriting run_id while its child is alive orphans that child: a later
// reset would kill and await only the RECORDED run, then `git clean -fd`
// the directory the orphan is still writing into. Stop the first run before starting a
@@ -1603,12 +1651,14 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
// waited for the lock — the lock makes that race visible instead of
// spawning a run for a lane that no longer exists.
if (!current) return { missing: true };
const live = current.run_id ? runs.getRun(current.run_id) : null;
const liveId = slot ? runs.runIdForLane(current.id, slot) : current.run_id;
const live = liveId ? runs.getRun(liveId) : null;
if (live && live.status === "running") {
return { conflict: true };
}
const handle = runs.spawnRun({
laneId: current.id,
slot,
initialPrompt: body.prompt || "",
cwd: current.cwd,
// The tool is the LANE's fact, never the request's: a client that
@@ -1626,7 +1676,10 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
resumeLast: body.resumeLast === true,
resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined),
});
lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" });
startedRunId = handle.id;
// An extra pane's session is not the lane's run: `run_id` stays the
// pane-0 session every other path (reset, router restart) names.
if (!slot) lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" });
return {};
});
if (outcome.missing) {
@@ -1641,9 +1694,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
}
case "stop": {
// A lane with no live run is already stopped — say so, don't 500.
if (lane.run_id) {
for (const id of laneRunIds(lane)) {
try {
runs.killRun(lane.run_id);
runs.killRun(id);
} catch {
/* already gone */
}
@@ -1677,7 +1730,10 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
}
broadcastLane(lane.id);
res.json({ lane: payload(lanesLib.getLane(lane.id)) });
res.json({
lane: payload(lanesLib.getLane(lane.id)),
...(startedRunId ? { run_id: startedRunId } : {}),
});
});
router.delete("/:id", sameOriginGuard, (req, res) => {
+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 {
+1 -1
View File
@@ -80,7 +80,7 @@ function initWebSocket(server) {
}
let ptyWss = null;
const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+)$/;
const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+(?:-[1-3])?)$/;
/**
* The attaching terminal's own size, off the attach URL's query. tmux resizes