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:
+2
-1
File diff suppressed because one or more lines are too long
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }),
|
||||||
|
|||||||
@@ -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: () => "" },
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user