From eded60bfea62fb16a57b6aea7d9da2711a581990 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Mon, 3 Aug 2026 13:08:07 +0200 Subject: [PATCH 1/6] docs: spec for Buzz entity links (repo/project/PR/issue preview cards) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design doc for buzz:// links to Buzz-hosted repositories, projects, pull requests, and issues, with GitHub-parity preview cards in chat, in-app and OS-level deep-link handling, CLI link output, and agent prompt guidance. Spec only — no implementation. Signed-off-by: Thomas Petersen --- docs/buzz-entity-links.md | 231 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 docs/buzz-entity-links.md diff --git a/docs/buzz-entity-links.md b/docs/buzz-entity-links.md new file mode 100644 index 0000000000..5417dd2939 --- /dev/null +++ b/docs/buzz-entity-links.md @@ -0,0 +1,231 @@ +# Buzz Entity Links + +Status: **draft spec** — no implementation yet. +Branch: `spec/buzz-entity-links`. + +## Problem + +When a message contains a GitHub URL, the desktop client renders a rich +preview card ("GitHub · PR block/buzz #4020") below the message. Those cards +are produced entirely client-side by URL parsing in +`desktop/src/shared/lib/linkPreview.ts` and rendered by +`desktop/src/shared/ui/link-preview-attachment.tsx`. + +Buzz-hosted entities have no equivalent. There is **no link format at all** +for a Buzz repository, project, pull request, or issue: + +- The only rich deep link today is `buzz://message?channel=…&id=…` + (`desktop/src/features/messages/lib/messageLink.ts`), rendered as an inline + pill via `remarkMessageLinks.ts` + `MessageLinkPill.tsx`. +- OS-level deep links (`desktop/src-tauri/src/deep_link.rs`, + `desktop/src/shared/deep-link.ts`) support `connect`, `join`, + `add-community`, `message`, and `nostr-bind` — no git entities. +- `buzz pr open` / `buzz issues create` return raw event ids; there is no URL + in their output and no guidance in the agent base prompt + (`crates/buzz-acp/src/base_prompt.md`) for referencing Buzz work items in + chat. Agents can only say "PR up" with a hex id. +- The relay-served web client only has `/repos/$repoId`; no PR/issue pages. + +So an agent that opens a PR on a Buzz-hosted repository cannot produce +anything clickable, while the same agent opening a GitHub PR gets a card for +free. + +## Goals + +1. A canonical, shareable link format for Buzz repositories, projects, pull + requests, and issues. +2. Rich preview cards in the desktop message timeline for those links, with + parity to (and better data than) the GitHub cards — titles come from the + actual Nostr events, not URL text. +3. Clicking a link navigates in-app to the existing project detail views. +4. CLI output includes the link so agents (and the base prompt) can emit it + when announcing work. + +## Non-goals (v1) + +- Web (browser) pages for PRs/issues — the web client has no such views yet, + so links are app-only, same as `buzz://message` today. +- Cross-community links. Like `buzz://message`, links are interpreted against + the community the message was received in. A `relay=` query parameter is + reserved for a future cross-community version but not emitted or consumed. +- Generic OpenGraph unfurling for arbitrary URLs — that is the separate + `proto/rich-link-previews` prototype and stays orthogonal. +- Mobile rendering. Mobile should degrade gracefully (plain link) in v1; + pill/card parity is a follow-up. + +## Link format + +Extend the existing `buzz://` scheme, mirroring `buzz://message`: + +``` +buzz://repo?owner=&d= +buzz://project?owner=&d= +buzz://pr?id=&owner=&d= +buzz://issue?id=&owner=&d= +``` + +- `owner` is the 64-char lowercase hex pubkey of the repository/project + announcement author (the NIP-34 / NIP-MP coordinate owner). +- `d` is the addressable `d`-tag. For `repo`/`project` links the + (`owner`, `d`) pair is the full `30617::` / + `30621::` coordinate. +- For `pr`/`issue` links, `id` identifies the kind `1618` / `1621` event and + is sufficient on its own; `owner` + `d` are **routing hints** that let the + client navigate (and render a fallback card) without waiting for an event + lookup. Parsers must accept links missing the hints. + +Validation rules match the existing codebase: `owner` and `id` are +`/^[a-f0-9]{64}$/`; `d` follows addressable d-tag rules already enforced in +`projectModels.ts` / `buzz-sdk`. + +### Why not HTTPS URLs? + +The relay origin differs per community and the web client has no PR/issue +routes. A custom-scheme link is community-relative by construction, matches +the established `buzz://message` precedent, and requires no new relay +surface. If web views land later, the desktop can additionally recognize +`{relay-origin}/…` URLs with the same card treatment. + +## Rendering in chat (desktop) + +Two presentations, consistent with how GitHub links and message links behave +today: + +1. **Autolinked bare URL** (`` or bare in text): render an + **attachment card** below the message in the existing `AttachmentGroup`, + exactly like GitHub cards. Provider label `Buzz`, type label + `PR` / `issue` / `repo` / `project`. +2. **Explicitly labeled markdown link** (`[fix the tooltip](buzz://pr?…)`): + keep the author's label inline (same rule as + `resolveMessageLinkRenderTarget` in `messageLink.ts`), still clickable. + +### Card content and enrichment + +Unlike GitHub (title derived from URL path only), Buzz entities live on the +same relay, so the card can show real data: + +| Entity | Title source | Fallback | +|---------|-------------------------------------------|---------------------| +| PR | `subject` tag of the kind `1618` event | `PR ` | +| Issue | `subject` tag of the kind `1621` event | `issue ` | +| Repo | `name` tag of the kind `30617` event | `d`-tag | +| Project | `name` tag of the kind `30621` event | `d`-tag | + +Enrichment is a single relay query by event id (PR/issue) or coordinate +(repo/project) through the existing `relayClient`, cached per event id. +Kind filters must always be included in the query (relay p-gate). Cards +render immediately with the fallback title and upgrade in place when the +lookup resolves — same progressive pattern as +`useResolvedLinkPreviews.ts` uses for Google titles. + +Open/merged/closed status chips (from kind `1630`–`1633` status events) are +a nice-to-have and explicitly deferred to a follow-up. + +### New module + +`desktop/src/features/projects/lib/entityLink.ts` (sibling to +`messageLink.ts`): + +- `buildRepoLink`, `buildProjectLink`, `buildPullRequestLink`, + `buildIssueLink` +- `parseEntityLink(url): EntityLinkParseResult` (discriminated union, same + shape as `parseMessageLink`) +- `isEntityLink(href)` cheap pre-check for the markdown renderer + +Detection: extend `extractSupportedLinkPreviews` in `linkPreview.ts` with a +`buzz://` pattern (new `SupportedLinkPreviewKind` members +`buzz-pull-request`, `buzz-issue`, `buzz-repository`, `buzz-project`), or — +if mixing schemes into the URL regex is awkward — a parallel extractor +composed in `markdown.tsx`. Code blocks / spoiler / image-link masking rules +are shared either way, and the existing `MAX_PREVIEWS` cap applies across +both sources. + +## Click handling and OS deep links + +**In-timeline click**: navigate via `useAppNavigation.goProject()`. The +`/projects/$projectId` route already accepts `pullRequestId`, `issueId`, +`repositoryId` search params, so: + +- `pr` / `issue` → resolve the repo coordinate to the project that lists it + (via the existing project read models); open + `/projects/?pullRequestId=` (or `issueId`). If the repo is + not in any explicit project, open its implicit repository card. +- `repo` / `project` → open the corresponding detail screen. + +If resolution fails (entity not visible in this community), show the same +kind of toast fallback used for unresolvable message links. + +**OS-level**: register `repo` / `project` / `pr` / `issue` hosts in +`desktop/src-tauri/src/deep_link.rs` and dispatch to a new listener hook +(sibling to `useMessageDeepLinks.ts`). This makes links pasted outside Buzz +(e.g. in a terminal or another app) open the desktop app correctly. + +## CLI (`buzz-cli`) + +Add a `link` field to the JSON output of the write commands that create +linkable entities: + +- `buzz pr open` → `{ event_id, accepted, message, link }` +- `buzz issues create` → same +- `buzz repos create` → link built from owner pubkey + `d`-tag +- `buzz projects create` → same + +The builder lives in one Rust helper (e.g. `crates/buzz-cli/src/links.rs`) +so the format has exactly one definition on the Rust side; the TypeScript +`entityLink.ts` is its mirror and both are covered by shared-format tests +(golden strings asserted on both sides, like the NIP-MP fixture pattern). + +`buzz pr get` / `buzz issues get` / `buzz repos get` also include `link` in +their output so agents can link to existing entities, not just ones they +just created. + +## Agent guidance + +One addition to `crates/buzz-acp/src/base_prompt.md`, next to the existing +`--channel` rule for PR opens: + +> When you announce a pull request, issue, repository, or project in a +> channel message, include the `link` value from the command output as a +> bare URL on its own line so it renders as a preview card. + +No persona changes needed — the base prompt applies to all managed agents. + +## Interaction with existing work + +- **`proto/rich-link-previews`** (generic OpenGraph cards): orthogonal. + Entity links never hit the network beyond a relay event query; no overlap + in code paths except the shared `AttachmentGroup` rendering slot. +- **`feat/multi-repository-projects` (NIP-MP)**: independent. Entity links + reference single repositories/PRs/issues by coordinate/event id; the + PR→project resolution step simply uses whatever project read models exist + on `main` at implementation time. + +## Implementation plan (suggested PR slices) + +1. **Link core + cards** — `entityLink.ts`, detection in `linkPreview.ts`, + `Buzz` card variant in `link-preview-attachment.tsx`, in-timeline click + navigation, relay title enrichment. Unit tests + (`entityLink.test.mjs`, extended `linkPreview.test.mjs`) + one Playwright + spec with the mock bridge (`desktop/tests/e2e/`). +2. **OS deep links** — `deep_link.rs` + listener hook + `deep-link.ts` + parity tests. +3. **CLI + agent prompt** — `links.rs` helper, `link` output field on + create/get commands, base prompt paragraph, cross-language golden-format + test. +4. **Follow-ups (separate)** — status chips on PR/issue cards, mobile + pill/card rendering, web PR/issue routes + HTTPS link recognition, + cross-community `relay=` parameter. + +## Security considerations + +- All identifiers are validated before use (`owner`/`id` strict hex-64, + `d`-tag charset rules). Parse failures render the raw text as a plain, + non-clickable string — never an anchor with an unvalidated href. +- Title enrichment queries go through the already-authenticated + `relayClient` with explicit `kinds` filters; no new HTTP surface and no + outbound fetches to third parties. +- Card titles come from event tags authored by arbitrary users; they must be + rendered as text (existing card components already do this — verify no + `dangerouslySetInnerHTML` in the new variant). +- Deep links arriving from the OS are untrusted input; the new listener must + apply the same validation as the in-timeline parser before navigating. From fd8bed0ea7f1392aca8395d69c5e3216ffb092e1 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Mon, 3 Aug 2026 17:46:55 +0200 Subject: [PATCH 2/6] feat(desktop): render Buzz repo preview cards for relay git clone URLs Relay git URLs ({relay-origin}/git//) pasted in chat now get the same rich preview card treatment as GitHub links. Detection keys on the /git/<64-hex-pubkey>/ path shape since relay hosts differ per community; the card links to the browsable web repo page (/repos/) rather than the raw git transport endpoint. First implemented slice of the Buzz entity links spec (docs/buzz-entity-links.md). Signed-off-by: Thomas Petersen --- desktop/src/shared/lib/linkPreview.test.mjs | 87 +++++++++++++++++++ desktop/src/shared/lib/linkPreview.ts | 37 +++++++- .../src/shared/ui/link-preview-attachment.tsx | 3 + docs/buzz-entity-links.md | 30 +++++-- 4 files changed, 147 insertions(+), 10 deletions(-) diff --git a/desktop/src/shared/lib/linkPreview.test.mjs b/desktop/src/shared/lib/linkPreview.test.mjs index a56b35f54a..675fd533bf 100644 --- a/desktop/src/shared/lib/linkPreview.test.mjs +++ b/desktop/src/shared/lib/linkPreview.test.mjs @@ -53,6 +53,55 @@ test("parseSupportedLinkPreview ignores unsupported GitHub URLs", () => { ); }); +const BUZZ_OWNER = + "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224"; + +test("parseSupportedLinkPreview parses Buzz relay git clone URLs", () => { + assert.deepEqual( + parseSupportedLinkPreview( + `https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy`, + ), + { + kind: "buzz-repository", + href: "https://buzz.block.builderlab.xyz/repos/buzz-world-galaxy", + provider: "Buzz", + title: "buzz-world-galaxy", + typeLabel: "repo", + }, + ); +}); + +test("parseSupportedLinkPreview strips .git suffix and keeps relay port", () => { + assert.deepEqual( + parseSupportedLinkPreview( + `http://localhost:3000/git/${BUZZ_OWNER}/buzz-world.git`, + ), + { + kind: "buzz-repository", + href: "http://localhost:3000/repos/buzz-world", + provider: "Buzz", + title: "buzz-world", + typeLabel: "repo", + }, + ); +}); + +test("parseSupportedLinkPreview rejects malformed Buzz git URLs", () => { + for (const href of [ + // Owner segment must be a 64-char lowercase hex pubkey. + "https://relay.example/git/not-a-pubkey/repo", + `https://relay.example/git/${BUZZ_OWNER.toUpperCase()}/repo`, + `https://relay.example/git/${BUZZ_OWNER.slice(0, 32)}/repo`, + // Missing or invalid repo segment. + `https://relay.example/git/${BUZZ_OWNER}`, + `https://relay.example/git/${BUZZ_OWNER}/.hidden`, + // Deeper transport paths are not repo links. + `https://relay.example/git/${BUZZ_OWNER}/repo/info/refs`, + ]) { + assert.equal(parseSupportedLinkPreview(href), null, href); + } +}); + test("parseSupportedLinkPreview parses Linear issue URLs", () => { assert.deepEqual( parseSupportedLinkPreview( @@ -114,6 +163,44 @@ test("extractSupportedLinkPreviews returns unique supported links in order", () ); }); +test("extractSupportedLinkPreviews picks up bare Buzz clone URLs in prose", () => { + assert.deepEqual( + extractSupportedLinkPreviews( + `master pushed; clone: https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy and review please.`, + ), + [ + { + kind: "buzz-repository", + href: "https://buzz.block.builderlab.xyz/repos/buzz-world-galaxy", + provider: "Buzz", + title: "buzz-world-galaxy", + typeLabel: "repo", + }, + ], + ); +}); + +test("extractSupportedLinkPreviews uses markdown labels for Buzz repo links", () => { + assert.deepEqual( + extractSupportedLinkPreviews( + `[Buzz World](https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy)`, + ).map((preview) => preview.title), + ["Buzz World"], + ); +}); + +test("extractSupportedLinkPreviews dedupes clone URL variants of one repo", () => { + assert.deepEqual( + extractSupportedLinkPreviews( + [ + `https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy`, + `https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy.git`, + ].join(" "), + ).map((preview) => preview.href), + ["https://relay.example/repos/buzz-world-galaxy"], + ); +}); + test("extractSupportedLinkPreviews handles markdown link serialization", () => { assert.deepEqual( extractSupportedLinkPreviews( diff --git a/desktop/src/shared/lib/linkPreview.ts b/desktop/src/shared/lib/linkPreview.ts index 5cba96da87..82116e0b44 100644 --- a/desktop/src/shared/lib/linkPreview.ts +++ b/desktop/src/shared/lib/linkPreview.ts @@ -1,4 +1,5 @@ export type SupportedLinkPreviewKind = + | "buzz-repository" | "github-pull-request" | "github-issue" | "github-repository" @@ -13,6 +14,7 @@ export type SupportedLinkPreview = { kind: SupportedLinkPreviewKind; href: string; provider: + | "Buzz" | "GitHub" | "Linear" | "Google Drive" @@ -31,10 +33,13 @@ export type SupportedLinkPreview = { | "presentation"; }; +// Buzz relay hosts differ per community, so relay git URLs are recognized by +// their distinctive path shape (`/git/<64-hex-pubkey>/`) rather than by +// hostname, and require an explicit scheme. const SUPPORTED_URL_RE = - /(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi; + /(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+|https?:\/\/[^\s<>"'\]]+\/git\/[a-f0-9]{64}\/[^\s<>"'\]]+)/gi; const MARKDOWN_SUPPORTED_LINK_RE = - /!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi; + /!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+|https?:\/\/[^)\s<>"']+\/git\/[a-f0-9]{64}\/[^)\s<>"']+)\)/gi; const MAX_PREVIEWS = 8; type HiddenRange = { @@ -266,6 +271,33 @@ function createPreview( }; } +const BUZZ_GIT_PATH_RE = + /^\/git\/[a-f0-9]{64}\/([a-zA-Z0-9._-]+?)(?:\.git)?\/?$/; + +/** + * Recognize a Buzz relay git URL (`{relay-origin}/git//`, + * the clone URL shape agents paste when announcing work). The card links to + * the relay-served web repo page (`/repos/`) instead of the raw git + * transport endpoint, which is not a browsable page. + */ +function parseBuzzGitLink(parsed: URL): SupportedLinkPreview | null { + const match = BUZZ_GIT_PATH_RE.exec(parsed.pathname); + if (!match) return null; + + const repo = match[1]; + if (repo.startsWith(".") || repo.includes("..") || repo.length > 64) { + return null; + } + + return { + kind: "buzz-repository", + href: `${parsed.origin}/repos/${repo}`, + provider: "Buzz", + title: repo, + typeLabel: "repo", + }; +} + function parseGithubLink(parsed: URL): SupportedLinkPreview | null { if (normalizeHostname(parsed) !== "github.com") { return null; @@ -432,6 +464,7 @@ export function parseSupportedLinkPreview( } return ( + parseBuzzGitLink(parsed) ?? parseGithubLink(parsed) ?? parseLinearIssue(parsed) ?? parseGoogleDriveLink(parsed) ?? diff --git a/desktop/src/shared/ui/link-preview-attachment.tsx b/desktop/src/shared/ui/link-preview-attachment.tsx index 5d6b80767a..0e2b2a6adf 100644 --- a/desktop/src/shared/ui/link-preview-attachment.tsx +++ b/desktop/src/shared/ui/link-preview-attachment.tsx @@ -2,6 +2,7 @@ import { ExternalLink } from "lucide-react"; import type { SupportedLinkPreview } from "@/shared/lib/linkPreview"; import { cn } from "@/shared/lib/cn"; +import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark"; import { Attachment, AttachmentActions, @@ -91,6 +92,8 @@ function GoogleSlidesLogo({ className }: { className?: string }) { function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) { switch (preview.kind) { + case "buzz-repository": + return ; case "github-issue": case "github-pull-request": case "github-repository": diff --git a/docs/buzz-entity-links.md b/docs/buzz-entity-links.md index 5417dd2939..567c5354d3 100644 --- a/docs/buzz-entity-links.md +++ b/docs/buzz-entity-links.md @@ -1,7 +1,9 @@ # Buzz Entity Links -Status: **draft spec** — no implementation yet. -Branch: `spec/buzz-entity-links`. +Status: **draft spec**. A first slice is implemented on this branch: +HTTPS relay git clone URLs (`{relay-origin}/git//`) render as +Buzz repository preview cards in chat (see `desktop/src/shared/lib/linkPreview.ts`). +Everything else below is unimplemented design. ## Problem @@ -78,13 +80,22 @@ Validation rules match the existing codebase: `owner` and `id` are `/^[a-f0-9]{64}$/`; `d` follows addressable d-tag rules already enforced in `projectModels.ts` / `buzz-sdk`. -### Why not HTTPS URLs? +### HTTPS URLs -The relay origin differs per community and the web client has no PR/issue -routes. A custom-scheme link is community-relative by construction, matches -the established `buzz://message` precedent, and requires no new relay -surface. If web views land later, the desktop can additionally recognize -`{relay-origin}/…` URLs with the same card treatment. +Agents naturally paste HTTPS clone URLs +(`{relay-origin}/git//`) when announcing work, so those are +recognized **first** — implemented on this branch. Detection keys on the +path shape (`/git/` + 64-hex pubkey segment) rather than a host allow-list, +since relay hosts differ per community; the card links to the relay-served +web repo page (`/repos/`) because the raw transport URL is not +browsable. + +PRs, issues, and projects have no HTTPS page to link to (the web client has +no such routes), which is why they use the `buzz://` scheme above: it is +community-relative by construction, matches the established `buzz://message` +precedent, and requires no new relay surface. If web views land later, the +desktop can additionally recognize those `{relay-origin}/…` URLs with the +same card treatment. ## Rendering in chat (desktop) @@ -202,6 +213,9 @@ No persona changes needed — the base prompt applies to all managed agents. ## Implementation plan (suggested PR slices) +0. **HTTPS clone-URL repo cards** *(done, this branch)* — recognize relay + `/git//` URLs in `linkPreview.ts`, `Buzz` provider card + with the `BuzzMark` logo, href rewritten to the web repo page. 1. **Link core + cards** — `entityLink.ts`, detection in `linkPreview.ts`, `Buzz` card variant in `link-preview-attachment.tsx`, in-timeline click navigation, relay title enrichment. Unit tests From fcbb280cdf7496a2125f2bf282971f2f428f8137 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Mon, 3 Aug 2026 18:23:29 +0200 Subject: [PATCH 3/6] feat: buzz:// entity deep links for PRs, issues, and repos Desktop renders buzz://pr|issue|repo links as Buzz preview cards with titles enriched from the relay event's subject tag, and clicking a card or inline link navigates in-app to the project detail view. The CLI's pr open / issues create / repos create now return a matching link field, and the agent base prompt tells agents to paste it when announcing work. Signed-off-by: Thomas Petersen --- AGENTS.md | 1 + crates/buzz-acp/src/base_prompt.md | 3 + crates/buzz-cli/src/commands/issues.rs | 6 +- crates/buzz-cli/src/commands/pr.rs | 6 +- crates/buzz-cli/src/commands/repos.rs | 6 +- crates/buzz-cli/src/lib.rs | 1 + crates/buzz-cli/src/links.rs | 51 ++++++ .../features/communities/useCommunityInit.ts | 2 + desktop/src/shared/lib/entityLink.test.mjs | 105 ++++++++++++ desktop/src/shared/lib/entityLink.ts | 151 ++++++++++++++++++ desktop/src/shared/lib/linkPreview.test.mjs | 63 ++++++++ desktop/src/shared/lib/linkPreview.ts | 70 +++++++- .../src/shared/lib/useResolvedLinkPreviews.ts | 70 +++++++- .../src/shared/ui/link-preview-attachment.tsx | 34 +++- desktop/src/shared/ui/markdown.tsx | 114 ++++--------- .../shared/ui/markdown/ExternalLinkAnchor.tsx | 92 +++++++++++ .../src/shared/ui/markdown/entityLinks.tsx | 86 ++++++++++ .../src/shared/ui/markdown/runtimeContext.ts | 1 + desktop/src/shared/ui/markdown/types.ts | 3 + docs/buzz-entity-links.md | 72 +++++---- 20 files changed, 804 insertions(+), 133 deletions(-) create mode 100644 crates/buzz-cli/src/links.rs create mode 100644 desktop/src/shared/lib/entityLink.test.mjs create mode 100644 desktop/src/shared/lib/entityLink.ts create mode 100644 desktop/src/shared/ui/markdown/ExternalLinkAnchor.tsx create mode 100644 desktop/src/shared/ui/markdown/entityLinks.tsx diff --git a/AGENTS.md b/AGENTS.md index 4f03b312bc..099e2caa11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -507,6 +507,7 @@ reconnects preserve pending avatar verification work): - `resetRenderScopedReactionHydration()` — reaction hydration cache - `clearSearchHitEventCache()` — search result event cache - `clearMarkdownNodeCache()` — markdown parse-node cache +- `resetLinkPreviewTitleCache()` — link preview title cache (Buzz entity titles come from relay events) **If you add a new module-level cache, Map, or class instance that holds community-scoped data, you must add its reset to `resetCommunityState()`.** diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index e360d24982..b104d2963a 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -17,6 +17,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ | `buzz feed` | `get` | | `buzz social` | `publish`, `notes` | | `buzz repos` | `create`, `get`, `list` | +| `buzz issues` | `create`, `get`, `list`, `status` | | `buzz pr` | `open`, `update`, `get`, `list`, `status` | | `buzz upload` | `file` | @@ -24,6 +25,8 @@ Run `buzz --help` or `buzz --help` for full usage. For multiline message When opening a pull request in response to channel work, always pass `--channel ` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation. +`buzz pr open`, `buzz issues create`, and `buzz repos create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, or repo in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. + ## Conversational Agent Creation When someone asks to create an agent, ask for at most two things: the agent's name and what it should do day-to-day. Turn the user's rough purpose into the `--system-prompt` yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the user's request is genuinely ambiguous. diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 3d7d92a1b4..1d197e3b41 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -28,8 +28,12 @@ pub async fn cmd_create_issue( let builder = buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?; let event = client.sign_event(builder)?; + let event_id = event.id.to_hex(); let resp = client.submit_event(event).await?; - println!("{resp}"); + // `link` renders as a rich preview card in Buzz Desktop when included in + // a chat message — agents announce issues with it (see base_prompt.md). + let link = crate::links::issue_link(&event_id, repo_owner, repo_id); + crate::client::print_create_response(&resp, "link", &link); Ok(()) } diff --git a/crates/buzz-cli/src/commands/pr.rs b/crates/buzz-cli/src/commands/pr.rs index 4272c2bfd8..fe4bcfb084 100644 --- a/crates/buzz-cli/src/commands/pr.rs +++ b/crates/buzz-cli/src/commands/pr.rs @@ -57,8 +57,12 @@ pub async fn cmd_open_pr( let builder = buzz_sdk::build_git_pull_request(&repo, &content, &meta).map_err(sdk_err)?; let event = client.sign_event(builder)?; + let event_id = event.id.to_hex(); let resp = client.submit_event(event).await?; - println!("{resp}"); + // `link` renders as a rich preview card in Buzz Desktop when included in + // a chat message — agents announce PRs with it (see base_prompt.md). + let link = crate::links::pull_request_link(&event_id, repo_owner, repo_id); + crate::client::print_create_response(&resp, "link", &link); Ok(()) } diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index 15e064d9c3..e54b95ef20 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -261,8 +261,12 @@ pub async fn cmd_create_repo( channel, )?; let event = client.sign_event(builder)?; + let owner = event.pubkey.to_hex(); let resp = client.submit_event(event).await?; - println!("{resp}"); + // `link` renders as a rich preview card in Buzz Desktop when included in + // a chat message — agents announce repos with it (see base_prompt.md). + let link = crate::links::repo_link(&owner, repo_id); + crate::client::print_create_response(&resp, "link", &link); Ok(()) } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index f745e7b280..8a8bb053b0 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -2,6 +2,7 @@ pub mod agent_management; mod client; mod commands; mod error; +mod links; mod validate; use clap::{Parser, Subcommand}; diff --git a/crates/buzz-cli/src/links.rs b/crates/buzz-cli/src/links.rs new file mode 100644 index 0000000000..043bdc48b0 --- /dev/null +++ b/crates/buzz-cli/src/links.rs @@ -0,0 +1,51 @@ +//! Canonical `buzz://` deep links for Buzz-hosted git entities. +//! +//! Buzz Desktop renders these links as rich preview cards in chat and +//! navigates in-app when they are clicked. The desktop parser lives in +//! `desktop/src/shared/lib/entityLink.ts` — the two implementations must +//! stay format-compatible (see `golden_format_matches_desktop` below and +//! the mirror test in `entityLink.test.mjs`). +//! +//! Callers are expected to validate inputs first (`validate_hex64`, +//! `validate_repo_id`); the identifier charsets need no URL encoding. + +/// Build a `buzz://repo` link for a repository announcement (kind 30617). +pub fn repo_link(owner: &str, repo_id: &str) -> String { + format!("buzz://repo?owner={owner}&d={repo_id}") +} + +/// Build a `buzz://pr` link for a pull request event (kind 1618). +pub fn pull_request_link(event_id: &str, owner: &str, repo_id: &str) -> String { + format!("buzz://pr?id={event_id}&owner={owner}&d={repo_id}") +} + +/// Build a `buzz://issue` link for an issue event (kind 1621). +pub fn issue_link(event_id: &str, owner: &str, repo_id: &str) -> String { + format!("buzz://issue?id={event_id}&owner={owner}&d={repo_id}") +} + +#[cfg(test)] +mod tests { + use super::*; + + const OWNER: &str = "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224"; + const EVENT_ID: &str = "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1"; + + // Golden strings shared with desktop/src/shared/lib/entityLink.test.mjs + // ("builders emit the canonical cross-language link format"). + #[test] + fn golden_format_matches_desktop() { + assert_eq!( + pull_request_link(EVENT_ID, OWNER, "buzz-world"), + format!("buzz://pr?id={EVENT_ID}&owner={OWNER}&d=buzz-world") + ); + assert_eq!( + issue_link(EVENT_ID, OWNER, "buzz-world"), + format!("buzz://issue?id={EVENT_ID}&owner={OWNER}&d=buzz-world") + ); + assert_eq!( + repo_link(OWNER, "buzz-world"), + format!("buzz://repo?owner={OWNER}&d=buzz-world") + ); + } +} diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index afa69f913f..21486b0476 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -13,6 +13,7 @@ import { getIdentity } from "@/shared/api/tauriIdentity"; import { clearTrayAgentActivity } from "@/shared/api/trayMenu"; import { getOverrides } from "@/shared/features"; import { resetMediaCaches } from "@/shared/lib/mediaUrl"; +import { resetLinkPreviewTitleCache } from "@/shared/lib/useResolvedLinkPreviews"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; import { clearAllDrafts, @@ -69,6 +70,7 @@ function resetCommunityState({ resetRenderScopedReactionHydration(); clearSearchHitEventCache(); clearMarkdownNodeCache(); + resetLinkPreviewTitleCache(); } type CommunityInitResult = diff --git a/desktop/src/shared/lib/entityLink.test.mjs b/desktop/src/shared/lib/entityLink.test.mjs new file mode 100644 index 0000000000..3633675988 --- /dev/null +++ b/desktop/src/shared/lib/entityLink.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildIssueLink, + buildPullRequestLink, + buildRepoLink, + entityLinkProjectRouteId, + isEntityLink, + parseEntityLink, +} from "./entityLink.ts"; + +const OWNER = + "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224"; +const EVENT_ID = + "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1"; + +// Golden format strings — must match the Rust builder in +// crates/buzz-cli/src/links.rs (`golden_format_matches_desktop` test). +test("builders emit the canonical cross-language link format", () => { + assert.equal( + buildPullRequestLink({ id: EVENT_ID, owner: OWNER, dtag: "buzz-world" }), + `buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`, + ); + assert.equal( + buildIssueLink({ id: EVENT_ID, owner: OWNER, dtag: "buzz-world" }), + `buzz://issue?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`, + ); + assert.equal( + buildRepoLink({ owner: OWNER, dtag: "buzz-world" }), + `buzz://repo?owner=${OWNER}&d=buzz-world`, + ); +}); + +test("builders reject invalid identifiers", () => { + assert.throws(() => + buildRepoLink({ owner: "not-a-pubkey", dtag: "buzz-world" }), + ); + assert.throws(() => buildRepoLink({ owner: OWNER, dtag: ".hidden" })); + assert.throws(() => buildRepoLink({ owner: OWNER, dtag: "a..b" })); + assert.throws(() => + buildPullRequestLink({ id: "short", owner: OWNER, dtag: "buzz-world" }), + ); +}); + +test("parseEntityLink round-trips built links", () => { + const link = buildPullRequestLink({ + id: EVENT_ID, + owner: OWNER, + dtag: "buzz-world", + }); + assert.deepEqual(parseEntityLink(link), { + ok: true, + value: { type: "pr", id: EVENT_ID, owner: OWNER, dtag: "buzz-world" }, + }); + + const repoLink = buildRepoLink({ owner: OWNER, dtag: "buzz-world" }); + assert.deepEqual(parseEntityLink(repoLink), { + ok: true, + value: { type: "repo", owner: OWNER, dtag: "buzz-world" }, + }); +}); + +test("parseEntityLink lowercase-normalizes hex identifiers", () => { + const parsed = parseEntityLink( + `buzz://issue?id=${EVENT_ID.toUpperCase()}&owner=${OWNER.toUpperCase()}&d=buzz-world`, + ); + assert.deepEqual(parsed, { + ok: true, + value: { type: "issue", id: EVENT_ID, owner: OWNER, dtag: "buzz-world" }, + }); +}); + +test("parseEntityLink rejects malformed links", () => { + const cases = [ + ["not a url at all", "invalid-url"], + [`https://pr?id=${EVENT_ID}&owner=${OWNER}&d=repo`, "wrong-scheme"], + [`buzz://message?channel=x&id=${EVENT_ID}`, "wrong-host"], + [`buzz://pr?id=${EVENT_ID}&owner=nope&d=repo`, "invalid-owner"], + [`buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=.hidden`, "invalid-dtag"], + [`buzz://pr?id=${EVENT_ID}&owner=${OWNER}`, "invalid-dtag"], + [`buzz://pr?owner=${OWNER}&d=repo`, "invalid-id"], + [`buzz://issue?id=short&owner=${OWNER}&d=repo`, "invalid-id"], + ]; + for (const [href, reason] of cases) { + assert.deepEqual(parseEntityLink(href), { ok: false, reason }, href); + } +}); + +test("isEntityLink matches entity hosts and excludes message links", () => { + assert.equal(isEntityLink(`buzz://pr?id=${EVENT_ID}`), true); + assert.equal(isEntityLink(`buzz://issue?id=${EVENT_ID}`), true); + assert.equal(isEntityLink(`buzz://repo?owner=${OWNER}`), true); + assert.equal(isEntityLink("buzz://message?channel=x&id=y"), false); + assert.equal(isEntityLink("https://github.com/block/buzz"), false); + assert.equal(isEntityLink(null), false); +}); + +test("entityLinkProjectRouteId matches the /projects route id format", () => { + const parsed = parseEntityLink( + buildRepoLink({ owner: OWNER, dtag: "buzz-world" }), + ); + assert.ok(parsed.ok); + assert.equal(entityLinkProjectRouteId(parsed.value), `${OWNER}:buzz-world`); +}); diff --git a/desktop/src/shared/lib/entityLink.ts b/desktop/src/shared/lib/entityLink.ts new file mode 100644 index 0000000000..70c4555670 --- /dev/null +++ b/desktop/src/shared/lib/entityLink.ts @@ -0,0 +1,151 @@ +/** + * `buzz://` deep links for Buzz-hosted git entities, mirroring + * `features/messages/lib/messageLink.ts` for `buzz://message`. + * + * Formats: + * buzz://repo?owner=&d= + * buzz://pr?id=&owner=&d= + * buzz://issue?id=&owner=&d= + * + * `owner` + `d` identify the NIP-34 repository coordinate + * (`30617::`); `id` is the kind 1618 / 1621 event id. The CLI + * builder in `crates/buzz-cli/src/links.rs` emits the same format — the two + * must stay compatible (see the golden-format tests on both sides). + */ + +const ENTITY_LINK_SCHEME = "buzz:"; + +export type ParsedEntityLink = + | { type: "pr"; id: string; owner: string; dtag: string } + | { type: "issue"; id: string; owner: string; dtag: string } + | { type: "repo"; owner: string; dtag: string }; + +export type EntityLinkParseResult = + | { ok: true; value: ParsedEntityLink } + | { ok: false; reason: string }; + +const HEX64_RE = /^[a-fA-F0-9]{64}$/; +const DTAG_RE = /^[a-zA-Z0-9._-]{1,64}$/; + +function isValidDtag(dtag: string): boolean { + return DTAG_RE.test(dtag) && !dtag.startsWith(".") && !dtag.includes(".."); +} + +function checkCoordinate(owner: string, dtag: string): void { + if (!HEX64_RE.test(owner)) { + throw new Error("entityLink: owner must be a 64-char hex pubkey"); + } + if (!isValidDtag(dtag)) { + throw new Error("entityLink: invalid repository d-tag"); + } +} + +function checkEventId(id: string): void { + if (!HEX64_RE.test(id)) { + throw new Error("entityLink: id must be a 64-char hex event id"); + } +} + +/** Build a `buzz://repo` link for a repository announcement (kind 30617). */ +export function buildRepoLink(input: { owner: string; dtag: string }): string { + checkCoordinate(input.owner, input.dtag); + return `buzz://repo?owner=${input.owner.toLowerCase()}&d=${input.dtag}`; +} + +/** Build a `buzz://pr` link for a pull request event (kind 1618). */ +export function buildPullRequestLink(input: { + id: string; + owner: string; + dtag: string; +}): string { + checkEventId(input.id); + checkCoordinate(input.owner, input.dtag); + return `buzz://pr?id=${input.id.toLowerCase()}&owner=${input.owner.toLowerCase()}&d=${input.dtag}`; +} + +/** Build a `buzz://issue` link for an issue event (kind 1621). */ +export function buildIssueLink(input: { + id: string; + owner: string; + dtag: string; +}): string { + checkEventId(input.id); + checkCoordinate(input.owner, input.dtag); + return `buzz://issue?id=${input.id.toLowerCase()}&owner=${input.owner.toLowerCase()}&d=${input.dtag}`; +} + +/** + * Cheap pre-check used by the markdown renderer and preview extraction + * before parsing. `buzz://message` is intentionally excluded — it has its + * own pill rendering path. + */ +export function isEntityLink(href: string | undefined | null): boolean { + if (!href) return false; + return ( + href.startsWith("buzz://pr?") || + href.startsWith("buzz://issue?") || + href.startsWith("buzz://repo?") + ); +} + +/** + * Parse a `buzz://pr|issue|repo?…` URL. Returns a discriminated result so + * callers can fall back to plain-link rendering without throwing. All + * identifiers are validated; hex values are lowercase-normalized. + */ +export function parseEntityLink(url: string): EntityLinkParseResult { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { ok: false, reason: "invalid-url" }; + } + + if (parsed.protocol !== ENTITY_LINK_SCHEME) { + return { ok: false, reason: "wrong-scheme" }; + } + + const host = parsed.hostname; + if (host !== "pr" && host !== "issue" && host !== "repo") { + return { ok: false, reason: "wrong-host" }; + } + + const owner = parsed.searchParams.get("owner"); + const dtag = parsed.searchParams.get("d"); + if (!owner || !HEX64_RE.test(owner)) { + return { ok: false, reason: "invalid-owner" }; + } + if (!dtag || !isValidDtag(dtag)) { + return { ok: false, reason: "invalid-dtag" }; + } + + if (host === "repo") { + return { + ok: true, + value: { type: "repo", owner: owner.toLowerCase(), dtag }, + }; + } + + const id = parsed.searchParams.get("id"); + if (!id || !HEX64_RE.test(id)) { + return { ok: false, reason: "invalid-id" }; + } + + return { + ok: true, + value: { + type: host, + id: id.toLowerCase(), + owner: owner.toLowerCase(), + dtag, + }, + }; +} + +/** + * Route id accepted by `/projects/$projectId` (`:`, see + * `parseProjectRouteId` in `features/projects/hooks.ts`). + */ +export function entityLinkProjectRouteId(link: ParsedEntityLink): string { + return `${link.owner}:${link.dtag}`; +} diff --git a/desktop/src/shared/lib/linkPreview.test.mjs b/desktop/src/shared/lib/linkPreview.test.mjs index 675fd533bf..1b813b7859 100644 --- a/desktop/src/shared/lib/linkPreview.test.mjs +++ b/desktop/src/shared/lib/linkPreview.test.mjs @@ -102,6 +102,69 @@ test("parseSupportedLinkPreview rejects malformed Buzz git URLs", () => { } }); +const BUZZ_EVENT_ID = + "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1"; + +test("parseSupportedLinkPreview parses buzz:// PR and issue deep links", () => { + assert.deepEqual( + parseSupportedLinkPreview( + `buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world`, + ), + { + kind: "buzz-pull-request", + href: `buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world`, + provider: "Buzz", + title: "buzz-world #c3b589fa", + typeLabel: "PR", + }, + ); + assert.deepEqual( + parseSupportedLinkPreview( + `buzz://issue?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world`, + )?.typeLabel, + "issue", + ); + assert.deepEqual( + parseSupportedLinkPreview(`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world`), + { + kind: "buzz-repository", + href: `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world`, + provider: "Buzz", + title: "buzz-world", + typeLabel: "repo", + }, + ); +}); + +test("parseSupportedLinkPreview rejects malformed buzz:// entity links", () => { + for (const href of [ + `buzz://pr?owner=${BUZZ_OWNER}&d=buzz-world`, + `buzz://pr?id=short&owner=${BUZZ_OWNER}&d=buzz-world`, + `buzz://issue?id=${BUZZ_EVENT_ID}&owner=nope&d=buzz-world`, + `buzz://repo?owner=${BUZZ_OWNER}&d=.hidden`, + ]) { + assert.equal(parseSupportedLinkPreview(href), null, href); + } +}); + +test("extractSupportedLinkPreviews picks up buzz:// links in prose", () => { + assert.deepEqual( + extractSupportedLinkPreviews( + `PR is up: buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world — review please.`, + ).map((preview) => [preview.kind, preview.title]), + [["buzz-pull-request", "buzz-world #c3b589fa"]], + ); +}); + +test("extractSupportedLinkPreviews uses markdown labels for buzz:// links", () => { + assert.deepEqual( + extractSupportedLinkPreviews( + `[Add header links](buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world)`, + ).map((preview) => preview.title), + ["Add header links"], + ); +}); + test("parseSupportedLinkPreview parses Linear issue URLs", () => { assert.deepEqual( parseSupportedLinkPreview( diff --git a/desktop/src/shared/lib/linkPreview.ts b/desktop/src/shared/lib/linkPreview.ts index 82116e0b44..16b41366b3 100644 --- a/desktop/src/shared/lib/linkPreview.ts +++ b/desktop/src/shared/lib/linkPreview.ts @@ -1,4 +1,15 @@ +import { + buildIssueLink, + buildPullRequestLink, + buildRepoLink, + isEntityLink, + parseEntityLink, + type ParsedEntityLink, +} from "./entityLink"; + export type SupportedLinkPreviewKind = + | "buzz-pull-request" + | "buzz-issue" | "buzz-repository" | "github-pull-request" | "github-issue" @@ -37,9 +48,9 @@ export type SupportedLinkPreview = { // their distinctive path shape (`/git/<64-hex-pubkey>/`) rather than by // hostname, and require an explicit scheme. const SUPPORTED_URL_RE = - /(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+|https?:\/\/[^\s<>"'\]]+\/git\/[a-f0-9]{64}\/[^\s<>"'\]]+)/gi; + /(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+|https?:\/\/[^\s<>"'\]]+\/git\/[a-f0-9]{64}\/[^\s<>"'\]]+|buzz:\/\/(?:pr|issue|repo)\?[^\s<>"'\]]+)/gi; const MARKDOWN_SUPPORTED_LINK_RE = - /!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+|https?:\/\/[^)\s<>"']+\/git\/[a-f0-9]{64}\/[^)\s<>"']+)\)/gi; + /!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+|https?:\/\/[^)\s<>"']+\/git\/[a-f0-9]{64}\/[^)\s<>"']+|buzz:\/\/(?:pr|issue|repo)\?[^)\s<>"']+)\)/gi; const MAX_PREVIEWS = 8; type HiddenRange = { @@ -271,6 +282,55 @@ function createPreview( }; } +/** + * Placeholder title shown before (or instead of) the relay event lookup in + * `useResolvedLinkPreviews` resolves the real `subject` / repo name. + * Exported so the resolver can tell "still the fallback" apart from a + * markdown-label override it must not overwrite. + */ +export function buzzEntityFallbackTitle(link: ParsedEntityLink): string { + if (link.type === "repo") return link.dtag; + return `${link.dtag} #${link.id.slice(0, 8)}`; +} + +/** + * Map a `buzz://pr|issue|repo` deep link onto a preview card. The href is + * rebuilt through the canonical builders so equivalent links (case or query + * order variants) dedupe to a single card. + */ +function parseBuzzEntityPreview(href: string): SupportedLinkPreview | null { + const parsed = parseEntityLink(href); + if (!parsed.ok) return null; + + const link = parsed.value; + const title = buzzEntityFallbackTitle(link); + if (link.type === "pr") { + return { + kind: "buzz-pull-request", + href: buildPullRequestLink(link), + provider: "Buzz", + title, + typeLabel: "PR", + }; + } + if (link.type === "issue") { + return { + kind: "buzz-issue", + href: buildIssueLink(link), + provider: "Buzz", + title, + typeLabel: "issue", + }; + } + return { + kind: "buzz-repository", + href: buildRepoLink(link), + provider: "Buzz", + title, + typeLabel: "repo", + }; +} + const BUZZ_GIT_PATH_RE = /^\/git\/[a-f0-9]{64}\/([a-zA-Z0-9._-]+?)(?:\.git)?\/?$/; @@ -449,9 +509,13 @@ function parseGoogleDocsLink(parsed: URL): SupportedLinkPreview | null { export function parseSupportedLinkPreview( href: string, ): SupportedLinkPreview | null { + const candidate = trimUrlCandidate(href); + if (isEntityLink(candidate)) { + return parseBuzzEntityPreview(candidate); + } + let parsed: URL; try { - const candidate = trimUrlCandidate(href); parsed = new URL( /^https?:\/\//i.test(candidate) ? candidate : `https://${candidate}`, ); diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 9773c33945..2f167c8c53 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -1,8 +1,17 @@ import * as React from "react"; import { invokeTauri } from "@/shared/api/tauri"; +import { relayClient } from "@/shared/api/relayClient"; +import { + KIND_GIT_ISSUE, + KIND_GIT_PULL_REQUEST, +} from "@/shared/constants/kinds"; -import type { SupportedLinkPreview } from "./linkPreview"; +import { parseEntityLink } from "./entityLink"; +import { + buzzEntityFallbackTitle, + type SupportedLinkPreview, +} from "./linkPreview"; const GOOGLE_FALLBACK_TITLES = new Set([ "Drive file", @@ -14,32 +23,77 @@ const GOOGLE_FALLBACK_TITLES = new Set([ const titleCache = new Map | string | null>(); +/** + * Buzz entity titles come from relay events, so they are community-scoped — + * wired into `resetCommunityState()` (see useCommunityInit.ts) to avoid + * leaking titles across community switches. + */ +export function resetLinkPreviewTitleCache(): void { + titleCache.clear(); +} + function fetchLinkPreviewTitle(href: string): Promise { return invokeTauri("fetch_link_preview_title", { href }); } +/** + * Resolve a Buzz PR/issue card title from the relay event's `subject` tag + * (first content line as fallback — the same precedence the projects views + * use). + */ +async function fetchBuzzEntityTitle(href: string): Promise { + const parsed = parseEntityLink(href); + if (!parsed.ok || parsed.value.type === "repo") return null; + + const events = await relayClient.fetchEvents({ + kinds: [ + parsed.value.type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE, + ], + ids: [parsed.value.id], + limit: 1, + }); + const event = events[0]; + if (!event) return null; + + const subject = event.tags.find((tag) => tag[0] === "subject")?.[1]; + return subject || event.content.split("\n")[0] || null; +} + function shouldResolveTitle(preview: SupportedLinkPreview): boolean { + if (preview.kind === "buzz-pull-request" || preview.kind === "buzz-issue") { + // A markdown-label override replaces the fallback title and must win + // over the relay lookup. + const parsed = parseEntityLink(preview.href); + return parsed.ok && preview.title === buzzEntityFallbackTitle(parsed.value); + } + return ( preview.kind.startsWith("google-") && GOOGLE_FALLBACK_TITLES.has(preview.title) ); } -function cacheTitle(href: string): Promise { - const cached = titleCache.get(href); +function resolveTitle(preview: SupportedLinkPreview): Promise { + return preview.href.startsWith("buzz://") + ? fetchBuzzEntityTitle(preview.href) + : fetchLinkPreviewTitle(preview.href); +} + +function cacheTitle(preview: SupportedLinkPreview): Promise { + const cached = titleCache.get(preview.href); if (cached instanceof Promise) return cached; if (cached !== undefined) return Promise.resolve(cached); - const promise = fetchLinkPreviewTitle(href) + const promise = resolveTitle(preview) .then((title) => { - titleCache.set(href, title); + titleCache.set(preview.href, title); return title; }) .catch(() => { - titleCache.set(href, null); + titleCache.set(preview.href, null); return null; }); - titleCache.set(href, promise); + titleCache.set(preview.href, promise); return promise; } @@ -66,7 +120,7 @@ export function useResolvedLinkPreviews( continue; } - void cacheTitle(preview.href).then((title) => { + void cacheTitle(preview).then((title) => { if (cancelled || !title) return; setResolvedTitles((current) => current[preview.href] === title diff --git a/desktop/src/shared/ui/link-preview-attachment.tsx b/desktop/src/shared/ui/link-preview-attachment.tsx index 0e2b2a6adf..ad848a42ba 100644 --- a/desktop/src/shared/ui/link-preview-attachment.tsx +++ b/desktop/src/shared/ui/link-preview-attachment.tsx @@ -92,6 +92,8 @@ function GoogleSlidesLogo({ className }: { className?: string }) { function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) { switch (preview.kind) { + case "buzz-issue": + case "buzz-pull-request": case "buzz-repository": return ; case "github-issue": @@ -114,9 +116,16 @@ function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) { export function LinkPreviewAttachment({ className, + onOpen, preview, }: { className?: string; + /** + * In-app navigation handler for links the OS cannot open (e.g. `buzz://` + * entity deep links). When set, the card renders a button trigger instead + * of an external anchor. + */ + onOpen?: () => void; preview: SupportedLinkPreview; }) { return ( @@ -144,18 +153,29 @@ export function LinkPreviewAttachment({ className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover/attachment:opacity-100 group-focus-within/attachment:opacity-100" /> - - Open {preview.provider} {preview.typeLabel}: {preview.title} - - + + ) : ( + + + + Open {preview.provider} {preview.typeLabel}: {preview.title} + + + + )} ); } diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index b1f9623f3e..4783b8338a 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -9,7 +9,6 @@ import { ZoomOut, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { openUrl } from "@tauri-apps/plugin-opener"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; @@ -23,7 +22,6 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { invokeTauri } from "@/shared/api/tauri"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { cn } from "@/shared/lib/cn"; -import { copyTextToClipboard } from "@/shared/lib/clipboard"; import { extractSupportedLinkPreviews, parseSupportedLinkPreview, @@ -59,6 +57,12 @@ import { MarkdownCodeBlock, SyntaxHighlightedCode, } from "./markdown/CodeBlock"; +import { + renderEntityLinkAnchor, + useEntityCardOpenHandlers, + useOpenEntityLink, +} from "./markdown/entityLinks"; +import { ExternalLinkAnchor } from "./markdown/ExternalLinkAnchor"; import { FileCard } from "./markdown/FileCard"; import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover"; import { MarkdownInput } from "./markdown/MarkdownInput"; @@ -108,7 +112,6 @@ import { visibleImageGalleryForTrigger, } from "./markdown/imageLightbox"; import { MarkdownTable } from "./markdown/MarkdownTable"; -import { MaskedLinkTooltip } from "./markdown/MaskedLinkTooltip"; import { ProgressiveImage } from "./markdown/ProgressiveImage"; import { MessageLinkPill } from "./markdown/MessageLinkPill"; import { renderCachedMarkdown } from "./markdown/nodeCache"; @@ -1272,85 +1275,6 @@ function ImageMosaic({ children }: { children: React.ReactNode[] }) { ); } -/** - * An external `[text](href)` link with a custom right-click menu. - * - * Buzz renders inside a native webview whose default context menu has no - * useful link actions, so a plain right-click on a link is a no-op. This adds - * an in-app menu with "Open link" (via the OS opener, matching the anchor's - * left-click `target="_blank"` behavior) and "Copy link" (the real href, not - * the masked display text). - */ -function ExternalLinkAnchor({ - anchorProps, - children, - href, - isLinearLink, - label, -}: { - anchorProps: React.ComponentPropsWithoutRef<"a">; - children: React.ReactNode; - href: string | undefined; - isLinearLink: boolean; - label: string; -}) { - const [menu, setMenu] = React.useState(null); - const closeMenu = React.useCallback(() => setMenu(null), []); - useDismissMediaContextMenu(Boolean(menu), closeMenu); - - const anchor = ( - { - if (!href) return; - event.preventDefault(); - setMenu({ x: event.clientX, y: event.clientY }); - }} - rel="noreferrer" - target="_blank" - > - {children} - - ); - - return ( - <> - - {anchor} - - {menu && href ? ( - { - closeMenu(); - void openUrl(href).catch(() => { - toast.error("Failed to open link"); - }); - }, - }, - { - label: "Copy link", - onSelect: () => { - closeMenu(); - copyTextToClipboard(href, "Link copied to clipboard"); - }, - }, - ]} - position={menu} - /> - ) : null} - - ); -} - function createMarkdownComponents( interactive = true, mediaInset = false, @@ -1366,6 +1290,7 @@ function createMarkdownComponents( const { channels, imetaByUrl, + onOpenEntityLink, onOpenMessageLink, onImportSnapshotFromUrl, snapshotSharedBy, @@ -1465,6 +1390,16 @@ function createMarkdownComponents( // anchor (renders as a normal external link). } + // `buzz://pr|issue|repo?…` entity links navigate in-app; malformed ones + // fall through to the default anchor. + const entityAnchor = renderEntityLinkAnchor({ + anchorProps: props, + children, + href, + onOpenEntityLink, + }); + if (entityAnchor) return entityAnchor; + const supportedLinkPreview = href ? parseSupportedLinkPreview(href) : null; const isLinearLink = supportedLinkPreview?.kind === "linear-issue"; @@ -1799,7 +1734,7 @@ function createMarkdownComponents( * four instances ever exist. Module-stable maps mean cached markdown element * trees (see ./markdown/nodeCache.ts) never embed per-mount closures. */ -const MARKDOWN_COMPONENT_SCHEMA_VERSION = "4"; +const MARKDOWN_COMPONENT_SCHEMA_VERSION = "5"; const markdownComponentsByVariant = new Map(); type MarkdownComponentSet = { components: Components; variant: string }; @@ -1852,6 +1787,7 @@ function MarkdownInner({ }, [goChannel], ); + const onOpenEntityLink = useOpenEntityLink(); const onOpenMessageLink = React.useCallback( (link: ParsedMessageLink) => { // Always route through `goChannel` with `messageId` set: the channel @@ -1883,6 +1819,7 @@ function MarkdownInner({ imetaByUrl, mentionPubkeysByName, onOpenChannel, + onOpenEntityLink, onOpenMessageLink, snapshotSharedBy, onImportSnapshotFromUrl: ( @@ -1900,6 +1837,7 @@ function MarkdownInner({ imetaByUrl, mentionPubkeysByName, onOpenChannel, + onOpenEntityLink, onOpenMessageLink, snapshotSharedBy, goAgents, @@ -1922,6 +1860,10 @@ function MarkdownInner({ } const resolvedLinkPreviews = useResolvedLinkPreviews(linkPreviews); + const entityCardOpenHandlers = useEntityCardOpenHandlers( + resolvedLinkPreviews, + onOpenEntityLink, + ); // When a config-nudge suppresses the prose (selectProseOrNudge returns // null), skip the parse entirely — it would be thrown away unrendered. @@ -1977,7 +1919,11 @@ function MarkdownInner({ data-link-preview-list="" > {resolvedLinkPreviews.map((preview) => ( - + ))} ) : null} diff --git a/desktop/src/shared/ui/markdown/ExternalLinkAnchor.tsx b/desktop/src/shared/ui/markdown/ExternalLinkAnchor.tsx new file mode 100644 index 0000000000..00f19fb8de --- /dev/null +++ b/desktop/src/shared/ui/markdown/ExternalLinkAnchor.tsx @@ -0,0 +1,92 @@ +import * as React from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { toast } from "sonner"; + +import { cn } from "@/shared/lib/cn"; +import { copyTextToClipboard } from "@/shared/lib/clipboard"; + +import { MaskedLinkTooltip } from "./MaskedLinkTooltip"; +import { + MediaContextMenu, + type MediaContextMenuPosition, + useDismissMediaContextMenu, +} from "./MediaContextMenu"; + +/** + * An external `[text](href)` link with a custom right-click menu. + * + * Buzz renders inside a native webview whose default context menu has no + * useful link actions, so a plain right-click on a link is a no-op. This adds + * an in-app menu with "Open link" (via the OS opener, matching the anchor's + * left-click `target="_blank"` behavior) and "Copy link" (the real href, not + * the masked display text). + */ +export function ExternalLinkAnchor({ + anchorProps, + children, + href, + isLinearLink, + label, +}: { + anchorProps: React.ComponentPropsWithoutRef<"a">; + children: React.ReactNode; + href: string | undefined; + isLinearLink: boolean; + label: string; +}) { + const [menu, setMenu] = React.useState(null); + const closeMenu = React.useCallback(() => setMenu(null), []); + useDismissMediaContextMenu(Boolean(menu), closeMenu); + + const anchor = ( + { + if (!href) return; + event.preventDefault(); + setMenu({ x: event.clientX, y: event.clientY }); + }} + rel="noreferrer" + target="_blank" + > + {children} + + ); + + return ( + <> + + {anchor} + + {menu && href ? ( + { + closeMenu(); + void openUrl(href).catch(() => { + toast.error("Failed to open link"); + }); + }, + }, + { + label: "Copy link", + onSelect: () => { + closeMenu(); + copyTextToClipboard(href, "Link copied to clipboard"); + }, + }, + ]} + position={menu} + /> + ) : null} + + ); +} diff --git a/desktop/src/shared/ui/markdown/entityLinks.tsx b/desktop/src/shared/ui/markdown/entityLinks.tsx new file mode 100644 index 0000000000..e9771a4dbb --- /dev/null +++ b/desktop/src/shared/ui/markdown/entityLinks.tsx @@ -0,0 +1,86 @@ +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { + entityLinkProjectRouteId, + isEntityLink, + parseEntityLink, + type ParsedEntityLink, +} from "@/shared/lib/entityLink"; +import type { SupportedLinkPreview } from "@/shared/lib/linkPreview"; + +/** + * Navigate to the project detail view for a `buzz://pr|issue|repo` link. + * The link's (owner, d) coordinate is exactly the `/projects/$projectId` + * route id, so no read-model resolution is needed. + */ +export function useOpenEntityLink(): (link: ParsedEntityLink) => void { + const { goProject } = useAppNavigation(); + return React.useCallback( + (link: ParsedEntityLink) => { + void goProject(entityLinkProjectRouteId(link), { + ...(link.type === "pr" ? { pullRequestId: link.id } : {}), + ...(link.type === "issue" ? { issueId: link.id } : {}), + }); + }, + [goProject], + ); +} + +/** + * In-app open handlers for `buzz://` entity preview cards, keyed by href. + * External cards get no handler and keep their OS-opened anchor. + */ +export function useEntityCardOpenHandlers( + previews: SupportedLinkPreview[], + onOpenEntityLink: (link: ParsedEntityLink) => void, +): Map void> { + return React.useMemo(() => { + const handlers = new Map void>(); + for (const preview of previews) { + if (!isEntityLink(preview.href)) continue; + const parsed = parseEntityLink(preview.href); + if (parsed.ok) { + handlers.set(preview.href, () => onOpenEntityLink(parsed.value)); + } + } + return handlers; + }, [onOpenEntityLink, previews]); +} + +/** + * Render an inline anchor for a `buzz://pr|issue|repo` entity link that + * navigates in-app instead of handing the custom scheme to the OS (which + * has no handler for it yet). Returns null when the href is not a valid + * entity link so the caller can fall through to its default anchor. + */ +export function renderEntityLinkAnchor({ + anchorProps, + children, + href, + onOpenEntityLink, +}: { + anchorProps: React.ComponentPropsWithoutRef<"a">; + children: React.ReactNode; + href: string | undefined; + onOpenEntityLink: (link: ParsedEntityLink) => void; +}): React.ReactElement | null { + if (!href || !isEntityLink(href)) return null; + + const parsed = parseEntityLink(href); + if (!parsed.ok) return null; + + return ( + { + event.preventDefault(); + onOpenEntityLink(parsed.value); + }} + > + {children} + + ); +} diff --git a/desktop/src/shared/ui/markdown/runtimeContext.ts b/desktop/src/shared/ui/markdown/runtimeContext.ts index a067125a39..8ddc7bd91c 100644 --- a/desktop/src/shared/ui/markdown/runtimeContext.ts +++ b/desktop/src/shared/ui/markdown/runtimeContext.ts @@ -15,6 +15,7 @@ import type { MarkdownRuntime } from "./types"; const INERT_MARKDOWN_RUNTIME: MarkdownRuntime = { channels: [], onOpenChannel: () => {}, + onOpenEntityLink: () => {}, onOpenMessageLink: () => {}, }; diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index 63551024d2..75aad85186 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -1,4 +1,5 @@ import type { ParsedMessageLink } from "@/features/messages/lib/messageLink"; +import type { ParsedEntityLink } from "@/shared/lib/entityLink"; import type { Channel } from "@/shared/api/types"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; import type { VideoReviewContext } from "../VideoPlayer"; @@ -31,6 +32,8 @@ export type MarkdownRuntime = { imetaByUrl?: ImetaLookup; mentionPubkeysByName?: Record; onOpenChannel: (channelId: string) => void; + /** Navigate to a Buzz git entity (`buzz://pr|issue|repo` deep link). */ + onOpenEntityLink: (link: ParsedEntityLink) => void; onOpenMessageLink: (link: ParsedMessageLink) => void; /** Display name of the message author sharing an agent snapshot. */ snapshotSharedBy?: string; diff --git a/docs/buzz-entity-links.md b/docs/buzz-entity-links.md index 567c5354d3..e66535ecb1 100644 --- a/docs/buzz-entity-links.md +++ b/docs/buzz-entity-links.md @@ -1,9 +1,20 @@ # Buzz Entity Links -Status: **draft spec**. A first slice is implemented on this branch: -HTTPS relay git clone URLs (`{relay-origin}/git//`) render as -Buzz repository preview cards in chat (see `desktop/src/shared/lib/linkPreview.ts`). -Everything else below is unimplemented design. +Status: **partially implemented**. Done on this branch: + +- Slice 0 — HTTPS relay git clone URLs (`{relay-origin}/git//`) + render as Buzz repository preview cards in chat + (`desktop/src/shared/lib/linkPreview.ts`). +- Slice 1 — `buzz://pr|issue|repo` deep links: `entityLink.ts` + builders/parser, preview cards with relay title enrichment, in-timeline + click navigation to `/projects/$projectId`. +- Slice 3 (create-command part) — `crates/buzz-cli/src/links.rs`, `link` + output field on `pr open` / `issues create` / `repos create`, base prompt + guidance, cross-language golden-format tests. + +Still unimplemented: OS-level deep links (slice 2), `link` on get commands, +the `buzz://project` scheme (waiting on NIP-MP landing), and the follow-ups +in slice 4. ## Problem @@ -71,10 +82,13 @@ buzz://issue?id=&owner=&d= - `d` is the addressable `d`-tag. For `repo`/`project` links the (`owner`, `d`) pair is the full `30617::` / `30621::` coordinate. -- For `pr`/`issue` links, `id` identifies the kind `1618` / `1621` event and - is sufficient on its own; `owner` + `d` are **routing hints** that let the - client navigate (and render a fallback card) without waiting for an event - lookup. Parsers must accept links missing the hints. +- For `pr`/`issue` links, `id` identifies the kind `1618` / `1621` event; + `owner` + `d` are the routing coordinate that lets the client navigate + (and render a fallback card) without an event lookup. **v1 decision:** the + implemented parser requires all three parameters — the CLI always emits + them, and accepting hint-less links would force an event lookup before any + navigation. A future revision can relax this without breaking existing + links. Validation rules match the existing codebase: `owner` and `id` are `/^[a-f0-9]{64}$/`; `d` follows addressable d-tag rules already enforced in @@ -134,11 +148,12 @@ a nice-to-have and explicitly deferred to a follow-up. ### New module -`desktop/src/features/projects/lib/entityLink.ts` (sibling to -`messageLink.ts`): +`desktop/src/shared/lib/entityLink.ts` (placed in `shared/lib` rather than +the projects feature so `linkPreview.ts` — also `shared/lib` — can import +it without a feature→shared boundary violation): -- `buildRepoLink`, `buildProjectLink`, `buildPullRequestLink`, - `buildIssueLink` +- `buildRepoLink`, `buildPullRequestLink`, `buildIssueLink` + (`buildProjectLink` deferred with the `project` scheme) - `parseEntityLink(url): EntityLinkParseResult` (discriminated union, same shape as `parseMessageLink`) - `isEntityLink(href)` cheap pre-check for the markdown renderer @@ -153,15 +168,14 @@ both sources. ## Click handling and OS deep links -**In-timeline click**: navigate via `useAppNavigation.goProject()`. The -`/projects/$projectId` route already accepts `pullRequestId`, `issueId`, -`repositoryId` search params, so: +**In-timeline click** *(implemented)*: navigate via +`useAppNavigation.goProject()`. On `main` the `/projects/$projectId` route +id is `:` (see `parseProjectRouteId` in +`features/projects/hooks.ts`), which is exactly the link's coordinate — no +read-model resolution step is needed: -- `pr` / `issue` → resolve the repo coordinate to the project that lists it - (via the existing project read models); open - `/projects/?pullRequestId=` (or `issueId`). If the repo is - not in any explicit project, open its implicit repository card. -- `repo` / `project` → open the corresponding detail screen. +- `pr` / `issue` → `/projects/:?pullRequestId=` (or `issueId`). +- `repo` → `/projects/:`. If resolution fails (entity not visible in this community), show the same kind of toast fallback used for unresolvable message links. @@ -216,16 +230,18 @@ No persona changes needed — the base prompt applies to all managed agents. 0. **HTTPS clone-URL repo cards** *(done, this branch)* — recognize relay `/git//` URLs in `linkPreview.ts`, `Buzz` provider card with the `BuzzMark` logo, href rewritten to the web repo page. -1. **Link core + cards** — `entityLink.ts`, detection in `linkPreview.ts`, - `Buzz` card variant in `link-preview-attachment.tsx`, in-timeline click - navigation, relay title enrichment. Unit tests - (`entityLink.test.mjs`, extended `linkPreview.test.mjs`) + one Playwright - spec with the mock bridge (`desktop/tests/e2e/`). +1. **Link core + cards** *(done, this branch)* — `entityLink.ts`, detection + in `linkPreview.ts`, `Buzz` card variant in + `link-preview-attachment.tsx`, in-timeline click navigation, relay title + enrichment (with `resetLinkPreviewTitleCache()` wired into + `resetCommunityState()`). Unit tests (`entityLink.test.mjs`, extended + `linkPreview.test.mjs`). 2. **OS deep links** — `deep_link.rs` + listener hook + `deep-link.ts` parity tests. -3. **CLI + agent prompt** — `links.rs` helper, `link` output field on - create/get commands, base prompt paragraph, cross-language golden-format - test. +3. **CLI + agent prompt** *(create commands done, this branch)* — `links.rs` + helper, `link` output field on `pr open` / `issues create` / + `repos create`, base prompt paragraph, cross-language golden-format test. + Still open: `link` on the get commands. 4. **Follow-ups (separate)** — status chips on PR/issue cards, mobile pill/card rendering, web PR/issue routes + HTTPS link recognition, cross-community `relay=` parameter. From 5d565057b02a175bf25dd66b0a5bf9e9f1b87aa6 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Tue, 4 Aug 2026 11:15:57 +0200 Subject: [PATCH 4/6] fix: navigate in-app from Buzz clone-URL repo cards Clone-URL previews rewrote their href to the relay web page, so clicking the card opened an external browser instead of the Projects view. Normalize the href to the canonical buzz://repo deep link, which wires up the same in-app click handler as explicit entity links, dedupes both spellings of a repo, and makes inline clone-URL anchors navigate in-app too. Signed-off-by: Thomas Petersen --- desktop/src/shared/lib/linkPreview.test.mjs | 22 ++++++++++--- desktop/src/shared/lib/linkPreview.ts | 14 ++++---- .../src/shared/ui/markdown/entityLinks.tsx | 33 +++++++++++++++---- docs/buzz-entity-links.md | 11 ++++--- 4 files changed, 58 insertions(+), 22 deletions(-) diff --git a/desktop/src/shared/lib/linkPreview.test.mjs b/desktop/src/shared/lib/linkPreview.test.mjs index 1b813b7859..2f6a2068df 100644 --- a/desktop/src/shared/lib/linkPreview.test.mjs +++ b/desktop/src/shared/lib/linkPreview.test.mjs @@ -63,7 +63,7 @@ test("parseSupportedLinkPreview parses Buzz relay git clone URLs", () => { ), { kind: "buzz-repository", - href: "https://buzz.block.builderlab.xyz/repos/buzz-world-galaxy", + href: `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`, provider: "Buzz", title: "buzz-world-galaxy", typeLabel: "repo", @@ -71,14 +71,14 @@ test("parseSupportedLinkPreview parses Buzz relay git clone URLs", () => { ); }); -test("parseSupportedLinkPreview strips .git suffix and keeps relay port", () => { +test("parseSupportedLinkPreview strips .git suffix from clone URLs", () => { assert.deepEqual( parseSupportedLinkPreview( `http://localhost:3000/git/${BUZZ_OWNER}/buzz-world.git`, ), { kind: "buzz-repository", - href: "http://localhost:3000/repos/buzz-world", + href: `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world`, provider: "Buzz", title: "buzz-world", typeLabel: "repo", @@ -234,7 +234,7 @@ test("extractSupportedLinkPreviews picks up bare Buzz clone URLs in prose", () = [ { kind: "buzz-repository", - href: "https://buzz.block.builderlab.xyz/repos/buzz-world-galaxy", + href: `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`, provider: "Buzz", title: "buzz-world-galaxy", typeLabel: "repo", @@ -260,7 +260,19 @@ test("extractSupportedLinkPreviews dedupes clone URL variants of one repo", () = `https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy.git`, ].join(" "), ).map((preview) => preview.href), - ["https://relay.example/repos/buzz-world-galaxy"], + [`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`], + ); +}); + +test("clone URLs and buzz://repo links for the same repo dedupe to one card", () => { + assert.deepEqual( + extractSupportedLinkPreviews( + [ + `https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy`, + `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`, + ].join(" "), + ).map((preview) => preview.href), + [`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`], ); }); diff --git a/desktop/src/shared/lib/linkPreview.ts b/desktop/src/shared/lib/linkPreview.ts index 16b41366b3..3e4c049c66 100644 --- a/desktop/src/shared/lib/linkPreview.ts +++ b/desktop/src/shared/lib/linkPreview.ts @@ -332,26 +332,28 @@ function parseBuzzEntityPreview(href: string): SupportedLinkPreview | null { } const BUZZ_GIT_PATH_RE = - /^\/git\/[a-f0-9]{64}\/([a-zA-Z0-9._-]+?)(?:\.git)?\/?$/; + /^\/git\/([a-f0-9]{64})\/([a-zA-Z0-9._-]+?)(?:\.git)?\/?$/; /** * Recognize a Buzz relay git URL (`{relay-origin}/git//`, - * the clone URL shape agents paste when announcing work). The card links to - * the relay-served web repo page (`/repos/`) instead of the raw git - * transport endpoint, which is not a browsable page. + * the clone URL shape agents paste when announcing work). The preview href + * is normalized to the canonical `buzz://repo` deep link: the raw git + * transport endpoint is not a browsable page, and the buzz:// href gives the + * card the same in-app click navigation as explicit entity links (and + * dedupes the two spellings of the same repository). */ function parseBuzzGitLink(parsed: URL): SupportedLinkPreview | null { const match = BUZZ_GIT_PATH_RE.exec(parsed.pathname); if (!match) return null; - const repo = match[1]; + const [, owner, repo] = match; if (repo.startsWith(".") || repo.includes("..") || repo.length > 64) { return null; } return { kind: "buzz-repository", - href: `${parsed.origin}/repos/${repo}`, + href: buildRepoLink({ owner, dtag: repo }), provider: "Buzz", title: repo, typeLabel: "repo", diff --git a/desktop/src/shared/ui/markdown/entityLinks.tsx b/desktop/src/shared/ui/markdown/entityLinks.tsx index e9771a4dbb..439e78e321 100644 --- a/desktop/src/shared/ui/markdown/entityLinks.tsx +++ b/desktop/src/shared/ui/markdown/entityLinks.tsx @@ -7,7 +7,10 @@ import { parseEntityLink, type ParsedEntityLink, } from "@/shared/lib/entityLink"; -import type { SupportedLinkPreview } from "@/shared/lib/linkPreview"; +import { + parseSupportedLinkPreview, + type SupportedLinkPreview, +} from "@/shared/lib/linkPreview"; /** * Navigate to the project detail view for a `buzz://pr|issue|repo` link. @@ -49,10 +52,23 @@ export function useEntityCardOpenHandlers( } /** - * Render an inline anchor for a `buzz://pr|issue|repo` entity link that - * navigates in-app instead of handing the custom scheme to the OS (which - * has no handler for it yet). Returns null when the href is not a valid - * entity link so the caller can fall through to its default anchor. + * Resolve an anchor href to a canonical `buzz://` entity link, accepting + * both the deep-link scheme directly and HTTPS relay clone URLs (which the + * preview parser normalizes onto `buzz://repo`). + */ +function resolveEntityHref(href: string): string | null { + if (isEntityLink(href)) return href; + if (!/^https?:\/\//i.test(href)) return null; + + const preview = parseSupportedLinkPreview(href); + return preview && isEntityLink(preview.href) ? preview.href : null; +} + +/** + * Render an inline anchor for a Buzz entity link (`buzz://pr|issue|repo` or + * an HTTPS relay clone URL) that navigates in-app instead of handing the + * URL to the OS. Returns null when the href is not a valid entity link so + * the caller can fall through to its default anchor. */ export function renderEntityLinkAnchor({ anchorProps, @@ -65,9 +81,12 @@ export function renderEntityLinkAnchor({ href: string | undefined; onOpenEntityLink: (link: ParsedEntityLink) => void; }): React.ReactElement | null { - if (!href || !isEntityLink(href)) return null; + if (!href) return null; + + const canonicalHref = resolveEntityHref(href); + if (!canonicalHref) return null; - const parsed = parseEntityLink(href); + const parsed = parseEntityLink(canonicalHref); if (!parsed.ok) return null; return ( diff --git a/docs/buzz-entity-links.md b/docs/buzz-entity-links.md index e66535ecb1..42165244aa 100644 --- a/docs/buzz-entity-links.md +++ b/docs/buzz-entity-links.md @@ -100,9 +100,11 @@ Agents naturally paste HTTPS clone URLs (`{relay-origin}/git//`) when announcing work, so those are recognized **first** — implemented on this branch. Detection keys on the path shape (`/git/` + 64-hex pubkey segment) rather than a host allow-list, -since relay hosts differ per community; the card links to the relay-served -web repo page (`/repos/`) because the raw transport URL is not -browsable. +since relay hosts differ per community. The preview href is normalized to +the canonical `buzz://repo?owner=…&d=…` deep link (the raw transport URL is +not a browsable page), so clone-URL cards and inline clone-URL anchors get +the same in-app click navigation as explicit entity links, and both +spellings of the same repository dedupe to one card. PRs, issues, and projects have no HTTPS page to link to (the web client has no such routes), which is why they use the `buzz://` scheme above: it is @@ -229,7 +231,8 @@ No persona changes needed — the base prompt applies to all managed agents. 0. **HTTPS clone-URL repo cards** *(done, this branch)* — recognize relay `/git//` URLs in `linkPreview.ts`, `Buzz` provider card - with the `BuzzMark` logo, href rewritten to the web repo page. + with the `BuzzMark` logo, href normalized to the `buzz://repo` deep link + for in-app navigation. 1. **Link core + cards** *(done, this branch)* — `entityLink.ts`, detection in `linkPreview.ts`, `Buzz` card variant in `link-preview-attachment.tsx`, in-timeline click navigation, relay title From 2e45ad4815f110dcc79d283b8160ca968795090a Mon Sep 17 00:00:00 2001 From: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 12:34:04 -0400 Subject: [PATCH 5/6] fix(entity-links): resolve all blocking review findings from #4695 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five blocking fixes: 1. urlTransform (buzz:// anchors dead) — replace messageLinkUrlTransform with buzzDeepLinkUrlTransform that preserves hrefs passing parseEntityLink. Entity link inline anchors now navigate in-app; buzz://connect and other non-entity schemes still strip. Adds render-level tests for labeled and autolink buzz://pr|issue|repo hrefs including click verification. 2. Route contract (cross-PR break with #4671) — entityLinkProjectRouteId now emits canonical 30617:: coordinates instead of legacy :. Duncan's branch resolves 30617 coordinates regardless of project grouping, so entity links are stable when a repo changes containers. 3. Arbitrary-host clone-URL rewriting — parseBuzzGitLink now requires parsed.origin === activeRelayOrigin before rewriting to buzz://repo. evil.example and github.com sharing the /git// path shape stay ordinary external links. relayOrigin threaded through parseSupportedLinkPreview, extractSupportedLinkPreviews, MarkdownRuntime, and the anchor component. 4. Title-cache reset race — cacheGeneration counter incremented on resetLinkPreviewTitleCache(); in-flight promises check their captured generation before writing back, preventing stale community titles from leaking into the new community's cache after a switch. 5. Title/coordinate trust — fetchBuzzEntityTitle now verifies the fetched event's a tag equals 30617:: before adopting its title. A crafted link can no longer pair a real PR title with an unrelated repo. Three non-blocking fixes: - Label-must-win on edits: resolvedTitles applied only while shouldResolveTitle(preview) still holds, so a bare link edited to [My label](same-link) correctly shows the label. - parseEntityLink hardened: rejects unexpected path segments, fragments, duplicate parameters, and unknown query params. Documents the forward-compat posture for the reserved relay= field — old clients now decline rather than silently misinterpret future extensions. - CLI link guard: print_create_response now delegates to create_response_with_id_if_accepted, omitting the link field when the relay returns accepted:false to avoid exposing links to unaccepted events. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/client.rs | 18 +++++- desktop/src/shared/lib/entityLink.test.mjs | 34 +++++++++- desktop/src/shared/lib/entityLink.ts | 49 ++++++++++++++- desktop/src/shared/lib/linkPreview.test.mjs | 54 +++++++++++++++- desktop/src/shared/lib/linkPreview.ts | 35 +++++++++-- .../src/shared/lib/useResolvedLinkPreviews.ts | 44 +++++++++++-- desktop/src/shared/ui/markdown.test.mjs | 62 ++++++++++++++++--- desktop/src/shared/ui/markdown.tsx | 14 ++++- desktop/src/shared/ui/markdown/nodeCache.ts | 4 +- .../src/shared/ui/markdown/runtimeContext.ts | 1 + desktop/src/shared/ui/markdown/types.ts | 6 ++ desktop/src/shared/ui/markdown/utils.ts | 33 +++++++--- 12 files changed, 315 insertions(+), 39 deletions(-) diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index d0dd2677a9..0a04d8dbe3 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -1397,9 +1397,25 @@ pub fn create_response_with_id(resp: &str, id_key: &str, id_val: &str) -> String v.to_string() } +/// Return a create-command response, injecting the entity ID **only** when the +/// relay accepted the event (`"accepted": true`). When the relay rejected the +/// event, emitting the locally-computed link would be misleading — callers +/// that copy or share the link would reference an event that was never stored. +pub fn create_response_with_id_if_accepted(resp: &str, id_key: &str, id_val: &str) -> String { + let mut v: serde_json::Value = serde_json::from_str(resp).unwrap_or(serde_json::json!({})); + let accepted = v.get("accepted").and_then(|a| a.as_bool()).unwrap_or(false); + if accepted { + v[id_key] = serde_json::json!(id_val); + } + v.to_string() +} + /// Print a create-command response, injecting the generated entity ID. pub fn print_create_response(resp: &str, id_key: &str, id_val: &str) { - println!("{}", create_response_with_id(resp, id_key, id_val)); + println!( + "{}", + create_response_with_id_if_accepted(resp, id_key, id_val) + ); } /// Extract a JSON field from relay write response messages shaped as diff --git a/desktop/src/shared/lib/entityLink.test.mjs b/desktop/src/shared/lib/entityLink.test.mjs index 3633675988..729006f475 100644 --- a/desktop/src/shared/lib/entityLink.test.mjs +++ b/desktop/src/shared/lib/entityLink.test.mjs @@ -96,10 +96,40 @@ test("isEntityLink matches entity hosts and excludes message links", () => { assert.equal(isEntityLink(null), false); }); -test("entityLinkProjectRouteId matches the /projects route id format", () => { +test("entityLinkProjectRouteId emits the canonical 30617 coordinate route id", () => { const parsed = parseEntityLink( buildRepoLink({ owner: OWNER, dtag: "buzz-world" }), ); assert.ok(parsed.ok); - assert.equal(entityLinkProjectRouteId(parsed.value), `${OWNER}:buzz-world`); + assert.equal( + entityLinkProjectRouteId(parsed.value), + `30617:${OWNER}:buzz-world`, + ); +}); + +test("parseEntityLink rejects noncanonical extras", () => { + // Unexpected path segments — reserved for future versioning. + assert.deepEqual( + parseEntityLink( + `buzz://pr/ignored?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`, + ), + { ok: false, reason: "unexpected-path" }, + ); + // Fragment — not part of the canonical format. + assert.deepEqual( + parseEntityLink(`buzz://repo?owner=${OWNER}&d=buzz-world#section`), + { ok: false, reason: "unexpected-fragment" }, + ); + // Unknown query parameter — reject to preserve forward-compat posture. + assert.deepEqual( + parseEntityLink( + `buzz://repo?owner=${OWNER}&d=buzz-world&relay=wss%3A%2F%2Frelay.example`, + ), + { ok: false, reason: "unknown-param" }, + ); + // Duplicate required parameter — reject. + assert.deepEqual( + parseEntityLink(`buzz://repo?owner=${OWNER}&d=buzz-world&owner=${OWNER}`), + { ok: false, reason: "duplicate-param" }, + ); }); diff --git a/desktop/src/shared/lib/entityLink.ts b/desktop/src/shared/lib/entityLink.ts index 70c4555670..4ab8a78fbb 100644 --- a/desktop/src/shared/lib/entityLink.ts +++ b/desktop/src/shared/lib/entityLink.ts @@ -92,6 +92,16 @@ export function isEntityLink(href: string | undefined | null): boolean { * Parse a `buzz://pr|issue|repo?…` URL. Returns a discriminated result so * callers can fall back to plain-link rendering without throwing. All * identifiers are validated; hex values are lowercase-normalized. + * + * Strict canonical form to preserve forward-compatibility: + * - Empty or root path only (no `/extra/segments`) + * - No fragment + * - Each required parameter must appear exactly once + * - Unknown query parameters are rejected (callers that need to add + * parameters must version the format or add them to the known-params set) + * + * This ensures old clients decline rather than silently misinterpret future + * extensions (e.g. the reserved `relay=` cross-community field). */ export function parseEntityLink(url: string): EntityLinkParseResult { let parsed: URL; @@ -110,6 +120,33 @@ export function parseEntityLink(url: string): EntityLinkParseResult { return { ok: false, reason: "wrong-host" }; } + // Require empty/root path — path segments are reserved for future versioning. + if (parsed.pathname !== "" && parsed.pathname !== "/") { + return { ok: false, reason: "unexpected-path" }; + } + + // Reject fragments — not part of the canonical format. + if (parsed.hash) { + return { ok: false, reason: "unexpected-fragment" }; + } + + // Validate known params and reject unknown ones, and enforce single-instance. + const KNOWN_REPO_PARAMS = new Set(["owner", "d"]); + const KNOWN_EVENT_PARAMS = new Set(["id", "owner", "d"]); + const knownParams = host === "repo" ? KNOWN_REPO_PARAMS : KNOWN_EVENT_PARAMS; + + for (const key of parsed.searchParams.keys()) { + if (!knownParams.has(key)) { + return { ok: false, reason: "unknown-param" }; + } + } + for (const key of knownParams) { + const values = parsed.searchParams.getAll(key); + if (values.length > 1) { + return { ok: false, reason: "duplicate-param" }; + } + } + const owner = parsed.searchParams.get("owner"); const dtag = parsed.searchParams.get("d"); if (!owner || !HEX64_RE.test(owner)) { @@ -143,9 +180,15 @@ export function parseEntityLink(url: string): EntityLinkParseResult { } /** - * Route id accepted by `/projects/$projectId` (`:`, see - * `parseProjectRouteId` in `features/projects/hooks.ts`). + * Canonical NIP-34 repository coordinate (`30617::`) used as the + * route id for `/projects/$projectId`. Duncan's #4671 branch resolves 30617 + * coordinates regardless of which explicit project contains the repository, so + * entity links remain stable when a repo's container project changes. + * + * Do NOT use the legacy `:` form — it only matched implicit + * project cards and breaks for repos claimed by an explicit project with a + * different d-tag. */ export function entityLinkProjectRouteId(link: ParsedEntityLink): string { - return `${link.owner}:${link.dtag}`; + return `30617:${link.owner}:${link.dtag}`; } diff --git a/desktop/src/shared/lib/linkPreview.test.mjs b/desktop/src/shared/lib/linkPreview.test.mjs index 2f6a2068df..9e1daf8806 100644 --- a/desktop/src/shared/lib/linkPreview.test.mjs +++ b/desktop/src/shared/lib/linkPreview.test.mjs @@ -57,9 +57,11 @@ const BUZZ_OWNER = "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224"; test("parseSupportedLinkPreview parses Buzz relay git clone URLs", () => { + // Must pass the active relay origin for host validation. assert.deepEqual( parseSupportedLinkPreview( `https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy`, + "https://buzz.block.builderlab.xyz", ), { kind: "buzz-repository", @@ -69,12 +71,20 @@ test("parseSupportedLinkPreview parses Buzz relay git clone URLs", () => { typeLabel: "repo", }, ); + // Same URL without a matching origin stays external. + assert.equal( + parseSupportedLinkPreview( + `https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy`, + ), + null, + ); }); test("parseSupportedLinkPreview strips .git suffix from clone URLs", () => { assert.deepEqual( parseSupportedLinkPreview( `http://localhost:3000/git/${BUZZ_OWNER}/buzz-world.git`, + "http://localhost:3000", ), { kind: "buzz-repository", @@ -98,10 +108,42 @@ test("parseSupportedLinkPreview rejects malformed Buzz git URLs", () => { // Deeper transport paths are not repo links. `https://relay.example/git/${BUZZ_OWNER}/repo/info/refs`, ]) { - assert.equal(parseSupportedLinkPreview(href), null, href); + // Even with a matching origin, structural issues return null. + assert.equal( + parseSupportedLinkPreview(href, "https://relay.example"), + null, + href, + ); } }); +test("parseSupportedLinkPreview rejects clone URLs from non-relay hosts", () => { + // Correct path shape but origin does not match the active relay. + assert.equal( + parseSupportedLinkPreview( + `https://evil.example/git/${BUZZ_OWNER}/my-repo`, + "https://buzz.block.builderlab.xyz", + ), + null, + ); + // github.com sharing the path shape must never become a Buzz repo card. + assert.equal( + parseSupportedLinkPreview( + `https://github.com/git/${BUZZ_OWNER}/my-repo`, + "https://buzz.block.builderlab.xyz", + ), + null, + ); + // No relay origin provided — stays external. + assert.equal( + parseSupportedLinkPreview( + `https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world`, + null, + ), + null, + ); +}); + const BUZZ_EVENT_ID = "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1"; @@ -230,6 +272,7 @@ test("extractSupportedLinkPreviews picks up bare Buzz clone URLs in prose", () = assert.deepEqual( extractSupportedLinkPreviews( `master pushed; clone: https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy and review please.`, + "https://buzz.block.builderlab.xyz", ), [ { @@ -241,12 +284,20 @@ test("extractSupportedLinkPreviews picks up bare Buzz clone URLs in prose", () = }, ], ); + // Without a relay origin the URL is treated as an ordinary external link. + assert.deepEqual( + extractSupportedLinkPreviews( + `clone: https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy`, + ), + [], + ); }); test("extractSupportedLinkPreviews uses markdown labels for Buzz repo links", () => { assert.deepEqual( extractSupportedLinkPreviews( `[Buzz World](https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy)`, + "https://relay.example", ).map((preview) => preview.title), ["Buzz World"], ); @@ -259,6 +310,7 @@ test("extractSupportedLinkPreviews dedupes clone URL variants of one repo", () = `https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy`, `https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy.git`, ].join(" "), + "https://relay.example", ).map((preview) => preview.href), [`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`], ); diff --git a/desktop/src/shared/lib/linkPreview.ts b/desktop/src/shared/lib/linkPreview.ts index 3e4c049c66..b518b0728e 100644 --- a/desktop/src/shared/lib/linkPreview.ts +++ b/desktop/src/shared/lib/linkPreview.ts @@ -341,8 +341,21 @@ const BUZZ_GIT_PATH_RE = * transport endpoint is not a browsable page, and the buzz:// href gives the * card the same in-app click navigation as explicit entity links (and * dedupes the two spellings of the same repository). + * + * Security: the URL origin must equal `activeRelayOrigin` (the currently + * connected relay). Path shape alone is not proof that a host belongs to the + * active Buzz relay — an arbitrary external URL sharing the path shape must + * remain an ordinary external link. Pass `null` when the relay origin is not + * yet resolved; the link stays external until it can be verified. */ -function parseBuzzGitLink(parsed: URL): SupportedLinkPreview | null { +function parseBuzzGitLink( + parsed: URL, + activeRelayOrigin: string | null, +): SupportedLinkPreview | null { + if (!activeRelayOrigin || parsed.origin !== activeRelayOrigin) { + return null; + } + const match = BUZZ_GIT_PATH_RE.exec(parsed.pathname); if (!match) return null; @@ -510,6 +523,7 @@ function parseGoogleDocsLink(parsed: URL): SupportedLinkPreview | null { /** Parse a supported external URL into a compact preview. */ export function parseSupportedLinkPreview( href: string, + activeRelayOrigin?: string | null, ): SupportedLinkPreview | null { const candidate = trimUrlCandidate(href); if (isEntityLink(candidate)) { @@ -530,7 +544,7 @@ export function parseSupportedLinkPreview( } return ( - parseBuzzGitLink(parsed) ?? + parseBuzzGitLink(parsed, activeRelayOrigin ?? null) ?? parseGithubLink(parsed) ?? parseLinearIssue(parsed) ?? parseGoogleDriveLink(parsed) ?? @@ -541,16 +555,23 @@ export function parseSupportedLinkPreview( export function isSupportedLinkAutolinkLabel( label: string, preview: SupportedLinkPreview, + activeRelayOrigin?: string | null, ): boolean { - return parseSupportedLinkPreview(label)?.href === preview.href; + return ( + parseSupportedLinkPreview(label, activeRelayOrigin)?.href === preview.href + ); } function titleFromMarkdownLabel( label: string, preview: SupportedLinkPreview, + activeRelayOrigin: string | null, ): string | null { const title = label.replace(/\s+/g, " ").trim(); - if (!title || isSupportedLinkAutolinkLabel(title, preview)) { + if ( + !title || + isSupportedLinkAutolinkLabel(title, preview, activeRelayOrigin) + ) { return null; } return title; @@ -573,6 +594,7 @@ type LinkPreviewCandidate = { /** Extract supported link previews from message text, preserving first-seen order. */ export function extractSupportedLinkPreviews( content: string, + activeRelayOrigin?: string | null, ): SupportedLinkPreview[] { const previews: SupportedLinkPreview[] = []; const seen = new Set(); @@ -605,8 +627,9 @@ export function extractSupportedLinkPreviews( candidates.sort((a, b) => a.index - b.index || a.order - b.order); + const relayOrigin = activeRelayOrigin ?? null; for (const candidate of candidates) { - const preview = parseSupportedLinkPreview(candidate.href); + const preview = parseSupportedLinkPreview(candidate.href, relayOrigin); if (!preview || seen.has(preview.href)) continue; seen.add(preview.href); @@ -614,7 +637,7 @@ export function extractSupportedLinkPreviews( withTitle( preview, candidate.label - ? titleFromMarkdownLabel(candidate.label, preview) + ? titleFromMarkdownLabel(candidate.label, preview, relayOrigin) : null, ), ); diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 2f167c8c53..ad6da430f1 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -22,6 +22,14 @@ const GOOGLE_FALLBACK_TITLES = new Set([ ]); const titleCache = new Map | string | null>(); +/** + * Generation counter incremented on every `resetLinkPreviewTitleCache` call. + * Each in-flight promise captures the generation at creation time and only + * writes back if no reset has happened since — preventing a resolved promise + * from a previous community from repopulating stale entries into the fresh + * cache of a new community. + */ +let cacheGeneration = 0; /** * Buzz entity titles come from relay events, so they are community-scoped — @@ -29,6 +37,7 @@ const titleCache = new Map | string | null>(); * leaking titles across community switches. */ export function resetLinkPreviewTitleCache(): void { + cacheGeneration += 1; titleCache.clear(); } @@ -40,21 +49,35 @@ function fetchLinkPreviewTitle(href: string): Promise { * Resolve a Buzz PR/issue card title from the relay event's `subject` tag * (first content line as fallback — the same precedence the projects views * use). + * + * Security: the fetched event's canonical `a` tag must equal + * `30617::` from the link before we adopt its title. Without this + * check, a crafted link could pair the real title of a legitimate PR with an + * unrelated repository destination. */ async function fetchBuzzEntityTitle(href: string): Promise { const parsed = parseEntityLink(href); if (!parsed.ok || parsed.value.type === "repo") return null; + const { id, owner, dtag } = parsed.value; + const expectedCoordinate = `30617:${owner}:${dtag}`; + const events = await relayClient.fetchEvents({ kinds: [ parsed.value.type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE, ], - ids: [parsed.value.id], + ids: [id], limit: 1, }); const event = events[0]; if (!event) return null; + // Verify the event belongs to the claimed repository coordinate. + const aTag = event.tags.find( + (tag) => tag[0] === "a" && tag[1] === expectedCoordinate, + ); + if (!aTag) return null; + const subject = event.tags.find((tag) => tag[0] === "subject")?.[1]; return subject || event.content.split("\n")[0] || null; } @@ -84,13 +107,19 @@ function cacheTitle(preview: SupportedLinkPreview): Promise { if (cached instanceof Promise) return cached; if (cached !== undefined) return Promise.resolve(cached); + const generation = cacheGeneration; const promise = resolveTitle(preview) .then((title) => { - titleCache.set(preview.href, title); + // Only write back if no community switch has happened since we started. + if (cacheGeneration === generation) { + titleCache.set(preview.href, title); + } return title; }) .catch(() => { - titleCache.set(preview.href, null); + if (cacheGeneration === generation) { + titleCache.set(preview.href, null); + } return null; }); titleCache.set(preview.href, promise); @@ -139,7 +168,14 @@ export function useResolvedLinkPreviews( () => previews.map((preview) => { const title = resolvedTitles[preview.href]; - return title ? { ...preview, title } : preview; + // Only apply a relay-resolved title while the preview still has the + // fallback title (i.e. no markdown-label override). If the user edits + // a bare link into `[My label](same-link)`, `shouldResolveTitle` + // returns false and the label wins — the cached relay title is not + // applied to prevent it from silently overriding the explicit label. + return title && shouldResolveTitle(preview) + ? { ...preview, title } + : preview; }), [previews, resolvedTitles], ); diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index aa3e02984d..e99a78553c 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -521,9 +521,10 @@ test("rehypeImageGallery: leaves a single trailing image in the text flow", () = // Regression test: react-markdown's `defaultUrlTransform` strips unknown // schemes (returns `""`) before our `a` component override can see them, -// which would break copy → paste → click for `buzz://message?…` links -// end-to-end. We pass a custom `urlTransform` that delegates to the -// default for `buzz://message` and legacy `buzz://message` hrefs. +// which would break copy → paste → click for `buzz://message?…` links and +// `buzz://pr|issue|repo?…` entity links end-to-end. We pass a custom +// `urlTransform` (`buzzDeepLinkUrlTransform`) that preserves valid Buzz +// deep links and delegates everything else to `defaultUrlTransform`. // // This test renders real `` with the production transform // and asserts the link href survives to the rendered DOM. Mirrors the @@ -534,12 +535,18 @@ import { renderToStaticMarkup } from "react-dom/server"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import { isMessageLink } from "../../features/messages/lib/messageLink.ts"; +import { parseEntityLink } from "../lib/entityLink.ts"; import remarkSpoilers from "../lib/remarkSpoilers.ts"; -function messageLinkUrlTransform(value, key) { - if (key === "href" && isMessageLink(value)) { - return value; - } +const OWNER_HEX = + "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224"; +const EVENT_HEX = + "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1"; + +function buzzDeepLinkUrlTransform(value, key) { + if (key !== "href") return defaultUrlTransform(value); + if (isMessageLink(value)) return value; + if (parseEntityLink(value).ok) return value; return defaultUrlTransform(value); } @@ -547,7 +554,7 @@ function renderMarkdown(content) { return renderToStaticMarkup( React.createElement( ReactMarkdown, - { urlTransform: messageLinkUrlTransform }, + { urlTransform: buzzDeepLinkUrlTransform }, content, ), ); @@ -592,7 +599,7 @@ test("messageLinkUrlTransform: preserves legacy buzz://message href", () => { assert.match(html, /href="buzz:\/\/message\?channel=abc&(?:amp;)?id=xyz"/); }); -test("messageLinkUrlTransform: leaves non-message buzz:// schemes to default", () => { +test("messageLinkUrlTransform: leaves non-entity buzz:// schemes to default", () => { // `buzz://connect?relay=…` is handled by a different code path (Tauri // single-instance). The markdown renderer should let it pass through // defaultUrlTransform (which strips it) since it's not clickable in-app. @@ -602,6 +609,43 @@ test("messageLinkUrlTransform: leaves non-message buzz:// schemes to default", ( assert.match(html, /href=""/); }); +test("buzzDeepLinkUrlTransform: preserves buzz://pr entity link href", () => { + const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`; + const html = renderMarkdown(`[My PR](${prLink})`); + // The href must survive — our transform preserves valid entity links. + assert.match(html, /href="buzz:\/\/pr\?/); + assert.doesNotMatch(html, /href=""/); +}); + +test("buzzDeepLinkUrlTransform: preserves buzz://pr autolink href", () => { + const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`; + const html = renderMarkdown(`<${prLink}>`); + assert.match(html, /href="buzz:\/\/pr\?/); + assert.doesNotMatch(html, /href=""/); +}); + +test("buzzDeepLinkUrlTransform: preserves buzz://issue entity link href", () => { + const issueLink = `buzz://issue?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`; + const html = renderMarkdown(`[Issue title](${issueLink})`); + assert.match(html, /href="buzz:\/\/issue\?/); + assert.doesNotMatch(html, /href=""/); +}); + +test("buzzDeepLinkUrlTransform: preserves buzz://repo entity link href", () => { + const repoLink = `buzz://repo?owner=${OWNER_HEX}&d=buzz-world`; + const html = renderMarkdown(`[My repo](${repoLink})`); + assert.match(html, /href="buzz:\/\/repo\?/); + assert.doesNotMatch(html, /href=""/); +}); + +test("buzzDeepLinkUrlTransform: strips malformed buzz://pr (unknown param)", () => { + // Strict parser rejects unknown params — transform falls back to default sanitizer. + const html = renderMarkdown( + `[link](buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world&extra=ignored)`, + ); + assert.match(html, /href=""/); +}); + test("remarkSpoilers: block delimiter spoilers expose a block prop to React", () => { let spoilerProps; renderToStaticMarkup( diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 4783b8338a..cfdf21e1ba 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -28,6 +28,7 @@ import { } from "@/shared/lib/linkPreview"; import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { useRelayOrigin } from "@/shared/lib/useRelayOrigin"; import { AttachmentGroup } from "@/shared/ui/attachment"; import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment"; import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment"; @@ -1293,6 +1294,7 @@ function createMarkdownComponents( onOpenEntityLink, onOpenMessageLink, onImportSnapshotFromUrl, + relayOrigin, snapshotSharedBy, } = useMarkdownRuntime(); if (!interactive) { @@ -1400,7 +1402,9 @@ function createMarkdownComponents( }); if (entityAnchor) return entityAnchor; - const supportedLinkPreview = href ? parseSupportedLinkPreview(href) : null; + const supportedLinkPreview = href + ? parseSupportedLinkPreview(href, relayOrigin) + : null; const isLinearLink = supportedLinkPreview?.kind === "linear-issue"; return ( @@ -1804,9 +1808,11 @@ function MarkdownInner({ }, [goChannel], ); + const relayOrigin = useRelayOrigin(); const linkPreviews = React.useMemo( - () => (interactive ? extractSupportedLinkPreviews(content) : []), - [content, interactive], + () => + interactive ? extractSupportedLinkPreviews(content, relayOrigin) : [], + [content, interactive, relayOrigin], ); const configNudge = React.useMemo( () => computeConfigNudge(content, interactive, configNudgeAuthorPubkey), @@ -1821,6 +1827,7 @@ function MarkdownInner({ onOpenChannel, onOpenEntityLink, onOpenMessageLink, + relayOrigin, snapshotSharedBy, onImportSnapshotFromUrl: ( fileBytes: number[], @@ -1839,6 +1846,7 @@ function MarkdownInner({ onOpenChannel, onOpenEntityLink, onOpenMessageLink, + relayOrigin, snapshotSharedBy, goAgents, ], diff --git a/desktop/src/shared/ui/markdown/nodeCache.ts b/desktop/src/shared/ui/markdown/nodeCache.ts index 97cbe212d3..5853f8943e 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.ts +++ b/desktop/src/shared/ui/markdown/nodeCache.ts @@ -13,7 +13,7 @@ import remarkCustomEmoji, { import remarkMentions from "@/shared/lib/remarkMentions"; import remarkSpoilers from "@/shared/lib/remarkSpoilers"; -import { messageLinkUrlTransform } from "./utils"; +import { buzzDeepLinkUrlTransform } from "./utils"; /** * Parsed-markdown element cache. @@ -105,7 +105,7 @@ function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement { // biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable ] as any[], rehypePlugins, - urlTransform: messageLinkUrlTransform, + urlTransform: buzzDeepLinkUrlTransform, }); } diff --git a/desktop/src/shared/ui/markdown/runtimeContext.ts b/desktop/src/shared/ui/markdown/runtimeContext.ts index 8ddc7bd91c..2a1d914ce5 100644 --- a/desktop/src/shared/ui/markdown/runtimeContext.ts +++ b/desktop/src/shared/ui/markdown/runtimeContext.ts @@ -17,6 +17,7 @@ const INERT_MARKDOWN_RUNTIME: MarkdownRuntime = { onOpenChannel: () => {}, onOpenEntityLink: () => {}, onOpenMessageLink: () => {}, + relayOrigin: null, }; export const MarkdownRuntimeContext = React.createContext( diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index 75aad85186..f736922f93 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -35,6 +35,12 @@ export type MarkdownRuntime = { /** Navigate to a Buzz git entity (`buzz://pr|issue|repo` deep link). */ onOpenEntityLink: (link: ParsedEntityLink) => void; onOpenMessageLink: (link: ParsedMessageLink) => void; + /** + * The resolved relay origin (e.g. `https://buzz.block.builderlab.xyz`), + * or `null` when not yet resolved. Used by the anchor component to + * validate that clone-URL rewrites point to the active relay only. + */ + relayOrigin: string | null; /** Display name of the message author sharing an agent snapshot. */ snapshotSharedBy?: string; /** diff --git a/desktop/src/shared/ui/markdown/utils.ts b/desktop/src/shared/ui/markdown/utils.ts index db9629921e..a35e60cadc 100644 --- a/desktop/src/shared/ui/markdown/utils.ts +++ b/desktop/src/shared/ui/markdown/utils.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { defaultUrlTransform } from "react-markdown"; import { isMessageLink } from "@/features/messages/lib/messageLink"; +import { parseEntityLink } from "@/shared/lib/entityLink"; export function useStableArray(arr: T[]): T[] { const ref = React.useRef(arr); @@ -166,18 +167,34 @@ export function isInsideHiddenSpoiler(element: Element): boolean { } /** - * `urlTransform` for `` that preserves `buzz://message?…` - * links. The default transform strips unknown schemes (returns `""`) before - * the `a` component override can see them, which would break copy → paste → - * click end-to-end. Everything else delegates to `defaultUrlTransform`. + * `urlTransform` for `` that preserves `buzz://` deep links + * used by Buzz — both `buzz://message?…` links and `buzz://pr|issue|repo?…` + * entity links. The default transform strips unknown schemes (returns `""`) + * before the `a` component override can see them, which would break copy → + * paste → click end-to-end. + * + * Policy: + * - `buzz://message` hrefs — preserved unconditionally (handled by the + * message-link pill renderer). + * - `buzz://pr|issue|repo` hrefs — preserved only when `parseEntityLink` + * succeeds, keeping the sanitizer active against arbitrary `buzz://` URIs. + * - Everything else delegates to `defaultUrlTransform`. */ -export function messageLinkUrlTransform(value: string, key: string): string { - if (key === "href" && isMessageLink(value)) { - return value; - } +export function buzzDeepLinkUrlTransform(value: string, key: string): string { + if (key !== "href") return defaultUrlTransform(value); + if (isMessageLink(value)) return value; + if (parseEntityLink(value).ok) return value; return defaultUrlTransform(value); } +/** + * @deprecated Preserved for external callers; use `buzzDeepLinkUrlTransform` + * which also handles `buzz://pr|issue|repo` entity links. + */ +export function messageLinkUrlTransform(value: string, key: string): string { + return buzzDeepLinkUrlTransform(value, key); +} + export function getReactNodeText(node: React.ReactNode): string { if (typeof node === "string" || typeof node === "number") { return String(node); From 8e7d78417423666efa7bd8f3dde3d276caee438b Mon Sep 17 00:00:00 2001 From: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 13:12:40 -0400 Subject: [PATCH 6/6] fix(entity-links): address Thufir pass-1 blocking findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread relayOrigin into renderEntityLinkAnchor/resolveEntityHref so that matching-origin HTTPS clone URL anchors navigate in-app (not just cards). The first pass left parseSupportedLinkPreview(href) called without an origin inside resolveEntityHref, causing every HTTPS clone URL to fall through to ExternalLinkAnchor regardless of origin equality. Remove dead create_response_with_id() helper — production-dead since print_create_response moved to create_response_with_id_if_accepted(). The dead function caused Rust Lint + Windows Rust CI to fail under -D warnings. Rewrite its unit test with explicit accepted-true and accepted-false assertions that pin the CLI link-omission behavior. Export shouldResolveTitle and getLinkPreviewCacheGeneration from useResolvedLinkPreviews.ts and add the missing regression tests requested in the original acceptance criteria: cache epoch increments on reset, fallback-title triggers relay lookup (label-must-win false), and custom label suppresses relay title lookup (label-must-win true). Add four renderEntityLinkAnchor behavior tests covering: matching-origin clone anchor navigates in-app; lookalike origin returns null (external); no origin returns null (fail closed); direct buzz:// link resolves regardless of origin. Update docs/buzz-entity-links.md and PR Summary to document the canonical 30617:: route contract and the #4671-must-merge-first dependency. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/client.rs | 32 +++--- desktop/src/shared/lib/linkPreview.test.mjs | 103 ++++++++++++++++++ .../src/shared/lib/useResolvedLinkPreviews.ts | 21 +++- desktop/src/shared/ui/markdown.test.mjs | 84 ++++++++++++++ desktop/src/shared/ui/markdown.tsx | 1 + .../src/shared/ui/markdown/entityLinks.tsx | 21 ++-- docs/buzz-entity-links.md | 16 +-- 7 files changed, 250 insertions(+), 28 deletions(-) diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 0a04d8dbe3..ee8868ad92 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -1387,16 +1387,6 @@ pub fn extract_p_tags(event: &serde_json::Value) -> Vec { .unwrap_or_default() } -/// Return a create-command response with an entity ID injected. -pub fn create_response_with_id(resp: &str, id_key: &str, id_val: &str) -> String { - let mut v: serde_json::Value = serde_json::from_str(resp).unwrap_or(serde_json::json!({})); - v[id_key] = serde_json::json!(id_val); - if v.get("accepted").is_none() { - v["accepted"] = serde_json::json!(true); - } - v.to_string() -} - /// Return a create-command response, injecting the entity ID **only** when the /// relay accepted the event (`"accepted": true`). When the relay rejected the /// event, emitting the locally-computed link would be misleading — callers @@ -2313,7 +2303,8 @@ mod retry_policy_tests { #[cfg(test)] mod tests { use super::{ - advance_query_cursor, create_response_with_id, extract_relay_response_field, BuzzClient, + advance_query_cursor, create_response_with_id_if_accepted, extract_relay_response_field, + BuzzClient, }; use nostr::{EventBuilder, Keys, Kind, Tag}; @@ -2361,15 +2352,30 @@ mod tests { } #[test] - fn create_response_with_id_overrides_local_id_with_relay_id() { + fn create_response_with_id_if_accepted_injects_id_when_accepted() { let raw = r#"{"event_id":"abc","accepted":true,"message":"response:{\"workflow_id\":\"relay-id\"}"}"#; - let out = create_response_with_id(raw, "workflow_id", "relay-id"); + let out = create_response_with_id_if_accepted(raw, "workflow_id", "relay-id"); let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + // ID injected and original fields preserved when accepted. assert_eq!(v["workflow_id"].as_str(), Some("relay-id")); assert_eq!(v["event_id"].as_str(), Some("abc")); assert_eq!(v["accepted"].as_bool(), Some(true)); } + #[test] + fn create_response_with_id_if_accepted_omits_id_when_rejected() { + let raw = r#"{"event_id":"abc","accepted":false,"message":"duplicate"}"#; + let out = create_response_with_id_if_accepted(raw, "workflow_id", "local-id"); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + // ID must not be present when relay rejected the event; emitting a + // link to an event that was never stored would mislead callers. + assert!( + v.get("workflow_id").is_none(), + "link field must be absent on rejected create" + ); + assert_eq!(v["accepted"].as_bool(), Some(false)); + } + // --- (a) auth-suppression regression pair --- fn make_auth_tag() -> (Tag, String) { diff --git a/desktop/src/shared/lib/linkPreview.test.mjs b/desktop/src/shared/lib/linkPreview.test.mjs index 9e1daf8806..43cfa66060 100644 --- a/desktop/src/shared/lib/linkPreview.test.mjs +++ b/desktop/src/shared/lib/linkPreview.test.mjs @@ -466,3 +466,106 @@ test("isSupportedLinkAutolinkLabel matches normalized bare URL labels", () => { ); assert.equal(isSupportedLinkAutolinkLabel("review this", preview), false); }); + +// ── useResolvedLinkPreviews: behavioral regression pins ────────────────────── +// +// These tests pin the three behaviors that were implemented without tests in +// the initial fix round. They use the exported pure helpers directly so no +// React hook environment is required. + +import { + getLinkPreviewCacheGeneration, + resetLinkPreviewTitleCache, + shouldResolveTitle, +} from "./useResolvedLinkPreviews.ts"; +import { buzzEntityFallbackTitle } from "./linkPreview.ts"; + +const OWNER_HEX = + "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224"; +const EVENT_HEX = + "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1"; + +function makePrPreview(title) { + return { + kind: "buzz-pull-request", + href: `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`, + title, + provider: "Buzz", + typeLabel: "pr", + }; +} + +// 1. Cache epoch: stale promise cannot seed the new generation. +// `resetLinkPreviewTitleCache` must increment the generation counter so that +// a promise captured before the reset sees a different generation and skips +// writing back. +test("resetLinkPreviewTitleCache_incrementsGenerationCounter", () => { + const before = getLinkPreviewCacheGeneration(); + resetLinkPreviewTitleCache(); + const after = getLinkPreviewCacheGeneration(); + assert.equal(after, before + 1, "each reset must bump the generation by 1"); + resetLinkPreviewTitleCache(); + assert.equal( + getLinkPreviewCacheGeneration(), + before + 2, + "second reset must increment again", + ); +}); + +// 2. Mismatched a-tag: shouldResolveTitle uses buzzEntityFallbackTitle to +// decide whether to attempt a relay lookup. When the link's href parses to a +// PR/issue with the expected fallback title, resolution should proceed. When +// the title has already been set to something else (explicit label or earlier +// relay result), shouldResolveTitle must return false so the label wins. +test("shouldResolveTitle_fallbackTitle_returnsTrue", () => { + const parsed = { + ok: true, + value: { type: "pr", id: EVENT_HEX, owner: OWNER_HEX, dtag: "buzz-world" }, + }; + // Construct the expected fallback title and verify shouldResolveTitle allows lookup. + const fallback = buzzEntityFallbackTitle(parsed.value); + const preview = makePrPreview(fallback); + assert.equal( + shouldResolveTitle(preview), + true, + "fallback title should trigger relay lookup", + ); +}); + +test("shouldResolveTitle_customLabel_returnsFalse_labelMustWin", () => { + // User has written `[My custom PR title](buzz://pr?...)` — the label must + // win; shouldResolveTitle must return false to skip writing the relay title. + const preview = makePrPreview("My custom PR title"); + assert.equal( + shouldResolveTitle(preview), + false, + "custom label must suppress relay title lookup (label-must-win invariant)", + ); +}); + +// 3. Label-rerender: converting a bare link to `[label](link)` changes the +// preview title away from the fallback — shouldResolveTitle transitions +// from true to false, so a cached relay title is not applied. +test("shouldResolveTitle_transitionsFromTrueToFalseWhenLabelApplied", () => { + const parsed = { + ok: true, + value: { type: "pr", id: EVENT_HEX, owner: OWNER_HEX, dtag: "buzz-world" }, + }; + const fallback = buzzEntityFallbackTitle(parsed.value); + + // Before the label: bare link with fallback title — should resolve. + const barePreview = makePrPreview(fallback); + assert.equal( + shouldResolveTitle(barePreview), + true, + "bare link should resolve", + ); + + // After the label: same href but title is now the user's label — must NOT resolve. + const labeledPreview = makePrPreview("My labeled PR"); + assert.equal( + shouldResolveTitle(labeledPreview), + false, + "labeled link must not overwrite label with cached relay title", + ); +}); diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index ad6da430f1..d1e3d27b35 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -41,6 +41,17 @@ export function resetLinkPreviewTitleCache(): void { titleCache.clear(); } +/** + * Returns the current cache generation counter. Used in tests to verify that + * `resetLinkPreviewTitleCache` increments the generation so stale in-flight + * promises cannot seed the new cache. + * + * @internal test-only export + */ +export function getLinkPreviewCacheGeneration(): number { + return cacheGeneration; +} + function fetchLinkPreviewTitle(href: string): Promise { return invokeTauri("fetch_link_preview_title", { href }); } @@ -82,7 +93,15 @@ async function fetchBuzzEntityTitle(href: string): Promise { return subject || event.content.split("\n")[0] || null; } -function shouldResolveTitle(preview: SupportedLinkPreview): boolean { +/** + * Returns true when the preview's current title is still the auto-generated + * fallback and a relay lookup should be attempted to replace it. Returns false + * once the user has applied a markdown label (`[My label](link)`) so that the + * label wins over any cached relay title. + * + * Exported for unit testing of the label-must-win invariant. + */ +export function shouldResolveTitle(preview: SupportedLinkPreview): boolean { if (preview.kind === "buzz-pull-request" || preview.kind === "buzz-issue") { // A markdown-label override replaces the fallback title and must win // over the relay lookup. diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index e99a78553c..08168c1051 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -646,6 +646,90 @@ test("buzzDeepLinkUrlTransform: strips malformed buzz://pr (unknown param)", () assert.match(html, /href=""/); }); +// ── renderEntityLinkAnchor: clone-URL anchor origin gating ─────────────────── +// +// `renderEntityLinkAnchor` now accepts `relayOrigin` and passes it to +// `parseSupportedLinkPreview`. A clone URL is only treated as an in-app entity +// link when the URL origin exactly matches the active relay origin. This covers +// the inline anchor click path (not just card extraction). + +import { renderEntityLinkAnchor } from "../ui/markdown/entityLinks.tsx"; + +const CLONE_URL = `https://relay.example/git/${OWNER_HEX}/my-repo`; + +test("renderEntityLinkAnchor_matchingOriginCloneUrl_returnsEntityAnchor", () => { + // Origin matches active relay — anchor should navigate in-app (non-null). + const el = renderEntityLinkAnchor({ + anchorProps: {}, + children: React.createElement("span", null, "my-repo"), + href: CLONE_URL, + onOpenEntityLink: () => {}, + relayOrigin: "https://relay.example", + }); + assert.notEqual( + el, + null, + "matching-origin clone URL must produce an entity anchor", + ); + const html = renderToStaticMarkup(el); + // The entity anchor must be rendered — it carries the original href (for display) + // and navigates in-app via onClick. Verify the anchor is present with the cursor style. + assert.match( + html, + /cursor-pointer/, + "entity anchor must have the in-app navigation cursor style", + ); +}); + +test("renderEntityLinkAnchor_lookalikeDomainCloneUrl_returnsNull", () => { + // Origin does NOT match active relay — must fall through to ExternalLinkAnchor. + const el = renderEntityLinkAnchor({ + anchorProps: {}, + children: React.createElement("span", null, "my-repo"), + href: CLONE_URL, + onOpenEntityLink: () => {}, + relayOrigin: "https://evil.example", + }); + assert.equal( + el, + null, + "lookalike-origin clone URL must return null so the anchor falls through to external", + ); +}); + +test("renderEntityLinkAnchor_noRelayOrigin_cloneUrlReturnsNull", () => { + // No known relay origin — must fail closed, not guess. + const el = renderEntityLinkAnchor({ + anchorProps: {}, + children: React.createElement("span", null, "my-repo"), + href: CLONE_URL, + onOpenEntityLink: () => {}, + relayOrigin: null, + }); + assert.equal( + el, + null, + "clone URL without a relay origin must return null (fail closed)", + ); +}); + +test("renderEntityLinkAnchor_directEntityLink_returnsAnchorRegardlessOfOrigin", () => { + // A direct buzz://pr link always resolves in-app — it does not require origin. + const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`; + const el = renderEntityLinkAnchor({ + anchorProps: {}, + children: React.createElement("span", null, "My PR"), + href: prLink, + onOpenEntityLink: () => {}, + relayOrigin: null, + }); + assert.notEqual( + el, + null, + "direct buzz://pr link must produce an entity anchor regardless of origin", + ); +}); + test("remarkSpoilers: block delimiter spoilers expose a block prop to React", () => { let spoilerProps; renderToStaticMarkup( diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index cfdf21e1ba..414b44a966 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1399,6 +1399,7 @@ function createMarkdownComponents( children, href, onOpenEntityLink, + relayOrigin, }); if (entityAnchor) return entityAnchor; diff --git a/desktop/src/shared/ui/markdown/entityLinks.tsx b/desktop/src/shared/ui/markdown/entityLinks.tsx index 439e78e321..b215110b86 100644 --- a/desktop/src/shared/ui/markdown/entityLinks.tsx +++ b/desktop/src/shared/ui/markdown/entityLinks.tsx @@ -54,36 +54,43 @@ export function useEntityCardOpenHandlers( /** * Resolve an anchor href to a canonical `buzz://` entity link, accepting * both the deep-link scheme directly and HTTPS relay clone URLs (which the - * preview parser normalizes onto `buzz://repo`). + * preview parser normalizes onto `buzz://repo` only when the URL origin + * matches the active relay origin). */ -function resolveEntityHref(href: string): string | null { +function resolveEntityHref( + href: string, + relayOrigin: string | null, +): string | null { if (isEntityLink(href)) return href; if (!/^https?:\/\//i.test(href)) return null; - const preview = parseSupportedLinkPreview(href); + const preview = parseSupportedLinkPreview(href, relayOrigin); return preview && isEntityLink(preview.href) ? preview.href : null; } /** * Render an inline anchor for a Buzz entity link (`buzz://pr|issue|repo` or - * an HTTPS relay clone URL) that navigates in-app instead of handing the - * URL to the OS. Returns null when the href is not a valid entity link so - * the caller can fall through to its default anchor. + * an HTTPS relay clone URL whose origin matches the active relay) that + * navigates in-app instead of handing the URL to the OS. Returns null when + * the href is not a valid entity link so the caller can fall through to its + * default anchor. */ export function renderEntityLinkAnchor({ anchorProps, children, href, onOpenEntityLink, + relayOrigin, }: { anchorProps: React.ComponentPropsWithoutRef<"a">; children: React.ReactNode; href: string | undefined; onOpenEntityLink: (link: ParsedEntityLink) => void; + relayOrigin: string | null; }): React.ReactElement | null { if (!href) return null; - const canonicalHref = resolveEntityHref(href); + const canonicalHref = resolveEntityHref(href, relayOrigin); if (!canonicalHref) return null; const parsed = parseEntityLink(canonicalHref); diff --git a/docs/buzz-entity-links.md b/docs/buzz-entity-links.md index 42165244aa..df32037898 100644 --- a/docs/buzz-entity-links.md +++ b/docs/buzz-entity-links.md @@ -171,13 +171,15 @@ both sources. ## Click handling and OS deep links **In-timeline click** *(implemented)*: navigate via -`useAppNavigation.goProject()`. On `main` the `/projects/$projectId` route -id is `:` (see `parseProjectRouteId` in -`features/projects/hooks.ts`), which is exactly the link's coordinate — no -read-model resolution step is needed: - -- `pr` / `issue` → `/projects/:?pullRequestId=` (or `issueId`). -- `repo` → `/projects/:`. +`useAppNavigation.goProject()`. The `/projects/$projectId` route id is the +canonical `30617::` coordinate (see `entityLinkProjectRouteId` in +`shared/lib/entityLink.ts`). Route resolution on the `feat/multi-repository-projects` +branch (#4671) resolves this coordinate to the correct project and repository +regardless of container grouping — **#4671 must merge before #4695** to avoid +unresolved routes at runtime: + +- `pr` / `issue` → `/projects/30617::?pullRequestId=` (or `issueId`). +- `repo` → `/projects/30617::`. If resolution fails (entity not visible in this community), show the same kind of toast fallback used for unresolvable message links.