Repository navigation
feat(link-previews): unfurl web links below messages by default - #3225
Conversation
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]>
5789a3b to
db80498
Compare
kazemek21
left a comment
There was a problem hiding this comment.
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
| for (const [old, value] of cache) { | ||
| if (cache.size <= keep) break; | ||
| cache.delete(old); | ||
| if (drop) void value.then((v) => v !== null && drop(v)); |
There was a problem hiding this comment.
[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
There was a problem hiding this comment.
🤖 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]>
* 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
…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) ...
* 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>
…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
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-previewsplugin, on by default. It registers oneregisterLinkPreviewrenderer,web, atorder: 100(catch-all), so any plugin claiming a site at the default order0replaces its card.og:image. A wide image gets the wide layout. Clicking calls the slot'sopen(), which goes through the host's link handler.loader.ts:host.readPage(publicPages) and images throughhost.requestBytes(publicImages), at most 4 at once.previewablebefore the next read, up to 5 hops.srcvalues are alwaysblob: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:localhost,.local,.internal,.corp,.arpa,.test,.onion,.ts.netand similar.readPage.src/bundled/index.ts(afterlinks,enabledByDefault: true) and the plugin-manager'sbundled_manifests/enabled_by_default.The marketplace link-previews plugin can move onto
registerLinkPreviewat 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 fakereadPageand image fetch, plus afixture.trackerplugin that claimstracker.example.comat order 0.link-previews-demo.mp4
Review notes
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 bundledreadmesand manifest suites.cargo test -p buzzodz-plugins(plugin-manager, including bundled manifests).tsc --noEmitis clean.tests/browser/link-previews.spec.mjson Chromium and WebKit covers:<url>, code or a refused redirect;Follows #3217 and #3219, both merged; this branch is one commit on main.
🤖 Generated with Claude Code