Navigate to snapshots from the transcript

This commit is contained in:
Ian Gulliver
2026-10-15 00:08:32 -07:00
parent 75c76eff0a
commit c778741a4a
23 changed files with 763 additions and 202 deletions
+83 -28
View File
@@ -1,6 +1,6 @@
import { get } from "./api.js";
import { errorText, h, replace } from "./dom.js";
import { onEach, state } from "./state.js";
import { onEach, pinSnapshot, snapshots, state } from "./state.js";
const statusLetter = {
added: "A",
modified: "M",
@@ -10,6 +10,12 @@ const statusLetter = {
};
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"];
@@ -25,7 +31,9 @@ export function mountChanges(root, compact = false) {
let timer;
let disposed = false;
let loadedSince;
const mode = () => (compact ? "main" : range);
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) {
@@ -33,8 +41,9 @@ export function mountChanges(root, compact = false) {
render();
return;
}
const since = mode() === "turn" ? turnStart() : undefined;
if (mode() === "turn" && !since) {
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();
@@ -45,31 +54,46 @@ export function mountChanges(root, compact = false) {
return;
}
loading = true;
const started = target;
renderHead();
try {
const fresh = await get("/api/tasks/{taskId}/changes", { path: { taskId }, query: { since } });
if (disposed || state.selectedTaskId !== taskId) {
return;
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 = "";
}
loadedSince = since;
if (fresh.tree !== changes?.tree || fresh.base !== changes?.base) {
diffs.clear();
}
changes = fresh;
error = "";
}
catch (err) {
error = errorText(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);
@@ -78,6 +102,22 @@ export function mountChanges(root, compact = false) {
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 = () => {
@@ -85,24 +125,34 @@ export function mountChanges(root, compact = false) {
return;
}
range = r;
changes = undefined;
diffs.clear();
error = "";
render();
void load();
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("span", { class: "changes-summary" }, error || (state.selectedTaskId ? "Loading changes…" : "No task selected.")), toggle());
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("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());
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" });
@@ -154,7 +204,7 @@ export function mountChanges(root, compact = false) {
return;
}
if (changes.files.length === 0) {
replace(list, h("div", { class: "empty" }, mode() === "turn" ? "No changes this turn." : "No changes from the default branch."));
replace(list, h("div", { class: "empty" }, emptyText[mode()]));
return;
}
const c = changes;
@@ -162,14 +212,19 @@ export function mountChanges(root, compact = false) {
replace(list, ...files.map((f) => fileRow(c, f)));
};
const offs = [
onEach(["selection"], () => {
changes = undefined;
diffs.clear();
error = "";
render();
void load();
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;