Repository navigation
feat(conversation): add a link preview slot below messages - #3219
Conversation
registerLinkPreview contributes a card below a message for one of its HTTPS links. The lowest order among matches draws it, so a plugin can claim its own URLs ahead of a catch-all web card. Bare and labeled links in prose get cards; <url>, code, images and attachment URLs do not. Past three cards the rest fold behind one button. Linear: AGNTOPS-773 Co-authored-by: Hertz <c6741012416ef5c49a865db79682113385c5fb998e8c890234fb442b59e22748@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Joah Gerstenberg <[email protected]>
kazemek21
left a comment
There was a problem hiding this comment.
I reviewed the PR and went through the Nexus review-tracker notes. Approving with the non-blocking comments below.
Posted by Codex (gpt-6.1-sol) on behalf of kylez
| }; | ||
| const visit = (node: Node) => { | ||
| if (skipped.has(node.type)) return; | ||
| if (node.type === "link") { |
There was a problem hiding this comment.
[P2] Keep hidden spoiler links out of previews
||[twist](https://example.org/plot-twist)|| currently produces a preview URL even while MessageMarkdown masks the link. A card can therefore reveal the hidden destination/title below the bubble. Could extraction exclude spoiler contents unless they have been revealed?
Posted by Codex (gpt-6.1-sol) on behalf of kylez
There was a problem hiding this comment.
🤖 Fixed in c773df5. Links inside a spoiler now get no preview, revealed or not. Reveal state belongs to the rendered message, and a card that appears on reveal would move the row under the reader. The || delimiters pair per parent, the same way remarkSpoilers pairs them, so links around the spoiler and in a later paragraph still get cards. An unclosed || renders as visible text, so it hides nothing. Test: keeps spoiler links hidden and shows the ones around them.
| "definition", | ||
| "image", | ||
| "imageReference", | ||
| "linkReference", |
There was a problem hiding this comment.
[P2] Resolve reference-style Markdown destinations
[docs][guide] followed by [guide]: https://example.org/docs renders a normal link but returns no preview because linkReference is skipped. Could these destinations resolve through scan.definitions and use the same filtering as inline links?
Posted by Codex (gpt-6.1-sol) on behalf of kylez
There was a problem hiding this comment.
🤖 Fixed in c773df5. linkReference nodes now resolve through scan.definitions (full, collapsed and shortcut references) and go through the same filters as inline links. A reference with no definition gets no card. Test: resolves reference links through their definitions.
| exclude: readonly string[] = [], | ||
| ): readonly string[] { | ||
| const prepared = prepareMarkdown(content); | ||
| if (prepared.kind === "plain" || !content.includes("https://")) return []; |
There was a problem hiding this comment.
[P2] Make the HTTPS fast-path check case-insensitive
HTTPS://example.org/docs returns no preview alone, although the URL parser accepts it. Adding an unrelated lowercase HTTPS URL makes that same link appear. Could this guard match schemes case-insensitively?
Posted by Codex (gpt-6.1-sol) on behalf of kylez
There was a problem hiding this comment.
🤖 Fixed in c773df5. The fast path is now /https:\/\//i. Test: reads the HTTPS scheme in any case.
…e links Links inside a spoiler no longer get a card that would reveal them; the delimiters pair per parent as remarkSpoilers pairs them. Reference links resolve through their definitions, and the HTTPS fast path ignores case. AGNTOPS-773 Co-authored-by: Hertz <c6741012416ef5c49a865db79682113385c5fb998e8c890234fb442b59e22748@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Joah Gerstenberg <[email protected]>
Linear: AGNTOPS-773 ## Why Joah asked for core Buzz to unfurl links automatically, with extension points so plugins can override the defaults ([plugin bounty thread](buzz://message?channel=9c38ed35-81e2-4f9c-a461-7ee1001bfbf5&id=5fc3468c0fb1c77fdcbfa74d055b1c93c8f792613c6be5fef4fd2446c14a141e)). #3217 (merged) added the host page read and #3219 (merged) added the per-link slot. This PR adds the default card that uses both. ## What A bundled `buzz.link-previews` plugin, on by default. It registers one `registerLinkPreview` renderer, `web`, at `order: 100` (catch-all), so any plugin claiming a site at the default order `0` replaces its card. - **Card:** site name · domain, then title, description and the `og:image`. A wide image gets the wide layout. Clicking calls the slot's `open()`, which goes through the host's link handler. - **Pages without a title get no card.** Failures are quiet and not cached. - **`loader.ts`:** - Reads go through `host.readPage` (`publicPages`) and images through `host.requestBytes` (`publicImages`), at most 4 at once. - Every redirect hop is checked with `previewable` before the next read, up to 5 hops. - The session cache keeps 200 pages and 60 images (LRU). - Image `src` values are always `blob:` URLs, held as leases: a URL is revoked once it has left the cache and no card holds it, and all are revoked on deactivate. - **`safety.ts`:** - Only HTTPS on port 443 is previewed. - IP literals and local or reserved suffixes are refused: `localhost`, `.local`, `.internal`, `.corp`, `.arpa`, `.test`, `.onion`, `.ts.net` and similar. - There are no Block-specific domains. - **Off switch:** turning the plugin off in Settings stops all page reads. The README's Getting started section says so, and explains that the site sees the reader's IP, as it does when you open the link. - The plugin doesn't register in the browser host, where there is no `readPage`. - **Registration:** `src/bundled/index.ts` (after `links`, `enabledByDefault: true`) and the plugin-manager's `bundled_manifests` / `enabled_by_default`. The marketplace link-previews plugin can move onto `registerLinkPreview` at order 0 for Block's own domains after this lands. A per-card × dismiss waits for #2677. ## Demo Fixture: real `MessageRow`s and the bundled plugin, with a fake `readPage` and image fetch, plus a `fixture.tracker` plugin that claims `tracker.example.com` at order 0. https://github.com/user-attachments/assets/d97593c8-17e4-4fec-a137-f2e80f871780 | Light | Dark | Narrow | | --- | --- | --- | |  |  |  | ## Review notes - On by default means every desktop user's Buzz reads linked public pages. That is the behavior Joah asked for, and turning the plugin off is the opt-out. ## Testing - `vitest`: `loader.test.ts` (new: public HTTPS names only, redirect hops followed and re-checked, a redirect to a local name refused before it is read, untitled pages and forgotten failures, no reader), `LinkPreview.test.tsx` (new: real domain beside the site name, nothing without a preview), and the bundled `readmes` and manifest suites. - `cargo test -p buzzodz-plugins` (plugin-manager, including bundled manifests). - `tsc --noEmit` is clean. - Playwright `tests/browser/link-previews.spec.mjs` on Chromium and WebKit covers: - cards for bare and labeled links, with none for `<url>`, code or a refused redirect; - a followed redirect; - fold and unfold; - the tracker override and its fallback when the tracker plugin is off; - no default cards once the plugin is off; - card click opening through the host. Follows #3217 and #3219, both merged; this branch is one commit on main. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Joah Gerstenberg <[email protected]> Co-authored-by: Andrew Harvard <[email protected]> Co-authored-by: Hertz <c6741012416ef5c49a865db79682113385c5fb998e8c890234fb442b59e22748@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <[email protected]>
…y-untagged-reactions * origin/main: (324 commits) fix(github): show private-repo body images from signed links (#3252) NEXUS-4029: Preserve semantic chips in the core composer (#2303) Make plugin signature enforcement opt-in (#2212) NEXUS-4156: Add workspace Leads and choose a chat's workspace (#2788) feat(plugins): report conclusively retired update providers (#3263) feat(messages): let attachment renderers open files from the host file card (#2239) feat(charts): bundle a Charts code-block renderer (#3226) Expose native reply grouping through Conversation.ui (#3245) NEXUS-4163: Keep plugin-owned chat deletion in Solo (#2787) NEXUS-4156: Expose Solo Workspaces to plugin regions (#2786) NEXUS-4022: Start region chats in the New Chat editor (#2784) feat(link-previews): unfurl web links below messages by default (#3225) fix(messages): keep names in literal Markdown as written when editing (#2834) feat(plugins): fetch a packaged managed plugin policy from a URL (#2504) fix(goose): enable deferred tool loading for goose agents (#3227) fix(unread): preserve manual intent in captured explicit reads (#3228) NEXUS-4337: Expose retained plugin update reviews (#3075) feat(conversation): add a link preview slot below messages (#3219) feat(plugins): let link previews read a public page's preview fields (#3217) Let sidebar row plugins decorate ordinary Solo chats (#3222) ...
…#3334) ## Problem #3219 added `previewLinks`, which `MessageRow` runs for every mounted Markdown row whenever a link-preview registry exists. The browser and desktop builds always create that registry (`src/features/conversation/service.tsx:212-217`), even when it has no providers. `previewLinks` called `prepareMarkdown(content)` first and checked for `https://` only afterwards (`src/features/messages/preview-links.ts:41-42`). So every link-free, non-plain Markdown row paid for preparation, which for non-trivial Markdown means a `fromMarkdown` parse via `scanMarkdown`, just to return `[]`. The row's own body preparation is cached across remounts in `MessageMarkdown.tsx` (`preparedBodies`). This call is not cached, so the cost recurs on every row mount: on channel open, on revisits and while scrolling through history. ## Change `src/features/messages/preview-links.ts`: test the raw content for `https://` before preparing it. If the test fails, return `[]` without preparing. ## No behavior change - Before, the function returned `[]` when `prepared.kind === "plain"` **or** the raw `content` lacked `https://`. After, it checks the same two conditions in the opposite order. - `prepareMarkdown` is pure and immutable (`markdown-preparation.ts:117`). Skipping it when the result would be discarded has no other effect. - The `https://` test still runs on the raw `content`, as before, not on the prepared content. - Every input that passes both checks takes the unchanged path. - The existing `preview-links.test.ts` cases pass unchanged: bare, labeled and reference links, angle links, code, images, spoilers and exclusions. ## Evidence **Linux only, on a single machine.** The Blox API returned 502 for the whole session, so no macOS run and no multi-box spread were possible. The macOS half of the usual evidence is missing. Method: - Interleaved A/B of main `b9c2edbb` vs main plus this change, using `pnpm perf` cases and the sweep's probes. - Chromium production build at 4× CPU, on one 11-vCPU Linux box. - 6 rounds × 3 samples, n=18 per variant. Order alternated each round. - The table shows medians. The range is the min..max of the per-round median deltas. p is a two-sided Mann–Whitney test. | scenario | metric | main | this PR | Δ (per-round range) | p | |---|---|---|---|---|---| | scroll-paging-fast-markdown | script | 7119 ms | 6755 ms | −5.1% (−6.8..−3.2) | 9e-6 | | scroll-paging-fast-markdown | LoAF blocking | 2916 ms | 2635 ms | −9.6% (−19.1..−6.9) | 5e-5 | | scroll-paging-fast-markdown | task | 13466 ms | 13053 ms | −3.1% (−4.8..−2.9) | 4e-4 | | channel-open-cold-markdown | script | 685 ms | 633 ms | −7.6% (−16.7..+3.5) | 0.002 | | channel-open-cold-markdown | first rows | 934 ms | 890 ms | −4.7% (−15.9..+1.9) | 0.005 | | hover across rows (control) | script | 81 ms | 83 ms | +1.6% | 0.4 (noise) | **Bisect: #3219 introduced the cost.** Method: interleaved A/B of #3219's parent `2a514ec6` vs #3219 `741f3a84`, 6 rounds × 2 samples (n=12 per variant), same box and method as above. | scenario | metric | #3219 parent | #3219 | Δ (per-round range) | p | |---|---|---|---|---|---| | channel-open-cold-markdown | script | 596 ms | 664 ms | +11.4% (+2.0..+15.2) | 0.007 | | channel-open-cold-markdown | first rows | 835 ms | 916 ms | +9.6% (+0.8..+14.2) | 0.009 | | channel-open-cold-markdown | LoAF blocking | 557 ms | 651 ms | +16.8% (+0.9..+22.0) | 0.008 | | scroll-paging-fast-markdown | script | 6765 ms | 7191 ms | +6.3% (−0.9..+11.7) | 0.001 | | scroll-paging-fast-markdown | LoAF blocking | 2686 ms | 3178 ms | +18.3% (−5.2..+34.6) | 0.003 | | warm switch, Markdown corpus | script | 172 ms | 172 ms | +0.1% | 0.6 (noise) | **Replication.** I re-ran on main `8697bd29`, 6 rounds × 2 samples (n=12 per variant), in a 4-way sweep of main, this PR, #3335 and both. The first run's warm Markdown switch result (−7.5%, p=0.02) did not repeat: it measured −0.7% (p=0.3). It has been dropped from the table above. These did repeat: | scenario | metric | Δ this PR vs main | p | |---|---|---|---| | channel-open-cold-markdown | script | −4.9% | 4e-4 | | channel-open-cold-markdown | first rows | −4.4% | 0.002 | | scroll-paging-fast-markdown | script | −4.2% | 0.001 | | scroll-paging-fast-markdown | LoAF blocking | −11.3% | 7e-4 | **Stacked with #3335.** Main `8697bd29` vs both PRs, n=12 per variant. The two changes touch separate paths and their wins add: | scenario | metric | Δ | p | |---|---|---|---| | channel-open-cold-markdown | script | −6.6% | 5e-4 | | channel-open-cold-markdown | first rows | −5.0% | 0.008 | | scroll-paging-fast-markdown | script | −4.5% | 0.001 | | scroll-paging-fast-markdown | LoAF blocking | −12.3% | 0.007 | Ten other configs are unchanged (all p ≥ 0.1): warm switch, 5k-member switch, typing, mention typing, 5k members filter, idle, hover, thread open, cold start and Markdown switch. ## Tests Checks run: - `vitest run src/features/messages`: 103 files, 3049 tests pass. - `tsc --noEmit`: pass. - `biome check --error-on-warnings` on the changed file: pass. - `scripts/check-staged-names.mjs`, the icon check and `git diff --check`: pass. The commit and push hooks could not start, because Hermit's pinned Node 24.18.0 download returned 502. I ran the pre-commit jobs above by hand with system Node v26.13 and committed with `LEFTHOOK=0`. I did not run the pre-push `check-push.mjs` lanes. Hosted CI is the gate for those. **Independent review:** one agent review, 9/10, no blockers and no changes recommended. **Still needed:** - A macOS A/B. - Human testing. Signed-off-by: Kalvin C <[email protected]>
…iews (#3466) ## Problem Link previews (#3219, #3225) find a message's preview URLs with `previewLinks`. For any body with an `https://` link, that function called `prepareMarkdown`, then scanned the prepared content again with `scanMarkdown`, and kept no result. The renderer already prepares the same body through its own cache. A link-bearing row therefore parsed its Markdown two or three more times than a row without links, on every mount. It did this again on each remount while scrolling or switching channels. ## Change - `markdown-preparation.ts`: - Owns the cache of recent prepared bodies. `recentResults` and `preparedBodies` move here from `MessageMarkdown.tsx`, unchanged. - Exposes it as `preparedMarkdown(content, scanned?)`. - `prepareMarkdown` takes an optional `scanned` callback that receives the scan it made. It stays pure and keeps no state. - `MessageMarkdown.tsx` reads through `preparedMarkdown`, with the same cache and budget as before. - `preview-links.ts`: - Uses `preparedMarkdown`. On a fresh preparation it takes the scan through the callback instead of scanning again. - Keeps recent preview link lists in a `recentResults` cache. The key is the content and exclusions; the weight counts both the key and the URLs. Cached lists are frozen. - `preview-links.test.ts` asserts that the renderer and previews together scan a body once. This fails on the previous code. ## Why behaviour is unchanged - `prepareMarkdown` is pure, so a cached prepared body is identical to a fresh one. - The scan passed to the callback is the scan of the returned `content`, the same input previews scanned before. - The link list depends only on content and exclusions, which are both in the cache key. ## Evidence Interleaved A/B on 9 Linux and 9 macOS boxes: - 6 rounds × 2 samples per variant on each box, so n=108 per variant per OS. - 4× CPU throttle. - Values are the mean of per-box medians. The baseline is `main` (`7bb45482`) with #3464 and #3465 applied. This change was measured on top of that. The measured revision handed the scan over through module state instead of a callback; the parse count is the same. A variant with preview scanning disabled entirely is the ceiling. | case | metric | Linux base | Linux PR | Δ | macOS base | macOS PR | Δ | ceiling Δ (Linux / macOS) | |---|---|---|---|---|---|---|---|---| | thread scroll open | script ms | 636.4 | 618.3 | −2.8% | 381.4 | 368.2 | −3.5% | −2.9% / −3.7% | | | task ms | 997.3 | 976.1 | −2.1% | 600.6 | 588.1 | −2.1% | −1.4% / −2.0% | | | LoAF blocking ms | 551.7 | 534.3 | −3.1% | 271.2 | 258.4 | −4.7% | −2.5% / −5.4% | | | first rows ms | 937.1 | 914.1 | −2.5% | 561.5 | 546.2 | −2.7% | −1.8% / −2.7% | | cold markdown channel open | script ms | 495.6 | 487.8 | −1.6% | 320.6 | 316.8 | −1.2% | −2.0% / −1.7% | | fast scroll paging (markdown) | LoAF blocking ms | 1489.8 | 1444.2 | −3.1% | 83.7 | 83.5 | −0.3% | −2.3% / −5.3% | - Thread scroll open improved on every box (9/9) for script and LoAF on both OSes. - The change recovers all the time that link-preview scanning costs in these scenarios. - An earlier A/B of desktop-v0.7.18 against the same baseline put thread scroll open at +2.6% script on both OSes, which this removes. ## Checks - `vitest run src/features/messages`: 3119 pass. - `link-previews.spec` and `markdown-documents.spec` pass on chromium and webkit. - `bin/pnpm check` is clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Kalvin Chau <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
Linear: AGNTOPS-773
Why
Core Buzz should unfurl links, and plugins should be able to override the default per site (Joah, plugin bounty thread). Plugins have no slot below a message today. The marketplace link-previews plugin renders its cards inside the link anchor, then moves the DOM nodes under the bubble and watches them with MutationObservers.
What
ctx.conversation.registerLinkPreview({ id, title, matches(url), order?, component }). Components receive{ url, messageId, open }.open()routes through the host's link handler, as clicking the link does, and returns false once the contribution or message retires.orderamong matches draws its card, the same resolutionregisterLinkand panels use. A default web card sits at the catch-all band (100), so a plugin claims its own site at 0. A component that renders nothing leaves that link without a card.previewLinks): credential-free HTTPS links in the message's Markdown prose, once each in the order written. Bare, labeled and reference links count; the scheme can be any case.<url>doesn't, since it asks for no preview, matching Let authors dismiss link previews with a one-link edit #2677's convention. Neither do links in code, images, spoilers, or links naming one of the message's attachments. Diffs, agent envelopes and messages drawn by a message renderer get none.MessageRowmounts the stack between the bubble and its attachments. After three cards, the rest fold behind "Show N more previews". Each card has its ownContributionBoundary, so a failing renderer drops only its card.plugin-architecture.mdandplugin-authoring-guide.md.No core card ships in this PR; the bundled
buzz.link-previewsplugin follows it in the stack.Review notes
contracts.tsandservice.tsxare FOUNDATION. This adds one contribution kind besideregisterLink, with the same validation and lifetime. Joah asked for this extension point explicitly.editedInBodyinMessageRowis now derived frommarkdownBody, which the previews also use. Behavior is unchanged.Testing
vitest:preview-links.test.ts(new),LinkPreviews.test.tsx(new: order override, fold and unfold, host open, a throwing card isolated),service.test.tsx(new: registration validation and withdrawal), and the existingMessageRowtests: 223 passed.tsc --noEmitis clean.Part of a stack:
buzz.link-previews, on by default.🤖 Generated with Claude Code