import { get } from "./api.js"; import { errorText, h, replace } from "./dom.js"; import { onEach, pinSnapshot, snapshots, state } from "./state.js"; const statusLetter = { added: "A", modified: "M", deleted: "D", renamed: "R", type_changed: "T", }; const open = new Set(); let range = "main"; let pinRange = "step"; const emptyText = { main: "No changes from the default branch.", turn: "No changes this turn.", step: "This step changed nothing.", }; function turnStart() { const ev = state.transcript.findLast((e) => e.kind === "user.message" && typeof e.payload["head"] === "string"); return ev?.payload["head"]; } export function mountChanges(root, compact = false) { const head = h("header", { class: "changes-header" }); const list = h("div", { class: "changes-list" }); const diffs = new Map(); let changes; let error = ""; let loading = false; let again = false; let timer; let disposed = false; let loadedSince; let target = 0; const pinned = () => (compact ? undefined : state.pinned); const mode = () => (compact ? "main" : pinned() !== undefined ? pinRange : range); const load = async () => { const taskId = state.selectedTaskId; if (!taskId) { changes = undefined; render(); return; } const snapshot = pinned(); const since = snapshot === undefined && mode() === "turn" ? turnStart() : undefined; if (snapshot === undefined && mode() === "turn" && !since) { changes = undefined; error = "No turn has started in this task yet."; render(); return; } if (loading) { again = true; return; } loading = true; const started = target; renderHead(); try { 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 = ""; } } catch (err) { if (started === target) { error = errorText(err); } } finally { loading = false; } if (disposed) { return; } render(); if (again) { again = false; void load(); } }; const retarget = () => { target++; clearTimeout(timer); changes = undefined; diffs.clear(); error = ""; render(); void load(); }; const soon = () => { clearTimeout(timer); timer = setTimeout(() => void load(), 1500); }; const toggle = () => { if (compact) { return null; } if (pinned() !== undefined) { const button = (r, label) => { 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); } const button = (r, label) => { const b = h("button", { type: "button", class: r === range ? "active" : "" }, label); b.onclick = () => { if (range === r) { return; } range = r; retarget(); }; return b; }; return h("nav", { class: "range-tabs" }, button("main", "Since main"), button("turn", "This turn")); }; const where = () => { const n = pinned(); if (n === undefined) { return null; } const snaps = snapshots(); const snap = snaps.find((s) => s.n === n); const step = (to, label) => { 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} →`)); }; const renderHead = () => { if (!changes) { replace(head, h("div", { class: "changes-where" }, where(), h("span", { class: "changes-summary" }, error || (state.selectedTaskId ? "Loading changes…" : "No task selected."))), toggle()); 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("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}`))), toggle()); }; const fileRow = (c, f) => { 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) { replace(list, h("div", { class: "empty" }, emptyText[mode()])); 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 = [ onEach(["selection"], retarget), onEach(["pin"], () => { if (!compact) { retarget(); } }), onEach(["transcript"], (fresh) => { if (pinned() !== undefined) { if (fresh.some((ev) => ev.kind === "snapshot.created")) { renderHead(); } return; } if (mode() === "turn" && turnStart() !== loadedSince) { void load(); return; } if (fresh.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) { 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 || " "); })); } //# sourceMappingURL=changes.js.map