From eb019537295ecfdaaf1aec022338a3acd2410224 Mon Sep 17 00:00:00 2001 From: "Snow Lee (Sungwon)" Date: Tue, 18 Aug 2026 14:04:35 -0700 Subject: [PATCH] feat(webapp): undo a whole agent run from the run card (#156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(webapp): undo a whole agent run from the run card The run card was grouped for this and stopped one button short: every row inside it carried an action, the header carried none, so reverting a bad run meant clicking file by file and hoping you got them all. POST /api/p//undo-run works out, for every path the run touched, the op that puts it back — a put at the pre-run blob, or a delete for a file the run created — and writes them all in ONE journal append. That is the atomicity argument, not an optimization: one Put of one object either lands or it does not, so there is no half-undone run to report. appendOps is the batch write every path in the package now goes through; appendOp is its single-op call. Selection is by the journal an op was READ FROM, never op.Device — that field is arbitrary JSON any member with write access can put in their own journal, and the card attributes rows the same way. The note form additionally requires an empty Session, because runs.ts can never file a session-carrying op under a note-keyed card. Append-only throughout: the run's own ops are never edited or removed, so one-writer-per-journal and deterministic replay both survive. The undo's ops carry a note naming the run, so the undo is itself a run card you can undo. The confirm asks the server for the file list rather than deriving it from the loaded feed (paged and filterable, so a client-computed list is wrong exactly when the run is old), lists every path with its action, and names the one thing that can burn someone: a file a teammate changed after the run is reverted too. Co-Authored-By: Claude Opus 5 (1M context) * fix(webapp): the undo confirm names the paths it will not write planUndo already refuses a path the hub's own upload door would refuse — a peer can push one under .bdrive/ or with a control character in it — but the dialog listed only what the undo WOULD do, which reads as "all of it". Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- README.md | 2 +- architecture/webapp-frontend.md | 1 + architecture/webapp-server.md | 14 +- internal/syncer/http_remote_test.go | 86 +++ .../webapp/frontend/e2e/session-run.spec.ts | 82 ++- internal/webapp/frontend/src/api/types.ts | 12 + internal/webapp/frontend/src/apps/Browser.tsx | 96 +++- .../frontend/src/components/HistoryView.tsx | 34 +- internal/webapp/frontend/src/modal.tsx | 13 +- internal/webapp/frontend/src/style.css | 33 +- internal/webapp/journalcache_test.go | 20 +- internal/webapp/server.go | 3 + .../webapp/static/assets/index-Cg3Ijtd_.js | 122 ---- ...{index-aFlVpSeL.css => index-DXUkAW4x.css} | 2 +- .../webapp/static/assets/index-NaccfD91.js | 122 ++++ internal/webapp/static/index.html | 4 +- internal/webapp/undorun.go | 296 ++++++++++ internal/webapp/undorun_test.go | 534 ++++++++++++++++++ internal/webapp/upload.go | 44 +- .../content/docs/guides/what-agents-read.md | 20 + 20 files changed, 1392 insertions(+), 148 deletions(-) delete mode 100644 internal/webapp/static/assets/index-Cg3Ijtd_.js rename internal/webapp/static/assets/{index-aFlVpSeL.css => index-DXUkAW4x.css} (78%) create mode 100644 internal/webapp/static/assets/index-NaccfD91.js create mode 100644 internal/webapp/undorun.go create mode 100644 internal/webapp/undorun_test.go diff --git a/README.md b/README.md index 5096853..278f916 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ hub's own storage, never something a syncing client points at directly: | `bdrive read-log [folder]` | Hook plumbing: queue agent file reads from a hook event (JSON on stdin) for the hub's read heatmap — native reads, grep matches, and files named in shell commands; drained on the next sync. Registered by `bdrive hooks install` | | `bdrive status [folder]` | Projects, daemon state, pending changes | | `bdrive log [folder] [-p path] [-n N]` | Change history: account, device, time, file — newest first by the time shown, which is when the file was written (ops recorded before this was tracked, and deletes, show their sync time instead) | -| `bdrive restore [version]` | Put an earlier version of a file back, as a new change (`--list` shows the versions; no version = the previous one). Nothing is erased and it syncs everywhere like any edit. To un-create a file a run *created*, use **undo — remove file** on that row in the hub's History view | +| `bdrive restore [version]` | Put an earlier version of a file back, as a new change (`--list` shows the versions; no version = the previous one). Nothing is erased and it syncs everywhere like any edit. To un-create a file a run *created*, use **undo — remove file** on that row in the hub's History view — or **undo this run** in the run card's header to put back every file that run touched at once | | `bdrive export [folder]` | Export the whole project — every device's journal, all blobs, full history — from its hub to a portable `.tar.gz` (`-o` names the file) | | `bdrive import ` | Import an export archive as a new project on the hub you're logged into (always a NEW project; `--name` overrides the archive's); history and authorship carry over. Refuses an archive whose journals reference content it doesn't hold (`--allow-incomplete` overrides). Move projects between hubs — cloud → self-hosted or back — with `export` + `login` + `import` | | `bdrive serve [folder \| storage-root-url]` | Web server: viewer (rendered markdown, downloads, history), uploads, multi-project sync hub (`bdrive web` is a deprecated alias) | diff --git a/architecture/webapp-frontend.md b/architecture/webapp-frontend.md index 3d16218..9815f38 100644 --- a/architecture/webapp-frontend.md +++ b/architecture/webapp-frontend.md @@ -97,6 +97,7 @@ classDiagram note for components "NewProjectDialog replaced ProjectNav's name-only modalPrompt: name + starting point, POSTing {name, template}. Its options come from useConfig()'s `templates`, never a hardcoded list, so a hub shipping another template needs no frontend change; the initial selection is options[0].value — the same array element the RECOMMENDED badge indexes, so the badged row and the checked row are one row by construction (on a template-less hub that row is 'I already have a folder', which still creates an empty project). modal.tsx keeps its one-field API — teaching it about choices would tax every other caller" note for components "HistoryFilters drives the SERVER (?q=/?user=/?since=/?until= on the history API), never the loaded page — filtering what is on screen would lie about everything below the fold and break next_cursor. Its state is Route.filters, so a narrowed feed is linkable, survives reload, and Back undoes it; the author list accumulates across fetches, because filtering by one author leaves only their rows loaded" note for components "FileView's transformHTML now drops `data:image/svg` from any rendered img and any `data:` href from any rendered link — goldmark admits them, and an inline SVG is a document rather than a picture (the same property the server's sandboxInline walls off). Insights builds its per-device folder bag with Object.create(null), since folder names come off a peer's journal and one named __proto__ silently emptied the matrix. style.css sets unicode-bidi isolate-override on the peer-authored strings a reader is expected to CHECK (listing rows, breadcrumb, history path/note/device) — journal.SafeText refuses the bidi CONTROLS, but a single strong-RTL LETTER is legal and still reorders a row" + note for components "HistoryView's RunGroup header carries the run-wide undo (POST undo-run, gated by the same write permission as the per-row restore/remove). It asks the SERVER for the file list first (preview: true) rather than deriving it from the loaded feed — that window is paged and filterable, so a client-computed list is wrong exactly when the run is old. modal.tsx's Confirm.message widened from string to ReactNode for it (the prompt's one-field API is untouched), so the dialog can show every path, its action, and the "changed after this run" warning inline" note for components "components/ui — shadcn/ui primitives (Radix, copied in), themed from BearDrive tokens in tw.css; rendered markdown is transformed as a string before mounting, link clicks delegated on the container — never patch the dangerouslySetInnerHTML subtree" class lib { diff --git a/architecture/webapp-server.md b/architecture/webapp-server.md index e7c7e75..ab7e009 100644 --- a/architecture/webapp-server.md +++ b/architecture/webapp-server.md @@ -65,6 +65,7 @@ classDiagram -loadSourcedOps(ctx) []sourcedOp -cacheJournals(keep, misses, parsed, sizes) -appendOp(ctx, op) + -appendOps(ctx, ops) ONE read-modify-write } class sourcedOp { +Op journal.Op @@ -116,7 +117,16 @@ classDiagram } note for DirectUploader "BlobSize replaced HasBlob: in direct mode the server never sees the bytes, so the CALLER's declared size was the only number it had to quota-check and journal — and the caller picks it. Size now comes from storage, and the commit journals and charges that" note for DirectUploader "Commit's note is "" for an upload and "restore <path>@<sha8>" for POST /api/p/{id}/restore — which is the upload commit minus the upload: find the historical op for (path, sha), journal a NEW put at its blob. Never rewrites a journal." - note for RemoteSource "Every write ends at appendOp: stamp Seq/Lamport/Time + this server's Identity, append ONE op to journal/<own-device>.jsonl. Commit does that for a put; Remove (POST /api/p/{id}/remove, restore's gates + a snapshot existence check) does it for a delete — the only server path that takes a file away, and itself undone by restoring the DELETED row." + note for RemoteSource "Every write ends at appendOps: stamp Seq/Lamport/Time + this server's Identity across the batch, append N ops to journal/<own-device>.jsonl in ONE read-modify-write (appendOp is the single-op call). Commit does that for a put; Remove (POST /api/p/{id}/remove, restore's gates + a snapshot existence check) does it for a delete — the only server path that takes a file away, and itself undone by restoring the DELETED row. The batch is not an optimization: ONE Put of ONE object either lands or it does not, which is the whole atomicity argument for undoRunDoor — a loop of appendOp there would leave half a run reverted with nothing to report it." + + class undoRunDoor { + <> + planUndo(sourced, undoSel) undoPlan + undoSel Device From-journal, Session xor Note + undoPlan Ops, Actions, Skipped, After, Refused + preview plan only, no write, no quota + } + note for undoRunDoor "The run-wide form of restore+remove: for every path the run touched, the op that puts it back — a put at the pre-run blob, or a delete for a file the run created. Selection is by sourcedOp.From (the journal, which /store gates), NEVER op.Device, and the note form additionally requires Session == "" because runs.ts can never file a session-carrying op under a note-keyed card. Append-only: the run's own ops are never touched. Same PermWrite + CheckWrite(org,0) gates as its two siblings; the undo's ops carry a note naming the run, so the undo is itself a run card you can undo." class journalDoor { <> @@ -470,6 +480,8 @@ classDiagram DeviceRegistry ..> DeviceInfo DeviceRegistry *-- devKey : (account, id) RemoteSource ..> sourcedOp : attribution comes from the journal key + undoRunDoor ..> sourcedOp : selects a run by the journal it was read from + undoRunDoor ..> RemoteSource : appendOps — the whole run in one Put RemoteSource *-- cachedJournal : parsed ops, keyed on size+mtime ReadLedger ..> ReadStat ReadLedger ..> SessionRead diff --git a/internal/syncer/http_remote_test.go b/internal/syncer/http_remote_test.go index da9b313..06df426 100644 --- a/internal/syncer/http_remote_test.go +++ b/internal/syncer/http_remote_test.go @@ -2,9 +2,12 @@ package syncer import ( "context" + "io" + "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -113,3 +116,86 @@ func TestReadOnlyServerClientStillPulls(t *testing.T) { t.Fatal("client should still pull from a read-only server") } } + +// Undoing a whole agent run at the hub converges like any other change: the +// hub journals the undo under its OWN device, and every other device +// materializes the pre-run content on its next cycle. The repo's convention +// is that a sync feature without a multi-device test is untested where it +// matters — this is that test for BEA-82. +func TestUndoRunConverges(t *testing.T) { + storage := sharedRemote(t) + ts, p := newHub(t, storage, true) + + viaServer, err := remote.Open(context.Background(), ts.URL+"/p/"+p.ID) + if err != nil { + t.Fatal(err) + } + defer viaServer.Close() + + a := newDevice(t, "deva", viaServer) // the agent's machine + b := newDevice(t, "devb", remote.Prefixed(storage, p.ID)) // a teammate + + // Before the run. + write(t, a.Folder, "notes/plan.md", "the plan, as written by a human") + cycle(t, a) + cycle(t, b) + if read(t, b.Folder, "notes/plan.md") != "the plan, as written by a human" { + t.Fatal("b never got the pre-run content") + } + + // The run: one file rewritten, one created, both stamped with the session + // id the agent hook sets. + time.Sleep(10 * time.Millisecond) + a.SessionID = "run-8f21e4" + write(t, a.Folder, "notes/plan.md", "REWRITTEN BY THE AGENT") + write(t, a.Folder, "notes/scratch.md", "invented by the agent") + cycle(t, a) + a.SessionID = "" + cycle(t, b) + if read(t, b.Folder, "notes/scratch.md") != "invented by the agent" { + t.Fatal("b never saw the run") + } + + // Undo the whole run from the hub. + body := strings.NewReader(`{"session":"run-8f21e4","device":"deva"}`) + resp, err := http.Post(ts.URL+"/api/p/"+p.ID+"/undo-run", "application/json", body) + if err != nil { + t.Fatal(err) + } + out, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("undo-run: %d %s", resp.StatusCode, out) + } + + // The teammate converges on the pre-run state without doing anything but + // syncing, and so does the device that made the mess. + time.Sleep(10 * time.Millisecond) + for _, d := range []*Session{b, a} { + cycle(t, d) + if got := read(t, d.Folder, "notes/plan.md"); got != "the plan, as written by a human" { + t.Fatalf("%s has %q after the undo, want the pre-run content", d.Device.ID, got) + } + if _, err := os.Stat(filepath.Join(d.Folder, "notes", "scratch.md")); !os.IsNotExist(err) { + t.Fatalf("%s still has the file the run created", d.Device.ID) + } + } + + // The undo is append-only: the agent's own journal still holds every op + // it ever wrote, and the undo lives in the hub's. + rc, err := storage.Get(context.Background(), p.ID+"/journal/deva.jsonl") + if err != nil { + t.Fatal(err) + } + devaJournal, _ := io.ReadAll(rc) + rc.Close() + // The run's ops are still there, session id and all: an undo appends, it + // never rewrites the journal it is undoing. + if !strings.Contains(string(devaJournal), "run-8f21e4") || + !strings.Contains(string(devaJournal), "notes/scratch.md") { + t.Fatalf("the undo edited the run's own journal — it must only ever append to the hub's:\n%s", devaJournal) + } + if _, err := storage.Get(context.Background(), p.ID+"/journal/hubdev.jsonl"); err != nil { + t.Fatalf("the hub journaled the undo somewhere other than its own journal: %v", err) + } +} diff --git a/internal/webapp/frontend/e2e/session-run.spec.ts b/internal/webapp/frontend/e2e/session-run.spec.ts index 0ffcb6d..313d70f 100644 --- a/internal/webapp/frontend/e2e/session-run.spec.ts +++ b/internal/webapp/frontend/e2e/session-run.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { login, wikiId } from "./helpers"; +import { login, wikiId, expectToast, READER } from "./helpers"; /* One agent run, both halves (BEA-98). History used to show only what a run CHANGED; the reads lived in a daily aggregate with no session dimension and @@ -43,3 +43,83 @@ test("a read-only row opens the file it names", async ({ page }) => { await page.locator(".hrun-read", { hasText: "index.md" }).click(); await expect(page).toHaveURL(new RegExp(`/${pid}/index.md`)); }); + +/* BEA-82: the run-wide verb the card was grouped for. Every row inside the + card already had an action; the header had none, so reverting a bad run + meant clicking file by file and hoping you got them all. + + Driven against the seeded run (session 8f21e4 on device `seed`: one file + rewritten, one created) and left exactly as it was found — the last step + undoes the undo, which is also the point: the undo is itself a run card. */ +test("undoing a whole run puts every file it touched back", async ({ page }) => { + await login(page); + const pid = await wikiId(page); + + // Somebody edits a file the run created, AFTER the run. The confirm has to + // say that this change is about to be overwritten too — the one thing in + // this feature that can burn a teammate. + await page.request.put(`/api/p/${pid}/upload/content?path=runbook.md`, { + data: "# Runbook\n\nEdited by a teammate after the run.\n", + }); + + await page.goto(`/${pid}/history`); + const card = page.locator(".hrun", { hasText: "claude-code session 8f21e4" }).first(); + await expect(card).toBeVisible(); + + // The header carries the action; the run's own rows still carry theirs. + await card.locator(".hrun-undo").click(); + const modal = page.locator(".modal"); + await expect(modal).toContainText("Undo this run?"); + // Every path the run touched, with what will happen to each. + const rows = modal.locator(".undo-row"); + await expect(rows).toHaveCount(2); + await expect(rows.filter({ hasText: "notes/readme.md" })).toContainText("restore to pre-run version"); + await expect(rows.filter({ hasText: "runbook.md" })).toContainText("remove (the run created it)"); + // ...and the warning, named out loud. + await expect(modal.locator(".undo-warn")).toContainText("changed by someone else after this run"); + + // It writes to every synced device, so Cancel has to mean nothing happened. + await modal.getByRole("button", { name: "Cancel" }).click(); + await page.goto(`/${pid}/runbook.md`); + await expect(page.locator("#content")).toContainText("Edited by a teammate"); + + await page.goto(`/${pid}/history`); + await card.locator(".hrun-undo").click(); + await page.locator(".modal .danger-btn").click(); + await expectToast(page, /Undid 2 files/); + + // The file the run edited holds its pre-run content again, and the file it + // created is gone. + await page.goto(`/${pid}/notes/readme.md`); + await expect(page.locator("#content")).toContainText("Nested folder content"); + await page.goto(`/${pid}/runbook.md`); + await expect(page.locator("#content")).toContainText("isn't in this project"); + + // The undo is itself a run card — same note on every op it wrote — so it + // carries the same button and walks the whole thing back. + await page.goto(`/${pid}/history`); + const undoCard = page.locator(".hrun", { hasText: "undo run 8f21e4" }).first(); + await expect(undoCard).toBeVisible(); + await undoCard.locator(".hrun-undo").click(); + await page.locator(".modal .danger-btn").click(); + await expectToast(page, /Undid 2 files/); + await page.goto(`/${pid}/notes/readme.md`); + await expect(page.locator("#content")).toContainText("Rewritten during the agent run"); + + // Put the fixture back: the teammate's post-run edit above is the one thing + // the round trip legitimately restored, and later specs read this file. + await page.request.put(`/api/p/${pid}/upload/content?path=runbook.md`, { + data: "# Runbook\n\nCreated during the agent run.\n", + }); +}); + +// A write action, so a read-only member gets no button rather than one that +// 403s — the same rule the per-row restore and remove follow. +test("a read-only member sees no undo button on a run card", async ({ page }) => { + await login(page, READER); + const pid = await wikiId(page); + await page.goto(`/${pid}/history`); + const card = page.locator(".hrun").first(); + await expect(card).toBeVisible(); + await expect(card.locator(".hrun-undo")).toHaveCount(0); +}); diff --git a/internal/webapp/frontend/src/api/types.ts b/internal/webapp/frontend/src/api/types.ts index 1a0843b..b8bee85 100644 --- a/internal/webapp/frontend/src/api/types.ts +++ b/internal/webapp/frontend/src/api/types.ts @@ -245,3 +245,15 @@ export interface UploadPlan { method?: string; headers?: Record; } + +// POST .../undo-run (handleUndoRun, undorun.go). The same shape answers a +// `preview: true` call and the real one, so the dialog and the result read +// from one type — and the plan the dialog showed is recomputed server-side +// before anything is written. +export interface UndoPlan { + ok: boolean; + undone: { path: string; action: "restore" | "remove" }[]; + skipped: string[]; // already at their pre-run content: nothing to write + changed_after: string[]; // someone landed a change on this path after the run + refused: string[]; // a path the hub's own upload door would refuse +} diff --git a/internal/webapp/frontend/src/apps/Browser.tsx b/internal/webapp/frontend/src/apps/Browser.tsx index bee9a9b..eb434a0 100644 --- a/internal/webapp/frontend/src/apps/Browser.tsx +++ b/internal/webapp/frontend/src/apps/Browser.tsx @@ -9,7 +9,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { atLeast } from "../api/types"; import { getJSON, postJSON } from "../api/http"; -import type { Project, ServerConfig } from "../api/types"; +import type { Project, ServerConfig, UndoPlan } from "../api/types"; import { useHeat, useTree } from "../hooks/useBrowse"; import { useShares } from "../hooks/useHub"; import { urlForPath, urlForView, type Route } from "../router"; @@ -30,6 +30,7 @@ import { Palette, type PaletteItem } from "../components/Palette"; import { ConnectGuide } from "../components/ConnectGuide"; import { Insights, useInsightsDevices } from "../components/Insights"; import { HistoryView, historyTitle } from "../components/HistoryView"; +import type { Run } from "../lib/runs"; import { VersionBanner } from "../components/VersionBanner"; // The hub's six share-time credential rules, in words. Only one caller @@ -340,6 +341,98 @@ export default function Browser(props: { [apiBase, qc], ); + /* ---- undo a whole agent run ---- + The run-wide form of the two above, and the reason the feed groups runs + at all: reverting a bad run used to mean clicking file by file and hoping + you got them all. + + Two calls, both to the same endpoint. The first (`preview: true`) writes + nothing and asks the SERVER which paths the run touched and what would + happen to each — the loaded window is paged and filterable, so a list + computed from what is on screen is wrong exactly when the run is old or + filtered. The second does it, recomputing the plan server-side rather + than trusting the one the dialog showed; an op that lands between the two + makes the confirm one op stale, never the write wrong. + + The warning block is the one thing here that can burn someone: a path a + teammate changed AFTER the run is reverted too. That is the model + (last-writer-wins, and per-row restore already behaves this way), so the + dialog says it out loud instead of the undo being a surprise. */ + const [undoingRun, setUndoingRun] = useState(""); + const onUndoRun = useCallback( + async (run: Run) => { + const id = run.session || run.note; + const sel = run.session + ? { session: run.session, device: run.entries[0]?.device?.id } + : { note: run.note, device: run.entries[0]?.device?.id }; + setUndoingRun(id); + try { + const plan = await postJSON(apiBase + "undo-run", { ...sel, preview: true }); + const after = new Set(plan.changed_after); + if (!plan.undone.length) { + toast("Nothing to undo — every file this run touched already holds its pre-run content."); + return; + } + const ok = await modalConfirm( + "Undo this run?", + <> +
+ {run.note || id} — {plan.undone.length} file + {plan.undone.length === 1 ? "" : "s"} +
+
+ {plan.undone.map((a) => ( +
+ {a.path} + {after.has(a.path) && changed after this run} + + {a.action === "remove" ? "remove (the run created it)" : "restore to pre-run version"} + +
+ ))} +
+ {after.size > 0 && ( +
+ {after.size} file{after.size === 1 ? " was" : "s were"} changed by someone else after + this run. Undoing overwrites {after.size === 1 ? "that change" : "those changes"} too. +
+ )} + {plan.skipped.length > 0 && ( +
+ {plan.skipped.length} already hold{plan.skipped.length === 1 ? "s" : ""} its pre-run + content and will be left alone. +
+ )} + {/* Never silently drop a category: a path the hub's own upload + door refuses is left out of the undo, and a dialog that + listed only what it WILL do would read as "all of it". */} + {plan.refused.length > 0 && ( +
+ {plan.refused.length} path{plan.refused.length === 1 ? "" : "s"} can't be written by + the hub and will be left alone: {plan.refused.join(", ")}. +
+ )} + , + "Undo run", + true, + ); + if (!ok) return; + const done = await postJSON(apiBase + "undo-run", sel); + qc.invalidateQueries({ queryKey: ["history", apiBase] }); + qc.invalidateQueries({ queryKey: ["tree", apiBase] }); + qc.invalidateQueries({ queryKey: ["render", apiBase] }); + qc.invalidateQueries({ queryKey: ["text"] }); + const skipped = done.skipped.length ? `, skipped ${done.skipped.length} (already current)` : ""; + toast(`Undid ${done.undone.length} file${done.undone.length === 1 ? "" : "s"}${skipped}.`); + } catch (err) { + toast("Undo failed: " + (err as Error).message, true); + } finally { + setUndoingRun(""); + } + }, + [apiBase, qc], + ); + const historyNow = useCallback(() => { if (!path) return openHistory(""); openHistory(isDir ? path + "/" : path); @@ -446,6 +539,7 @@ export default function Browser(props: { onRendered={onRendered} restore={canRestore ? { onRestore, busy: restoring } : undefined} remove={canRestore ? { onRemove, busy: removing } : undefined} + undoRun={canRestore ? { onUndoRun, busy: undoingRun } : undefined} filters={route.filters} /* push, not replace: a filter is a navigation, and Back undoes it */ onFilters={(f) => navigate(urlForView("history", project?.id, route.viewTarget || "", f))} diff --git a/internal/webapp/frontend/src/components/HistoryView.tsx b/internal/webapp/frontend/src/components/HistoryView.tsx index dc889aa..9251e22 100644 --- a/internal/webapp/frontend/src/components/HistoryView.tsx +++ b/internal/webapp/frontend/src/components/HistoryView.tsx @@ -9,6 +9,17 @@ import { groupRuns, runFileCount, type Run } from "../lib/runs"; import { HistoryFilters, authorsOf } from "./HistoryFilters"; import { historyFilterQuery, hasHistoryFilters, type HistoryFilters as Filters } from "../router"; +// Undoing a WHOLE run — the run-wide form of restore/remove, and the only +// action the card header carries. Absent when the viewer can't write, like +// its two per-row siblings, so a read-only member never sees a button that +// 403s. The card hands over the run itself, not a file list: which paths are +// reverted is worked out server-side, because this window is paged and +// filtered and a client-computed list is wrong exactly when the run is old. +export type UndoRunAction = { + onUndoRun: (run: Run) => void; + busy?: string; // the session (or note) currently in flight +}; + /* ---- history ---- Every change ever made, straight from the journals: who (account), when, from which device (name, OS, IP as the server saw it). The route stores @@ -30,12 +41,13 @@ export function HistoryView(props: { onRendered?: () => void; restore?: RestoreAction; remove?: RemoveAction; + undoRun?: UndoRunAction; // Reader filters, straight from the URL. Applied server-side, so they // narrow the whole feed and not just the loaded page. filters?: Filters; onFilters?: (f: Filters) => void; }) { - const { apiBase, target, isFolder, onMeta, onRendered, restore, remove, filters } = props; + const { apiBase, target, isFolder, onMeta, onRendered, restore, remove, undoRun, filters } = props; const q = !target ? { prefix: "" } : isFolder(target) @@ -150,6 +162,7 @@ export function HistoryView(props: { recreates={recreates} restore={restore} remove={remove} + undoRun={undoRun} /> ) : ( void; @@ -206,6 +220,7 @@ function RunGroup({ recreates: (i: number) => boolean; restore?: RestoreAction; remove?: RemoveAction; + undoRun?: UndoRunAction; }) { const [open, setOpen] = useState(true); const first = run.entries[0]; @@ -236,6 +251,7 @@ function RunGroup({ // Distinct paths, not ops: repeat edits to one file must not inflate the // one number that sizes a run (BEA-39). Every op is still a row below. const n = runFileCount(run); + const undoing = !!undoRun?.busy && undoRun.busy === (run.session || run.note); return (
@@ -259,6 +275,22 @@ function RunGroup({ {dev ? " · " + dev : ""} {span} + {/* The one action the header carries. Every row inside the card + already has its own; this is the verb the card was grouped for — + reverting a run file by file and hoping you got them all is what + it replaces. onUndoRun confirms before anything is written. */} + {undoRun && ( + + )}
{open && (
diff --git a/internal/webapp/frontend/src/modal.tsx b/internal/webapp/frontend/src/modal.tsx index 08db116..d063c47 100644 --- a/internal/webapp/frontend/src/modal.tsx +++ b/internal/webapp/frontend/src/modal.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, useSyncExternalStore } from "react"; +import { type ReactNode, useRef, useState, useSyncExternalStore } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -26,7 +26,11 @@ type Prompt = { type Confirm = { kind: "confirm"; title: string; - message: string; + // A node, not a string: the run-wide undo has to SHOW the file list and the + // "changed after this run" warning it is asking about, and a confirm whose + // text can't hold them would push that list somewhere the user has to go + // find. Every existing caller passes a string, which is a ReactNode. + message: ReactNode; confirmLabel: string; danger: boolean; resolve: (v: boolean) => void; @@ -54,7 +58,7 @@ export function modalPrompt( export function modalConfirm( title: string, - message: string, + message: ReactNode, confirmLabel = "Confirm", danger = false, ): Promise { @@ -170,7 +174,8 @@ function ConfirmBody({ m }: { m: Confirm }) {

{m.title}

-

{m.message}

+ {/* a div, not a p: a p may not legally contain the path list */} +
{m.message}