From c778741a4af4515a84f674c8103e1b901321c99e Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Thu, 15 Oct 2026 00:08:32 -0700 Subject: [PATCH] Navigate to snapshots from the transcript --- api/openapi.yaml | 11 +- docs/operations.md | 8 +- internal/api/api.gen.go | 242 ++++++++++++++++++++--------------- internal/git/changes.go | 19 ++- internal/git/changes_test.go | 84 ++++++++++++ internal/server/changes.go | 49 +++++++ internal/store/snapshot.go | 8 ++ web/dist/api.js.map | 2 +- web/dist/changes.js | 111 ++++++++++++---- web/dist/changes.js.map | 2 +- web/dist/panes.js | 6 + web/dist/panes.js.map | 2 +- web/dist/state.js | 9 ++ web/dist/state.js.map | 2 +- web/dist/task.js | 81 +++++++++++- web/dist/task.js.map | 2 +- web/src/api.gen.ts | 12 +- web/src/api.ts | 1 + web/src/changes.ts | 146 ++++++++++++++++----- web/src/panes.ts | 7 + web/src/state.ts | 15 ++- web/src/task.ts | 101 ++++++++++++++- web/style.css | 45 +++++++ 23 files changed, 763 insertions(+), 202 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 4e3e7ef..8da6e0f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -254,9 +254,12 @@ paths: 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. + 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: - - { 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: "200": description: Per-file stats. @@ -641,8 +644,8 @@ components: type: object required: [base, tree, files] properties: - base: { type: string, description: Merge-base commit of the task branch and the default branch. } - tree: { type: string, description: Tree of the worktree as if everything were staged. } + 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, or the snapshot's tree." } files: type: array items: { $ref: "#/components/schemas/FileChange" } diff --git a/docs/operations.md b/docs/operations.md index ee6509a..3dde9d9 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 `, 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 @@ -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. -Merging or discarding a task stops new snapshots for it, waits for one in progress, and deletes its `refs/byre/tasks//` 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//` 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=` 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 · ·