189 Commits
Author SHA1 Message Date
82e23f2382 feat(webapp): copy a file's source to the clipboard (BEA-153) (#190)
You could read a doc in the hub and download it, but there was no way to
get its text onto the clipboard — the workaround was downloading a file
you didn't want, or select-all over rendered markdown, which silently
mangles what you paste.

Copy sits beside Download in the ⋯ menu and in the ⌘K palette, and puts
the file's raw source there: the whole file, frontmatter included, so it
round-trips with Download. With a version pinned (?v=) it copies THAT
version's bytes, the same rule Download already follows.

It fetches directly rather than reading the ["text", url] query cache:
TextView stores a bare string under that key and SniffView stores a
BlobText object, so a cache read would have refused .csv/.txt files
while working fine on the markdown a reviewer would test with.

copyText returns false instead of throwing, so the toast branches on it
— otherwise every http:// self-host gets a success message over an empty
clipboard.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 09:30:45 -07:00
b0029f914c fix(webapp): a background refresh never moves the reader (BEA-155) (#188)
* fix(webapp): a background refresh never moves the reader (BEA-155)

Reading a file was interrupted every so often by the viewport jumping back
to the top. Nothing polls the document — the 60s read-count poll does it:
`heatMap` sat in MarkdownView's meta-effect deps and that effect ended by
calling `onRendered()`, which the scroll restorer reads as "content landed"
and answers with `scrollTo(top: 0)`. Capped at 3 attempts per route, hence
"from time to time". `onScroll` then memoized 0, so Back also returned to
the top.

Two changes. The second is the reported bug; the first is why it can't come
back wearing a different hat:

  - The scroll goal retires itself the moment the reader scrolls, so no
    onRendered caller — present or future, honest or not — can move a reader
    who has taken over. Two clauses are load-bearing and each has its own
    test: scrollTo fires a scroll event of its own (so "moved" is measured
    against the goal, never against zero), and a page shorter than the last
    one makes the browser CLAMP the carried-over offset to the bottom, which
    is either the old page's offset arriving or a goal that doesn't fit yet
    — the exact case the retries exist for.
  - MarkdownView's effect splits in two: onMeta keeps its heatMap
    dependency, onRendered fires on [html, diagrams]. A metadata refresh is
    not a render. A mermaid diagram landing used to call onRendered never,
    so Back into a diagram-heavy file never got its late re-apply; now it
    does, which is what the retry budget was written for.

The state machine moves to src/lib/scroll.ts because the frontend suite is
`node --test` over pure TS with no jsdom — that is the only way this gets a
regression test at all. Browser.tsx keeps the DOM bits.

The other four onRendered callers are audited and deliberately left alone:
FolderListing's "Recent changes" feed, HistoryView, and SniffView/TextView
on ["text", …] all fire on content that genuinely changes the page height,
which is what the retries are for. MarkdownView was the only one whose
trigger was pure metadata.

No poll interval is touched, and every programmatic scroll keeps
behavior:"instant" — #content carries scroll-behavior:smooth, and an
animated restore would fire intermediate scroll events that the new guard
would read as the reader.

* fix(webapp): apply the scroll goal when it is armed, not only later (BEA-155)

Back landed at the top of the file instead of the offset it remembered —
already true on main, and the same restorer this branch is repairing, so it
lands here rather than as its own issue.

React runs CHILD effects before the parent's. A view calls onRendered from
its own effect, so by the time Browser's route effect arms the goal for the
new route, that call has already happened — against the goal of the route
just left, where the key check discarded it. The goal then sat armed and
nobody applied it: on PUSH the container's carried-over offset was clamped
by the shorter page and looked close enough to right, but on POP the
remembered offset was simply never restored.

Arm and apply in the same pass. Later onRendered calls still cover content
that grows after first paint, which is what the remaining budget is for.

* docs(architecture): lib/scroll.ts, the scroll goal Browser now delegates to (BEA-155)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:06:38 -07:00
4f04b78c71 feat(webapp): frontmatter moves to a collapsible side panel (BEA-154) (#187)
A doc's YAML frontmatter rendered as a table pinned to the top of the
reading column, so on anything with more than two or three keys the
document itself started below the fold. It is a panel beside the prose
now — a sticky rail on a wide window, a closed disclosure above the body
on anything narrower — and the reading column starts with the document.

The table was built on the server and handed to the client inside one HTML
string, so this is not a CSS change: markdown.go splits the parse
(frontmatterPairs) from the markup, /api/render gains an ordered
`frontmatter` field, and the viewer switches to RenderMarkdownPairs.

RenderMarkdown keeps its exact output — it is the public share page, and
every /s/ link ever minted serves it. shares_test now pins that, because
nothing else would have failed if a later cleanup moved shares.go onto the
pairs path.

Values cross the wire as literal text plus a `code` flag rather than
pre-escaped HTML, so the panel is ordinary React text nodes and never
touches dangerouslySetInnerHTML: "a value containing markup renders as
text" holds by construction.

The rail's breakpoint is 1400px, not the 1180px the plan named — 768 of
prose + 28 + 240 of rail needs 1036px of column, and at 1280 the reading
measure lost 110px, which is the squeeze the panel exists to avoid.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:55:29 -07:00
b5bd8dddff fix(webapp): a run card keeps the reads of files the project deleted (BEA-152) (#186)
The History run card carried a footnote saying reads are shown only for
files the project still has. Nothing implements that: SessionPaths does a
ListBySession and consults no tree, so a read outlives the file it read —
which is why the Dashboard's hot path listed `scratch.md · 4` while the run
card showed nothing. Two surfaces, one ledger, and the only explanation on
offer was wrong about the mechanism.

The card is narrower than the project totals because it is one session on
one device, not because deleted files are filtered. Say that instead, and
label a vanished path the way the Dashboard already labels it — same words,
same `.in-hp-gone` class. Reads of deleted files count, on both surfaces.

The seeded run now reads `scratch.md` too (the fixture's deleted-but-read
file), so the label has something to render against; the Go test pins the
policy the copy now states out loud.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:43:12 -07:00
ae14d11ac3 fix(webapp): three readers on one network are three share opens (BEA-151) (#185)
The share actor key was token+"/"+IP, so every browser behind one NAT was
the same reader and the 10-minute visit debounce folded a whole office into
a single open — three personas each measured "1 open" for three readers, and
the panel's own copy promised the opposite.

The key gains a truncated hash of the User-Agent. ShareOpens already sums
across actor buckets and takes the max Last, so opens: 3 and an advancing
last_opened fall out with no aggregator change, no new field, and no change
to readDebounce. The UA is hashed because Record persists the actor through
ReadRepo into storage; token+"/"+IP stays the prefix so the existing leak
assertions keep covering the wider key.

The copy now states the rule the code implements, including its residual:
two people on one network in the same browser still count as one.

Deviation from the plan, deliberate: TestSec_Share_VisitorCannotInflateOrRedirectTheLedger
pinned "a visitor cannot split its own visits by varying the User-Agent".
That is now intended behavior, so the two UA rows move out of the
must-collapse set into an explicit assertion that they count separately.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 12:32:53 -07:00
eef37fa89b fix(webapp): the hub 404s a root file it does not have (BEA-148) (#184)
GET /llms.txt answered 200 with the sign-in page. So did /robots.txt,
/sitemap.xml and every mistyped root file: the SPA fallback treated only
api/, auth/ and s/ as genuine 404s, so a crawler probing any conventional
path got a success status and a chunk of login HTML.

In hub mode a first path segment is a project id (UUID or p-xxxxxxxx) or a
reserved word, none of which contain a dot, so a single dotted segment with
no embedded asset can only be a file that is not there. The check runs after
the asset lookup, so a real root asset (share-mermaid.js today, favicon.ico
whenever the build emits one) is unaffected with no allowlist to maintain.

Gated on hub mode: the plain-folder viewer shares this handler and there
/README.md IS the route for a file, so ungating this would make every
top-level file in every `bdrive serve <dir>` unreachable. There is a test
that fails if the gate is removed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:21:09 -07:00
20f59352ca fix(webapp): badge the credential the share gate already found (BEA-147) (#183)
* fix(webapp): badge the credential the share gate already found (BEA-147)

The hub could identify an AWS access key on line 3 well enough to refuse to
publish the file, and rendered that same key to every member as ordinary body
text. scanSecrets had exactly one caller — share minting — so the strongest
protection in the product sat on the rarest path and was absent from the path
every file takes.

The render response now carries the same finding, omitted when the file is
clean, and the markdown file view shows an advisory strip above the content.
Advisory only: nothing is blocked and nothing is redacted, because a member
who can open the file could already read the key.

The label vocabulary moves out of Browser.tsx into lib/secrets.ts, shared by
the badge and the share dialog, so the two surfaces cannot drift apart on the
wording of the same finding.

The ?sha= history render is scanned too — two lines, and it stops the badge
vanishing the moment you click into history on the file it was warning about.

Rule ids and line numbers only. The matched text reaches no response body and
no log line, pinned by a test on the new caller the way shares_test.go pins
the old one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(architecture): the credential scan gains a render-path caller (BEA-147)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:11:00 -07:00
039e350d79 fix(webapp): the manual install block stops promising one command (BEA-142) (#182)
The 'Or run it yourself' section said 'One command: init signs this device
in, registers the sync hooks and starts syncing' directly above a block of
three, one of which is the bdrive login it claimed init did for you.

The commands are correct and unchanged — bdrive login <origin> is what
points the device at this hub; without it init aims at the remembered
server or beardrive.ai, wrong for every self-hosted reader this block
serves. So only the sentence moves, now describing the three steps it
actually sits above.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:00:33 -07:00
3e13f0bfa5 fix(webapp): Settings → About says how to take your files elsewhere (BEA-141) (#181)
bdrive export moves a whole project between hubs with full fidelity, and
the web UI mentioned it nowhere — the answer to "why trust a hub with my
files" only existed in a file a user had no reason to open.

Adds one paragraph to the About card: what the archive holds (every
device's journal and every blob, so full history and authorship), that
bdrive import restores it into any hub, that export warns on unpushed
changes, and a link to the migration docs. Copy only — no server route,
no download button, no palette action. The card already sits outside
every mayEdit guard, so every project member sees it; the member-role
settings e2e asserts that.

One correction to the reviewed plan: bdrive export takes a folder, not a
project id, so the copy says to run it in the synced folder rather than
leaning on the project id rendered above it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:49:32 -07:00
52156f1b21 fix(webapp): a project URL by name opens the project (BEA-140) (#180)
Two personas guessed /wiki independently — the project name is what the
sidebar shows them, and the id (4c400e3f-…) never appears in the UI as
something to copy. Both got a page that argued with itself: the correct
project's chrome, breadcrumb and full file tree, with a body reading
"Project not found. This project doesn't exist, or you're no longer a
member." One of them owned the project.

Both halves land in the same place, and the net effect is one conditional
removed. `projectMissing` becomes an early return:

  - A first segment that names exactly one of your projects, matched
    case-insensitively on the DECODED segment (route.project is the
    still-encoded slice, so a name with a space would never have
    matched), redirects to /<id> with the rest of the URL — path, view,
    target, filters, version — carried along. Exactly one: ProjectDB
    names are scoped per organization, so a viewer in two orgs can hold
    two projects called "wiki", and the not-found page is the honest
    answer there.

  - Anything else renders the not-found body in a shell with NO tree —
    the same shape the `!current` branch already uses — so no other
    project's files sit beside a body denying the one that was asked
    for. The copy names the segment and drops the "no longer a member"
    claim, which was told to readers who may never have been members.

The `current` fallback chain is untouched: "Back to <project>" still
points at it. With the early return in place, none of the four redirects
below can be reached on a missing project, so the `if (!projectMissing)`
wrapper is gone and its reason now lives in the return.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:40:16 -07:00
1868228d29 fix(webapp): history shows it is loading instead of reading as empty (BEA-131) (#179)
The history filters are part of the react-query key, so changing one drops
`data` back to undefined. The feed rendered the filter bar and nothing else,
which is pixel-identical to the resolved "No changes match these filters."
state — a reader concluded twice that a file had no history when it had
three entries.

Render the shell unconditionally and put the in-repo `.empty` loading row
inside it, so a pending request and an empty result look different. Not
while `error`: failures already report through onMeta, and a permanent
spinner would hide them.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:29:19 -07:00
b3695d666d fix(webapp): hand the public link back from the table that manages it (BEA-130) (#178)
The Public links table listed path, owner and expiry with a Revoke button —
and no URL, not in a column, not in the DOM. The only way to re-send a link
you had already minted was to remember the file, navigate to it and read the
URL off its banner.

Each active row now carries an icon copy control beside Revoke, using the
`url` the server already composes through requestBaseURL (never rebuilt
client-side out of origin + token, which would diverge behind a proxy). It
renders for read-only members too: ShareBanner already prints the whole
/s/<token> URL as text to anyone with read (BEA-69), so gating the clipboard
shortcut would only make Settings stricter than the file page.

The actions track grows 110px -> 150px to hold it — not back to the 186px
that once starved the detail cell, and that cell wraps now anyway.

The e2e config grants clipboard-write: headless Chromium withholds the
permission a real browser gives the focused tab, so without it every copy
control reports failure and a spec measures the fallback path.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:19:47 -07:00
d4fd7eb372 feat(webapp): say what a conflict copy is, where a reader meets it (BEA-128) (#177)
A concurrent edit is preserved as `<name>.bdrive-conflict-<device>-<utc>`
— the guarantee the whole shared-folder promise rests on. Until now that
promise appeared in the README, the docs and syncer.go, and nowhere in
the hub: a conflict copy was an ordinary row with an alarming name, and a
user could only learn what it was by reading the README.

conflictName is a pure function of the path, so the frontend recovers the
device and the moment from the string alone — no server route, no journal
field, no request. lib/conflict.ts holds the parser (anchored suffix, a
strictly narrower match of the Go convention; anything malformed is null,
never a throw), the listing marks the row, and ConflictBanner explains the
file and links the version that kept the original name.

History and the Dashboard stay out, per the spec's stated cut.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:09:43 -07:00
6f8eb99606 fix(palette): the project you are in answers to its own name (#170)
The palette's placeholder and empty state both promise project search, but
the switcher loop rightly excludes the project you are already inside — so
typing `wiki` from inside `wiki` matched nothing at all. The destination was
there the whole time, as the unconditional row labelled "Go to project root"
and tagged ACTION, which no project name will ever match.

Relabel that one row with the project's name and tag it `project`. One row,
not two; still first and unconditional, so the BEA-52 dead-route escape
hatch is literally the same row it always was.

Closes BEA-105

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 10:58:33 -07:00
a3408bb198 fix(shares): a folder is not a sync problem (#169)
bdrive share <folder> answered "not synced to this project yet" on a fully
synced folder, so users went looking for a sync fault that wasn't there.
snap.files maps FILES, so a folder always missed the existence check and
landed in the same 404 as a path that doesn't exist — and the CLI bolted
"wait a few seconds for the daemon" onto it.

Tell the two apart in the handler (any key under p+"/" — never bare p, or
notes-archive/x.md makes "notes" a folder): 400 with "share links are
per-file" naming the lexicographically smallest file inside, 404 with
today's wording otherwise. The CLI's daemon hint was already 404-only, so
it stops appearing on its own; the only CLI change is printing a 400 body
without httpBodyError's status prefix. The web UI mints through the same
handler, so it gets the same distinction.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 10:41:28 -07:00
31db19b9c6 fix(webapp): carry the hot-and-stale warning to the file and folder views (BEA-119) (#175)
* fix(webapp): carry the hot-and-stale warning to the file and folder views (BEA-119)

The Dashboard flagged archive/retired-spec.md as read-a-lot and
unmaintained, then the file's own page served it with raw counts and a
raw date and left the reader to do the staleness arithmetic. The warning
existed on the one screen nobody opens before trusting a doc.

HOT_READS, STALE_DAYS and the danger predicate were module-private to
Insights.tsx, so no other surface could reach the verdict — even though
both inputs (heatMap, Node.time) were already in hand on both of them.
They move to lib/heat.ts, whose own header says every read-count surface
shares one arithmetic, and Insights.tsx imports them instead.

The predicate takes (reads, days) rather than a heat entry: only the
Dashboard has a reader lens, so it keeps passing its lens-filtered count
while the file and folder views pass heatTotal.

- file page: "⚠ stale · last changed 7 months ago", leading the meta line
  because #meta is nowrap + ellipsis and a trailing warning is the first
  thing a narrow window eats
- folder listing: ⚠ beside the heat dot, files only (a folder's heat is a
  subtree sum with no single mtime), with a real aria-label rather than a
  hover-only title
- no threshold change: the Dashboard flags the same set, pinned by both a
  unit test on the boundaries and an e2e test on all three surfaces

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(architecture): heat.ts now owns the hot-and-stale verdict (BEA-119)

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 22:15:22 -07:00
3fbe1252db fix(webapp): say that a read count includes your own views (BEA-61) (#173)
* fix(webapp): say that a read count includes your own views (BEA-61)

"14 human reads" counted the author's own browsing, and no surface said so.
Snow's call was to keep counting them — so this is disclosure, not ingest:
recordRead and /heat are untouched.

One HEAT_DISCLOSURE constant beside heatText in lib/heat.ts, consumed by all
four surfaces that print a count. The file header carries it as hover text
plus an .sr-only span rather than visible text — #meta is nowrap + ellipsis,
so anything appended there is the first thing a narrow window truncates away.
The folder page says it once out loud, covering the summary and every row,
and the heat dot keeps it in title and aria-label for a row met on its own.
The Dashboard folds it into the caption already there, both scope branches.

A unit test pins the constant as the only copy of the sentence in src/ —
"defined once" is the acceptance criterion no browser test can see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(architecture): note HEAT_DISCLOSURE in the frontend lib diagram (BEA-61)

The diagram enumerates heat.ts's exports, so a new one belongs in it — and
the note says why the constant sits beside the arithmetic instead of in the
four components that print 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 18:04:15 -07:00
c3b9fa5858 fix(cli): status looks at the folder, not just the cache (BEA-106) (#171)
`bdrive status` answered from the state cache and the journal and never
looked at the working folder. With the daemon stopped, an edit nobody has
scanned is in neither — so the one command that answers "is this folder in
sync?" reported `pending: 0` with the change sitting right there. A wrong
"you're clean" is worse than no answer.

syncer.Drift is a sibling of Explain/SyncedFiles with the same contract:
loadFilter + walkFolder + the scan's own size+mtime compare, and nothing
else. status prints it as a `local:` line, distinct from `pending` — they
are different states and a change can be in either or both.

The load-bearing property is that it stays a pure read. status is what
someone runs when sync is stuck; a version that scanned-and-committed would
change what it was asked to describe, and would write ops from a command
nobody expects to write. Pinned by a test hashing the device journal and the
state cache before and after.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:04:14 -07:00
Snow Lee (Sungwon)andGitHub a3dfa73fef Catch a credential when it syncs, not only when you share it (#162)
* refactor(secrets): lift the share-time credential rules into internal/secrets

The rules only ever ran on the rarest path a file takes. Moving them out of
internal/webapp is what lets internal/syncer run the same six rules on the
path every file takes, without inverting the dependency.

Pure move plus one addition: Label(), the six human strings that until now
lived only in the frontend's SECRET_LABELS — so 'bdrive share' stops printing
a bare rule id where the web dialog says 'an AWS access key'. Rule ids and the
rule/line JSON tags are unchanged: Browser.tsx keys off them, so they are a
wire contract.

* feat(sync): warn when a synced file looks like it holds a credential

The six share-time rules now run on the path every file takes. A file with an
AWS key in it used to ride a normal sync to the hub, to every teammate's disk
and into every future agent's context with no badge and no warning — while the
Share dialog one click later blocked that exact file.

Warn, never block: the op is journaled and pushed exactly as before. A hold arm
would mean a false positive silently parks someone's changes, and it would
break the cycle's degrade-to-offline posture.

- scan() reads the blob PutBlobFile just wrote (the bytes that were actually
  journaled), only on the branches that wrote one — an unchanged file is still
  never re-read.
- Findings persist per path in secrets-<mount>.json, merged rather than
  replaced: nearly every cycle scans zero files, and a whole-set rewrite would
  erase the warning seconds after it appeared. Fixing the file clears it.
- bdrive status grows a secrets block; the agent hook appends one advisory
  sentence. Rule ids and line numbers only, never the matched bytes.
- SaveSecrets failing logs and continues: advisory telemetry never gets a veto
  over convergence.

* docs: the credential check now runs on sync, not only on share

README, the CLI reference and project-files get the new bdrive status block
and the warn-never-block posture, with the three limits stated (checked when
it changes, first 1 MiB, writing device only). Diagrams: internal/secrets is a
package of its own in the overview, secretLog joins the sync engine, and the
share-gate class notes that it no longer owns the rules.

* test(sync): assert an unchanged file is never re-read for credentials

The check must ride the branch that already reads the file. Clearing the record
by hand and cycling proves it: a scan that re-read unchanged files would put the
finding back, and the daemon's 3-second tick would pay for it on every file.
2026-08-18 15:08:44 -07:00
398f30d64b fix(webapp): a wikilink is a real link, not a wiki: string (BEA-136) (#151)
[[guide]] rendered as href="wiki:guide" — a pseudo-scheme no browser can
resolve. The delegated click handler rescued a plain left-click, so the
feature looked fine until someone copied the link, middle-clicked it, or
opened it in a new tab and got a dead string.

Resolution moves from click time to transform time: transformHTML (the
pass that already rewrites this HTML before the mount) matches the target
against flatFiles and writes the real urlForPath() URL, plus a data-wiki
marker. A wikilink matching no file loses its href entirely and renders as
.wiki-missing, so no "wiki:" survives into the DOM either way.

The matching rules didn't change — they moved into a pure resolveWiki() in
util.ts, where node --test covers the whole matrix without a browser.

The consequence to get right is the click: real hrefs mean a plain click
must be intercepted (or it does a full document load) and every modified
click must be let through (or the fix buys nothing) — the same rule
nav.ts:linkProps applies everywhere else. The guard sits above both
branches, so cmd-clicking a relative markdown link now opens a tab too
instead of SPA-navigating the current one.

markdown.go is unchanged: wiki: stays the marker the server leaves behind
because RenderMarkdown has no file tree. /s/<token> share pages keep their
dead wikilinks by the spec's decision — the target isn't part of the share.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:48:34 -07:00
fd392aa9b0 fix(webapp): a broken mermaid fence says which line, and why (BEA-135) (#150)
* fix(webapp): a broken mermaid fence says which line, and why (BEA-135)

A fence that doesn't parse showed "Couldn't render this diagram." and
nothing else — no line, no parser output, no way to fix it. The
diagnostic already existed: mermaid throws a parse error carrying the
line number, the offending source, a caret column and the expected
tokens, and the catch discarded it one line from where it was needed.

Bind it and print it under the existing note, in a sibling element so
.mermaid-err's text stays exactly what it was. textContent, never
innerHTML: the message quotes the author's source verbatim and what
renderMermaid returns is mounted through dangerouslySetInnerHTML. The
seeded broken fence now carries a complete <img onerror=x> tag so the
e2e proves that — short on purpose, since the parser's window is 20
characters of past input and a tag it truncated would leave no start tag
for an innerHTML bug to mount.

The line number is the diagram's, not the file's: the helper only ever
sees rendered HTML, never the .md around it.

Both surfaces, one helper: the hub viewer reads style.css, the share page
reads the inline shell in shares.go and never loads the app's stylesheet.
`white-space: pre` is load-bearing (the caret only lines up in a
monospace, non-wrapping box) and max-height + overflow keeps a
pathological message inside its own box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(webapp): rebuild static/ for the mermaid diagnostics

go:embed needs the built output in the module. Split from the source
commit because rollup re-hashes the whole mermaid chunk graph when its
importer changes: 118 of these files are renames with byte-identical
content, only their import filenames differ.

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:30:38 -07:00
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
6f0f474903 feat(hub): count file changes and headless users server-side (#164)
The frontend's PostHog tracker sees everything a person clicks, but a
device syncing through /store/* never loads a page — so an agent editing
files all day was invisible, and "number of file changes" and "daily
active users" both undercounted by however much of the product runs
headless.

One event, files_changed, from every write door: sync, upload (relay and
direct commit), remove, restore. Its distinct_id is the same email
analytics.ts identifies with, so a person on a laptop and a browser is
one user, and its puts/deletes properties sum to the change count.

The count comes from ops the hub has not stored before, not from the
request body: a device PUTs its WHOLE journal every cycle, so counting
the body would re-report the device's entire history every ten seconds
and the metric would climb while nobody edited anything.
journalKeepsItsOps already parsed the stored journal for the append-only
check and threw the sequence away; it returns storedMax now, so this
costs no extra read. Blob PUTs are deliberately not change events —
content-addressed storage skips a blob it already holds, so blob writes
undercount edits while ops are exact.

No SDK: posthog-go would ship a tracker inside every self-hoster's
binary, which is the exact thing the frontend avoids by loading
posthog-js from a CDN only when a key is configured. Capture is one JSON
POST, on its own goroutine, that does nothing when Analytics.Key is
empty — an OSS hub still contacts nobody.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:50:25 -07:00
Snow Lee (Sungwon)andGitHub 3de8590b6b feat(sync): gzip the sync wire, without touching what a hash means (#160)
Nothing on the /store/* wire was compressed, while the corpus it carries is
markdown and source. Compression lands as a pure transport concern: content
addressing, the storage layout and the journal format all stay over the
uncompressed bytes.

The two legs are not symmetric. Pull needs no negotiation — net/http already
sends Accept-Encoding: gzip and inflates transparently — so devices built
before this get it the day the hub ships; a real pre-compression binary
receives 19,958 bytes for a 148 KB corpus (7.4x) with no client change. Push
is negotiated through sign()'s accept_encoding, because a gzip body posted to
an old hub would be stored under the sha256 of its plaintext.

The hub inflates ABOVE spool — the sha a key promises, the ops a journal
carries and the size that gets billed are all plaintext properties — and the
inflate is bounded at 256 MiB, because Content-Encoding severs the
one-wire-byte-one-disk-byte relationship that made spool safe unbounded. The
presigned direct-to-storage leg stays raw and is asserted to.

Known deployment caveat: a compressed push clears ContentLength, so it goes
out chunked where every push was sized before. A reverse proxy that buffers or
rejects chunked request bodies would fail pushes (degrading to Offline and
retrying, not losing data).
2026-08-13 12:20:36 -07:00
edfe46c0aa post_sync: run a local command when teammates' changes land (#163)
Inbound sync was invisible to the machine it landed on — a local index,
cache or notifier had to poll. A `post_sync` command in the folder's own
.bdrive/config.json now runs once per cycle that applied peer changes,
with the batch as JSON on stdin.

The batch rides out on a new Result.Inbound rather than the inbound
spool: DrainInbound is destructive and `bdrive sync --hook` is its only
consumer, so a second drainer would silently empty the agent's
"teammates changed X" context. Both are kept, and both comments now say
why.

Cycle becomes a thin wrapper over cycleLocked so the hook is spawned
after the volume flock drops — a property of the code shape, not a rule
each of the seven call sites has to remember.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:04:23 -07:00
0dd474baab Delta sync: large files move as content-defined chunks (#161)
* feat(sync): delta sync — large files move as content-defined chunks

Files over 4 MiB push as chunks/<sha256> pieces plus a manifests/<sha256>
chunk list keyed by the whole file's hash, so Op.Blob alone locates it and
the journal format is byte-identical. A 1-byte edit to a 20 MiB file now
transfers ~2 MB instead of ~21 MB, both directions; chunk boundaries come
from a rolling hash (restic/chunker), so front insertions stay cheap.

The hub reassembles whole blobs on demand (spool, verify, backfill, serve),
which is the entire backward-compatibility story: old clients ask for
blobs/<sha> and never learn anything changed. Proven by e2e tests that build
the real pre-change binary from the pinned merge-base commit.

The push skip-proof is one Exists per chunk — three cheaper proxies (local
basis, manifest existence, stored manifest content) each proved false or
forgeable across four CTO review rounds and are recorded in the code
comment. Hub-side, manifests are write-once and must name only chunks the
store holds; reassembly is bounded at 256 MiB against amplified manifests.

Also: per-file sync ceiling 32 -> 100 MiB; import refuses archives whose
journals reference content they do not hold (--allow-incomplete overrides).

Deploy hubs before clients: old hubs refuse chunk keys (push degrades to
offline-retry), and old clients cap reads at 32 MiB so 32-100 MiB files
report "blob corrupt on remote" until the client upgrades.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6nqxi5a9qcENmJvrgBJF7

* fix(test): fetch the pinned pre-delta commit on shallow CI clones

buildOldBinary archives the pinned merge-base sha, which a fetch-depth-1
actions/checkout does not have — all three old-binary e2e tests failed in
CI with exit 128 while passing on any full local clone. On archive failure,
fetch just that commit (--depth=1, one object; actions/checkout persists
credentials so the in-job fetch works) and retry. Verified against a real
GitHub shallow clone: archive fails, the single-sha fetch succeeds, archive
then yields the pre-delta tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6nqxi5a9qcENmJvrgBJF7

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 07:18:37 -07:00
33ca0caeab fix(sync): connecting a folder adopts the project's files instead of forking them (#159)
A device's first cycle on a volume was treated as a concurrent edit for every
path the project already held. Whichever side's clock happened to sort higher
won -- so a joiner's seeded .bdriveignore or agent-written AGENTS.md could
replace the team's -- and the loser landed beside it as a
.bdrive-conflict-<device>-<time> file.

A first cycle is a join, not an edit. Cycle step 1b holds the scan's ops back
over the pull and demotes any whose path the project already holds to lamport
0, which sorts under every op a project can carry (scan's clock starts at 1).
The project's version then wins deterministically on every device, and
conflictCopies skips those ops the way it already skips re-asserted ones. The
local content is still journaled and pushed, so it stays in History and
`bdrive restore --list <path>` can bring it back. Reported as `adopted: N`.

Concurrent edits after the join are untouched.


Claude-Session: https://claude.ai/code/session_01GLCyEWP2XZhYjBssvdCDQm

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:10:10 -07:00
f70d54192a feat(web): a Beta pill beside the BearDrive wordmark (#154)
BearDrive launches free with a beta label, so the hub says so next to its
own name. Subtle honey pill at low alpha, not the solid accent: the accent
is already spent on the mark two elements to its left, and this sits there
until it doesn't.

It keys on what the lockup actually SAYS (`brand === "BearDrive"`), not on
whether `brand` was configured. A hub that set its own brand is labelling
somebody else's product and "Acme Docs Beta" is a claim we have no business
making for them — but the managed hub and every default self-host reach here
with the string "BearDrive", and the obvious `!config.brand` test would have
missed them: CLOUD_BRAND defaults to "BearDrive", so a BuiltinAuth hub sends
the name rather than an empty field, and the pill would have shown only on
the PropelAuth path.


Claude-Session: https://claude.ai/code/session_019vWP4DbsWCeD6zWN5gaqsV

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 20:27:45 -07:00
94091a8795 fix(hub): device binding is a provider contract, not a BuiltinAuth field (#147)
ownJournal refuses a journal write unless the device id is bound to the
caller's account, for EVERY provider — it asks only whether s.Devices is
nil. The only thing that creates that binding is DeviceRegistry.Bind, whose
only caller is BuiltinAuth.finishLogin, and that hook was wired behind
`if a, ok := s.Auth.(*BuiltinAuth); ok`.

So a hub running a managed AuthProvider — the deployment the seam exists
for — bound nothing, ever, and refused every journal push from every device
forever. Everything around it read healthy: /api/auth/me answered, project
permissions said write, and blobs (content-addressed, so ownerless) uploaded
fine. Only the journal PUT died. Signing in again could not help, because
signing in was the step that was supposed to bind.

UseDeviceBinder moves the hook onto the AuthProvider interface — a breaking
change for an out-of-tree provider, deliberately, so one that ignores a
precondition of a gate the hub enforces for it does not compile. The hub
cannot bind on the provider's behalf: a bind must be reachable only from a
completed authentication, and Authenticate reports who a request is, never
which credential class it presented. A device token still cannot reach a
bind; no new door was added, and every /store/* door still creates nothing.

Bind also reported success for a row the store had refused — observeLocked
logged the write failure and swallowed it — so a login could hand back a
token whose every push was then denied, with nothing in the hub explaining
why. It now propagates and the login fails honestly.

And a hub that refuses a journal write while holding no binding at all logs
that its provider is not calling the binder — the sentence that would have
ended this investigation on day one instead of day two.

Tested by driving the real binary against a hub with a managed provider
(cli_provider_e2e_test.go), which is the configuration no test in this repo
covered and the reason this shipped: every existing test used BuiltinAuth,
where the wiring happened to work. Both directions are pinned — a provider
that binds pushes, one that ignores the binder reproduces the reported
symptom exactly.


Claude-Session: https://claude.ai/code/session_01GSHsQU4pBCzKkPyPeXSwTm

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:23:34 -07:00
922886b949 fix(sync): a refused push stays refused, and says why (BEA-403) (#146)
A device whose journal push the hub 403s reported healthy sync between
every pair of remote passes, and never showed the hub's reason for the
refusal — so a user whose device was not registered to their account
re-ran `bdrive login` (which the message told them to), re-checked their
project permissions (write), and had nowhere left to look.

Two causes, both local to the client:

- Cycle recomputed st.Access from scratch at the end of every pass,
  including the daemon's cheap local-only ticks that never reach the hub.
  Three of those run between remote passes, so the daemon alternated
  "read-only on this project" / "access restored; syncing normally" every
  few seconds and `bdrive status` reported OK moments after a refused
  push. Now each leg records its own verdict — pull clears no-access, push
  records read-only or clears it — and a cycle that asked nothing leaves
  the last answer standing.
- The hub's own sentence was summarized into "read-only (pull only)",
  which describes the STATUS CODE. It is the only thing that tells a
  device-registration refusal from a project the user really is a reader
  on. It now rides in SyncState.AccessReason and Result.Reason(), printed
  by `bdrive sync`, `bdrive status` and the daemon log, and dropped unless
  it passes journal.SafeText — hub text reaching a terminal.

The hub's refusal also now names the upgrade: the binding is made by the
login request naming its device, which a CLI older than the gate does not
do, so "run `bdrive login`" alone sent that user in a circle. Hub and CLI
deploy separately, so the skew is the expected state right after the gate
ships.


Claude-Session: https://claude.ai/code/session_01GSHsQU4pBCzKkPyPeXSwTm

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:33:26 -07:00
Snow Lee (Sungwon)andGitHub 709c40e76c fix(webapp): restore asks before it syncs to every device (BEA-129) (#145)
Clicking `restore` in History posted straight to the hub — no dialog —
while the two other web actions that leave the browser (revoke a share
link, remove a file) both confirm first. The gated action was the
reversible one and the effectively-irreversible one wasn't.

The dialog's second line is case-aware, because the two cases really do
differ: swapping a live file's content is walk-backable from History,
but bringing a deleted file back is not — the run card's
"undo — remove file" is the only delete control in the whole web UI, and
a restore produces no run card. So that branch says so instead of
promising an undo the UI can't keep.

The flag needs no new computation: headBlob's "" already marks a path
whose newest op is a delete, so HistoryView passes `recreates` down
beside `restoreSha` (both feed shapes — standalone rows and rows inside
a run card). A row can't decide this itself: an older EDIT row of a
deleted path re-creates the file just as much as the DELETED row does,
and only the feed knows that.

Confirm styling is non-danger on purpose: restore adds content, it takes
none away, so it doesn't wear Remove's red.

Building the missing undo for the ADDED row a restore creates is
deliberately out of scope (Snow scoped this to "add confirmation").
2026-08-10 14:40:56 -07:00
Snow Lee (Sungwon)andGitHub 2875e033be feat(webapp): mermaid fences render as diagrams, in the viewer and on share pages (BEA-91) (#143)
A ```mermaid fence rendered as a wall of `graph TD` source on both surfaces.
It now renders as an SVG in the hub file viewer and on public /s/<token>
markdown share pages.

Mermaid ships inside the binary (no CDN, so air-gapped self-hosters keep
working) and is imported lazily: a document with no fence downloads none of
it. A fence that doesn't parse — the common case for hand-written wiki
diagrams — keeps today's <pre><code> plus a small note, and one bad fence
never stops the good ones beside it. A blocked or offline chunk lands in the
same place.

The share page is the harder half: it is server-rendered Go HTML with no
JavaScript, and its `sandbox allow-scripts` CSP makes the origin opaque, so a
module script and every import() it makes arrive with `Origin: null`. The CSP
is unchanged and gains no allow-same-origin; instead the real-asset branch of
frontend() now sets Access-Control-Allow-Origin, which only ever touches files
that are already public and cookie-less. The script tag itself is injected
only when the rendered document actually contains a fence.

Also fixes an embed bug this change surfaced: `//go:embed static` silently
skips names beginning with `_`, and Vite's first shared chunk is
`_commonjsHelpers-<hash>.js`. The build passed, the commit looked right, and
the served app was blank. It is `all:static` now, with a test that every file
on disk is in the binary.
2026-08-10 14:18:18 -07:00
d3d92bf904 feat(webapp): refuse to share a file that looks like it holds credentials (BEA-111) (#137)
Minting a share link ran zero content checks: a file holding an AWS-shaped
key became a public URL on one click, and the CLI printed nothing but the
link. handleShareCreate now reads the first 1 MiB and runs six anchored
rules between the synced-path check and Shares.Create, answering 409 with
rule ids and line numbers unless the request carries confirm: true.

The matched text never leaves scanSecrets — not into the body, not into a
log line. TestShareSecretNeverEchoed greps both for the planted string,
because a 409 body is the easiest place in this codebase to leak it.

Both callers carry the override, since the gate alone would turn any false
positive into a hard block with no way out: `bdrive share --force`, and the
browser's Share-anyway dialog on modalConfirm (no new component). A path
that already has a live link skips the scan — its content is public
already, so withholding the URL protects nobody — but alreadyPublic drops
links whose creator left the org, since those 404 at /s/ and would
otherwise wave a secrets file straight through.

A failed blob read is 503, not a silent pass: the repo's "degrade rather
than fail" posture is for sync cycles, and a check that skips itself on a
storage hiccup is the false confidence this exists to remove.

Every user-facing string says the file was checked at the moment you shared
it. A link serves the file's LATEST content forever, so a key written into
an already-shared file is never caught — that open loop stays open, and the
copy is the only thing stopping v1 from claiming otherwise.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 04:33:05 +09:00
Snow Lee (Sungwon)andGitHub 5f1ac98dae feat(hub): see what each agent session read, not just what it changed (BEA-98) (#135)
History showed what an agent run CHANGED. What it read lived in a daily
aggregate with no session dimension, so the two could not be joined and
nobody could answer "when my agent answered, what did it look at — and was
it the fresh version or archive/retired-spec.md?".

The join is one string carried through four places: hook -> spool -> hub ->
run card. A run card now marks each change the run also read, lists the
files it read and never touched, and says on screen why a read can be
missing.

The three landmines the issue asks be named here:

1. Op.Note is USER-SETTABLE (`bdrive sync --note`), so joining reads to
   writes on the note string would let any member with write access forge a
   note that collides with a teammate's run card and hang their reads off
   it. Fixed by adding journal.Op.Session — set only by `bdrive sync
   --hook`, never by --note — and joining on that. The note stays settable
   and stays untrusted; the join simply never reads it. Op.Session is
   additive JSONL and, like Mtime, is never an input to Less or Replay, so
   replay determinism is untouched and older ops carry "".

   The read half has the same hole one step further on: POST /reads takes
   the session id from the CLIENT, so a member could report reads under a
   teammate's session and paint files onto their card. Every session row is
   therefore pinned to the ownsDevice-validated device, and the query
   requires ?session= AND ?device= together — a forged row can only be found
   under the forger's own device, which MayActAs guarantees is never
   somebody else's.

2. BUCKET CARDINALITY. Putting the session in the read_stats key would take
   a 2k-file project from ~2k to ~100k rows/day, into a table ReadLedger
   loads whole at boot and full-scans on every heat request, hub-wide — so
   it would slow the Dashboard for projects that never ran an agent. This
   is the escape hatch the spec itself names, taken up front: session rows
   live in their own read_sessions repo, outside ReadLedger.byKey. No
   read_stats PK migration, no change to the resident-row count, ?by=device
   byte-identical. They get their own retention (session_retention_days,
   default 30) which DELETES rather than folds — no heat total was ever
   derived from them.

3. READS ARE RECORDED ONLY FOR PATHS IN THE CURRENT REPLAY, so a session
   that read a file it then deleted shows a change with no read. That is by
   design, and the run card says so in its footer rather than leaving it to
   read as a bug.

Privacy ruling, written into internal/webapp/reads.go before anything
serves it: a session id appears only in History responses on the op that
carries it, and as a ?session= filter INPUT. It is never enumerated — no
listing endpoint, no session column in /heat output, nothing new in
?by=device.

Also: PendingReads now dedupes on (path, session), not path alone. Two
agent sessions on one device between syncs used to collapse into one event
carrying whichever session flushed last — one session's reads silently
credited to another.

Tests: journal round-trip + Less-ignores-Session; the forge test (`sync
--note "claude-code session <someone-else's>"` leaves Session empty); a
multi-device syncer test carrying the session through convergence; spool
per-session dedup; hub round-trip, cross-device forge, query contract and
non-enumeration; db_conformance on file, sqlite AND postgres; runs.ts
grouping incl. legacy fallback; a Playwright spec on the seeded run card.
2026-08-11 04:18:53 +09:00
831d5cda31 fix(webapp): shared-page inline code reads as code in dark mode (BEA-90) (#134)
BEA-71 fixed the ordering bug that made the dark block lose to the light
`code` rule, but it stopped at the background: a dark chip still inherits
the body's #eef0f3, so `bdrive init` mid-sentence looks exactly like the
prose around it. That was the reporter's actual ask — "make the text more
distinct".

Inline code now carries the hub viewer's warm tint (#e4d9c4) plus an edge.
The edge is an inset box-shadow, not a border, so the chip's box metrics
don't move between modes and light mode stays byte-identical — verified by
screenshotting the same share page in both schemes before and after.

`pre code` resets both, so a fenced block stays one dark slab instead of a
row of bordered chips.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:15:07 +09:00
8fce9d2c46 perf(webapp): cache parsed journals so history stops re-downloading everything (BEA-85) (#133)
History folded every journal of a project on every request — 8-10s on a real
project, and paging made it worse rather than better, since each "load more"
re-downloaded and re-parsed the lot. There is no DB in this path, so no index
was ever going to help.

Journals only GROW: a device appends only to its own (the one-writer
invariant) and the hub's appendOp rewrites its key with strictly more bytes.
So the (Size, Modified) that List already reports proves a parse is still
current — no staleness window, no expiry, no new Backend method. A warm
request now pays one List and nothing else.

The cold path can never be helped by a cache, so the Get loop is concurrent
too (errgroup, limit 8) instead of one serial round trip per device.

Files() reads the same funnel, so folder listings get it for free.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:08:47 +09:00
32a099997e fix(webapp): a bogus project deep link says so instead of swapping projects (BEA-83) (#131)
/no-such-project-xyz/some/file.md used to redirect to whichever project the
fallback chain picked, dropping the path — nothing on screen distinguished
"that link is wrong" from "you opened your project."

An unknown route.project now renders a "Project not found" panel at the URL
as typed, mirroring the org-not-found page two blocks up: shell, sidebar and
account bar stay mounted, and "Back to <name>" points at the same project the
fallback picks today. The fallback chain itself is unchanged, and "/" still
redirects to the remembered project (BEA-75).

All four redirects below the flag rewrite the address bar off current.id, so
they are gated together — /bad-id/insights and /bad-id/notes/ would otherwise
undo the fix on their own.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:05:23 +09:00
e19fec0534 feat(webapp): old URLs follow a moved file, and share links follow the file (BEA-81) (#130)
There is no rename in beardrive: the scanner emits a put at the new path and
a delete at the old, same device, same blob, one cycle. Everything keyed on a
path therefore broke the moment a file moved — the viewer 404'd, history lost
the file's own past versions, restore refused them, and a share link either
404'd or silently served whatever unrelated file later took its address.

internal/webapp/moves.go derives the pairing from the ops the replay already
walks, cached with the snapshot. Deliberately not a rename op: journal.Less
and Replay are what every device converges to, and every already-shipped
journal would still need the heuristic to read its own history.

The two rules point in opposite directions on purpose. A viewer URL is an
address, so a LIVE path always wins and only an empty one redirects. A share
token is a promise about one file, so it follows the file even when a new one
takes the old address — and 404s forever once the file is deleted.

Nothing here writes an op or touches sync.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:52:20 +09:00
aa33ba55cc docs: the ⌘K palette description matches what it actually offers (BEA-84) (#132)
README.md:499 promised "share/history/upload actions" in the command
palette. Upload has never existed there (browser upload is deliberately
absent, Browser.tsx:175), download went unmentioned, and share/download
are file-scoped — so a reader on a folder route goes hunting for controls
that are correctly hidden. The doc comment at Palette.tsx:8 carried the
same wrong list, so fixing only the README would leave the next reader of
the component with the same picture.

Prose and a comment only; no code path changes. `npm run build` produces
a byte-identical internal/webapp/static (source comments are stripped
from the bundle), so no assets to commit.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:12:42 +09:00
1894646093 fix(webapp): the new-project dialog no longer opens itself on arrival (BEA-80) (#129)
A signed-in account with zero projects landed on / with the create dialog
already open over the onboarding page. The page renders its own "New
project" button (#ob-new), so the same call to action appeared twice and
the rear one was permanently pointer-intercepted — Playwright retried a
click against it for 30s before failing.

The auto-open was deliberate: "With no projects at all there is nothing
else on the page to do." That stopped being true when the empty state
gained the agent paste-prompt card — the route the docs treat as primary,
which the dialog was covering.

Delete the effect and its ref; fix the two comments that documented it;
rewrite the e2e test that asserted the auto-open by name.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 01:53:36 +09:00
Snow Lee (Sungwon)andGitHub 6bb7debe2b fix(webapp): #content shows it scrolls, Public links moves up (BEA-79) (#128)
Project settings at 1440x900 ended at People. Public links — the section
that answers "is anything of ours public right now?" — was below the fold
of #content, the app's only scroll container, and with macOS overlay
scrollbars nothing at rest said there was more.

#content now takes a real scrollbar. The plan called for scrollbar-width
+ scrollbar-color alongside ::-webkit-scrollbar; measured, that combination
is a no-op: Chromium drops every ::-webkit-scrollbar rule as soon as either
standard property is set on the same element, and the standard properties
alone leave the macOS overlay bar, which takes no width and is invisible at
rest. So the standard properties sit behind @supports not
selector(::-webkit-scrollbar) — Firefox only. Verified: 10px reserved in
Chromium, 0 on touch.

Public links also moves from fourth card to second (General → Public links
→ People → About → Danger zone), so the security answer is above the fold
even without the cue.

browse.spec reached the shares table by .last(), which the reorder would
have silently repointed at the members table — both render through
AdminTable. It selects .shares-table now.

Known gap: Firefox on macOS always uses overlay scrollbars and no CSS
overrides it, so the bar there stays hidden at rest — that is an OS
setting, not something this can fix.
2026-08-11 01:36:19 +09:00
7a20849631 fix(webapp): Public links says "Loading…", not a premature "no" (BEA-78) (#127)
The public-links panel is where a member answers "is anything of ours
public right now?". SharesTable read an empty array as a settled answer,
and both callers hand it `shares || []` while the request is still in
flight — so the panel printed "No public links." as a confident NO, then
swapped to a table listing an active, never-expiring link.

Fix at the choke point: an optional `loading` prop honored above the
empty branch, in the same .admin-list/.admin-empty shell so the section
is one row tall either way and doesn't jump when the data lands. Wired at
both call sites — project Settings and the org-wide cross-project audit,
which had the identical bug.

`isLoading`, not `isPending`: OrgAdmin's shares query is `enabled: owner`,
and TanStack reports isPending true forever for a disabled query.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 01:21:19 +09:00
Snow Lee (Sungwon)andGitHub 7dd5d097fe docs: one answer on Windows support — macOS and Linux only (BEA-77) (#126)
"Can my Windows teammate join?" got three answers from three official
surfaces: README's feature list said macOS & Linux, its CLI table
documented an HKCU Run entry on Windows, and the hub's Installation page
named no OS at all.

Windows autostart is real code that cannot run — internal/store's flock
and internal/daemon's Kill/Setsid are unix-only, so GOOS=windows does
not build. The fix is the docs, not the port: every Windows claim comes
out of README, web/docs and `bdrive autostart`'s own help, and the
Installation page gains one line naming the supported systems.

No Go source is deleted; internal/autostart/autostart_windows.go stays
compiled-but-dormant for whenever the port happens.
2026-08-11 00:52:46 +09:00
826d13795f fix(webapp): show how many times a public link has been opened (BEA-76) (#125)
Every /s/<token> hit was already recorded as a share-kind read, carrying
both a count and a timestamp — and then thrown away at the UI layer. The
Public links table showed only who shared a file and when, six inches
below a file header that already said "1 shared". Two personas filed it
independently on the same tour.

The number now rides the shares list:

  * ReadLedger.ShareOpens(project) aggregates share-kind buckets per path,
    all-time. Share-kind only is what makes Last mean *last opened* —
    HeatEntry.LastRead is cross-kind, so a member viewing the file in the
    hub would otherwise move the date.
  * shareJSON takes the project's opens map, built ONCE per project by the
    caller and indexed per row. Both callers — the project list and the
    org-wide audit — hoist it above their loops; a per-share call would be
    a full byKey scan per row.
  * Counts, never openers. The share actor is token+"/"+IP, a public
    credential joined to an IP, and it stays in the ledger. There is no
    distinct-openers field, deliberately.
  * Reads off means the keys are ABSENT, not zero: `0` would claim nobody
    has opened a link on a hub that never looked.

shareDetail() is the leverage — the settings table, the org-wide audit and
the file page's share banner all render through it, so one string function
covers three surfaces. Once the row carried the receipt it truncated to
"3 op…", so the detail cell wraps instead of ellipsizing; the path keeps
its ellipsis, since it is a link with a tooltip and the column that can be
arbitrarily long.

Counted per FILE, not per link: heat is keyed by path, so two tokens on one
file report the same number. Worded that way in the section copy, alongside
the other honesty — opens are debounced visits, not requests.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:38:44 +09:00
Snow Lee (Sungwon)andGitHub 119d4abf79 feat(webapp): render .csv/.tsv as a table instead of a wall of monospace (BEA-74) (#124)
A .csv already previewed — as raw text in a <pre>, columns lining up only
if the file happened to be padded. It now renders as an HTML table with the
first row as a header.

The parser is a new pure lib/csv.ts (~50 lines of RFC 4180: quoted
delimiters, "" as a literal quote, newlines inside quotes), so no
papaparse. It never throws: null means "not a table" — an unterminated
quote, or a file with no delimiter at all — and the caller falls back to
the very <pre> it renders today. That fallback is structural rather than a
second code path, because TextView gained a `delim` prop instead of a new
component: it also keeps the ["text", fileURL] query key a restore
invalidates and the retry:false a pinned ?v= version needs.

.tsv is new here — it used to fall through to SniffView and render as
text. The delimiter comes from the extension, never from sniffing.

Big files are capped at 5,000 rows with the count stated on screen
(virtualization is out of scope). Wide files scroll inside .csvbox, whose
rules are scoped under that class on purpose: the file pane carries
.markdown, and the plain .markdown table rules — including the ≤900px one
that turns a table into its own scroller — would otherwise out-specify a
bare .csvview and give the page two nested scrollers.

Not doing: sorting, filtering, search, editing, XLSX.
2026-08-11 00:22:18 +09:00
Snow Lee (Sungwon)andGitHub 27cc558ac7 fix(webapp): new-project dialog preselects the RECOMMENDED row (BEA-72) (#123)
The RECOMMENDED badge renders on options[0]; the initial selection was
hard-wired to "" (the last row, Empty project), so on any hub shipping
templates the dialog contradicted its own advice — and the default was the
option that produces a project with nothing to look at.

Hoist the options array above the state and seed `template` from
options[0].value: badge row and checked row are now the same element by
construction, not by coincidence. On a template-less hub options[0] is
"I already have a folder", which still creates an empty project.

The e2e test that pinned the old default now asserts the invariant (badged
row === checked row) rather than a title, and checks that creating without
touching the radios actually seeds the template.
2026-08-05 17:25:53 +09:00
5d399c3d31 fix(webapp): share page dark mode no longer shows white slabs (BEA-71) (#122)
The share page already followed the system, but its dark @media block sat
ABOVE the light pre/code/blockquote/table rules at equal specificity, so
every light rule after it won. A dark-system visitor got a white frontmatter
slab, a white code block and light-grey table borders on a near-black page —
the surface strangers see first reading as a different product.

Source order is the whole fix: the block moves to the end of the stylesheet
and gets completed there, which repairs pre/code for free. Values are the
hub's @theme tokens from frontend/src/tw.css instead of the hand-picked greys
(#c6cbd3 body text, #3a3a44/#888 footer) that were the "different product"
half of the complaint.

Light mode is pixel-identical — only the dark block moved. Response headers
(sandbox CSP, nosniff, referrer) are untouched.

The test asserts placement, not presence: "a dark rule exists" passed while
the page was still wrong, so it checks the dark block comes after the last
light literal.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:06:50 +09:00
5fb834e3a4 fix(webapp): keep the provenance line on mobile, unclipped in History (BEA-70) (#121)
Below 900px the file view hid the who/when/how-hot line outright
(`#meta { display: none }`) and the History run header ellipsised its
note and author to `claude-…` / `Alice <ali…` — the line the product is
differentiated by, gone exactly on the surface people catch up from.

CSS-only, inside the existing `@media (max-width: 900px)` block:

- `#topbar` wraps (`height: auto; min-height: 52px`) and `#meta` takes its
  own full-width row, left-aligned and wrapping. `order: 1` is what keeps
  Search / Share / ⋯ on row 1 — meta precedes them in the DOM, so a bare
  flex-wrap would drag them down. `#meta:empty` keeps folder, dashboard
  and history routes from gaining a blank strip.
- `.hrun-head` wraps; the note loses its 46% cap and both spans stop
  ellipsising, with `.hrun-meta` on its own line under the note and the
  time still on row 1.

Desktop (≥901px) is untouched — every rule lives inside the breakpoint.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 16:38:00 +09:00
631e5be947 test(webapp): pin "a read member sees grants and public links" (BEA-69) (#120)
A read-only member seeing the People matrix and every active public link
is deliberate — the owner re-confirmed it against Google Drive, where a
viewer can see who has access. Nothing asserted it: TestReadOnlyMemberRoutes
only checks GET .../shares is not 403, so a hardening pass that returned an
empty list (or dropped the creator field) to read members would pass every
test in the repo.

Adds TestReadMemberSeesSharesAndGrants — 200 plus the link's token, path,
url and creator from a read-only session — and extends the two rationale
comments the next reader lands on instead of adding a third copy.

No route change, no UI change; the ShareBanner edit is a comment, so
internal/webapp/static is unchanged.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 16:20:16 +09:00
Snow Lee (Sungwon)andGitHub 00581f2839 fix(hub): the Dashboard can filter by shared reads, not just human and agent (#117)
The page named three read types and let you filter two. The missing chip was
share — traffic from public links an owner minted, which is the category they
most want to isolate.

Everything but the lens already spoke "share": heatTotal, heatText and
hotPathSplit all carry it, and /heat returns it per path. So widening the
union makes the treemap, the scatter and the Hot path arithmetic correct for
free. The one place that decided anything was the Hot path bar, which
branched agent-vs-human and would have painted shared reads in somebody
else's colour; it now looks the pure lens up in a table instead.

The e2e seed had no share reads at all, so the lens could not be asserted.
It gains one on notes/deep/topic.md — a path no other assertion counts, so
nothing else's totals move.

BEA-62
2026-08-05 16:19:01 +09:00