2026-10-14 19:35:34 -07:00
|
|
|
|
import { get, type ByreEvent, type Schemas } from "./api.js";
|
|
|
|
|
|
import { errorText, h, replace } from "./dom.js";
|
2026-10-15 00:08:32 -07:00
|
|
|
|
import { onEach, pinSnapshot, snapshots, state } from "./state.js";
|
2026-10-14 19:35:34 -07:00
|
|
|
|
|
|
|
|
|
|
type Changes = Schemas["TaskChanges"];
|
|
|
|
|
|
type FileChange = Schemas["FileChange"];
|
|
|
|
|
|
|
|
|
|
|
|
const statusLetter: Record<FileChange["status"], string> = {
|
|
|
|
|
|
added: "A",
|
|
|
|
|
|
modified: "M",
|
|
|
|
|
|
deleted: "D",
|
|
|
|
|
|
renamed: "R",
|
|
|
|
|
|
type_changed: "T",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const open = new Set<string>();
|
|
|
|
|
|
let range: "main" | "turn" = "main";
|
2026-10-15 00:08:32 -07:00
|
|
|
|
let pinRange: "main" | "step" = "step";
|
|
|
|
|
|
|
|
|
|
|
|
const emptyText: Record<"main" | "turn" | "step", string> = {
|
|
|
|
|
|
main: "No changes from the default branch.",
|
|
|
|
|
|
turn: "No changes this turn.",
|
|
|
|
|
|
step: "This step changed nothing.",
|
|
|
|
|
|
};
|
2026-10-14 19:35:34 -07:00
|
|
|
|
|
|
|
|
|
|
function turnStart(): string | undefined {
|
|
|
|
|
|
const ev = state.transcript.findLast((e) => e.kind === "user.message" && typeof e.payload["head"] === "string");
|
|
|
|
|
|
return ev?.payload["head"] as string | undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function mountChanges(root: HTMLElement, compact = false): () => void {
|
|
|
|
|
|
const head = h("header", { class: "changes-header" });
|
|
|
|
|
|
const list = h("div", { class: "changes-list" });
|
|
|
|
|
|
const diffs = new Map<string, string>();
|
|
|
|
|
|
let changes: Changes | undefined;
|
|
|
|
|
|
let error = "";
|
|
|
|
|
|
let loading = false;
|
|
|
|
|
|
let again = false;
|
|
|
|
|
|
let timer: number | undefined;
|
|
|
|
|
|
let disposed = false;
|
|
|
|
|
|
let loadedSince: string | undefined;
|
2026-10-15 00:08:32 -07:00
|
|
|
|
let target = 0;
|
|
|
|
|
|
const pinned = () => (compact ? undefined : state.pinned);
|
|
|
|
|
|
const mode = () => (compact ? "main" : pinned() !== undefined ? pinRange : range);
|
2026-10-14 19:35:34 -07:00
|
|
|
|
|
|
|
|
|
|
const load = async () => {
|
|
|
|
|
|
const taskId = state.selectedTaskId;
|
|
|
|
|
|
if (!taskId) {
|
|
|
|
|
|
changes = undefined;
|
|
|
|
|
|
render();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-15 00:08:32 -07:00
|
|
|
|
const snapshot = pinned();
|
|
|
|
|
|
const since = snapshot === undefined && mode() === "turn" ? turnStart() : undefined;
|
|
|
|
|
|
if (snapshot === undefined && mode() === "turn" && !since) {
|
2026-10-14 19:35:34 -07:00
|
|
|
|
changes = undefined;
|
|
|
|
|
|
error = "No turn has started in this task yet.";
|
|
|
|
|
|
render();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
|
again = true;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
loading = true;
|
2026-10-15 00:08:32 -07:00
|
|
|
|
const started = target;
|
2026-10-14 19:35:34 -07:00
|
|
|
|
renderHead();
|
|
|
|
|
|
try {
|
2026-10-15 00:08:32 -07:00
|
|
|
|
const query = snapshot === undefined ? { since } : { snapshot, step: pinRange === "step" };
|
|
|
|
|
|
const fresh = await get("/api/tasks/{taskId}/changes", { path: { taskId }, query });
|
|
|
|
|
|
if (started === target) {
|
|
|
|
|
|
loadedSince = since;
|
|
|
|
|
|
if (fresh.tree !== changes?.tree || fresh.base !== changes?.base) {
|
|
|
|
|
|
diffs.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
changes = fresh;
|
|
|
|
|
|
error = "";
|
2026-10-14 19:35:34 -07:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
2026-10-15 00:08:32 -07:00
|
|
|
|
if (started === target) {
|
|
|
|
|
|
error = errorText(err);
|
|
|
|
|
|
}
|
2026-10-14 19:35:34 -07:00
|
|
|
|
} finally {
|
|
|
|
|
|
loading = false;
|
|
|
|
|
|
}
|
2026-10-15 00:08:32 -07:00
|
|
|
|
if (disposed) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-14 19:35:34 -07:00
|
|
|
|
render();
|
|
|
|
|
|
if (again) {
|
|
|
|
|
|
again = false;
|
|
|
|
|
|
void load();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-15 00:08:32 -07:00
|
|
|
|
const retarget = () => {
|
|
|
|
|
|
target++;
|
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
|
changes = undefined;
|
|
|
|
|
|
diffs.clear();
|
|
|
|
|
|
error = "";
|
|
|
|
|
|
render();
|
|
|
|
|
|
void load();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-14 19:35:34 -07:00
|
|
|
|
const soon = () => {
|
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
|
timer = setTimeout(() => void load(), 1500);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const toggle = (): HTMLElement | null => {
|
|
|
|
|
|
if (compact) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2026-10-15 00:08:32 -07:00
|
|
|
|
if (pinned() !== undefined) {
|
|
|
|
|
|
const button = (r: typeof pinRange, label: string) => {
|
|
|
|
|
|
const b = h("button", { type: "button", class: r === pinRange ? "active" : "" }, label);
|
|
|
|
|
|
b.onclick = () => {
|
|
|
|
|
|
if (pinRange === r) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
pinRange = r;
|
|
|
|
|
|
retarget();
|
|
|
|
|
|
};
|
|
|
|
|
|
return b;
|
|
|
|
|
|
};
|
|
|
|
|
|
const live = h("button", { type: "button", title: "Back to the live worktree" }, "Live");
|
|
|
|
|
|
live.onclick = () => pinSnapshot(undefined);
|
|
|
|
|
|
return h("nav", { class: "range-tabs" }, button("main", "Since main"), button("step", "This step"), live);
|
|
|
|
|
|
}
|
2026-10-14 19:35:34 -07:00
|
|
|
|
const button = (r: typeof range, label: string) => {
|
|
|
|
|
|
const b = h("button", { type: "button", class: r === range ? "active" : "" }, label);
|
|
|
|
|
|
b.onclick = () => {
|
|
|
|
|
|
if (range === r) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
range = r;
|
2026-10-15 00:08:32 -07:00
|
|
|
|
retarget();
|
2026-10-14 19:35:34 -07:00
|
|
|
|
};
|
|
|
|
|
|
return b;
|
|
|
|
|
|
};
|
|
|
|
|
|
return h("nav", { class: "range-tabs" }, button("main", "Since main"), button("turn", "This turn"));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-15 00:08:32 -07:00
|
|
|
|
const where = (): HTMLElement | null => {
|
|
|
|
|
|
const n = pinned();
|
|
|
|
|
|
if (n === undefined) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const snaps = snapshots();
|
|
|
|
|
|
const snap = snaps.find((s) => s.n === n);
|
|
|
|
|
|
const step = (to: number, label: string) => {
|
|
|
|
|
|
const b = h("button", { type: "button", class: "snap-step", disabled: !snaps.some((s) => s.n === to) }, label);
|
|
|
|
|
|
b.onclick = () => pinSnapshot(to);
|
|
|
|
|
|
return b;
|
|
|
|
|
|
};
|
|
|
|
|
|
return h(
|
|
|
|
|
|
"div",
|
|
|
|
|
|
{ class: "snap-where" },
|
|
|
|
|
|
`snapshot ${n}`,
|
|
|
|
|
|
snap ? ` · ${snap.trigger} · ${new Date(snap.created_at).toLocaleTimeString([], { hour12: false })}` : "",
|
|
|
|
|
|
" · ",
|
|
|
|
|
|
step(n - 1, `← #${n - 1}`),
|
|
|
|
|
|
" ",
|
|
|
|
|
|
step(n + 1, `#${n + 1} →`),
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-14 19:35:34 -07:00
|
|
|
|
const renderHead = () => {
|
|
|
|
|
|
if (!changes) {
|
2026-10-15 00:08:32 -07:00
|
|
|
|
replace(
|
|
|
|
|
|
head,
|
|
|
|
|
|
h("div", { class: "changes-where" }, where(), h("span", { class: "changes-summary" }, error || (state.selectedTaskId ? "Loading changes…" : "No task selected."))),
|
|
|
|
|
|
toggle(),
|
|
|
|
|
|
);
|
2026-10-14 19:35:34 -07:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const adds = changes.files.reduce((n, f) => n + f.additions, 0);
|
|
|
|
|
|
const dels = changes.files.reduce((n, f) => n + f.deletions, 0);
|
|
|
|
|
|
replace(
|
|
|
|
|
|
head,
|
|
|
|
|
|
h(
|
2026-10-15 00:08:32 -07:00
|
|
|
|
"div",
|
|
|
|
|
|
{ class: "changes-where" },
|
|
|
|
|
|
where(),
|
|
|
|
|
|
h(
|
|
|
|
|
|
"span",
|
|
|
|
|
|
{ class: "changes-summary" },
|
|
|
|
|
|
`${changes.files.length} ${changes.files.length === 1 ? "file" : "files"} `,
|
|
|
|
|
|
h("span", { class: "adds" }, `+${adds}`),
|
|
|
|
|
|
" ",
|
|
|
|
|
|
h("span", { class: "dels" }, `−${dels}`),
|
|
|
|
|
|
h("span", { class: "base", title: changes.base }, ` vs ${changes.base.slice(0, 8)}`),
|
|
|
|
|
|
error && h("span", { class: "error-text" }, ` ${error}`),
|
|
|
|
|
|
),
|
2026-10-14 19:35:34 -07:00
|
|
|
|
),
|
|
|
|
|
|
toggle(),
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const fileRow = (c: Changes, f: FileChange): HTMLElement => {
|
|
|
|
|
|
const body = h("div", { class: "file-diff" });
|
|
|
|
|
|
const details = h(
|
|
|
|
|
|
"details",
|
|
|
|
|
|
{ class: `file ${f.status}`, open: open.has(f.path) },
|
|
|
|
|
|
h(
|
|
|
|
|
|
"summary",
|
|
|
|
|
|
{},
|
|
|
|
|
|
h("span", { class: `status ${f.status}`, title: f.status.replace("_", " ") }, statusLetter[f.status]),
|
|
|
|
|
|
h("span", { class: "file-path" }, f.old_path ? `${f.old_path} → ${f.path}` : f.path),
|
|
|
|
|
|
f.binary ? h("span", { class: "binary" }, "binary") : h("span", { class: "counts" }, h("span", { class: "adds" }, `+${f.additions}`), " ", h("span", { class: "dels" }, `−${f.deletions}`)),
|
|
|
|
|
|
),
|
|
|
|
|
|
body,
|
|
|
|
|
|
);
|
|
|
|
|
|
const show = async () => {
|
|
|
|
|
|
const cached = diffs.get(f.path);
|
|
|
|
|
|
if (cached !== undefined) {
|
|
|
|
|
|
replace(body, renderPatch(cached));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
replace(body, h("div", { class: "hint" }, "loading…"));
|
|
|
|
|
|
try {
|
|
|
|
|
|
const d = await get("/api/tasks/{taskId}/diff", {
|
|
|
|
|
|
path: { taskId: state.selectedTaskId! },
|
|
|
|
|
|
query: { base: c.base, tree: c.tree, path: f.path, old_path: f.old_path },
|
|
|
|
|
|
});
|
|
|
|
|
|
diffs.set(f.path, d.patch);
|
|
|
|
|
|
replace(body, renderPatch(d.patch));
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
replace(body, h("div", { class: "error-text" }, errorText(err)));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
details.ontoggle = () => {
|
|
|
|
|
|
if (details.open) {
|
|
|
|
|
|
open.add(f.path);
|
|
|
|
|
|
void show();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
open.delete(f.path);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
if (details.open) {
|
|
|
|
|
|
void show();
|
|
|
|
|
|
}
|
|
|
|
|
|
return details;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const render = () => {
|
|
|
|
|
|
renderHead();
|
|
|
|
|
|
if (compact) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!state.selectedTaskId) {
|
|
|
|
|
|
replace(list, h("div", { class: "empty" }, "No task selected."));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!changes) {
|
|
|
|
|
|
replace(list, h("div", { class: "empty" }, error ? "" : "Loading…"));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (changes.files.length === 0) {
|
2026-10-15 00:08:32 -07:00
|
|
|
|
replace(list, h("div", { class: "empty" }, emptyText[mode()]));
|
2026-10-14 19:35:34 -07:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const c = changes;
|
|
|
|
|
|
const files = [...c.files].sort((a, b) => a.path.localeCompare(b.path));
|
|
|
|
|
|
replace(list, ...files.map((f) => fileRow(c, f)));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const offs = [
|
2026-10-15 00:08:32 -07:00
|
|
|
|
onEach(["selection"], retarget),
|
|
|
|
|
|
onEach(["pin"], () => {
|
|
|
|
|
|
if (!compact) {
|
|
|
|
|
|
retarget();
|
|
|
|
|
|
}
|
2026-10-14 19:35:34 -07:00
|
|
|
|
}),
|
|
|
|
|
|
onEach(["transcript"], (fresh) => {
|
2026-10-15 00:08:32 -07:00
|
|
|
|
if (pinned() !== undefined) {
|
|
|
|
|
|
if ((fresh as ByreEvent[]).some((ev) => ev.kind === "snapshot.created")) {
|
|
|
|
|
|
renderHead();
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-14 19:35:34 -07:00
|
|
|
|
if (mode() === "turn" && turnStart() !== loadedSince) {
|
|
|
|
|
|
void load();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((fresh as ByreEvent[]).some((ev) => ev.kind === "agent.tool_result" || ev.kind === "turn.ended")) {
|
|
|
|
|
|
soon();
|
|
|
|
|
|
}
|
|
|
|
|
|
}),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
replace(root, compact ? h("div", { class: "changes-view compact" }, head) : h("div", { class: "changes-view" }, head, list));
|
|
|
|
|
|
render();
|
|
|
|
|
|
void load();
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
disposed = true;
|
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
|
for (const off of offs) {
|
|
|
|
|
|
off();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderPatch(patch: string): HTMLElement {
|
|
|
|
|
|
const lines = patch.split("\n");
|
|
|
|
|
|
const start = lines.findIndex((l) => l.startsWith("@@"));
|
|
|
|
|
|
const body = start === -1 ? [] : lines.slice(start);
|
|
|
|
|
|
if (body.length === 0) {
|
|
|
|
|
|
return h("div", { class: "hint" }, "No textual changes.");
|
|
|
|
|
|
}
|
|
|
|
|
|
return h(
|
|
|
|
|
|
"pre",
|
|
|
|
|
|
{ class: "diff" },
|
|
|
|
|
|
...body.map((l) => {
|
|
|
|
|
|
const cls = l.startsWith("@@") ? "hunk" : l.startsWith("+") ? "add" : l.startsWith("-") ? "del" : l.startsWith("\\") ? "meta" : "ctx";
|
|
|
|
|
|
return h("span", { class: `line ${cls}` }, l || " ");
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|