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

- Changes panel at full screen: the whole Files view (finder, expand-all,
  tree) becomes a left sidebar and the Files tab is dropped; a working-copy
  file opens straight into the editor (Save/Cancel live once the text
  differs, Cancel restores it, Whole file toggles back to the diff), and the
  page is height-locked so the editor no longer collapses with no live run.
- Overview splits into Staged Changes / Changes groups in the work range.
- A changed file's whole tree row takes its status tint.
- Drag an image onto the lane terminal: POST /api/run/drop-image saves it
  (png/jpeg/gif/webp, random name, 0600, pruned after a day) and the path
  is pasted into the pane, which is how claude attaches it.
- Claude Opus 5.5 gets its own pricing row (4/20/0.2/5/8, fast 8/40);
  claude-opus-5% had been pricing it as Opus 5.
- server/lib/pricing-sync.js: a model family used in token_usage with no
  row of its own is inserted from LiteLLM's public price list, never
  updated; off with AGENTHUB_PRICING_SYNC=0. repo-links allows exactly
  that one third-party GitHub raw URL.
This commit is contained in:
2026-09-24 17:05:15 +07:00
parent 804b193ac7
commit 158783d5d5
25 changed files with 1266 additions and 540 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+195 -90
View File
@@ -110,11 +110,13 @@ function noEditReason(text: { binary?: boolean; truncated: boolean }) {
/** The explorer's decoration for a porcelain code: one letter + its colour, /** The explorer's decoration for a porcelain code: one letter + its colour,
* VS Code's convention (U untracked, A added, D deleted, R renamed, M else). */ * VS Code's convention (U untracked, A added, D deleted, R renamed, M else). */
function treeMark(code: string): { letter: string; tone: string } { function treeMark(code: string): { letter: string; tone: string } {
if (code === "??") return { letter: "U", tone: "text-status-success" }; // The tint fills the whole row, not just the letter, so a changed file
if (code.includes("A")) return { letter: "A", tone: "text-status-success" }; // stands out of a long tree at a glance.
if (code.includes("D")) return { letter: "D", tone: "text-status-danger" }; if (code === "??") return { letter: "U", tone: "text-status-success bg-status-success/10" };
if (code.includes("R")) return { letter: "R", tone: "text-status-warning" }; if (code.includes("A")) return { letter: "A", tone: "text-status-success bg-status-success/10" };
return { letter: "M", tone: "text-status-warning" }; if (code.includes("D")) return { letter: "D", tone: "text-status-danger bg-status-danger/10" };
if (code.includes("R")) return { letter: "R", tone: "text-status-warning bg-status-warning/10" };
return { letter: "M", tone: "text-status-warning bg-status-warning/10" };
} }
/** /**
@@ -129,6 +131,7 @@ function TreeNodes({
expanded, expanded,
onToggleDir, onToggleDir,
onOpenFile, onOpenFile,
active,
}: { }: {
path: string; path: string;
depth: number; depth: number;
@@ -136,6 +139,8 @@ function TreeNodes({
expanded: Set<string>; expanded: Set<string>;
onToggleDir: (path: string) => void; onToggleDir: (path: string) => void;
onOpenFile: (path: string) => void; onOpenFile: (path: string) => void;
/** The file open beside the tree, drawn selected. */
active?: string | null;
}) { }) {
const entries = children[path]; const entries = children[path];
if (!entries) return null; if (!entries) return null;
@@ -150,9 +155,10 @@ function TreeNodes({
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)} data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))} onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }} style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
aria-current={active === e.path ? "true" : undefined}
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${ className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
mark ? mark.tone : e.changed ? "text-status-warning" : "text-fg-primary" mark ? mark.tone : e.changed ? "text-status-warning" : "text-fg-primary"
}`} } ${active === e.path ? "!bg-surface-4" : ""}`}
> >
{e.dir ? ( {e.dir ? (
expanded.has(e.path) ? ( expanded.has(e.path) ? (
@@ -191,6 +197,7 @@ function TreeNodes({
expanded={expanded} expanded={expanded}
onToggleDir={onToggleDir} onToggleDir={onToggleDir}
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
active={active}
/> />
</ul> </ul>
)} )}
@@ -349,10 +356,14 @@ function ChangeRow({
export function LaneChangesPanel({ export function LaneChangesPanel({
laneId, laneId,
onNewLane, onNewLane,
full = false,
}: { }: {
laneId: number; laneId: number;
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */ /** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
onNewLane?: (branch?: string) => void; onNewLane?: (branch?: string) => void;
/** The panel has the whole width: the file tree becomes a sidebar and a
* whole file opens straight into the editor. */
full?: boolean;
}) { }) {
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
// One read of the working copy, shared by the header and every write below: // One read of the working copy, shared by the header and every write below:
@@ -483,14 +494,15 @@ export function LaneChangesPanel({
}, [mode, laneId, reread]); }, [mode, laneId, reread]);
useEffect(() => { useEffect(() => {
if (mode !== "browse" || query.trim() || "" in treeChildren) return; const wanted = full || (mode === "browse" && !query.trim());
if (!wanted || "" in treeChildren) return;
void api.lanes void api.lanes
.gitTree(laneId, "") .gitTree(laneId, "")
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, "": r.entries })))) .then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, "": r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err))); .catch((err) => setError(err instanceof Error ? err.message : String(err)));
// `treeChildren` is the cache this fills — reading it as a dep would loop. // `treeChildren` is the cache this fills — reading it as a dep would loop.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, laneId, query, reread]); }, [mode, laneId, query, reread, full]);
// A folder's children are fetched lazily, the first time it is expanded. // A folder's children are fetched lazily, the first time it is expanded.
const toggleDir = (path: string) => { const toggleDir = (path: string) => {
@@ -528,7 +540,7 @@ export function LaneChangesPanel({
useEffect(() => { useEffect(() => {
const q = query.trim(); const q = query.trim();
if (mode !== "browse" || !q) { if ((mode !== "browse" && !full) || !q) {
setHits(null); setHits(null);
return; return;
} }
@@ -545,7 +557,7 @@ export function LaneChangesPanel({
live = false; live = false;
window.clearTimeout(timer); window.clearTimeout(timer);
}; };
}, [mode, laneId, query]); }, [mode, laneId, query, full]);
// The whole-file read is a different endpoint from the diff, so it is its // The whole-file read is a different endpoint from the diff, so it is its
// own fetch — and only while the viewer is actually showing it. // own fetch — and only while the viewer is actually showing it.
@@ -571,6 +583,16 @@ export function LaneChangesPanel({
.catch((err) => setError(err instanceof Error ? err.message : String(err))); .catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, whole, openFile, laneId, range, sha]); }, [mode, whole, openFile, laneId, range, sha]);
useEffect(() => {
if (full && mode === "browse") setMode("overview");
}, [full, mode]);
// Going full width with a working file already open: straight to the editor.
useEffect(() => {
if (full && mode === "file" && range !== "commit") setWhole(true);
// Only on the switch itself — Whole file must still toggle afterwards.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [full]);
// A lane switch must not leave the previous lane's file open — its path // A lane switch must not leave the previous lane's file open — its path
// almost certainly does not exist in the new one. // almost certainly does not exist in the new one.
useEffect(() => { useEffect(() => {
@@ -616,7 +638,9 @@ export function LaneChangesPanel({
} }
setOpenFile(path); setOpenFile(path);
setContext(CONTEXT_STEPS[0]); setContext(CONTEXT_STEPS[0]);
setWhole(false); // Full width edits by default: a working file opens as the editor, and
// Whole file toggles back to its diff. A commit is history, so a diff.
setWhole(full && range !== "commit");
setText(null); setText(null);
setDraft(null); setDraft(null);
setMode("file"); setMode("file");
@@ -771,6 +795,14 @@ export function LaneChangesPanel({
return next; return next;
}); });
// Full width edits in place: an editable whole file IS the editor, and
// Cancel just puts the saved text back (this effect re-seeds the draft).
const editableText = full && mode === "file" && whole && range !== "commit" && text?.sha;
useEffect(() => {
if (editableText && draft === null && text) setDraft(text.lines.join("\n"));
}, [editableText, draft, text]);
const dirty = draft !== null && draft !== text?.lines.join("\n");
const files = data?.available ? data.files : []; const files = data?.available ? data.files : [];
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path)); const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path));
const stagedCount = range === "work" ? files.filter((f) => f.staged).length : 0; const stagedCount = range === "work" ? files.filter((f) => f.staged).length : 0;
@@ -786,8 +818,97 @@ export function LaneChangesPanel({
} }
}, [data, files, confirmDiscard]); }, [data, files, confirmDiscard]);
// A tree or quick-open pick opens the whole file. Always-on editing (full
// width) makes a stray click here the easy way to lose typed work.
const openWhole = (p: string) => {
if (dirty && !window.confirm(t("changes.dropEdits"))) return;
show(p);
setWhole(true);
};
// The Files mode's body. At full width it lives in the sidebar instead, so
// the Files tab is gone there and the main area never shows it twice.
const browsePane = (
<div data-testid="lane-changes-browse">
{/* Pinned: the finder is what you reach for after scrolling a
long tree, not only from its top. */}
<div className="sticky top-0 z-10 flex items-center gap-1.5 bg-surface-3 pb-2">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("changes.quickOpen")}
aria-label={t("changes.quickOpen")}
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
{!hits && (
<IconButton
label={
expandedDirs.size > 0
? t("changes.collapseAllFolders")
: t("changes.expandAllFolders")
}
disabled={treeBusy}
onClick={() =>
expandedDirs.size > 0 ? setExpandedDirs(new Set()) : void expandAllDirs()
}
>
{expandedDirs.size > 0 ? (
<ChevronsDownUp className="h-3.5 w-3.5" />
) : (
<ChevronsUpDown className="h-3.5 w-3.5" />
)}
</IconButton>
)}
</div>
{hits ? (
<ul className="space-y-0.5">
{hits.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => openWhole(p)}
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{p}
</button>
</li>
))}
{hits.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("changes.noMatches")}</li>
)}
</ul>
) : (
<ul className="space-y-0.5">
<TreeNodes
path=""
depth={0}
children={treeChildren}
expanded={expandedDirs}
onToggleDir={toggleDir}
onOpenFile={openWhole}
active={full && mode === "file" ? openFile : null}
/>
</ul>
)}
</div>
);
return ( return (
<div data-testid="lane-changes" className="flex min-h-0 flex-1 flex-col"> <div
data-testid="lane-changes"
className={`flex min-h-0 flex-1 ${full ? "gap-3" : "flex-col"}`}
>
{/* Full width has room for an explorer: the tree stays beside the file
it opened, instead of being a mode you leave to read one. */}
{full && (
<aside
data-testid="lane-changes-sidebar"
className="flex w-64 shrink-0 flex-col overflow-y-auto border-r border-border-light pr-2"
>
{browsePane}
</aside>
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<LaneGitHeader laneId={laneId} git={git} onChanged={rereadAll} onNewLane={onNewLane} /> <LaneGitHeader laneId={laneId} git={git} onChanged={rereadAll} onNewLane={onNewLane} />
{/* ONE wrapping row: where you are (mode) then how you are looking at it {/* ONE wrapping row: where you are (mode) then how you are looking at it
(range, layout, context, whole file), refresh last. Each group is (range, layout, context, whole file), refresh last. Each group is
@@ -813,7 +934,10 @@ export function LaneChangesPanel({
</button> </button>
) : ( ) : (
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5"> <div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
{(["browse", "overview", "review", "history"] as const).map((m) => ( {(full
? (["overview", "review", "history"] as const)
: (["browse", "overview", "review", "history"] as const)
).map((m) => (
<button <button
key={m} key={m}
type="button" type="button"
@@ -921,7 +1045,9 @@ export function LaneChangesPanel({
<IconButton <IconButton
label={t("changes.expandContext")} label={t("changes.expandContext")}
disabled={whole || context >= LAST_CONTEXT} disabled={whole || context >= LAST_CONTEXT}
onClick={() => setContext(CONTEXT_STEPS.find((s) => s > context) ?? context)} onClick={() =>
setContext(CONTEXT_STEPS.find((s) => s > context) ?? context)
}
> >
<UnfoldVertical className="h-3.5 w-3.5" /> <UnfoldVertical className="h-3.5 w-3.5" />
</IconButton> </IconButton>
@@ -934,7 +1060,7 @@ export function LaneChangesPanel({
<button <button
type="button" type="button"
aria-pressed={whole} aria-pressed={whole}
disabled={draft !== null} disabled={dirty}
onClick={() => setWhole(!whole)} onClick={() => setWhole(!whole)}
className={`rounded border border-border-light px-1.5 py-0.5 text-[11px] ${ className={`rounded border border-border-light px-1.5 py-0.5 text-[11px] ${
whole ? "bg-surface-4 text-fg-primary" : "text-fg-secondary hover:bg-surface-2" whole ? "bg-surface-4 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"
@@ -978,7 +1104,9 @@ export function LaneChangesPanel({
className="w-full rounded px-1 py-0.5 text-left hover:bg-surface-2" className="w-full rounded px-1 py-0.5 text-left hover:bg-surface-2"
> >
<span className="flex items-baseline gap-1.5"> <span className="flex items-baseline gap-1.5">
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{c.short}</span> <span className="shrink-0 font-mono text-[10px] text-fg-muted">
{c.short}
</span>
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary"> <span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary">
{c.subject} {c.subject}
</span> </span>
@@ -994,74 +1122,8 @@ export function LaneChangesPanel({
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li> <li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
)} )}
</ul> </ul>
) : mode === "browse" ? ( ) : mode === "browse" && !full ? (
<div data-testid="lane-changes-browse"> browsePane
{/* Pinned: the finder is what you reach for after scrolling a
long tree, not only from its top. */}
<div className="sticky top-0 z-10 flex items-center gap-1.5 bg-surface-3 pb-2">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("changes.quickOpen")}
aria-label={t("changes.quickOpen")}
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
{!hits && (
<IconButton
label={
expandedDirs.size > 0
? t("changes.collapseAllFolders")
: t("changes.expandAllFolders")
}
disabled={treeBusy}
onClick={() =>
expandedDirs.size > 0 ? setExpandedDirs(new Set()) : void expandAllDirs()
}
>
{expandedDirs.size > 0 ? (
<ChevronsDownUp className="h-3.5 w-3.5" />
) : (
<ChevronsUpDown className="h-3.5 w-3.5" />
)}
</IconButton>
)}
</div>
{hits ? (
<ul className="space-y-0.5">
{hits.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => {
show(p);
setWhole(true);
}}
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{p}
</button>
</li>
))}
{hits.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("changes.noMatches")}</li>
)}
</ul>
) : (
<ul className="space-y-0.5">
<TreeNodes
path=""
depth={0}
children={treeChildren}
expanded={expandedDirs}
onToggleDir={toggleDir}
onOpenFile={(p) => {
show(p);
setWhole(true);
}}
/>
</ul>
)}
</div>
) : !data ? ( ) : !data ? (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p> <p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
) : !data.available ? ( ) : !data.available ? (
@@ -1139,8 +1201,40 @@ export function LaneChangesPanel({
)} )}
</div> </div>
)} )}
<ul data-testid="lane-changes-overview" className="space-y-0.5"> {/* The work range splits like VS Code's Source Control: what the
{files.map((f) => ( next commit takes, then what it leaves behind. A partly staged
file sits in both, each row offering only its own index move. */}
<div data-testid="lane-changes-overview">
{(range === "work"
? [
{
key: "staged",
rows: files
.filter((f) => f.staged)
.map((f) => ({ ...f, unstaged: false, untracked: false })),
},
{
key: "unstaged",
rows: files
.filter((f) => f.unstaged || f.untracked)
.map((f) => ({ ...f, staged: false })),
},
]
: [{ key: "all", rows: files }]
).map(
(g) =>
g.rows.length > 0 && (
<div key={g.key} className="mb-1.5">
{g.key !== "all" && (
<div className="flex items-center gap-1.5 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-fg-secondary">
{t(`changes.${g.key}Group`)}
<span className="rounded-full bg-surface-4 px-1.5 font-mono text-fg-primary">
{g.rows.length}
</span>
</div>
)}
<ul data-group={g.key} className="space-y-0.5">
{g.rows.map((f) => (
<ChangeRow <ChangeRow
key={f.path} key={f.path}
f={f} f={f}
@@ -1153,6 +1247,10 @@ export function LaneChangesPanel({
/> />
))} ))}
</ul> </ul>
</div>
)
)}
</div>
</> </>
) : mode === "file" ? ( ) : mode === "file" ? (
current || (whole && text) ? ( current || (whole && text) ? (
@@ -1163,7 +1261,11 @@ export function LaneChangesPanel({
{openFile} {openFile}
</span> </span>
{current && ( {current && (
<Delta added={current.added} deleted={current.deleted} binary={current.binary} /> <Delta
added={current.added}
deleted={current.deleted}
binary={current.binary}
/>
)} )}
{/* Only the working file is editable: a commit is history. */} {/* Only the working file is editable: a commit is history. */}
{range !== "commit" && {range !== "commit" &&
@@ -1196,7 +1298,7 @@ export function LaneChangesPanel({
<button <button
type="button" type="button"
data-testid="lane-file-save" data-testid="lane-file-save"
disabled={busy} disabled={busy || !dirty}
onClick={() => void saveDraft()} onClick={() => void saveDraft()}
className="shrink-0 self-center rounded border border-accent bg-accent/15 px-1.5 py-0.5 text-[11px] text-accent hover:bg-accent/25 disabled:opacity-40" className="shrink-0 self-center rounded border border-accent bg-accent/15 px-1.5 py-0.5 text-[11px] text-accent hover:bg-accent/25 disabled:opacity-40"
> >
@@ -1204,7 +1306,7 @@ export function LaneChangesPanel({
</button> </button>
<button <button
type="button" type="button"
disabled={busy} disabled={busy || (full && !dirty)}
onClick={() => setDraft(null)} onClick={() => setDraft(null)}
className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40" className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
> >
@@ -1324,7 +1426,9 @@ export function LaneChangesPanel({
</span> </span>
)} )}
</div> </div>
{!shut && <LaneDiffView file={f} layout={layout} hunkActions={hunkActions(f)} />} {!shut && (
<LaneDiffView file={f} layout={layout} hunkActions={hunkActions(f)} />
)}
</div> </div>
); );
})} })}
@@ -1336,6 +1440,7 @@ export function LaneChangesPanel({
)} )}
</div> </div>
</div> </div>
</div>
); );
} }
@@ -11,7 +11,7 @@
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; 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 userEvent from "@testing-library/user-event";
import { LaneChangesPanel } from "../LaneChangesPanel"; import { LaneChangesPanel } from "../LaneChangesPanel";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
@@ -80,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(() => { beforeEach(() => {
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false }); vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.changes).mockReset(); vi.mocked(api.lanes.changes).mockReset();
@@ -301,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 () => { it("needs two clicks to discard, and only the second one reaches the server", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview"); await screen.findByTestId("lane-changes-overview");
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" }); await user.click(discardOf("server/lib/git-ops.js"));
await user.click(discard!);
expect(api.lanes.gitAction).not.toHaveBeenCalled(); expect(api.lanes.gitAction).not.toHaveBeenCalled();
await user.click( await user.click(
@@ -341,8 +417,7 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview"); await screen.findByTestId("lane-changes-overview");
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" }); await user.click(discardOf("server/lib/git-ops.js"));
await user.click(discard!);
// The file was committed/reverted elsewhere; a later reappearance must not be pre-armed. // The file was committed/reverted elsewhere; a later reappearance must not be pre-armed.
const base = changes(); const base = changes();
+79 -1
View File
@@ -11,15 +11,23 @@
* still LOOKS live, but no keystroke or mouse report reaches tmux again. * 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 * Always painted dark, in the palette of the user's chosen DARK theme
* (`lib/terminalTheme.ts`), recoloured live without re-attaching. * (`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> * @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 { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { CanvasAddon } from "@xterm/addon-canvas"; import { CanvasAddon } from "@xterm/addon-canvas";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useTheme } from "../../hooks/useTheme"; import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme"; 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 { interface TerminalViewProps {
runId: string; runId: string;
@@ -45,6 +53,42 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
const paletteRef = useRef(palette); const paletteRef = useRef(palette);
paletteRef.current = palette; paletteRef.current = palette;
const termRef = useRef<Terminal | null>(null); 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(() => { useEffect(() => {
// An explicit fontFamily pins every glyph to one bundled webfont. Left // An explicit fontFamily pins every glyph to one bundled webfont. Left
@@ -248,11 +292,45 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
}, [palette]); }, [palette]);
return ( return (
<div
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 <div
ref={containerRef} ref={containerRef}
className="h-full w-full" className="h-full w-full"
style={{ backgroundColor: palette.background }} style={{ backgroundColor: palette.background }}
data-testid="terminal-view" 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>
); );
} }
@@ -8,7 +8,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 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"; import { TerminalView } from "../TerminalView";
const writeMock = vi.fn(); const writeMock = vi.fn();
@@ -18,6 +18,11 @@ const disposeMock = vi.fn();
const wheelHandlerMock = vi.fn(); const wheelHandlerMock = vi.fn();
const keyHandlerMock = vi.fn(); const keyHandlerMock = vi.fn();
const focusMock = 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(); const loadAddonMock = vi.fn();
// Mutable stand-ins for the two live terminal facts the wheel handler reads. // Mutable stand-ins for the two live terminal facts the wheel handler reads.
const termState = { const termState = {
@@ -61,6 +66,7 @@ vi.mock("@xterm/xterm", () => ({
attachCustomWheelEventHandler: wheelHandlerMock, attachCustomWheelEventHandler: wheelHandlerMock,
attachCustomKeyEventHandler: keyHandlerMock, attachCustomKeyEventHandler: keyHandlerMock,
focus: focusMock, focus: focusMock,
paste: pasteMock,
})), })),
})); }));
// jsdom loads no stylesheet, so stand in for the CSS-variable probe. // jsdom loads no stylesheet, so stand in for the CSS-variable probe.
@@ -314,4 +320,31 @@ describe("TerminalView", () => {
expect(MockWebSocket.instances).toHaveLength(1); expect(MockWebSocket.instances).toHaveLength(1);
vi.useRealTimers(); 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();
});
}); });
+3
View File
@@ -112,6 +112,9 @@
"changes.size.full": "Full screen", "changes.size.full": "Full screen",
"changes.stageAll": "Stage all ({{count}})", "changes.stageAll": "Stage all ({{count}})",
"changes.unstageAll": "Unstage 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.stage": "Stage this file",
"changes.title": "Changes", "changes.title": "Changes",
"changes.truncated": "Some changes were left out to keep this panel responsive.", "changes.truncated": "Some changes were left out to keep this panel responsive.",
+6
View File
@@ -164,5 +164,11 @@
"input": "Input", "input": "Input",
"label": "Context", "label": "Context",
"output": "Output" "output": "Output"
},
"drop": {
"hint": "Drop image to attach",
"uploading": "Uploading image…",
"failed": "Could not attach the image",
"imagesOnly": "Only images can be dropped here"
} }
} }
+3
View File
@@ -112,6 +112,9 @@
"changes.size.full": "Toàn màn hình", "changes.size.full": "Toàn màn hình",
"changes.stageAll": "Stage tất cả ({{count}})", "changes.stageAll": "Stage tất cả ({{count}})",
"changes.unstageAll": "Bỏ 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.stage": "Đưa file này vào index",
"changes.title": "Thay đổi", "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.", "changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
+6
View File
@@ -163,5 +163,11 @@
"input": "Input", "input": "Input",
"label": "Context", "label": "Context",
"output": "Output" "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"
} }
} }
+8
View File
@@ -1982,6 +1982,14 @@ export const api = {
if (q) qs.set("q", q); if (q) qs.set("q", q);
return request<{ items: string[] }>(`/run/files?${qs.toString()}`); 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. */ /** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
start: (args: RunStartArgs) => start: (args: RunStartArgs) =>
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }), request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
+6 -2
View File
@@ -583,8 +583,11 @@ export function Workspace() {
// Viewport locked only when a live run is showing (TerminalView needs locked // Viewport locked only when a live run is showing (TerminalView needs locked
// viewport for chat scrolling). The config-card screen needs normal page flow // viewport for chat scrolling). The config-card screen needs normal page flow
// so the form is fully reachable on short windows. // so the form is fully reachable on short windows. A full-width Changes panel
const viewportLocked = paneHasActiveRun; // 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>) => { const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
setLaneActionError(null); setLaneActionError(null);
@@ -1032,6 +1035,7 @@ export function Workspace() {
</div> </div>
<LaneChangesPanel <LaneChangesPanel
laneId={currentLane.id} laneId={currentLane.id}
full={changesSize === "full"}
// A sibling worktree forks from the lane's SOURCE repo when // A sibling worktree forks from the lane's SOURCE repo when
// it has one (this lane is itself a worktree of it); an // it has one (this lane is itself a worktree of it); an
// adopted lane is its own repo. // adopted lane is its own repo.
@@ -75,6 +75,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
}), }),
pipelines: r({ pipelines: [] }), pipelines: r({ pipelines: [] }),
git: r({ available: false }), git: r({ available: false }),
gitTree: r({ entries: [] }),
runtime: r({ up: false, ports: {}, lastError: null }), runtime: r({ up: false, ports: {}, lastError: null }),
features: { list: r({ features: [] }), show: r({ feature: null }) }, features: { list: r({ features: [] }), show: r({ feature: null }) },
proof: { list: r({ features: [] }), imageUrl: () => "" }, proof: { list: r({ features: [] }), imageUrl: () => "" },
+1
View File
@@ -1994,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/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/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 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) (skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt? } POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt? }
GET /api/run/:id Run handle (returns live run state) GET /api/run/:id Run handle (returns live run state)
+3 -1
View File
@@ -389,7 +389,9 @@ CREATE TABLE notifications (
### model_pricing ### 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 ```sql
CREATE TABLE model_pricing ( CREATE TABLE model_pricing (
+12 -2
View File
@@ -575,7 +575,13 @@ says more than any sentence the dashboard could write.
To the right of the lane detail sits a collapsible **Changes** card (its state 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 persists per viewer in `localStorage`). Its header holds **one** control, which
steps **Narrow** (30%) → **Medium** (50/50) → **Full screen** (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 alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it
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 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 which one is "work" changes several times an hour, so the width is a step, not
@@ -667,7 +673,11 @@ 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 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 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 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 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 reader's scroll position is worse than a slightly stale view, and those are the
+1
View File
@@ -703,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/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/tmux` | `{ available }` — whether the `tmux` binary exists |
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) | | `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 | | `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 | | `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 | | `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
+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);
});
});
+3 -1
View File
@@ -79,7 +79,9 @@ const RULES = [
// a Gitea /raw/branch/<ref>/ URL instead. // a Gitea /raw/branch/<ref>/ URL instead.
re: /raw\.githubusercontent\.com\/[\w-]+\/[\w.-]+/g, re: /raw\.githubusercontent\.com\/[\w-]+\/[\w.-]+/g,
fix: "use a same-origin path, or a Gitea /raw/branch/<ref>/ URL", 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", 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 url = new URL(p, BASE);
const headers = { ...(opts.headers || {}) }; const headers = { ...(opts.headers || {}) };
let body; 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)); body = Buffer.from(JSON.stringify(opts.body));
headers["Content-Type"] = "application/json"; headers["Content-Type"] = "application/json";
headers["Content-Length"] = body.length; headers["Content-Length"] = body.length;
@@ -203,3 +207,45 @@ describe("/api/run", () => {
assert.equal(res.status, 400); 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-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], ["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) // 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-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-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], ["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). // One-time legacy-session backfill (a no-op once its marker file exists).
autoImportLegacySessions(); 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 provisioning job belongs to the previous Node process and cannot survive
// a restart. Recover those rows now so the dashboard never shows a permanent // a restart. Recover those rows now so the dashboard never shows a permanent
// provisioning spinner for work that was interrupted by the restart. // provisioning spinner for work that was interrupted by the restart.
+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 };
+55 -1
View File
@@ -19,8 +19,11 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @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 fs = require("node:fs");
const os = require("node:os");
const crypto = require("node:crypto");
const path = require("node:path"); const path = require("node:path");
const runs = require("../lib/pty-run"); const runs = require("../lib/pty-run");
const tmux = require("../lib/tmux"); 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 * 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. * 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) => { router.get("/history", (req, res) => {
let dr = null; let dr = null;
try { try {