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
+7 -4
View File
@@ -254,9 +254,12 @@ paths:
description: > description: >
Everything in the task's worktree (committed, staged, unstaged and untracked, respecting .gitignore) as a tree, Everything in the task's worktree (committed, staged, unstaged and untracked, respecting .gitignore) as a tree,
compared with the merge-base of the task branch and the project's default branch, or with since when given. compared with the merge-base of the task branch and the project's default branch, or with since when given.
The worktree's own index is not touched. The worktree's own index is not touched. With snapshot, the tree is that snapshot's instead, compared with the
merge-base of its commit and the default branch, or with the previous snapshot's tree when step is true.
parameters: parameters:
- { name: since, in: query, required: false, schema: { type: string }, description: "A commit to compare with instead of the merge-base, such as a user.message event's head." } - { name: since, in: query, required: false, schema: { type: string }, description: "A commit to compare with instead of the merge-base, such as a user.message event's head. Not allowed with snapshot." }
- { name: snapshot, in: query, required: false, schema: { type: integer }, description: "A snapshot's n: show the task as it was in that snapshot rather than the live worktree." }
- { name: step, in: query, required: false, schema: { type: boolean }, description: "With snapshot: compare with the previous snapshot (the snapshot commit's parent for the first one)." }
responses: responses:
"200": "200":
description: Per-file stats. description: Per-file stats.
@@ -641,8 +644,8 @@ components:
type: object type: object
required: [base, tree, files] required: [base, tree, files]
properties: properties:
base: { type: string, description: Merge-base commit of the task branch and the default branch. } base: { type: string, description: "The commit or tree compared with: the merge-base of the task branch and the default branch, since, or the previous snapshot's tree." }
tree: { type: string, description: Tree of the worktree as if everything were staged. } tree: { type: string, description: "Tree of the worktree as if everything were staged, or the snapshot's tree." }
files: files:
type: array type: array
items: { $ref: "#/components/schemas/FileChange" } items: { $ref: "#/components/schemas/FileChange" }
+6 -2
View File
@@ -138,7 +138,7 @@ Answers reach the agent as one message, listing each answer under the question o
`GET /api/tasks/{taskId}/changes` snapshots the task's worktree as if everything were staged: it copies the worktree's index to a temporary file, runs `git add --all` against that copy (`GIT_INDEX_FILE`), and `git write-tree`s it, so committed, staged, unstaged and untracked work is included, `.gitignore` is respected, and the worktree's real index is never touched. Starting from the real index rather than `read-tree HEAD` keeps git's stat cache, so unchanged files aren't rehashed. The tree is compared with `git merge-base HEAD <default branch>`, or with `since` when given, using rename detection, giving per-file status and line counts. Work already merged into the default branch therefore drops out. The Changes view toggles between **Since main** and **This turn**, which passes the latest `user.message` event's `head` as `since`; the summary above the inbox always shows since main. `GET /api/tasks/{taskId}/diff` returns one file's patch between a `base` and `tree` from that response. Each request writes the changed blobs and a tree into the repo's object store as loose objects. `GET /api/tasks/{taskId}/changes` snapshots the task's worktree as if everything were staged: it copies the worktree's index to a temporary file, runs `git add --all` against that copy (`GIT_INDEX_FILE`), and `git write-tree`s it, so committed, staged, unstaged and untracked work is included, `.gitignore` is respected, and the worktree's real index is never touched. Starting from the real index rather than `read-tree HEAD` keeps git's stat cache, so unchanged files aren't rehashed. The tree is compared with `git merge-base HEAD <default branch>`, or with `since` when given, using rename detection, giving per-file status and line counts. Work already merged into the default branch therefore drops out. The Changes view toggles between **Since main** and **This turn**, which passes the latest `user.message` event's `head` as `since`; the summary above the inbox always shows since main. `GET /api/tasks/{taskId}/diff` returns one file's patch between a `base` and `tree` from that response. Each request writes the changed blobs and a tree into the repo's object store as loose objects.
The Changes view lists the files with their counts, opens a file's diff on click (open files stay open across refreshes), and refreshes when it opens, when the selected task changes, and 1.5s after the selected task's last tool result or turn end. The Inbox view shows just its summary line (file count, +/− totals, base) for the selected task at the top. The Changes view lists the files with their counts, opens a file's diff on click (open files stay open across refreshes), and refreshes when it opens, when the selected task changes, and 1.5s after the selected task's last tool result or turn end. It can also be pinned to a snapshot (see "Snapshots"). The Inbox view shows just its summary line (file count, +/− totals, base) for the selected task at the top.
## Snapshots ## Snapshots
@@ -153,7 +153,11 @@ Triggers (`trigger`):
A snapshot whose tree equals the task's latest snapshot's tree is skipped. Snapshots run one at a time per task: a trigger that arrives while one is running replaces any already waiting one, and the waiting one runs when the current one finishes, so a burst of tool calls costs at most two. Failures are logged and dropped. A snapshot whose tree equals the task's latest snapshot's tree is skipped. Snapshots run one at a time per task: a trigger that arrives while one is running replaces any already waiting one, and the waiting one runs when the current one finishes, so a burst of tool calls costs at most two. Failures are logged and dropped.
Merging or discarding a task stops new snapshots for it, waits for one in progress, and deletes its `refs/byre/tasks/<task-id>/` refs; the rows stay. `GET /api/tasks/{taskId}/snapshots` lists a task's rows, oldest first. `git for-each-ref refs/byre/` in the project's repo lists the live refs. The UI doesn't show snapshots yet. Merging or discarding a task stops new snapshots for it, waits for one in progress, and deletes its `refs/byre/tasks/<task-id>/` refs; the rows stay. `GET /api/tasks/{taskId}/snapshots` lists a task's rows, oldest first. `git for-each-ref refs/byre/` in the project's repo lists the live refs.
`GET /api/tasks/{taskId}/changes?snapshot=<n>` reports a snapshot instead of the live worktree: its `tree` is the snapshot's tree, and `base` is the merge-base of the snapshot's commit and the default branch, or with `step=true` the previous snapshot's tree (the snapshot commit's parent for snapshot 1). It reads the stored shas, so it works only while the task is active and its refs keep the objects; `since` with `snapshot` is a 400. `GET /api/tasks/{taskId}/diff` takes that `base` and `tree` as usual.
The transcript is the timeline. The UI learns a task's snapshots from the `snapshot.created` events in its transcript (`snapshots` in `web/src/state.ts`). Each snapshot is marked `#n` at the right of the transcript row it belongs to: for `tool_call`, `timer` and `ask_human`, the last tool call, `ask_human` note or turn end whose `seq` is at most the snapshot's `event_seq` (the PostToolUse hook fires before the tool's result is recorded, so that is the call that made it); for `turn_end`, that turn's end line, which may be the turn after `event_seq` since the snapshot and `turn.ended` race. A row with several snapshots shows `#a–#b`, and a collapsed group of tool calls shows the range of all its calls' snapshots. Clicking a mark pins the latest snapshot it covers (`state.pinned`, cleared when the selected task changes) and shows the Changes tab in the pane the transcript isn't in, unless a pane already shows it. Pinned, the Changes header reads `snapshot <n> · <trigger> · <time>` with ← and → to step to the neighbouring snapshots, and the range buttons are **Since main** and **This step** (`step=true`, the default when pinning) plus **Live**, which unpins. A pinned view doesn't refresh on tool results, since a snapshot never changes.
## Events ## Events
+138 -104
View File
@@ -677,11 +677,11 @@ type TaskStatus string
// TaskChanges defines model for TaskChanges. // TaskChanges defines model for TaskChanges.
type TaskChanges struct { type TaskChanges struct {
// Base Merge-base commit of the task branch and the default branch. // Base The commit or tree compared with: the merge-base of the task branch and the default branch, since, or the previous snapshot's tree.
Base string `json:"base"` Base string `json:"base"`
Files []FileChange `json:"files"` Files []FileChange `json:"files"`
// Tree Tree of the worktree as if everything were staged. // Tree Tree of the worktree as if everything were staged, or the snapshot's tree.
Tree string `json:"tree"` Tree string `json:"tree"`
} }
@@ -761,8 +761,14 @@ type RemovePushSubscriptionParams struct {
// GetTaskChangesParams defines parameters for GetTaskChanges. // GetTaskChangesParams defines parameters for GetTaskChanges.
type GetTaskChangesParams struct { type GetTaskChangesParams struct {
// Since A commit to compare with instead of the merge-base, such as a user.message event's head. // Since A commit to compare with instead of the merge-base, such as a user.message event's head. Not allowed with snapshot.
Since *string `form:"since,omitempty" json:"since,omitempty"` Since *string `form:"since,omitempty" json:"since,omitempty"`
// Snapshot A snapshot's n: show the task as it was in that snapshot rather than the live worktree.
Snapshot *int `form:"snapshot,omitempty" json:"snapshot,omitempty"`
// Step With snapshot: compare with the previous snapshot (the snapshot commit's parent for the first one).
Step *bool `form:"step,omitempty" json:"step,omitempty"`
} }
// GetTaskDiffParams defines parameters for GetTaskDiff. // GetTaskDiffParams defines parameters for GetTaskDiff.
@@ -1505,6 +1511,32 @@ func (siw *ServerInterfaceWrapper) GetTaskChanges(w http.ResponseWriter, r *http
return return
} }
// ------------- Optional query parameter "snapshot" -------------
err = runtime.BindQueryParameterWithOptions("form", true, false, "snapshot", r.URL.Query(), &params.Snapshot, runtime.BindQueryParameterOptions{Type: "integer", Format: ""})
if err != nil {
var requiredError *runtime.RequiredParameterError
if errors.As(err, &requiredError) {
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "snapshot"})
} else {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "snapshot", Err: err})
}
return
}
// ------------- Optional query parameter "step" -------------
err = runtime.BindQueryParameterWithOptions("form", true, false, "step", r.URL.Query(), &params.Step, runtime.BindQueryParameterOptions{Type: "boolean", Format: ""})
if err != nil {
var requiredError *runtime.RequiredParameterError
if errors.As(err, &requiredError) {
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "step"})
} else {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "step", Err: err})
}
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetTaskChanges(w, r, taskId, params) siw.Handler.GetTaskChanges(w, r, taskId, params)
})) }))
@@ -1996,107 +2028,109 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
// const string: with thousands of chunks the chained `+` fold is several // const string: with thousands of chunks the chained `+` fold is several
// times slower for the Go compiler than parsing a slice literal. // times slower for the Go compiler than parsing a slice literal.
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
"m/8NAAD//w==", "TlsNbpPCRrc69kuB6ZIRJfbjetGnryH0r7d85N6KQxQ4qrj+k+hquFPxkMAiuoCxH0/gmT7h7HN0L+bh",
"t4zcgBMxm77C8TK6wTG6rwc3H5G8SclfC6nWIar0oZBzBMcOV123dHgMe9ReTXqgLWoJ/+VHBE10jUlS",
"nn5Gdzxhe+iaUfTWna0Z9T9KBUae2GT2rr1E5SE7z8MaY93n/n24Y2TyT5t3urv73wAAAP//",
} }
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
+13 -6
View File
@@ -18,20 +18,27 @@ var statuses = map[byte]api.FileChangeStatus{
} }
func Changes(dir, defaultBranch, since string) (api.TaskChanges, error) { func Changes(dir, defaultBranch, since string) (api.TaskChanges, error) {
tree, err := Snapshot(dir)
if err != nil {
return api.TaskChanges{}, err
}
return Compare(dir, defaultBranch, "HEAD", tree, since)
}
func Parent(dir, commit string) (string, error) {
return run(dir, "rev-parse", "--verify", commit+"^")
}
func Compare(dir, defaultBranch, head, tree, since string) (api.TaskChanges, error) {
base := since base := since
if base == "" { if base == "" {
mb, err := run(dir, "merge-base", "HEAD", defaultBranch) mb, err := run(dir, "merge-base", head, defaultBranch)
if err != nil { if err != nil {
return api.TaskChanges{}, err return api.TaskChanges{}, err
} }
base = mb base = mb
} }
tree, err := Snapshot(dir)
if err != nil {
return api.TaskChanges{}, err
}
files, err := stats(dir, base, tree) files, err := stats(dir, base, tree)
if err != nil { if err != nil {
return api.TaskChanges{}, err return api.TaskChanges{}, err
+84
View File
@@ -142,3 +142,87 @@ func TestChanges(t *testing.T) {
t.Fatalf("CommitAll on a clean tree: %q %v", again, err) t.Fatalf("CommitAll on a clean tree: %q %v", again, err)
} }
} }
func TestCompareSnapshots(t *testing.T) {
dir := t.TempDir()
git := func(args ...string) string {
t.Helper()
out, err := run(dir, args...)
if err != nil {
t.Fatal(err)
}
return out
}
write := func(name, content string) {
t.Helper()
err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)
if err != nil {
t.Fatal(err)
}
}
snap := func(n int) (string, string) {
t.Helper()
tree, err := Snapshot(dir)
if err != nil {
t.Fatal(err)
}
commit, err := CommitSnapshot(dir, tree, SnapshotRef("t1", n), "snap", nil)
if err != nil {
t.Fatal(err)
}
return commit, tree
}
paths := func(c api.TaskChanges) string {
names := []string{}
for _, f := range c.Files {
names = append(names, f.Path)
}
return strings.Join(names, ",")
}
git("init", "--quiet", "--initial-branch=main")
git("config", "user.email", "test@example.com")
git("config", "user.name", "test")
write("a.txt", "a\n")
git("add", ".")
git("commit", "--quiet", "-m", "base")
git("switch", "--quiet", "-c", "task")
write("b.txt", "b\n")
commit1, tree1 := snap(1)
write("c.txt", "c\n")
commit2, tree2 := snap(2)
write("d.txt", "d\n")
sinceMain, err := Compare(dir, "main", commit2, tree2, "")
if err != nil {
t.Fatal(err)
}
if got := paths(sinceMain); got != "b.txt,c.txt" {
t.Fatalf("snapshot 2 since main: got %s", got)
}
step, err := Compare(dir, "main", commit2, tree2, tree1)
if err != nil {
t.Fatal(err)
}
if got := paths(step); got != "c.txt" {
t.Fatalf("snapshot 2 step: got %s", got)
}
patch, err := FileDiff(dir, step.Base, step.Tree, "c.txt", "")
if err != nil || !strings.Contains(patch, "+c") {
t.Fatalf("snapshot 2 step patch: %q %v", patch, err)
}
parent, err := Parent(dir, commit1)
if err != nil {
t.Fatal(err)
}
first, err := Compare(dir, "main", commit1, tree1, parent)
if err != nil {
t.Fatal(err)
}
if got := paths(first); got != "b.txt" {
t.Fatalf("snapshot 1 step: got %s", got)
}
}
+49
View File
@@ -29,6 +29,20 @@ func (s *Server) GetTaskChanges(w http.ResponseWriter, r *http.Request, taskID s
return return
} }
step := params.Step != nil && *params.Step
if params.Snapshot != nil {
if params.Since != nil {
writeError(w, http.StatusBadRequest, errors.New("since and snapshot can't be used together"))
return
}
s.snapshotChanges(w, r, t, p, *params.Snapshot, step)
return
}
if step {
writeError(w, http.StatusBadRequest, errors.New("step needs snapshot"))
return
}
since := "" since := ""
if params.Since != nil { if params.Since != nil {
since = *params.Since since = *params.Since
@@ -46,6 +60,41 @@ func (s *Server) GetTaskChanges(w http.ResponseWriter, r *http.Request, taskID s
writeJSON(w, http.StatusOK, changes) writeJSON(w, http.StatusOK, changes)
} }
func (s *Server) snapshotChanges(w http.ResponseWriter, r *http.Request, t api.Task, p api.Project, n int, step bool) {
snap, err := s.store.GetSnapshot(r.Context(), t.Id, n)
if err != nil {
writeStoreError(w, err)
return
}
since := ""
if step {
since, err = s.stepBase(r, t, snap)
if err != nil {
writeStoreError(w, err)
return
}
}
changes, err := git.Compare(t.WorktreePath, p.DefaultBranch, snap.CommitSha, snap.TreeSha, since)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, changes)
}
func (s *Server) stepBase(r *http.Request, t api.Task, snap api.Snapshot) (string, error) {
if snap.N == 1 {
return git.Parent(t.WorktreePath, snap.CommitSha)
}
prev, err := s.store.GetSnapshot(r.Context(), t.Id, snap.N-1)
if err != nil {
return "", err
}
return prev.TreeSha, nil
}
func (s *Server) GetTaskDiff(w http.ResponseWriter, r *http.Request, taskID string, params api.GetTaskDiffParams) { func (s *Server) GetTaskDiff(w http.ResponseWriter, r *http.Request, taskID string, params api.GetTaskDiffParams) {
t, err := s.store.GetTask(r.Context(), taskID) t, err := s.store.GetTask(r.Context(), taskID)
if err != nil { if err != nil {
+8
View File
@@ -46,6 +46,14 @@ func (s *Store) ListSnapshots(ctx context.Context, taskID string) ([]api.Snapsho
return snaps, rows.Err() return snaps, rows.Err()
} }
func (s *Store) GetSnapshot(ctx context.Context, taskID string, n int) (api.Snapshot, error) {
snap, err := scanSnapshot(s.db.QueryRowContext(ctx, `SELECT `+snapshotColumns+` FROM snapshots WHERE task_id = ? AND n = ?`, taskID, n))
if errors.Is(err, sql.ErrNoRows) {
return snap, ErrNotFound
}
return snap, err
}
func (s *Store) LastSnapshot(ctx context.Context, taskID string) (api.Snapshot, error) { func (s *Store) LastSnapshot(ctx context.Context, taskID string) (api.Snapshot, error) {
snap, err := scanSnapshot(s.db.QueryRowContext(ctx, `SELECT `+snapshotColumns+` FROM snapshots WHERE task_id = ? ORDER BY n DESC LIMIT 1`, taskID)) snap, err := scanSnapshot(s.db.QueryRowContext(ctx, `SELECT `+snapshotColumns+` FROM snapshots WHERE task_id = ? ORDER BY n DESC LIMIT 1`, taskID))
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"api.js","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"} {"version":3,"file":"api.js","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
+83 -28
View File
@@ -1,6 +1,6 @@
import { get } from "./api.js"; import { get } from "./api.js";
import { errorText, h, replace } from "./dom.js"; import { errorText, h, replace } from "./dom.js";
import { onEach, state } from "./state.js"; import { onEach, pinSnapshot, snapshots, state } from "./state.js";
const statusLetter = { const statusLetter = {
added: "A", added: "A",
modified: "M", modified: "M",
@@ -10,6 +10,12 @@ const statusLetter = {
}; };
const open = new Set(); const open = new Set();
let range = "main"; 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() { function turnStart() {
const ev = state.transcript.findLast((e) => e.kind === "user.message" && typeof e.payload["head"] === "string"); const ev = state.transcript.findLast((e) => e.kind === "user.message" && typeof e.payload["head"] === "string");
return ev?.payload["head"]; return ev?.payload["head"];
@@ -25,7 +31,9 @@ export function mountChanges(root, compact = false) {
let timer; let timer;
let disposed = false; let disposed = false;
let loadedSince; 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 load = async () => {
const taskId = state.selectedTaskId; const taskId = state.selectedTaskId;
if (!taskId) { if (!taskId) {
@@ -33,8 +41,9 @@ export function mountChanges(root, compact = false) {
render(); render();
return; return;
} }
const since = mode() === "turn" ? turnStart() : undefined; const snapshot = pinned();
if (mode() === "turn" && !since) { const since = snapshot === undefined && mode() === "turn" ? turnStart() : undefined;
if (snapshot === undefined && mode() === "turn" && !since) {
changes = undefined; changes = undefined;
error = "No turn has started in this task yet."; error = "No turn has started in this task yet.";
render(); render();
@@ -45,31 +54,46 @@ export function mountChanges(root, compact = false) {
return; return;
} }
loading = true; loading = true;
const started = target;
renderHead(); renderHead();
try { try {
const fresh = await get("/api/tasks/{taskId}/changes", { path: { taskId }, query: { since } }); const query = snapshot === undefined ? { since } : { snapshot, step: pinRange === "step" };
if (disposed || state.selectedTaskId !== taskId) { const fresh = await get("/api/tasks/{taskId}/changes", { path: { taskId }, query });
return; 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) { catch (err) {
error = errorText(err); if (started === target) {
error = errorText(err);
}
} }
finally { finally {
loading = false; loading = false;
} }
if (disposed) {
return;
}
render(); render();
if (again) { if (again) {
again = false; again = false;
void load(); void load();
} }
}; };
const retarget = () => {
target++;
clearTimeout(timer);
changes = undefined;
diffs.clear();
error = "";
render();
void load();
};
const soon = () => { const soon = () => {
clearTimeout(timer); clearTimeout(timer);
timer = setTimeout(() => void load(), 1500); timer = setTimeout(() => void load(), 1500);
@@ -78,6 +102,22 @@ export function mountChanges(root, compact = false) {
if (compact) { if (compact) {
return null; 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 button = (r, label) => {
const b = h("button", { type: "button", class: r === range ? "active" : "" }, label); const b = h("button", { type: "button", class: r === range ? "active" : "" }, label);
b.onclick = () => { b.onclick = () => {
@@ -85,24 +125,34 @@ export function mountChanges(root, compact = false) {
return; return;
} }
range = r; range = r;
changes = undefined; retarget();
diffs.clear();
error = "";
render();
void load();
}; };
return b; return b;
}; };
return h("nav", { class: "range-tabs" }, button("main", "Since main"), button("turn", "This turn")); 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 = () => { const renderHead = () => {
if (!changes) { 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; return;
} }
const adds = changes.files.reduce((n, f) => n + f.additions, 0); const adds = changes.files.reduce((n, f) => n + f.additions, 0);
const dels = changes.files.reduce((n, f) => n + f.deletions, 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 fileRow = (c, f) => {
const body = h("div", { class: "file-diff" }); const body = h("div", { class: "file-diff" });
@@ -154,7 +204,7 @@ export function mountChanges(root, compact = false) {
return; return;
} }
if (changes.files.length === 0) { 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; return;
} }
const c = changes; const c = changes;
@@ -162,14 +212,19 @@ export function mountChanges(root, compact = false) {
replace(list, ...files.map((f) => fileRow(c, f))); replace(list, ...files.map((f) => fileRow(c, f)));
}; };
const offs = [ const offs = [
onEach(["selection"], () => { onEach(["selection"], retarget),
changes = undefined; onEach(["pin"], () => {
diffs.clear(); if (!compact) {
error = ""; retarget();
render(); }
void load();
}), }),
onEach(["transcript"], (fresh) => { onEach(["transcript"], (fresh) => {
if (pinned() !== undefined) {
if (fresh.some((ev) => ev.kind === "snapshot.created")) {
renderHead();
}
return;
}
if (mode() === "turn" && turnStart() !== loadedSince) { if (mode() === "turn" && turnStart() !== loadedSince) {
void load(); void load();
return; return;
+1 -1
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -21,6 +21,12 @@ export function openTranscript() {
} }
(panes.find((p) => p.current() !== "tasks") ?? panes[0])?.show("transcript"); (panes.find((p) => p.current() !== "tasks") ?? panes[0])?.show("transcript");
} }
export function openChanges(from) {
if (panes.some((p) => p.current() === "changes")) {
return;
}
(panes.find((p) => !p.el.contains(from)) ?? panes[0])?.show("changes");
}
export class Pane { export class Pane {
key; key;
el; el;
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"panes.js","sourceRoot":"","sources":["../src/panes.ts"],"names":[],"mappings":"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"} {"version":3,"file":"panes.js","sourceRoot":"","sources":["../src/panes.ts"],"names":[],"mappings":"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"}
+9
View File
@@ -8,6 +8,7 @@ export const state = {
selectedTaskId: undefined, selectedTaskId: undefined,
transcript: [], transcript: [],
lastTranscriptSeq: 0, lastTranscriptSeq: 0,
pinned: undefined,
connected: false, connected: false,
}; };
const listeners = new Map(); const listeners = new Map();
@@ -48,6 +49,13 @@ export function findTask(id) {
export function currentSession(taskId) { export function currentSession(taskId) {
return state.sessions.get(taskId)?.findLast((s) => s.state !== "completed") ?? state.sessions.get(taskId)?.at(-1); return state.sessions.get(taskId)?.findLast((s) => s.state !== "completed") ?? state.sessions.get(taskId)?.at(-1);
} }
export function snapshots() {
return state.transcript.filter((e) => e.kind === "snapshot.created").map((e) => e.payload);
}
export function pinSnapshot(n) {
state.pinned = n;
notify("pin");
}
export async function loadMe() { export async function loadMe() {
state.me = await get("/api/me"); state.me = await get("/api/me");
notify("me"); notify("me");
@@ -95,6 +103,7 @@ export function selectTask(taskId) {
state.selectedTaskId = taskId; state.selectedTaskId = taskId;
state.transcript = []; state.transcript = [];
state.lastTranscriptSeq = 0; state.lastTranscriptSeq = 0;
state.pinned = undefined;
notify("selection"); notify("selection");
if (!taskId) { if (!taskId) {
return; return;
+1 -1
View File
File diff suppressed because one or more lines are too long
+76 -5
View File
@@ -1,7 +1,8 @@
import { get, post } from "./api.js"; import { get, post } from "./api.js";
import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js"; import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
import { markdown } from "./markdown.js"; import { markdown } from "./markdown.js";
import { currentSession, findTask, onEach, state } from "./state.js"; import { openChanges } from "./panes.js";
import { currentSession, findTask, onEach, pinSnapshot, state } from "./state.js";
const models = ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"]; const models = ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"];
export function mountTranscript(root) { export function mountTranscript(root) {
const head = h("header", { class: "task-header" }); const head = h("header", { class: "task-header" });
@@ -47,6 +48,8 @@ export function mountTranscript(root) {
return { return {
tools: new Map(), tools: new Map(),
run: undefined, run: undefined,
anchors: [],
unplaced: [],
lastMessage: undefined, lastMessage: undefined,
turnStart: undefined, turnStart: undefined,
root: task?.worktree_path ?? "", root: task?.worktree_path ?? "",
@@ -191,7 +194,7 @@ export function mountTranscript(root) {
const addToRun = (el, call) => { const addToRun = (el, call) => {
const run = ctx.run; const run = ctx.run;
if (!run) { if (!run) {
ctx.run = { first: el, group: undefined, calls: call ? [call] : [] }; ctx.run = { first: el, group: undefined, calls: call ? [call] : [], snaps: [] };
put(el); put(el);
return; return;
} }
@@ -207,7 +210,50 @@ export function mountTranscript(root) {
run.group = { el: group, summary, body }; run.group = { el: group, summary, body };
} }
run.group.body.append(el); run.group.body.append(el);
replace(run.group.summary, runSummary(run.calls)); replace(run.group.summary, runSummary(run.calls), snapMark(run.snaps));
};
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, runSummary(run.calls), snapMark(run.snaps));
}
};
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; let stream;
const dropStream = () => { const dropStream = () => {
@@ -251,6 +297,7 @@ export function mountTranscript(root) {
body.append(node); body.append(node);
node = next; node = next;
} }
return summary;
}; };
const appendEvents = (events) => { const appendEvents = (events) => {
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80; const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
@@ -274,8 +321,14 @@ export function mountTranscript(root) {
contextChanged = true; contextChanged = true;
continue; continue;
} }
if (ev.kind === "snapshot.created") {
placeSnapshot(p);
continue;
}
if (ev.kind === "agent.tool_call") { if (ev.kind === "agent.tool_call") {
addToRun(renderToolCall(p, ctx), { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) }); const el = renderToolCall(p, ctx);
addToRun(el, { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) });
addAnchor(ev.seq, el.querySelector("summary"), false);
continue; continue;
} }
const el = renderEvent(ev, ctx); const el = renderEvent(ev, ctx);
@@ -288,7 +341,7 @@ export function mountTranscript(root) {
} }
ctx.run = undefined; ctx.run = undefined;
if (ev.kind === "turn.ended" && p["handoff"] === true && ctx.turnStart?.parentNode === transcript) { if (ev.kind === "turn.ended" && p["handoff"] === true && ctx.turnStart?.parentNode === transcript) {
foldTurn(ctx.turnStart, el); addAnchor(ev.seq, foldTurn(ctx.turnStart, el), true);
ctx.turnStart = undefined; ctx.turnStart = undefined;
continue; continue;
} }
@@ -296,6 +349,9 @@ export function mountTranscript(root) {
if (ev.kind === "user.message") { if (ev.kind === "user.message") {
ctx.turnStart = el; ctx.turnStart = el;
} }
if (ev.kind === "turn.ended" || ev.kind === "inbox.created") {
addAnchor(ev.seq, el, ev.kind === "turn.ended");
}
} }
if (contextChanged) { if (contextChanged) {
renderHead(); renderHead();
@@ -580,6 +636,21 @@ function renderToolCall(p, ctx) {
ctx.tools.set(String(p["id"]), result); ctx.tools.set(String(p["id"]), result);
return h("details", { class: "tool" }, h("summary", {}, h("span", { class: "tool-name" }, String(p["name"])), " ", h("span", { class: "tool-brief" }, toolBrief(p["input"], ctx.root))), h("pre", { class: "tool-input" }, JSON.stringify(p["input"], null, 2)), result); return h("details", { class: "tool" }, h("summary", {}, h("span", { class: "tool-name" }, String(p["name"])), " ", h("span", { class: "tool-brief" }, toolBrief(p["input"], ctx.root))), h("pre", { class: "tool-input" }, JSON.stringify(p["input"], null, 2)), result);
} }
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);
openChanges(b);
};
return b;
}
function runSummary(calls) { function runSummary(calls) {
const counts = new Map(); const counts = new Map();
for (const c of calls) { for (const c of calls) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+8 -4
View File
@@ -245,7 +245,7 @@ export interface paths {
}; };
cookie?: never; cookie?: never;
}; };
/** @description Everything in the task's worktree (committed, staged, unstaged and untracked, respecting .gitignore) as a tree, compared with the merge-base of the task branch and the project's default branch, or with since when given. The worktree's own index is not touched. */ /** @description Everything in the task's worktree (committed, staged, unstaged and untracked, respecting .gitignore) as a tree, compared with the merge-base of the task branch and the project's default branch, or with since when given. The worktree's own index is not touched. With snapshot, the tree is that snapshot's instead, compared with the merge-base of its commit and the default branch, or with the previous snapshot's tree when step is true. */
get: operations["getTaskChanges"]; get: operations["getTaskChanges"];
put?: never; put?: never;
post?: never; post?: never;
@@ -599,9 +599,9 @@ export interface components {
title: string; title: string;
}; };
TaskChanges: { TaskChanges: {
/** @description Merge-base commit of the task branch and the default branch. */ /** @description The commit or tree compared with: the merge-base of the task branch and the default branch, since, or the previous snapshot's tree. */
base: string; base: string;
/** @description Tree of the worktree as if everything were staged. */ /** @description Tree of the worktree as if everything were staged, or the snapshot's tree. */
tree: string; tree: string;
files: components["schemas"]["FileChange"][]; files: components["schemas"]["FileChange"][];
}; };
@@ -1266,8 +1266,12 @@ export interface operations {
getTaskChanges: { getTaskChanges: {
parameters: { parameters: {
query?: { query?: {
/** @description A commit to compare with instead of the merge-base, such as a user.message event's head. */ /** @description A commit to compare with instead of the merge-base, such as a user.message event's head. Not allowed with snapshot. */
since?: string; since?: string;
/** @description A snapshot's n: show the task as it was in that snapshot rather than the live worktree. */
snapshot?: number;
/** @description With snapshot: compare with the previous snapshot (the snapshot commit's parent for the first one). */
step?: boolean;
}; };
header?: never; header?: never;
path: { path: {
+1
View File
@@ -16,6 +16,7 @@ export type TurnRef = Schemas["TurnRef"];
export type AgentProcess = Schemas["AgentProcess"]; export type AgentProcess = Schemas["AgentProcess"];
export type Zombie = Schemas["Zombie"]; export type Zombie = Schemas["Zombie"];
export type ProtocolErrors = Schemas["ProtocolErrors"]; export type ProtocolErrors = Schemas["ProtocolErrors"];
export type Snapshot = Schemas["Snapshot"];
type Method = "get" | "post" | "put" | "delete"; type Method = "get" | "post" | "put" | "delete";
+111 -35
View File
@@ -1,6 +1,6 @@
import { get, type ByreEvent, type Schemas } from "./api.js"; import { get, type ByreEvent, type Schemas } from "./api.js";
import { errorText, h, replace } from "./dom.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 Changes = Schemas["TaskChanges"];
type FileChange = Schemas["FileChange"]; type FileChange = Schemas["FileChange"];
@@ -15,6 +15,13 @@ const statusLetter: Record<FileChange["status"], string> = {
const open = new Set<string>(); const open = new Set<string>();
let range: "main" | "turn" = "main"; 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 { function turnStart(): string | undefined {
const ev = state.transcript.findLast((e) => e.kind === "user.message" && typeof e.payload["head"] === "string"); 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 timer: number | undefined;
let disposed = false; let disposed = false;
let loadedSince: string | undefined; 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 load = async () => {
const taskId = state.selectedTaskId; const taskId = state.selectedTaskId;
@@ -41,8 +50,9 @@ export function mountChanges(root: HTMLElement, compact = false): () => void {
render(); render();
return; return;
} }
const since = mode() === "turn" ? turnStart() : undefined; const snapshot = pinned();
if (mode() === "turn" && !since) { const since = snapshot === undefined && mode() === "turn" ? turnStart() : undefined;
if (snapshot === undefined && mode() === "turn" && !since) {
changes = undefined; changes = undefined;
error = "No turn has started in this task yet."; error = "No turn has started in this task yet.";
render(); render();
@@ -53,23 +63,29 @@ export function mountChanges(root: HTMLElement, compact = false): () => void {
return; return;
} }
loading = true; loading = true;
const started = target;
renderHead(); renderHead();
try { try {
const fresh = await get("/api/tasks/{taskId}/changes", { path: { taskId }, query: { since } }); const query = snapshot === undefined ? { since } : { snapshot, step: pinRange === "step" };
if (disposed || state.selectedTaskId !== taskId) { const fresh = await get("/api/tasks/{taskId}/changes", { path: { taskId }, query });
return; 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) { } catch (err) {
error = errorText(err); if (started === target) {
error = errorText(err);
}
} finally { } finally {
loading = false; loading = false;
} }
if (disposed) {
return;
}
render(); render();
if (again) { if (again) {
again = false; 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 = () => { const soon = () => {
clearTimeout(timer); clearTimeout(timer);
timer = setTimeout(() => void load(), 1500); timer = setTimeout(() => void load(), 1500);
@@ -86,6 +112,22 @@ export function mountChanges(root: HTMLElement, compact = false): () => void {
if (compact) { if (compact) {
return null; 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 button = (r: typeof range, label: string) => {
const b = h("button", { type: "button", class: r === range ? "active" : "" }, label); const b = h("button", { type: "button", class: r === range ? "active" : "" }, label);
b.onclick = () => { b.onclick = () => {
@@ -93,20 +135,44 @@ export function mountChanges(root: HTMLElement, compact = false): () => void {
return; return;
} }
range = r; range = r;
changes = undefined; retarget();
diffs.clear();
error = "";
render();
void load();
}; };
return b; return b;
}; };
return h("nav", { class: "range-tabs" }, button("main", "Since main"), button("turn", "This turn")); 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 = () => { const renderHead = () => {
if (!changes) { 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; return;
} }
const adds = changes.files.reduce((n, f) => n + f.additions, 0); const adds = changes.files.reduce((n, f) => n + f.additions, 0);
@@ -114,14 +180,19 @@ export function mountChanges(root: HTMLElement, compact = false): () => void {
replace( replace(
head, head,
h( h(
"span", "div",
{ class: "changes-summary" }, { class: "changes-where" },
`${changes.files.length} ${changes.files.length === 1 ? "file" : "files"} `, where(),
h("span", { class: "adds" }, `+${adds}`), h(
" ", "span",
h("span", { class: "dels" }, `−${dels}`), { class: "changes-summary" },
h("span", { class: "base", title: changes.base }, ` vs ${changes.base.slice(0, 8)}`), `${changes.files.length} ${changes.files.length === 1 ? "file" : "files"} `,
error && h("span", { class: "error-text" }, ` ${error}`), 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(), toggle(),
); );
@@ -187,7 +258,7 @@ export function mountChanges(root: HTMLElement, compact = false): () => void {
return; return;
} }
if (changes.files.length === 0) { 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; return;
} }
const c = changes; const c = changes;
@@ -196,14 +267,19 @@ export function mountChanges(root: HTMLElement, compact = false): () => void {
}; };
const offs = [ const offs = [
onEach(["selection"], () => { onEach(["selection"], retarget),
changes = undefined; onEach(["pin"], () => {
diffs.clear(); if (!compact) {
error = ""; retarget();
render(); }
void load();
}), }),
onEach(["transcript"], (fresh) => { onEach(["transcript"], (fresh) => {
if (pinned() !== undefined) {
if ((fresh as ByreEvent[]).some((ev) => ev.kind === "snapshot.created")) {
renderHead();
}
return;
}
if (mode() === "turn" && turnStart() !== loadedSince) { if (mode() === "turn" && turnStart() !== loadedSince) {
void load(); void load();
return; return;
+7
View File
@@ -28,6 +28,13 @@ export function openTranscript(): void {
(panes.find((p) => p.current() !== "tasks") ?? panes[0])?.show("transcript"); (panes.find((p) => p.current() !== "tasks") ?? panes[0])?.show("transcript");
} }
export function openChanges(from: Element): void {
if (panes.some((p) => p.current() === "changes")) {
return;
}
(panes.find((p) => !p.el.contains(from)) ?? panes[0])?.show("changes");
}
export class Pane { export class Pane {
readonly el: HTMLElement; readonly el: HTMLElement;
private readonly tabs: HTMLElement; private readonly tabs: HTMLElement;
+13 -2
View File
@@ -1,6 +1,6 @@
import { get, subscribe, type AgentSession, type ByreEvent, type InboxItem, type Project, type Task, type User } from "./api.js"; import { get, subscribe, type AgentSession, type ByreEvent, type InboxItem, type Project, type Snapshot, type Task, type User } from "./api.js";
export type Topic = "me" | "projects" | "tasks" | "sessions" | "inbox" | "selection" | "transcript" | "stream" | "connection" | "event"; export type Topic = "me" | "projects" | "tasks" | "sessions" | "inbox" | "selection" | "transcript" | "stream" | "connection" | "event" | "pin";
export const state = { export const state = {
me: undefined as User | undefined, me: undefined as User | undefined,
@@ -11,6 +11,7 @@ export const state = {
selectedTaskId: undefined as string | undefined, selectedTaskId: undefined as string | undefined,
transcript: [] as ByreEvent[], transcript: [] as ByreEvent[],
lastTranscriptSeq: 0, lastTranscriptSeq: 0,
pinned: undefined as number | undefined,
connected: false, connected: false,
}; };
@@ -59,6 +60,15 @@ export function currentSession(taskId: string): AgentSession | undefined {
return state.sessions.get(taskId)?.findLast((s) => s.state !== "completed") ?? state.sessions.get(taskId)?.at(-1); return state.sessions.get(taskId)?.findLast((s) => s.state !== "completed") ?? state.sessions.get(taskId)?.at(-1);
} }
export function snapshots(): Snapshot[] {
return state.transcript.filter((e) => e.kind === "snapshot.created").map((e) => e.payload as unknown as Snapshot);
}
export function pinSnapshot(n: number | undefined): void {
state.pinned = n;
notify("pin");
}
export async function loadMe(): Promise<void> { export async function loadMe(): Promise<void> {
state.me = await get("/api/me"); state.me = await get("/api/me");
notify("me"); notify("me");
@@ -115,6 +125,7 @@ export function selectTask(taskId: string | undefined): void {
state.selectedTaskId = taskId; state.selectedTaskId = taskId;
state.transcript = []; state.transcript = [];
state.lastTranscriptSeq = 0; state.lastTranscriptSeq = 0;
state.pinned = undefined;
notify("selection"); notify("selection");
if (!taskId) { if (!taskId) {
+94 -7
View File
@@ -1,7 +1,8 @@
import { get, post, type ByreEvent } from "./api.js"; import { get, post, type ByreEvent, type Snapshot } from "./api.js";
import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js"; import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
import { markdown } from "./markdown.js"; import { markdown } from "./markdown.js";
import { currentSession, findTask, onEach, state } from "./state.js"; import { openChanges } from "./panes.js";
import { currentSession, findTask, onEach, pinSnapshot, state } from "./state.js";
const models = ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"]; const models = ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"];
@@ -11,11 +12,23 @@ type Run = {
first: HTMLElement; first: HTMLElement;
group: { el: HTMLDetailsElement; summary: HTMLElement; body: HTMLElement } | undefined; group: { el: HTMLDetailsElement; summary: HTMLElement; body: HTMLElement } | undefined;
calls: Call[]; calls: Call[];
snaps: number[];
};
type Anchor = {
seq: number;
turnEnd: boolean;
el: HTMLElement;
snaps: number[];
mark: HTMLElement | undefined;
run: Run | undefined;
}; };
type Ctx = { type Ctx = {
tools: Map<string, HTMLElement>; tools: Map<string, HTMLElement>;
run: Run | undefined; run: Run | undefined;
anchors: Anchor[];
unplaced: Snapshot[];
root: string; root: string;
lastMessage: HTMLElement | undefined; lastMessage: HTMLElement | undefined;
turnStart: HTMLElement | undefined; turnStart: HTMLElement | undefined;
@@ -70,6 +83,8 @@ export function mountTranscript(root: HTMLElement): () => void {
return { return {
tools: new Map(), tools: new Map(),
run: undefined, run: undefined,
anchors: [],
unplaced: [],
lastMessage: undefined, lastMessage: undefined,
turnStart: undefined, turnStart: undefined,
root: task?.worktree_path ?? "", root: task?.worktree_path ?? "",
@@ -230,7 +245,7 @@ export function mountTranscript(root: HTMLElement): () => void {
const addToRun = (el: HTMLElement, call: Call | undefined) => { const addToRun = (el: HTMLElement, call: Call | undefined) => {
const run = ctx.run; const run = ctx.run;
if (!run) { if (!run) {
ctx.run = { first: el, group: undefined, calls: call ? [call] : [] }; ctx.run = { first: el, group: undefined, calls: call ? [call] : [], snaps: [] };
put(el); put(el);
return; return;
} }
@@ -246,7 +261,53 @@ export function mountTranscript(root: HTMLElement): () => void {
run.group = { el: group, summary, body }; run.group = { el: group, summary, body };
} }
run.group.body.append(el); run.group.body.append(el);
replace(run.group.summary, runSummary(run.calls)); replace(run.group.summary, runSummary(run.calls), snapMark(run.snaps));
};
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, runSummary(run.calls), snapMark(run.snaps));
}
};
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; let stream: { el: HTMLElement; text: string; timer: number } | undefined;
@@ -281,7 +342,7 @@ export function mountTranscript(root: HTMLElement): () => void {
}, 100); }, 100);
}; };
const foldTurn = (start: HTMLElement, end: HTMLElement) => { const foldTurn = (start: HTMLElement, end: HTMLElement): HTMLElement => {
const summary = h("summary"); const summary = h("summary");
summary.append(...end.childNodes); summary.append(...end.childNodes);
const body = h("div", { class: "folded-turn" }); const body = h("div", { class: "folded-turn" });
@@ -292,6 +353,7 @@ export function mountTranscript(root: HTMLElement): () => void {
body.append(node); body.append(node);
node = next; node = next;
} }
return summary;
}; };
const appendEvents = (events: ByreEvent[]) => { const appendEvents = (events: ByreEvent[]) => {
@@ -316,8 +378,14 @@ export function mountTranscript(root: HTMLElement): () => void {
contextChanged = true; contextChanged = true;
continue; continue;
} }
if (ev.kind === "snapshot.created") {
placeSnapshot(p as unknown as Snapshot);
continue;
}
if (ev.kind === "agent.tool_call") { if (ev.kind === "agent.tool_call") {
addToRun(renderToolCall(p, ctx), { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) }); const el = renderToolCall(p, ctx);
addToRun(el, { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) });
addAnchor(ev.seq, el.querySelector("summary")!, false);
continue; continue;
} }
const el = renderEvent(ev, ctx); const el = renderEvent(ev, ctx);
@@ -330,7 +398,7 @@ export function mountTranscript(root: HTMLElement): () => void {
} }
ctx.run = undefined; ctx.run = undefined;
if (ev.kind === "turn.ended" && p["handoff"] === true && ctx.turnStart?.parentNode === transcript) { if (ev.kind === "turn.ended" && p["handoff"] === true && ctx.turnStart?.parentNode === transcript) {
foldTurn(ctx.turnStart, el); addAnchor(ev.seq, foldTurn(ctx.turnStart, el), true);
ctx.turnStart = undefined; ctx.turnStart = undefined;
continue; continue;
} }
@@ -338,6 +406,9 @@ export function mountTranscript(root: HTMLElement): () => void {
if (ev.kind === "user.message") { if (ev.kind === "user.message") {
ctx.turnStart = el; ctx.turnStart = el;
} }
if (ev.kind === "turn.ended" || ev.kind === "inbox.created") {
addAnchor(ev.seq, el, ev.kind === "turn.ended");
}
} }
if (contextChanged) { if (contextChanged) {
renderHead(); renderHead();
@@ -661,6 +732,22 @@ function renderToolCall(p: Record<string, unknown>, ctx: Ctx): HTMLElement {
); );
} }
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);
openChanges(b);
};
return b;
}
function runSummary(calls: Call[]): HTMLElement { function runSummary(calls: Call[]): HTMLElement {
const counts = new Map<string, { call: Call; n: number }>(); const counts = new Map<string, { call: Call; n: number }>();
for (const c of calls) { for (const c of calls) {
+45
View File
@@ -709,6 +709,34 @@ button.compact.danger {
color: var(--danger); color: var(--danger);
} }
.snap-mark {
float: right;
margin-left: 12px;
padding: 0 6px;
border-radius: 4px;
font-size: 11px;
color: var(--accent);
background: transparent;
}
.turn-end:not(.error):has(.snap-mark) {
display: flex;
align-items: center;
gap: 12px;
}
.turn-end:not(.error):has(.snap-mark)::before {
content: "";
flex: 1;
height: 1px;
background: var(--border);
}
.turn-end:not(.error) .snap-mark {
float: none;
margin-left: 0;
}
.note.ask { .note.ask {
color: var(--accent); color: var(--accent);
} }
@@ -862,6 +890,23 @@ details.note .md {
color: var(--muted); color: var(--muted);
} }
.changes-where {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.snap-where {
font-size: 12px;
}
.snap-step {
padding: 0 6px;
border-radius: 4px;
font-size: 11px;
}
.adds { .adds {
color: var(--ok); color: var(--ok);
} }