Skip to content

feat(link-previews): unfurl web links below messages by default - #3225

Merged
joahg merged 2 commits into
block:mainfrom
joahg:joah/bundled-link-previews
Nov 11, 2026
Merged

joahg merged 2 commits into
block:mainfrom
joahg:joah/bundled-link-previews

Conversation

@joahg

@joahg joahg commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

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). #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 MessageRows and the bundled plugin, with a fake readPage and image fetch, plus a fixture.tracker plugin that claims tracker.example.com at order 0.

link-previews-demo.mp4
Light Dark Narrow
Light theme link preview cards Dark theme link preview cards Narrow window link preview cards

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

Adds the bundled buzz.link-previews plugin, on by default. It registers a
catch-all link preview (order 100) that reads a page's preview fields with
host.readPage and its image with host.requestBytes, checking every redirect
hop before following it. Plugins claim their own sites at the default
order. Ported from Andrew Harvard's link-previews marketplace plugin without
its Block-only providers, curl fetch or internal domain list.

Linear: AGNTOPS-773

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]>
Signed-off-by: Joah Gerstenberg <[email protected]>
@joahg
joahg force-pushed the joah/bundled-link-previews branch from 5789a3b to db80498 Compare November 10, 2026 22:37
@joahg
joahg marked this pull request as ready for review November 10, 2026 22:38
@joahg
joahg requested a review from a team as a code owner November 10, 2026 22:38
@joahg
joahg requested a review from kazemek21 November 10, 2026 22:39

@kazemek21 kazemek21 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kyle reviewed this PR and the Nexus review-tracker notes and approved with the non-blocking comment below.

Posted by Codex (gpt-6.1-sol) on behalf of kylez

Comment thread src/bundled/link-previews/loader.ts Outdated
for (const [old, value] of cache) {
if (cache.size <= keep) break;
cache.delete(old);
if (drop) void value.then((v) => v !== null && drop(v));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep pending image URLs alive through first render

When more than 60 distinct image previews are requested before they finish loading, this evicts a still-pending entry and revokes its blob URL as soon as it resolves. The mounted card then receives an already-revoked URL, so its thumbnail fails to load—for example after expanding a message with many links.

Could pending/live image lifetime be separate from LRU retention, so eviction cannot revoke a URL before its consumers finish using it?

Posted by Codex (gpt-6.1-sol) on behalf of kylez

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in 4b6ec8e. loader.image() now returns a lease, { src, release }, and the card releases it when it unmounts or its image changes. LRU retention still keeps 60 entries for reuse, but eviction only takes an entry out of the cache. A blob URL is revoked once it's out of the cache and its last lease is released, so a pending or on-screen image survives eviction. stop() still revokes everything. Test: revokes an evicted image only after every card using it lets go requests 61 images while the first is still held.

Image previews are now leases: the cache keeps 60 for reuse, but a blob
URL is revoked only once it has left the cache and every card holding it
has released it, so eviction can no longer revoke a pending or shown
image.

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]>
@joahg
joahg merged commit 94c1d03 into block:main Nov 11, 2026
64 checks passed
joahg added a commit to joahg/buzz-app that referenced this pull request Nov 11, 2026
* origin/main:
  feat(messages): let attachment renderers open files from the host file card (block#2239)
  feat(charts): bundle a Charts code-block renderer (block#3226)
  Expose native reply grouping through Conversation.ui (block#3245)
  NEXUS-4163: Keep plugin-owned chat deletion in Solo (block#2787)
  NEXUS-4156: Expose Solo Workspaces to plugin regions (block#2786)
  NEXUS-4022: Start region chats in the New Chat editor (block#2784)
  feat(link-previews): unfurl web links below messages by default (block#3225)
  fix(messages): keep names in literal Markdown as written when editing (block#2834)
  feat(plugins): fetch a packaged managed plugin policy from a URL (block#2504)

Signed-off-by: Joah Gerstenberg <[email protected]>

# Conflicts:
#	crates/plugin-manager/tests/management.rs
#	docs/plugin-architecture.md
bostonaholic added a commit that referenced this pull request Nov 11, 2026
…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)
  ...
tlongwell-block added a commit that referenced this pull request Nov 11, 2026
* origin/main: (121 commits)
  Expose Solo region state before a component mounts (#3296)
  fix(builderlab): say a 401 was refused, not expired, and show each sign-in failure once (#3241)
  feat(agents): file new Solo chats in explicit personal workspaces (#2857)
  perf(css): limit broad style invalidation (#3267)
  Delay and fade in the Bestie badge celebration (#3292)
  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)
  ...

Signed-off-by: Tyler Longwell <[email protected]>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
kalvinnchau added a commit that referenced this pull request Nov 13, 2026
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants