You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 8254ed9
Browse filesBrowse the repository at this point in the historyBrowse files
s39-dev
committed
feat(webui): three per-turn diff endpoints and their client wrapper
Ticket 80's plan B, batch 2. The runtime already records what each turn
changed — real per-file line counts, and whether that turn can still be
changed — under the msg_id of the turn's last assistant message. Batch 1
put that id on the wire; nothing read it. These endpoints read it.
GET /api/turn-diff
POST /api/turn-diff/revert
POST /api/turn-diff/revert is a real write: the engine verifies each file
against the snapshot it captured and then writes or removes it. POST
/api/turn-diff/reapply puts the turn back.
Zero new backend. All three are thin projections over
`applications.session.diff`, reached through the same catalogue host
routes/plugins.js uses. The route reaches exactly that leaf: the
`applications` tree also carries `session.lifecycle`, which can delete a
session, so handing out the handle would hand out deletion too. A
tripwire asserts the only member ever dereferenced is `session`, and that
the route never mentions a gate of its own — auth, rate limiting and
read-only mode are inherited from lib/gates.js.
The engine's selector degrades to "this session's latest turn" when it is
given no id, so a request that lost the coordinate would answer with
ANOTHER turn's counts and its undo button would rewrite that turn's
files. A request without `assistantMessageId` therefore never reaches
the engine at all: it answers `{ok:true,turnDiff:null}`. An id the engine
does not know is a different case and is passed through verbatim, so the
engine — not the route — decides it, and its empty answer stays empty.
A malformed sessionId is a 400. The tests assert the call count, not just
the body, because only the call count catches a "helpful" default.
The engine's 409 for a non-latest turn is passed through with its own
sentence, because that sentence is the only thing that says why an undo
did nothing.
What a successful mutation does NOT do is guess at refreshing the
browser. The response carries the status and the canUndo / canReapply
flips; the client's re-reads are a separate slice.
it once, at the end of the transcript, from the file paths the turn's
1540
+
edit tools named, and recorded in this document that the desktop's line
1541
+
counts and undo button were unreachable. **Both became reachable in
1542
+
ticket 83**, and this section now describes what the card does with
1543
+
them.
1544
+
1545
+
The header reuses the existing `activity.editedFiles` key for its
1546
+
sentence; ticket 83 added keys only for the new affordances (undo, redo,
1547
+
the failure copy). The activity-group summary and this card are the same
1548
+
sentence, so they have to stay the same sentence.
1544
1549
1545
1550
### Where the data comes from
1546
1551
1547
-
The three-tier fallback chain from ticket 61 §4.2 does not resolve at
1548
-
tier 1. The card lands on tier 2, but with better semantics than tier 2
1549
-
was sketched for: the source is not "the workspace's git changes" but
1550
-
**the file paths the turn's edit tools actually named in the transcript**,
1551
-
which is turn-scoped by construction.
1552
+
Two sources, in strict order of authority, and never merged:
1552
1553
1553
-
|Tier|Source | Finding|
1554
+
|Source|What it is | What it can prove|
1554
1555
|---|---|---|
1555
-
| 1 — turn changeset |`SessionDiffApplication.getTurnDiff`|**Unreachable.**`local-runtime-v2` really does have it (`application/session/diff-application.ts:51`, backed by v1's `LocalTurnDiffCapability` over `SqliteLocalTurnDiffStore`, carrying per-file `additions`/`deletions` and `undo` snapshots). The blocker is addressing, not existence: the transcript is a flat array of glyph-prefixed strings carrying neither `turnId` nor `assistantMessageId`, and `findTurnDiffRecord` (`local-runtime/src/turns/diff-api.ts:209-220`) falls back to `latestForSession` when no selector is supplied — so every historical assistant message would render the same "last turn" card. Separately, `server/lib/runtime-host.js:176-178` forwards only `cliService` / `apiHost` / `controller` and drops the `application` the v2 host returns. Closing either gap means touching `local-runtime-v2`, which ticket 61 §6 risk 1 already reserves for a separate decision. |
1556
-
| 2 — the turn's file paths | the transcript's `file-edit` tool blocks |**Adopted.**`collectEditedFiles` (`webapp/lib/edited-files.ts`) walks every `RenderUnit`, reads `toolPaths` off the blocks `isFileEditTool` accepts, normalises separators, de-duplicates, and keeps first-seen order. |
1557
-
| 3 — nothing changed | the list is empty |**The card is not rendered at all** (A1). With no file to name there is no empty shell and no 「已编辑 0 个文件」 placeholder. |
1556
+
|**The engine's record**|`GET /api/turn-diff` with the turn's `assistantMessageId`| The real per-file `+N` / `-N`, the real file list (which includes edits made through tools whose arguments name no path), and the engine's own `canUndo` / `canReapply`|
1557
+
|**The transcript scan**|`collectTurnEditedFiles` (`webapp/lib/edited-files.ts`) over the turn's `RenderUnit`s | Only the file paths a `file-edit` tool named. No counts, no gates. |
1558
+
1559
+
The engine's list REPLACES the scan rather than merging with it. A merge
1560
+
would double-count a file the two name differently, and the counts would
1561
+
then sit on the wrong row. When there is no record — a session recorded
1562
+
before the turn coordinate shipped, a legacy transcript read, the `exec`
1563
+
transport — the scan is the whole story and the card is exactly what
1564
+
ticket 77 shipped.
1565
+
1566
+
### The turn coordinate
1567
+
1568
+
The engine persists a turn's record under the msg_id of that turn's
the last agent *message* response; a turn carries more than one id on
1571
+
the wire, one per message segment). The ACP transport already delivered
1572
+
that id; the server used to drop it.
1573
+
1574
+
-**Live**: `acp.mjs#prompt` keeps the last `agent_message_chunk`
1575
+
`messageId`; `mcode-acp.js#finalize` writes `§§ turn_msg=<id>` next to
1576
+
the `§§ processed_duration=Nms` marker it already wrote. The `§§`
1577
+
family is the established convention for server-written per-turn
1578
+
metadata — third reuse, not a new grammar.
1579
+
-**Restored**: `server/lib/transcript.js`'s v2 probe now selects the
1580
+
`turn_id` and `msg_id` columns the message table already carries, and
1581
+
synthesises the same marker from the last assistant row of each turn.
1582
+
Switching away and back gives an existing session its coordinates
1583
+
without asking the engine anything.
1584
+
1585
+
`decodeTranscript` consumes the marker and hangs the id on the turn's
1586
+
LAST assistant block. A transcript without the marker decodes exactly as
1587
+
it did before — every session older than the marker has to keep
1588
+
rendering, and a decoder that assumed the marker would throw on all of
1589
+
them.
1558
1590
1559
1591
### What each desktop element maps to
1560
1592
1561
1593
| Desktop element | Here | Why |
1562
1594
|---|---|---|
1563
1595
| Header glyph |**Present**| The `pencil` icon from the existing `icons.tsx` set. |
1564
-
| 「已编辑 N 个文件」 |**Present**| N counts **distinct files**, not edit calls — a file edited five times is still one file. |
1565
-
| Green `+N` / red `-N`|**Absent**| The transcript carries no line statistics and there is no reachable turn-scoped source. Drawing one would be a fabricated number. |
1566
-
| 「撤销」 (undo) |**Absent**| No turn-scoped revert endpoint exists; the engine's `rewindTurnDiff` is a side effect of rewinding history, not a per-card undo. A dead button is worse than no button. |
1567
-
| 「Review」 |**Absent — the file rows replace it**| A file row opens the real preview through the existing `onOpenFile` chain (red line 4). That is the affordance this codebase can actually honour. |
1596
+
| 「已编辑 N 个文件」 |**Present**| N counts **distinct files**, not edit calls. With a record it is the engine's own file count; without one it is the count of paths the scan could name. |
1597
+
| Green `+N` / red `-N`|**Present, with a record**| The engine's own per-turn counts, summed in the header. **Absent without one** — a card showing `+0 -0` would say "this file did not change", which is a different and false claim. A zero side is not drawn as `+0` / `-0` either. |
1598
+
| 「撤销」 (undo) |**Present, gated on `canUndo`**| Calls `POST /api/turn-diff/revert`, which rewrites real workspace files. |
1599
+
| 「重做」 (redo) |**Present, gated on `canReapply`**| Calls `POST /api/turn-diff/reapply`. Appears independently of undo: a reverted turn offers redo and no undo. |
1600
+
| 「Review」 |**Absent — the file rows replace it**| A file row opens the real preview through the existing `onOpenFile` chain (red line 4). |
1568
1601
| File row: type icon + name |**Present**| A `file` icon plus the path's trailing segment; the full path rides on `title` and `data-file-path`. |
1569
-
| Per-row added/deleted |**Absent**|Same reason as the header badge. |
1602
+
| Per-row added/deleted |**Present, with a record**|The engine's per-file counts, not the header total repeated. |
1570
1603
| Collapse (3 rows, then expand) |**Present**| A pure client-side state machine, `reduceEditedFilesCardState`; the toggle appears only past three rows. |
1571
1604
1605
+
The two buttons are **not** greyed out when they cannot act; they are
1606
+
absent. Only the latest turn diff can be changed — the engine answers
1607
+
`canUndo:false` before the user clicks, and 409 `TURN_DIFF_CONFLICT`
1608
+
if they click anyway — so a disabled button would promise an action the
1609
+
engine has already refused. The card never re-derives "is this the last
1610
+
turn?" from the transcript; the whole coordinate system exists because
1611
+
that inference cannot be made safely.
1612
+
1613
+
### What a successful undo refreshes
1614
+
1615
+
A revert rewrites files the browser is already showing, so five things
1616
+
move, in this order:
1617
+
1618
+
1. the server's session-tree cache (`invalidateSessionTree()`);
1619
+
2. a `session-tree-changed` broadcast (the sidebar);
1620
+
3. a `workspace-files-changed` broadcast — a new named SSE frame, no
1621
+
payload, the only signal the webui has that files on disk moved;
1622
+
4. on that frame, the files tree re-reads every directory it has open
1623
+
and the git panel re-reads status and branches;
1624
+
5. on the same frame, the open file preview re-reads through the refresh
1625
+
path — scroll position preserved, a deleted file shown as a named
1626
+
banner rather than a blank pane, and a **dirty draft left alone**,
1627
+
because its baseline is what a later save conflict-checks against.
1628
+
1572
1629
### Contract notes
1573
1630
1574
1631
- The collapse state is **not** persisted in `localStorage`: a reload
1575
1632
returns to collapsed. It is derived state, not a user preference, so
1576
1633
red line 3 is untouched.
1577
-
-The card derives from the **full**`units` list, not the virtualised
1634
+
-Cards are derived from the **full**`units` list, not the virtualised
1578
1635
`visibleUnits` window — otherwise scrolling past 200 units would make
1579
-
it blink in and out.
1636
+
them blink in and out. Each turn's card sits at **its own** turn's last
1637
+
unit, so a three-turn session reads as three cards; the final turn's
1638
+
card stays at the transcript tail, after the message-action row, which
1639
+
is where ticket 77 put the single card and where the desktop puts it.
1580
1640
- With no `onOpenFile` wired, a file row degrades to plain text rather
1581
1641
than to a button that does nothing.
1642
+
- The route exposes `applications.session.diff` and nothing else; the
1643
+
`applications` tree also carries `session.lifecycle`, which can delete
1644
+
a session.
1645
+
-`previewState` is declared by the protocol and is always `undefined` on
1646
+
this path. Nothing renders it.
1582
1647
1583
1648
## Loading states: transcript skeleton and streaming indicator (ticket U8)
0 commit comments