Files
beardrive/architecture/webapp-frontend.md
T
eb01953729 feat(webapp): undo a whole agent run from the run card (#156)
* 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/<id>/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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:04:35 -07:00

11 KiB

Hub frontend (React SPA) — module diagram

Source of truth: internal/webapp/frontend/src. The built output is committed at internal/webapp/static (the go:embed target), so go build never needs Node. Reflects the code as of this commit; update this file in any PR that changes these modules or their relationships.

classDiagram
    direction LR

    class ErrorBoundary {
        getDerivedStateFromError
        renders a page with a way back
    }
    note for ErrorBoundary "ErrorBoundary.tsx — the app's floor, mounted in main.tsx ABOVE QueryClientProvider so it covers every route. React unmounts the whole tree when a render throws and nothing catches it, and the address bar keeps the URL, so a reload reproduces the blank page: a permanent client-side DoS that another member's CONTENT can reach (a link in a teammate's markdown reaching decodePath, a folder named `constructor` reaching ProjectIcon). Deliberately the smallest thing that works — no reporting, no retry machine, no per-route boundaries"

    class shareMermaid {
        <<second Vite entry>>
        src/share-mermaid.ts → static/share-mermaid.js
        picks DARK/LIGHT from prefers-color-scheme
    }
    note for shareMermaid "The only script the server-rendered /s/ share page ever loads, and only when shares.go finds a mermaid fence in the document. Built as a SECOND rollup input with a fixed, unhashed name at the static root — sharedMarkdownShell is a Go const and cannot know a content hash, and server.go marks assets/ immutable for a year, so an unhashed file there would pin a stale bundle in shared caches. Mermaid's own chunks keep their hashed assets/ names, which is why the share page needs the ACAO header: its sandbox origin is opaque"

    class App {
        mode from /api/config
    }
    note for App "App.tsx — picks HubApp (multi-project) or VolumeApp (single volume) from server config; frontend learns everything from the API, never sees storage or credentials"

    class HubApp {
        project list, org walls
        admin panels, invites
        remembers last opened project (localStorage)
    }
    class VolumeApp {
        thin wrapper: one volume
    }
    class Browser {
        folder listing, file view
        per-view routes
        +moved: /resolve?path= on a tree miss only
    }
    note for Browser "A missing path is decided from /tree alone — the file is never fetched — so the X-Bdrive-Canonical-Path header /file answers with would never reach the browser, and a moved FOLDER has no content fetch to hang a header on. The not-found branch asks GET /resolve?path= instead, then replaceState-navigates to the destination and prints one Moved from … line above it (BEA-81)"

    class router {
        +VIEW_ROUTES dashboard history install settings
        +LEGACY_VIEWS insights to dashboard
        +top-level routes orgs billing
        +parseRoute(url, mode) Route
        +Route.version ?v= sha, one past version
        +Route.trailingSlash notes/ resolves, then replaces to notes
        +Route.queryTarget history ?path= / ?prefix= resolves, then replaces to /history/target
        +Route.filters q user since until, history feed
        +historyFilterQuery(filters) / hasHistoryFilters
        +urlForPath(path, projectId, version)
        +urlForView(view, projectId, target, filters) / encodePath / decodePath
    }
    class nav {
        +navigate(url)
        +useLocationPath() pathname + search
        +linkProps(href)
        +Redirect
    }
    note for router "Two lookups on peer-authored path segments are now prototype-safe and one is throw-safe: legacyView() goes through Object.hasOwn, because LEGACY_VIEWS['constructor'] is truthy and turned a folder of that name into a view whose name was a FUNCTION; decodePath falls back to the raw segment instead of letting decodeURIComponent throw URIError out of a useMemo during render. Same shape as ProjectIcon's PROJECT_ICONS lookup in shell.tsx"
    note for nav "nav.ts + router.ts — deliberately NOT a router library (react-router v7 startTransition left stale views); History-API path routing, slashes literal, every user-facing page owns a URL path. A version is not a view route (the first segment after the project id is reserved for view names) — it rides as ?v=, so useLocationPath must snapshot the search too or the URL changes and nothing re-renders"

    class api {
        +getJSON / postJSON / api
        +getResponse (raw bytes)
        +PRODUCT_EVENTS method+path → event
        types.ts server contracts
    }
    note for api "api/http.ts — all URLs root-absolute so deep paths never break relative resolution. Every mutating call goes through api()/postJSON(), so one table there is the whole product-event surface: a new write is measured or it isn't, instead of depending on someone remembering a capture() call"

    class analytics {
        +initAnalytics(config)
        +track(event, props)
    }
    note for analytics "analytics.ts — posthog-js is fetched from the CDN at runtime, never installed: with no `analytics` in /api/config this module makes no request and the OSS bundle carries no tracker. capture_pageview history_change because the router is History-API. Replay masks every text node (maskTextSelector *) — in this product nearly all of it is customer file names and document bodies"

    class hooks {
        +useConfig
        +useHub
        +useBrowse
        +useTextAt (any URL) → useBlobText (sha-keyed, immutable)
    }
    note for hooks "TanStack Query wrappers over the viewer APIs. useTextAt fetches any URL and sniffs it — the Content-Length cheap-out lives here (HTTP), the byte decision in lib/sniff.ts (pure). A live path must not be cached immutable; a sha can be"

    class components {
        FileView FolderListing FileTree
        HistoryView HistoryRow HistoryFilters DiffView VersionBanner
        Insights ShareDialog NewProjectDialog
        ShareBanner SharesTable AdminTable
        OrgAdmin HubSettings ProjectSettings
        Palette shell AccountBar ...
    }
    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 &quot;changed after this run&quot; 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.ts splitLines lcsDiff diffText
        +runs.ts groupRuns runFileCount
        +heat.ts heatFor heatTotal heatText heatLevel hotPathSplit
        +heat.ts ageRange isFlatRange ageSpanLabel (treemap scale)
        +heat.ts orphanPaths (reads whose file left the tree)
        +heat.ts placeLabels LABEL_MAX (scatter danger-dot labels)
        +sniff.ts sniffBytes BlobText MAX_BYTES
        +csv.ts parseDelimited Csv CSV_ROWS
        +mermaid.ts hasMermaid renderMermaid Palette DARK LIGHT
        +utils.ts
    }
    note for lib "mermaid.ts is the one exception to 'pure, no React, unit-tested on node': it needs a DOM and a browser-only library, so its coverage is Playwright. html in → html out, so neither caller can be tempted to patch a live subtree. It imports mermaid only when hasMermaid() says a document has a fence — that gate is what keeps a diagram-free page from downloading any of it — and every failure (unparseable fence, render throw, chunk that never loads) returns the untouched &lt;pre&gt;&lt;code&gt; instead of throwing"
    note for lib "pure, no React, unit-tested on node (npm test) — the line diff is ~40 lines, cheaper than auditing a diff package. heat.ts is the one read-count arithmetic: every surface (file header, folder listing, Dashboard bar) totals and splits through it, so they cannot disagree; useBrowse re-exports it"
    note for lib "csv.ts parses .csv/.tsv for FileView's table view — ~50 lines against RFC 4180, so no papaparse. It NEVER throws: null means 'not a table' (unterminated quote, no delimiter) and the caller falls back to the plain-text preview, which is why the fallback is a type-level guarantee rather than a try/catch someone can forget"

    ErrorBoundary --> App : wraps the whole tree
    App --> HubApp
    App --> VolumeApp
    HubApp --> Browser
    VolumeApp --> Browser
    HubApp --> router
    Browser --> router
    Browser --> components
    HubApp --> components
    components --> nav : linkProps navigate
    components --> lib : diffText groupRuns hotPathSplit placeLabels parseDelimited renderMermaid
    hooks --> lib : re-exports heat.ts, sniffBytes
    shareMermaid --> lib : renderMermaid
    hooks --> api
    Browser --> hooks
    HubApp --> hooks
    hooks --> analytics : initAnalytics + identify on config
    api --> analytics : track(product event)
    Browser --> analytics : share_created (the one raw fetch)