Fork tasks from snapshots; snapshot only when text follows tool calls

This commit is contained in:
Ian Gulliver
2026-10-15 11:36:51 -07:00
parent 3fe0374ac5
commit 0000db1f86
25 changed files with 893 additions and 421 deletions
+51 -86
View File
@@ -1,7 +1,7 @@
import { get, post, type AgentSession, type ByreEvent, type Participant, type Snapshot } from "./api.js";
import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
import { markdown } from "./markdown.js";
import { currentSession, findTask, focusTab, onEach, pinSnapshot, setFocus, state, type Topic } from "./state.js";
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, state, type Topic } from "./state.js";
export type Usage = { used: number; window: number; model: string };
@@ -38,7 +38,6 @@ type Run = {
first: HTMLElement;
group: { el: HTMLDetailsElement; summary: HTMLElement; body: HTMLElement } | undefined;
calls: Call[];
snaps: number[];
parent: string;
};
@@ -48,21 +47,10 @@ type Subagent = {
status: HTMLElement;
};
type Anchor = {
seq: number;
turnEnd: boolean;
el: HTMLElement;
snaps: number[];
mark: HTMLElement | undefined;
run: Run | undefined;
};
type Ctx = {
tools: Map<string, HTMLElement>;
subagents: Map<string, Subagent>;
run: Run | undefined;
anchors: Anchor[];
unplaced: Snapshot[];
root: string;
taskId: string | undefined;
lastMessage: HTMLElement | undefined;
@@ -234,8 +222,6 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
tools: new Map(),
subagents: new Map(),
run: undefined,
anchors: [],
unplaced: [],
lastMessage: undefined,
turnStart: undefined,
keepTurnStart: false,
@@ -344,12 +330,12 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
pending = pending.filter((p) => p !== entry);
};
const runHead = (run: Run) => [subagentTag(run.parent, ctx), runSummary(run.calls), snapMark(run.snaps)];
const runHead = (run: Run) => [subagentTag(run.parent, ctx), runSummary(run.calls)];
const addToRun = (el: HTMLElement, call: Call | undefined, parent: string) => {
const run = ctx.run;
if (!run || run.parent !== parent) {
ctx.run = { first: el, group: undefined, calls: call ? [call] : [], snaps: [], parent };
ctx.run = { first: el, group: undefined, calls: call ? [call] : [], parent };
put(el);
return;
}
@@ -368,52 +354,6 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
replace(run.group.summary, ...runHead(run));
};
const addAnchor = (seq: number, el: HTMLElement, turnEnd: boolean) => {
const anchor: Anchor = { seq, turnEnd, el, snaps: [], mark: undefined, run: turnEnd ? undefined : ctx.run };
ctx.anchors.push(anchor);
if (!turnEnd) {
return;
}
const waiting = ctx.unplaced;
ctx.unplaced = [];
for (const snap of waiting) {
placeSnapshot(snap);
}
};
const markAnchor = (anchor: Anchor, n: number) => {
anchor.snaps.push(n);
anchor.mark?.remove();
anchor.mark = snapMark(anchor.snaps) ?? undefined;
if (anchor.mark) {
anchor.el.append(anchor.mark);
}
const run = anchor.run;
if (!run) {
return;
}
run.snaps.push(n);
if (run.group) {
replace(run.group.summary, ...runHead(run));
}
};
const placeSnapshot = (snap: Snapshot) => {
const before = ctx.anchors.findLast((a) => a.seq <= snap.event_seq);
if (snap.trigger !== "turn_end" || before?.turnEnd) {
if (before) {
markAnchor(before, snap.n);
}
return;
}
const after = ctx.anchors.find((a) => a.turnEnd && a.seq > snap.event_seq);
if (after) {
markAnchor(after, snap.n);
return;
}
ctx.unplaced.push(snap);
};
let stream: { el: HTMLElement; text: string; timer: number } | undefined;
const dropStream = () => {
if (!stream) {
@@ -495,20 +435,17 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
continue;
}
if (ev.kind === "snapshot.created") {
placeSnapshot(p as unknown as Snapshot);
ctx.run = undefined;
put(snapRow(p as unknown as Snapshot, ctx.taskId));
continue;
}
if (ev.kind === "agent.tool_call" && isSubagentCall(p)) {
ctx.run = undefined;
const el = renderSubagent(p, ctx);
put(el);
addAnchor(ev.seq, el.querySelector("summary")!, false);
put(renderSubagent(p, ctx));
continue;
}
if (ev.kind === "agent.tool_call") {
const el = renderToolCall(p, ctx);
addToRun(el, { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) }, parent);
addAnchor(ev.seq, el.querySelector("summary")!, false);
addToRun(renderToolCall(p, ctx), { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) }, parent);
continue;
}
const el = renderEvent(ev, ctx);
@@ -521,7 +458,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
}
ctx.run = undefined;
if (ev.kind === "turn.ended" && p["handoff"] === true && ctx.handoff) {
addAnchor(ev.seq, closeHandoff(el)!, true);
closeHandoff(el);
continue;
}
if (ev.kind === "turn.ended" || (ev.kind === "user.message" && p["mid_turn"] !== true)) {
@@ -546,9 +483,6 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
ctx.turnStart = el;
ctx.keepTurnStart = p["continuation"] === true;
}
if (ev.kind === "turn.ended" || ev.kind === "inbox.created") {
addAnchor(ev.seq, el, ev.kind === "turn.ended");
}
}
if (contextChanged) {
renderHead();
@@ -913,20 +847,28 @@ function claudeTaskStatus(p: Record<string, unknown>, ctx: Ctx): void {
}
}
function snapMark(ns: number[]): HTMLElement | null {
if (ns.length === 0) {
return null;
}
const lo = Math.min(...ns);
const hi = Math.max(...ns);
const b = h("button", { type: "button", class: "snap-mark", title: `Show snapshot ${hi} in the Changes tab` }, lo === hi ? `#${hi}` : `#${lo}–#${hi}`);
b.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
pinSnapshot(hi);
function snapRow(snap: Snapshot, taskId: string | undefined): HTMLElement {
const changes = h("button", { type: "button", title: `Show snapshot ${snap.n} in the Changes tab` }, "changes");
changes.onclick = () => {
pinSnapshot(snap.n);
focusTab("changes");
};
return b;
const fork = h("button", { type: "button", title: "Start a new task from this snapshot, its agent continuing the conversation as it stood here" }, "fork");
fork.onclick = async () => {
if (!taskId) {
return;
}
fork.disabled = true;
try {
const task = await post("/api/tasks/{taskId}/snapshots/{n}/fork", { path: { taskId, n: snap.n } });
focusTask(task.id);
} catch (err) {
fork.disabled = false;
void showAlert(errorText(err));
}
};
const time = new Date(snap.created_at).toLocaleTimeString([], { hour12: false });
return h("div", { class: "snap-row", title: `${snap.trigger.replace("_", " ")} · ${time}` }, `snapshot #${snap.n}`, changes, fork);
}
function runSummary(calls: Call[]): HTMLElement {
@@ -1040,6 +982,29 @@ function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
return null;
}
case "task.created": {
if (typeof p["forked_from"] !== "string") {
return null;
}
const src = findTask(p["forked_from"]);
const title = src?.title ?? p["forked_from"];
const link =
src?.status === "active"
? h(
"a",
{
href: `#/task/${src.id}`,
onclick: (e: MouseEvent) => {
e.preventDefault();
focusTask(src.id);
},
},
title,
)
: title;
return h("div", { class: "note" }, `${ev.actor} forked this task from `, link, ` at snapshot #${String(p["forked_at"])}; its agent continues that task's conversation as it stood there `, when);
}
case "task.merge_title":
return h("div", { class: "note" }, `${ev.actor} set the merge commit title to "${String(p["merge_title"])}" `, when);