Navigate to snapshots from the transcript
This commit is contained in:
+111
-35
@@ -1,6 +1,6 @@
|
||||
import { get, type ByreEvent, type Schemas } from "./api.js";
|
||||
import { errorText, h, replace } from "./dom.js";
|
||||
import { onEach, state } from "./state.js";
|
||||
import { onEach, pinSnapshot, snapshots, state } from "./state.js";
|
||||
|
||||
type Changes = Schemas["TaskChanges"];
|
||||
type FileChange = Schemas["FileChange"];
|
||||
@@ -15,6 +15,13 @@ const statusLetter: Record<FileChange["status"], string> = {
|
||||
|
||||
const open = new Set<string>();
|
||||
let range: "main" | "turn" = "main";
|
||||
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.",
|
||||
};
|
||||
|
||||
function turnStart(): string | undefined {
|
||||
const ev = state.transcript.findLast((e) => e.kind === "user.message" && typeof e.payload["head"] === "string");
|
||||
@@ -32,7 +39,9 @@ export function mountChanges(root: HTMLElement, compact = false): () => void {
|
||||
let timer: number | undefined;
|
||||
let disposed = false;
|
||||
let loadedSince: string | undefined;
|
||||
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;
|
||||
@@ -41,8 +50,9 @@ export function mountChanges(root: HTMLElement, compact = false): () => void {
|
||||
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();
|
||||
@@ -53,23 +63,29 @@ export function mountChanges(root: HTMLElement, compact = false): () => void {
|
||||
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;
|
||||
@@ -77,6 +93,16 @@ export function mountChanges(root: HTMLElement, compact = false): () => void {
|
||||
}
|
||||
};
|
||||
|
||||
const retarget = () => {
|
||||
target++;
|
||||
clearTimeout(timer);
|
||||
changes = undefined;
|
||||
diffs.clear();
|
||||
error = "";
|
||||
render();
|
||||
void load();
|
||||
};
|
||||
|
||||
const soon = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => void load(), 1500);
|
||||
@@ -86,6 +112,22 @@ export function mountChanges(root: HTMLElement, compact = false): () => void {
|
||||
if (compact) {
|
||||
return null;
|
||||
}
|
||||
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);
|
||||
}
|
||||
const button = (r: typeof range, label: string) => {
|
||||
const b = h("button", { type: "button", class: r === range ? "active" : "" }, label);
|
||||
b.onclick = () => {
|
||||
@@ -93,20 +135,44 @@ export function mountChanges(root: HTMLElement, compact = false): () => void {
|
||||
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 = (): 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} →`),
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -114,14 +180,19 @@ export function mountChanges(root: HTMLElement, compact = false): () => void {
|
||||
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}`),
|
||||
"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(),
|
||||
);
|
||||
@@ -187,7 +258,7 @@ export function mountChanges(root: HTMLElement, compact = false): () => void {
|
||||
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;
|
||||
@@ -196,14 +267,19 @@ export function mountChanges(root: HTMLElement, compact = false): () => void {
|
||||
};
|
||||
|
||||
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 as ByreEvent[]).some((ev) => ev.kind === "snapshot.created")) {
|
||||
renderHead();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mode() === "turn" && turnStart() !== loadedSince) {
|
||||
void load();
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user