Fork tasks from snapshots; snapshot only when text follows tool calls
This commit is contained in:
Vendored
+46
-71
@@ -1,7 +1,7 @@
|
||||
import { get, post } 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 } from "./state.js";
|
||||
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, state } from "./state.js";
|
||||
export const models = ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"];
|
||||
const quietReply = "NO_REPLY";
|
||||
const taskSource = {
|
||||
@@ -155,8 +155,6 @@ export function mountChat(root, src, makeHead) {
|
||||
tools: new Map(),
|
||||
subagents: new Map(),
|
||||
run: undefined,
|
||||
anchors: [],
|
||||
unplaced: [],
|
||||
lastMessage: undefined,
|
||||
turnStart: undefined,
|
||||
keepTurnStart: false,
|
||||
@@ -257,11 +255,11 @@ export function mountChat(root, src, makeHead) {
|
||||
releaseImages(entry.imgs);
|
||||
pending = pending.filter((p) => p !== entry);
|
||||
};
|
||||
const runHead = (run) => [subagentTag(run.parent, ctx), runSummary(run.calls), snapMark(run.snaps)];
|
||||
const runHead = (run) => [subagentTag(run.parent, ctx), runSummary(run.calls)];
|
||||
const addToRun = (el, call, parent) => {
|
||||
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;
|
||||
}
|
||||
@@ -279,49 +277,6 @@ export function mountChat(root, src, makeHead) {
|
||||
run.group.body.append(el);
|
||||
replace(run.group.summary, ...runHead(run));
|
||||
};
|
||||
const addAnchor = (seq, el, turnEnd) => {
|
||||
const 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, n) => {
|
||||
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) => {
|
||||
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;
|
||||
const dropStream = () => {
|
||||
if (!stream) {
|
||||
@@ -402,20 +357,17 @@ export function mountChat(root, src, makeHead) {
|
||||
continue;
|
||||
}
|
||||
if (ev.kind === "snapshot.created") {
|
||||
placeSnapshot(p);
|
||||
ctx.run = undefined;
|
||||
put(snapRow(p, 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);
|
||||
@@ -428,7 +380,7 @@ export function mountChat(root, src, makeHead) {
|
||||
}
|
||||
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)) {
|
||||
@@ -453,9 +405,6 @@ export function mountChat(root, src, makeHead) {
|
||||
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();
|
||||
@@ -768,20 +717,29 @@ function claudeTaskStatus(p, ctx) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
function snapMark(ns) {
|
||||
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, taskId) {
|
||||
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) {
|
||||
const counts = new Map();
|
||||
@@ -871,6 +829,23 @@ function renderEvent(ev, ctx) {
|
||||
}
|
||||
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) => {
|
||||
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);
|
||||
case "task.merged":
|
||||
|
||||
Reference in New Issue
Block a user