From 67193364168b5a21e04877db8d8dcb1e00061d58 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 17 Aug 2026 12:15:52 -0700 Subject: [PATCH 01/27] feat(messages): reveal linked message metadata inline Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/communities/useCommunityInit.ts | 2 + .../messages/lib/messageLinkMetadata.test.mjs | 24 +++ .../messages/lib/messageLinkMetadata.ts | 29 ++++ .../src/shared/lib/useResolvedLinkPreviews.ts | 7 + desktop/src/shared/ui/markdown.test.mjs | 4 +- .../shared/ui/markdown/ChannelDeepLink.tsx | 155 ++++++++++++++---- .../shared/ui/markdown/MessageLinkPill.tsx | 105 +++++++++++- .../src/shared/ui/markdown/entityLinks.tsx | 108 ++++++++++-- .../ui/markdown/useMessageLinkMetadata.ts | 114 +++++++++++++ .../e2e/entity-link-recipient-cards.spec.ts | 116 ++++++++++++- desktop/tests/e2e/navigation.spec.ts | 118 ++++++++++++- 11 files changed, 719 insertions(+), 63 deletions(-) create mode 100644 desktop/src/features/messages/lib/messageLinkMetadata.test.mjs create mode 100644 desktop/src/features/messages/lib/messageLinkMetadata.ts create mode 100644 desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index e1cdee41a76..e792358713f 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -34,6 +34,7 @@ import { resetAvatarPresentations } from "@/features/profile/avatarPresentationS import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { clearMarkdownNodeCache } from "@/shared/ui/markdown/nodeCache"; +import { resetMessageLinkMetadataCache } from "@/shared/ui/markdown/useMessageLinkMetadata"; import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState"; import { @@ -77,6 +78,7 @@ async function resetCommunityState({ resetLinkPreviewPreparations(); clearSearchHitEventCache(); clearMarkdownNodeCache(); + resetMessageLinkMetadataCache(); } type CommunityInitResult = diff --git a/desktop/src/features/messages/lib/messageLinkMetadata.test.mjs b/desktop/src/features/messages/lib/messageLinkMetadata.test.mjs new file mode 100644 index 00000000000..5b44c799c8f --- /dev/null +++ b/desktop/src/features/messages/lib/messageLinkMetadata.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { summarizeMessageLinkContent } from "./messageLinkMetadata.ts"; + +test("summarizeMessageLinkContent projects markdown to bounded plain text", () => { + assert.equal( + summarizeMessageLinkContent( + "**Hello** [team](https://example.com)\n\n![secret](https://example.com/a.png) ||hidden||", + ), + "Hello team", + ); + assert.equal( + summarizeMessageLinkContent("https://example.com"), + "No message text", + ); +}); + +test("summarizeMessageLinkContent truncates on grapheme-safe character boundaries", () => { + const result = summarizeMessageLinkContent(`Lead ${"🦄".repeat(200)}`); + assert.ok(Array.from(result).length <= 160); + assert.ok(result.endsWith("…")); + assert.ok(!result.includes("\ud83e") || result.includes("🦄")); +}); diff --git a/desktop/src/features/messages/lib/messageLinkMetadata.ts b/desktop/src/features/messages/lib/messageLinkMetadata.ts new file mode 100644 index 00000000000..848b207d693 --- /dev/null +++ b/desktop/src/features/messages/lib/messageLinkMetadata.ts @@ -0,0 +1,29 @@ +const MESSAGE_LINK_SNIPPET_MAX_LENGTH = 160; + +/** Build a compact, non-recursive plain-text preview for a linked message. */ +export function summarizeMessageLinkContent(content: string): string { + const normalized = Array.from(content, (character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) + ? " " + : character; + }) + .join("") + .replace(/\|\|[^|]*(?:\|(?!\|)[^|]*)*\|\|/g, " ") + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/?/g, " ") + .replace(/[`*_~>#|]/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!normalized) return "No message text"; + + const characters = Array.from(normalized); + if (characters.length <= MESSAGE_LINK_SNIPPET_MAX_LENGTH) return normalized; + const clipped = characters + .slice(0, MESSAGE_LINK_SNIPPET_MAX_LENGTH - 1) + .join(""); + const lastSpace = clipped.lastIndexOf(" "); + const snippet = lastSpace > 96 ? clipped.slice(0, lastSpace) : clipped; + return `${snippet.trimEnd()}…`; +} diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 8b210f6ab35..6e71de2181a 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -381,6 +381,13 @@ const entityMetadataLoader = createMetadataLoader({ fetcher: fetchBuzzEntityMetadata, }); +/** Share deduplicated relay-native entity metadata across cards and inline tooltips. */ +export async function loadBuzzEntityMetadata( + href: string, +): Promise { + return (await entityMetadataLoader.load(href)).metadata; +} + /** Clear ephemeral metadata when the active relay/community changes. */ export function resetLinkPreviewMetadataCache(): void { metadataLoader.reset(); diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 5e829f62434..462de011cea 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -1096,7 +1096,7 @@ test("bare Buzz permalinks render cohesive icon-prefixed chips", () => { assert.equal((html.match(/data-buzz-link=""/g) ?? []).length, 6); assert.equal((html.match(/inline-chip-icon-message/g) ?? []).length, 2); - assert.equal((html.match(/>engineering · c3b589faengineering { ), ); - assert.match(html, />580ca78b · c3b589fa580ca78b580ca78b + + {children} + + {description ? ( + + {description} + + ) : null} + + {channelTooltipFooter(channel)} + + + + + ); +} + function channelPermalinkLabel( channels: ReturnType["channels"], channelId: string, @@ -66,17 +141,21 @@ export function ChannelDeepLinkAnchor({ ); } const label = channelPermalinkLabel(channels, parsed.value.channelId); + const channel = channels.find( + (candidate) => candidate.id === parsed.value.channelId, + ); return ( - onOpenChannel(parsed.value.channelId)} - > - {label} - + + onOpenChannel(parsed.value.channelId)} + > + {label} + + ); } @@ -110,18 +189,22 @@ export function MarkdownChannelDeepLink({ ); } const label = channelPermalinkLabel(channels, parsed.value.channelId); + const channel = channels.find( + (candidate) => candidate.id === parsed.value.channelId, + ); return ( - onOpenChannel(parsed.value.channelId)} - > - {label} - + + onOpenChannel(parsed.value.channelId)} + > + {label} + + ); } @@ -141,19 +224,21 @@ export function MarkdownChannelReference({ candidate.name.toLowerCase() === channelName.toLowerCase(), ); return ( - { - if (channel) onOpenChannel(channel.id); - }} - > - {channelName} - + + { + if (channel) onOpenChannel(channel.id); + }} + > + {channelName} + + ); } diff --git a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx index 1bbe33d9579..b31b5fd2edf 100644 --- a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx +++ b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx @@ -2,8 +2,15 @@ import * as React from "react"; import { buildMessageLink } from "@/features/messages/lib/messageLink"; import { cn } from "@/shared/lib/cn"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/shared/ui/tooltip"; import { BuzzLinkChip } from "./BuzzLinkChip"; +import { useMessageLinkMetadata } from "./useMessageLinkMetadata"; import type { MessageLinkPillProps } from "./types"; import { getMessageLinkLabel } from "@/features/messages/lib/messageLinkLabel"; @@ -14,6 +21,20 @@ const graphemeSegmenter = const emojiGraphemePattern = /(?:\p{Extended_Pictographic}|\p{Regional_Indicator}|[\uFE0F\u20E3])/u; +function formatMessageAge(createdAt: number): string { + const elapsedMinutes = Math.max( + 0, + Math.floor((Date.now() - createdAt * 1_000) / 60_000), + ); + if (elapsedMinutes < 1) return "just now"; + if (elapsedMinutes < 60) return `${elapsedMinutes}m ago`; + const elapsedHours = Math.floor(elapsedMinutes / 60); + if (elapsedHours < 24) return `${elapsedHours}h ago`; + const elapsedDays = Math.floor(elapsedHours / 24); + if (elapsedDays < 7) return `${elapsedDays}d ago`; + return `${Math.floor(elapsedDays / 7)}w ago`; +} + function segmentLinkLabel(label: string): Array<{ isEmoji: boolean; start: number; @@ -38,6 +59,47 @@ function segmentLinkLabel(label: string): Array<{ return segments; } +function MessageLinkMetadataTooltip({ + children, + footer, + metadata, +}: { + children: React.ReactElement; + footer: string; + metadata: ReturnType; +}) { + if (metadata.state.kind !== "ready" || !metadata.state.snippet.trim()) { + return children; + } + const content = metadata.state.snippet; + const sender = metadata.state.author; + const age = formatMessageAge(metadata.state.createdAt); + return ( + + + {children} + + + {content} + + + {footer} + {sender ? ` · ${sender}` : null} + {` · ${age}`} + + + + + ); +} + export function MessageLinkPill({ channels, href, @@ -50,9 +112,32 @@ export function MessageLinkPill({ const [isHovered, setIsHovered] = React.useState(false); const channel = channels.find((c) => c.id === link.channelId); const channelLabel = channel?.name ?? link.channelId.slice(0, 8); - const shortId = link.messageId.slice(0, 8); + const channelReadable = + channel !== undefined && + (channel.isMember || channel.visibility === "open"); + const shouldLoadMetadata = + channelReadable && interactive && variant === "default"; + const metadata = useMessageLinkMetadata(link, shouldLoadMetadata); + const metadataPending = + shouldLoadMetadata && + (metadata.state.kind === "idle" || metadata.state.kind === "loading"); + const inlineContext = + metadata.state.kind === "ready" + ? metadata.state.snippet + : metadataPending + ? link.messageId.slice(0, 8) + : null; const isSentFromThread = variant === "sent-from-thread"; const permalink = href ?? buildMessageLink(link); + const destination = + channel?.channelType === "dm" ? channelLabel : `#${channelLabel}`; + const tooltipFooter = link.threadRootId + ? `Thread in ${destination}` + : channel?.channelType === "dm" + ? `Direct message with ${destination}` + : channel?.channelType === "forum" + ? `Forum post in ${destination}` + : destination; const label = getMessageLinkLabel({ channelName: channelLabel, threadExcerpt, @@ -60,21 +145,31 @@ export function MessageLinkPill({ }); if (!isSentFromThread) { - return ( + const chip = ( { onOpenMessageLink(link); }} > - {channelLabel} · {shortId} + + {channelLabel} + {inlineContext ? ` · ${inlineContext}` : null} + ); + return interactive ? ( + + {chip} + + ) : ( + chip + ); } if (!interactive) { diff --git a/desktop/src/shared/ui/markdown/entityLinks.tsx b/desktop/src/shared/ui/markdown/entityLinks.tsx index 7eb5ea53c56..b5e00fa6464 100644 --- a/desktop/src/shared/ui/markdown/entityLinks.tsx +++ b/desktop/src/shared/ui/markdown/entityLinks.tsx @@ -12,8 +12,71 @@ import { type SupportedLinkPreview, } from "@/shared/lib/linkPreview"; +import { + loadBuzzEntityMetadata, + type LinkPreviewMetadata, +} from "@/shared/lib/useResolvedLinkPreviews"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/shared/ui/tooltip"; + import { BuzzInlineLink, BuzzLinkChip } from "./BuzzLinkChip"; +function EntityMetadataTooltip({ + children, + fallback, + footer, + href, +}: { + children: ( + metadata: LinkPreviewMetadata | null | undefined, + ) => React.ReactElement; + fallback: string; + footer: string; + href: string; +}) { + const [metadata, setMetadata] = React.useState< + LinkPreviewMetadata | null | undefined + >(undefined); + React.useEffect(() => { + let cancelled = false; + void loadBuzzEntityMetadata(href).then((value) => { + if (!cancelled) setMetadata(value); + }); + return () => { + cancelled = true; + }; + }, [href]); + const context = metadata?.title.trim() || fallback; + const chip = children(metadata); + return ( + + + {chip} + + + {context} + {metadata?.description ? ` · ${metadata.description}` : null} + + + {footer} + + + + + ); +} + function entityLinkPresentation(link: ParsedEntityLink) { switch (link.type) { case "repo": @@ -25,24 +88,28 @@ function entityLinkPresentation(link: ParsedEntityLink) { label: link.commitHash ? `${link.dtag} · ${link.commitHash.slice(0, 8)}` : link.dtag, + tooltipFooter: "Repository", }; case "pr": return { ariaLabel: `Open pull request ${link.id.slice(0, 8)} in repository ${link.dtag}`, icon: "pr" as const, label: `${link.dtag} · ${link.id.slice(0, 8)}`, + tooltipFooter: `Pull request · ${link.dtag}`, }; case "issue": return { ariaLabel: `Open issue ${link.id.slice(0, 8)} in repository ${link.dtag}`, icon: "issue" as const, label: `${link.dtag} · ${link.id.slice(0, 8)}`, + tooltipFooter: `Issue · ${link.dtag}`, }; case "project": return { ariaLabel: `Open project ${link.dtag}`, icon: "project" as const, label: link.dtag, + tooltipFooter: "Project", }; } } @@ -161,17 +228,36 @@ export function renderEntityLinkAnchor({ ); } - return ( - onOpenEntityLink(parsed.value)} + const chip = (metadata?: LinkPreviewMetadata | null) => { + const resolvedContext = metadata?.title.trim(); + const label = + resolvedContext && + (parsed.value.type === "issue" || parsed.value.type === "pr") + ? `${parsed.value.dtag} · ${resolvedContext}` + : presentation.label; + return ( + onOpenEntityLink(parsed.value)} + > + {label} + + ); + }; + return interactive ? ( + - {presentation.label} - + {chip} + + ) : ( + chip() ); } diff --git a/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts b/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts new file mode 100644 index 00000000000..1bd5c9d2350 --- /dev/null +++ b/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts @@ -0,0 +1,114 @@ +import * as React from "react"; + +import type { ParsedMessageLink } from "@/features/messages/lib/messageLink"; +import { summarizeMessageLinkContent } from "@/features/messages/lib/messageLinkMetadata"; +import { getEventById } from "@/shared/api/tauri"; +import { getUserProfile } from "@/shared/api/tauriProfiles"; +import { truncatePubkey } from "@/shared/lib/pubkey"; + +const MESSAGE_METADATA_RETRY_DELAY_MS = 750; +const PREVIEWABLE_MESSAGE_KINDS = new Set([9, 40002, 45001, 45003]); + +function waitForMessageMetadataRetry(): Promise { + return new Promise((resolve) => { + window.setTimeout(resolve, MESSAGE_METADATA_RETRY_DELAY_MS); + }); +} + +async function getMessageLinkEvent(messageId: string) { + try { + return await getEventById(messageId); + } catch { + await waitForMessageMetadataRetry(); + return getEventById(messageId); + } +} + +type MessageLinkMetadata = { + author: string; + createdAt: number; + snippet: string; +}; +type MessageLinkMetadataState = + | { kind: "idle" } + | { kind: "loading" } + | ({ kind: "ready" } & MessageLinkMetadata) + | { kind: "unavailable" }; + +type CachedMessageLinkMetadata = + | ({ kind: "ready" } & MessageLinkMetadata) + | { kind: "unavailable" }; + +const metadataCache = new Map>(); + +export function resetMessageLinkMetadataCache() { + metadataCache.clear(); +} + +function fetchMetadata( + link: Pick, +): Promise { + const key = `${link.channelId}:${link.messageId}`; + let request = metadataCache.get(key); + if (!request) { + request = getMessageLinkEvent(link.messageId) + .then(async (event) => { + const eventChannelId = event.tags.find((tag) => tag[0] === "h")?.[1]; + if ( + eventChannelId !== link.channelId || + !PREVIEWABLE_MESSAGE_KINDS.has(event.kind) + ) { + return { kind: "unavailable" } as const; + } + const profile = await getUserProfile(event.pubkey).catch(() => null); + return { + kind: "ready" as const, + author: + profile?.displayName?.trim() || + profile?.nip05Handle?.trim() || + truncatePubkey(event.pubkey), + createdAt: event.created_at, + snippet: summarizeMessageLinkContent(event.content), + }; + }) + .catch(() => ({ kind: "unavailable" }) as const); + metadataCache.set(key, request); + void request.then((result) => { + if (result.kind === "unavailable" && metadataCache.get(key) === request) { + metadataCache.delete(key); + } + }); + } + return request; +} + +export function useMessageLinkMetadata( + link: ParsedMessageLink, + channelReadable: boolean, +): { state: MessageLinkMetadataState } { + const [state, setState] = React.useState({ + kind: "idle", + }); + const requestId = React.useRef(0); + React.useEffect(() => { + requestId.current += 1; + setState({ kind: "idle" }); + if (!channelReadable) return; + + const currentRequest = requestId.current; + const currentLink = { + channelId: link.channelId, + messageId: link.messageId, + }; + setState({ kind: "loading" }); + void fetchMetadata(currentLink).then((metadata) => { + if (requestId.current === currentRequest) { + setState(metadata); + } + }); + return () => { + requestId.current += 1; + }; + }, [channelReadable, link.channelId, link.messageId]); + return { state }; +} diff --git a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts index d8d4c9c123f..dc6ebe4a707 100644 --- a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts +++ b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts @@ -24,29 +24,43 @@ test("agent-style message with bare buzz:// links renders entity cards without s page, }) => { await page.addInitScript( - ({ repoAddress, prId, alicePubkey, subject }) => { + ({ repoAddress, prId, issueId, alicePubkey, prSubject, issueSubject }) => { + const createdAt = Math.floor(Date.now() / 1000) - 60; window.__BUZZ_E2E_EXTRA_PROJECT_EVENTS__ = [ { id: prId, kind: 1618, // KIND_GIT_PULL_REQUEST pubkey: alicePubkey, - created_at: Math.floor(Date.now() / 1000) - 60, + created_at: createdAt, content: "PR body", tags: [ ["a", repoAddress], - ["subject", subject], + ["subject", prSubject], ["c", "abc123".padEnd(40, "0")], ["branch-name", "fix/entity-cards"], ["clone", "https://github.com/block/relay-tools.git"], ], }, + { + id: issueId, + kind: 1621, // KIND_GIT_ISSUE + pubkey: alicePubkey, + created_at: createdAt, + content: "Issue body", + tags: [ + ["a", repoAddress], + ["subject", issueSubject], + ], + }, ]; }, { repoAddress: REPO_ADDRESS, prId: PR_ID, + issueId: ISSUE_ID, alicePubkey: ALICE_PUBKEY, - subject: PR_SUBJECT, + prSubject: PR_SUBJECT, + issueSubject: ISSUE_SUBJECT, }, ); await installMockBridge(page); @@ -60,18 +74,20 @@ test("agent-style message with bare buzz:// links renders entity cards without s // Simulate an agent/CLI sender: plain kind-9 message with bare buzz:// // URLs in the content and NO link-preview snapshot tags. await page.evaluate( - ({ prId, alicePubkey }) => { + ({ prId, issueId, alicePubkey }) => { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", pubkey: alicePubkey, content: [ "PR is up — review when you can:", `buzz://pr?id=${prId}&owner=${alicePubkey}&d=relay-tools`, + `Issue: buzz://issue?id=${issueId}&owner=${alicePubkey}&d=relay-tools`, `Repo: buzz://repo?owner=${alicePubkey}&d=relay-tools`, + `Missing repo: buzz://repo?owner=${alicePubkey}&d=missing-repo`, ].join("\n"), }); }, - { prId: PR_ID, alicePubkey: ALICE_PUBKEY }, + { prId: PR_ID, issueId: ISSUE_ID, alicePubkey: ALICE_PUBKEY }, ); const row = page @@ -96,10 +112,39 @@ test("agent-style message with bare buzz:// links renders entity cards without s await expect( prCard.locator("[data-link-preview-hostname-favicon]"), ).toHaveCount(0); + const prChip = row.getByRole("button", { + name: /Open pull request .* in repository relay-tools/, + }); + await expect(prChip).not.toHaveAttribute("title"); + await prChip.hover(); + const prTooltip = page.getByRole("tooltip"); + await expect( + prTooltip.locator('[data-buzz-tooltip-metadata-content=""]'), + ).toContainText(PR_SUBJECT); + await expect( + prTooltip.locator('[data-buzz-tooltip-metadata-type=""]'), + ).toHaveText("Pull request · relay-tools"); + await expect(prChip).toContainText(`relay-tools · ${PR_SUBJECT}`); + + const issueChip = row.getByRole("button", { + name: /Open issue .* in repository relay-tools/, + }); + await expect(issueChip).toContainText(`relay-tools · ${ISSUE_SUBJECT}`); + await expect(issueChip).toHaveClass(/max-w-64/); + await issueChip.hover(); + const issueTooltip = page.getByRole("tooltip"); + await expect( + issueTooltip.locator('[data-buzz-tooltip-metadata-content=""]'), + ).toContainText(ISSUE_SUBJECT); + await expect( + issueTooltip.locator('[data-buzz-tooltip-metadata-type=""]'), + ).toHaveText("Issue · relay-tools"); // The repository card uses its signed announcement metadata and remains // image-less. - const repoCard = row.locator('[data-link-preview="buzz-repository"]'); + const repoCard = row + .locator('[data-link-preview="buzz-repository"]') + .filter({ hasText: "relay-tools" }); await expect(repoCard).toBeVisible(); await expect(repoCard).toContainText("relay-tools"); await expect(repoCard).toContainText( @@ -116,6 +161,30 @@ test("agent-style message with bare buzz:// links renders entity cards without s await expect( repoCard.locator("[data-link-preview-hostname-favicon]"), ).toHaveCount(0); + const repoChip = row.getByRole("button", { + name: "Open repository relay-tools", + }); + await repoChip.hover(); + const repoTooltip = page.getByRole("tooltip"); + await expect( + repoTooltip.locator('[data-buzz-tooltip-metadata-content=""]'), + ).toContainText("Operator tooling and admin CLI for relay deployments."); + await expect( + repoTooltip.locator('[data-buzz-tooltip-metadata-type=""]'), + ).toHaveText("Repository"); + const missingRepoChip = row.getByRole("button", { + name: "Open repository missing-repo", + }); + // Failed metadata falls back to the chip's stable identity instead of + // removing the tooltip or leaving a type-only footer. + await missingRepoChip.hover(); + const missingRepoTooltip = page.getByRole("tooltip"); + await expect( + missingRepoTooltip.locator('[data-buzz-tooltip-metadata-content=""]'), + ).toHaveText("missing-repo"); + await expect( + missingRepoTooltip.locator('[data-buzz-tooltip-metadata-type=""]'), + ).toHaveText("Repository"); // Default typography is 14px; keep the image-less card compact while // allowing fractional line-height rounding across rendering platforms. expect( @@ -129,6 +198,39 @@ test("agent-style message with bare buzz:// links renders entity cards without s }); }); +test("entity tooltip keeps stable identity while relay metadata is delayed", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("channel-general").click(); + await page.evaluate(() => + window.__BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__?.(300), + ); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + await page.evaluate( + ({ issueId, owner }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: `Delayed issue: buzz://issue?id=${issueId}&owner=${owner}&d=buzz`, + }); + }, + { issueId: ISSUE_ID, owner: DEFAULT_MOCK_PUBKEY }, + ); + + const issueChip = page.getByRole("button", { + name: /Open issue .* in repository buzz/, + }); + await issueChip.hover(); + await expect( + page + .getByRole("tooltip") + .locator('[data-buzz-tooltip-metadata-content=""]'), + ).toHaveText(`buzz · ${ISSUE_ID.slice(0, 8)}`); +}); + test("desktop composer shows entity card and send is not blocked by missing snapshot", async ({ page, }) => { diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index 8adee2c0cf1..b01e2b55039 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -401,6 +401,11 @@ test("message links to visible root messages open the thread panel", async ({ await expect(page.getByTestId("message-timeline")).toContainText( "Welcome to general", ); + await page.evaluate(() => { + ( + window as Window & { __BUZZ_E2E_DEFER_GET_EVENT__?: string | null } + ).__BUZZ_E2E_DEFER_GET_EVENT__ = "mock-general-welcome"; + }); const link = "buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=mock-general-welcome"; @@ -433,14 +438,95 @@ test("message links to visible root messages open the thread panel", async ({ .last(); await expect(linkMessage).toBeVisible(); const rootThreadLink = linkMessage.getByRole("button", { - name: "Open message mock-gen in channel general", + name: "Open message in channel general", }); await expect(rootThreadLink).toHaveText("general · mock-gen"); + await expect + .poll(() => + page.evaluate( + () => + (window as Window & { __BUZZ_E2E_GET_EVENT_CALL_COUNT__?: number }) + .__BUZZ_E2E_GET_EVENT_CALL_COUNT__ ?? 0, + ), + ) + .toBe(1); + await page.evaluate(() => { + ( + window as Window & { __BUZZ_E2E_RELEASE_GET_EVENT__?: () => number } + ).__BUZZ_E2E_RELEASE_GET_EVENT__?.(); + }); + await expect(rootThreadLink).toHaveText("general · Welcome to general"); await expect(rootThreadLink).toHaveClass(/mention-chip/); + await expect(rootThreadLink).toHaveClass(/max-w-64/); + await expect(rootThreadLink).not.toHaveAttribute("title"); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_COMMAND_LOG__?: Array<{ command: string }>; + } + ).__BUZZ_E2E_COMMAND_LOG__?.filter( + ({ command }) => command === "get_event", + ).length ?? 0, + ), + ) + .toBe(1); + await rootThreadLink.hover(); + const messageTooltip = page.getByRole("tooltip"); + await expect( + messageTooltip.locator('[data-buzz-tooltip-metadata-content=""]'), + ).toHaveText("Welcome to general"); + await expect( + messageTooltip.locator('[data-buzz-tooltip-metadata-content=""]'), + ).toHaveClass(/line-clamp-2/); + await expect( + messageTooltip.locator('[data-buzz-tooltip-metadata-type=""]'), + ).toHaveText(/#general · .+ · (just now|\d+[mhdw] ago)/); + const messageChipBox = await rootThreadLink.boundingBox(); + const messageTooltipBox = await messageTooltip.boundingBox(); + if (!messageChipBox || !messageTooltipBox) { + throw new Error("Expected visible message chip and tooltip"); + } + expect(Math.abs(messageTooltipBox.x - messageChipBox.x)).toBeLessThanOrEqual( + 1, + ); + await page.getByTestId("chat-title").hover(); + await rootThreadLink.hover(); + await expect( + page + .getByRole("tooltip") + .locator('[data-buzz-tooltip-metadata-content=""]'), + ).toHaveText("Welcome to general"); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_COMMAND_LOG__?: Array<{ command: string }>; + } + ).__BUZZ_E2E_COMMAND_LOG__?.filter( + ({ command }) => command === "get_event", + ).length ?? 0, + ), + ) + .toBe(1); const randomChannelLink = linkMessage.getByRole("button", { name: "Open channel random", }); await expect(randomChannelLink).toBeVisible(); + await expect(randomChannelLink).not.toHaveAttribute("title"); + await randomChannelLink.hover(); + const channelTooltip = page.getByRole("tooltip"); + await expect( + channelTooltip.locator('[data-buzz-tooltip-metadata-content=""]'), + ).toHaveText("Off-topic, fun stuff"); + await expect( + channelTooltip.locator('[data-buzz-tooltip-metadata-type=""]'), + ).toHaveText("Public channel"); + await rootThreadLink.hover(); await rootThreadLink.click({ button: "right" }); const linkMenu = page.locator("[data-buzz-link-context-menu]"); @@ -481,6 +567,32 @@ test("message links to visible root messages open the thread panel", async ({ ); }); +test("message links omit tooltips when preview metadata is unavailable", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + const missingMessageId = "f".repeat(64); + await page + .getByTestId("message-input") + .fill( + `Missing preview buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=${missingMessageId}`, + ); + await page.getByTestId("send-message").click(); + + const linkMessage = page + .getByTestId("message-row") + .filter({ hasText: "Missing preview" }) + .last(); + const missingMessageLink = linkMessage.getByRole("button", { + name: "Open message in channel general", + }); + await expect(missingMessageLink).toHaveText("general"); + await missingMessageLink.hover(); + await expect(page.getByRole("tooltip")).toHaveCount(0); +}); + test("message links reopen a closed thread when the same messageId is already in the URL", async ({ page, }) => { @@ -511,9 +623,9 @@ test("message links reopen a closed thread when the same messageId is already in .last(); await expect(linkMessage).toBeVisible(); const rootThreadLink = linkMessage.getByRole("button", { - name: "Open message mock-gen in channel general", + name: "Open message in channel general", }); - await expect(rootThreadLink).toHaveText("general · mock-gen"); + await expect(rootThreadLink).toHaveText("general · Welcome to general"); await rootThreadLink.click(); await expect(threadPanel).toBeVisible(); From 2da0cb4da0181df9a24310949295af78ab4e2855 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 17 Aug 2026 16:23:26 -0700 Subject: [PATCH 02/27] feat(messages): distinguish deleted message links Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../messages/ui/SentFromThreadLine.tsx | 3 + .../src/shared/styles/globals/markdown.css | 6 ++ desktop/src/shared/ui/markdown.tsx | 10 +-- .../ui/markdown/ChannelDeepLink.test.mjs | 34 ++++++++ .../shared/ui/markdown/ChannelDeepLink.tsx | 5 +- .../shared/ui/markdown/MessageLinkPill.tsx | 24 +++++- desktop/src/shared/ui/markdown/types.ts | 1 + .../ui/markdown/useMessageLinkMetadata.ts | 21 ++++- desktop/src/testing/e2eBridge.ts | 8 +- .../e2e/entity-link-recipient-cards.spec.ts | 86 +++++++++++++++++++ 10 files changed, 184 insertions(+), 14 deletions(-) create mode 100644 desktop/src/shared/ui/markdown/ChannelDeepLink.test.mjs diff --git a/desktop/src/features/messages/ui/SentFromThreadLine.tsx b/desktop/src/features/messages/ui/SentFromThreadLine.tsx index 75e8d1ae1f1..a78d7df2cb4 100644 --- a/desktop/src/features/messages/ui/SentFromThreadLine.tsx +++ b/desktop/src/features/messages/ui/SentFromThreadLine.tsx @@ -44,6 +44,9 @@ export function SentFromThreadLine({ channels={channels} interactive link={link} + onOpenChannel={(targetChannelId) => { + void goChannel(targetChannelId); + }} onOpenMessageLink={onOpenMessageLink} threadExcerpt={reference.rootExcerpt} variant="sent-from-thread" diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index 7d0a238c179..b9c70e2db1f 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -87,6 +87,12 @@ color: hsl(var(--primary)); } +.message-markdown .mention-chip.buzz-link-deleted, +.message-markdown .mention-chip.buzz-link-deleted:hover { + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); +} + .message-markdown .mention-chip.inbox-channel-chip { background: hsl(var(--muted)); color: hsl(var(--muted-foreground) / 0.82); diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index ca34180d12c..2bc4f232b04 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1271,6 +1271,7 @@ export function createMarkdownComponents( const { channels, imetaByUrl, + onOpenChannel, onOpenEntityLink, onOpenMessageLink, onImportSnapshotFromUrl, @@ -1280,10 +1281,6 @@ export function createMarkdownComponents( if (!interactive) { return {children}; } - - // Markdown image-link syntax (`[![alt](src)](href)`) otherwise nests the - // image lightbox button inside an anchor. Keep the image as the lightbox - // trigger and suppress the parent link activation for block media. if (hasBlockMedia(React.Children.toArray(children))) { return <>{children}; } @@ -1358,6 +1355,7 @@ export function createMarkdownComponents( channels={channels} interactive={interactive} link={messageLinkTarget.link} + onOpenChannel={onOpenChannel} onOpenMessageLink={onOpenMessageLink} /> ); @@ -1681,7 +1679,8 @@ export function createMarkdownComponents( }: { children?: React.ReactNode; }) { - const { channels, onOpenMessageLink } = useMarkdownRuntime(); + const { channels, onOpenChannel, onOpenMessageLink } = + useMarkdownRuntime(); const href = String(children ?? ""); const parsed = parseMessageLink(href); if (!parsed.ok) { @@ -1693,6 +1692,7 @@ export function createMarkdownComponents( channels={channels} interactive={interactive} link={parsed.value} + onOpenChannel={onOpenChannel} onOpenMessageLink={onOpenMessageLink} /> ); diff --git a/desktop/src/shared/ui/markdown/ChannelDeepLink.test.mjs b/desktop/src/shared/ui/markdown/ChannelDeepLink.test.mjs new file mode 100644 index 00000000000..75858a10fe8 --- /dev/null +++ b/desktop/src/shared/ui/markdown/ChannelDeepLink.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { channelTooltipFooter } from "./ChannelDeepLink.tsx"; + +const channel = { + id: "channel-id", + name: "history", + channelType: "forum", + visibility: "private", + description: "", + topic: null, + purpose: null, + memberCount: 0, + memberPubkeys: [], + lastMessageAt: null, + archivedAt: "2026-08-17T00:00:00Z", + participants: [], + participantPubkeys: [], + isMember: true, + ttlSeconds: null, + ttlDeadline: null, +}; + +test("channelTooltipFooter adds archived status without changing existing metadata", () => { + assert.equal( + channelTooltipFooter(channel), + "Private channel · Forum · Archived", + ); + assert.equal( + channelTooltipFooter({ ...channel, archivedAt: null }), + "Private channel · Forum", + ); +}); diff --git a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx index 1d705994bab..8dcb2560198 100644 --- a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx +++ b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx @@ -35,10 +35,11 @@ function formatChannelActivity(timestamp: string): string | null { return `Active ${Math.floor(elapsedDays / 7)}w ago`; } -function channelTooltipFooter(channel: Channel) { +export function channelTooltipFooter(channel: Channel) { const details = [ channel.visibility === "private" ? "Private channel" : "Public channel", channel.channelType === "forum" ? "Forum" : null, + channel.archivedAt ? "Archived" : null, channel.lastMessageAt ? formatChannelActivity(channel.lastMessageAt) : null, ]; return details.filter(Boolean).join(" · "); @@ -136,6 +137,7 @@ export function ChannelDeepLinkAnchor({ href={href} interactive={interactive} link={messageLink} + onOpenChannel={onOpenChannel} onOpenMessageLink={onOpenMessageLink} /> ); @@ -184,6 +186,7 @@ export function MarkdownChannelDeepLink({ href={href} interactive={interactive} link={messageLink} + onOpenChannel={onOpenChannel} onOpenMessageLink={onOpenMessageLink} /> ); diff --git a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx index b31b5fd2edf..15d38b07cbc 100644 --- a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx +++ b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx @@ -68,6 +68,16 @@ function MessageLinkMetadataTooltip({ footer: string; metadata: ReturnType; }) { + if (metadata.state.kind === "deleted") { + return ( + + + {children} + Message deleted + + + ); + } if (metadata.state.kind !== "ready" || !metadata.state.snippet.trim()) { return children; } @@ -105,6 +115,7 @@ export function MessageLinkPill({ href, interactive, link, + onOpenChannel, onOpenMessageLink, threadExcerpt, variant = "default", @@ -118,6 +129,7 @@ export function MessageLinkPill({ const shouldLoadMetadata = channelReadable && interactive && variant === "default"; const metadata = useMessageLinkMetadata(link, shouldLoadMetadata); + const isDeleted = metadata.state.kind === "deleted"; const metadataPending = shouldLoadMetadata && (metadata.state.kind === "idle" || metadata.state.kind === "loading"); @@ -150,10 +162,18 @@ export function MessageLinkPill({ data-message-link="" href={permalink} icon="message" - aria-label={`Open message in channel ${channelLabel}`} - className="max-w-64" + aria-label={ + isDeleted + ? `Deleted message in channel ${channelLabel}` + : `Open message in channel ${channelLabel}` + } + className={cn("max-w-64", isDeleted && "buzz-link-deleted")} interactive={interactive} onOpenLink={() => { + if (isDeleted) { + onOpenChannel(link.channelId); + return; + } onOpenMessageLink(link); }} > diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index 2a0a40ff63b..fa10c1914ad 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -26,6 +26,7 @@ export type MessageLinkPillProps = { href?: string; interactive: boolean; link: ParsedMessageLink; + onOpenChannel: (channelId: string) => void; onOpenMessageLink: (link: ParsedMessageLink) => void; threadExcerpt?: string | null; variant?: "default" | "sent-from-thread"; diff --git a/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts b/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts index 1bd5c9d2350..66f3955bdce 100644 --- a/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts +++ b/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts @@ -7,8 +7,18 @@ import { getUserProfile } from "@/shared/api/tauriProfiles"; import { truncatePubkey } from "@/shared/lib/pubkey"; const MESSAGE_METADATA_RETRY_DELAY_MS = 750; +const EVENT_NOT_FOUND_MESSAGE = "event not found"; const PREVIEWABLE_MESSAGE_KINDS = new Set([9, 40002, 45001, 45003]); +function isEventNotFoundError(error: unknown): boolean { + if (typeof error === "string") { + return error.includes(EVENT_NOT_FOUND_MESSAGE); + } + return ( + error instanceof Error && error.message.includes(EVENT_NOT_FOUND_MESSAGE) + ); +} + function waitForMessageMetadataRetry(): Promise { return new Promise((resolve) => { window.setTimeout(resolve, MESSAGE_METADATA_RETRY_DELAY_MS); @@ -18,7 +28,8 @@ function waitForMessageMetadataRetry(): Promise { async function getMessageLinkEvent(messageId: string) { try { return await getEventById(messageId); - } catch { + } catch (error) { + if (isEventNotFoundError(error)) throw error; await waitForMessageMetadataRetry(); return getEventById(messageId); } @@ -33,10 +44,12 @@ type MessageLinkMetadataState = | { kind: "idle" } | { kind: "loading" } | ({ kind: "ready" } & MessageLinkMetadata) + | { kind: "deleted" } | { kind: "unavailable" }; type CachedMessageLinkMetadata = | ({ kind: "ready" } & MessageLinkMetadata) + | { kind: "deleted" } | { kind: "unavailable" }; const metadataCache = new Map>(); @@ -71,7 +84,11 @@ function fetchMetadata( snippet: summarizeMessageLinkContent(event.content), }; }) - .catch(() => ({ kind: "unavailable" }) as const); + .catch((error) => + isEventNotFoundError(error) + ? ({ kind: "deleted" } as const) + : ({ kind: "unavailable" } as const), + ); metadataCache.set(key, request); void request.then((result) => { if (result.kind === "unavailable" && metadataCache.get(key) === request) { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 278363e8f4d..f9d486aaa60 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9992,12 +9992,12 @@ async function resolveGetEvent( }, config: E2eConfig | undefined, ) { + // Allow test specs to mark specific event IDs as definitively deleted. + if (config?.mock?.deletedEventIds?.includes(args.eventId)) { + throw new Error("event not found"); + } const identity = getIdentity(config); if (!identity) { - // Allow test specs to mark specific event IDs as definitively deleted. - if (config?.mock?.deletedEventIds?.includes(args.eventId)) { - throw new Error("event not found"); - } const knownEvents: RelayEvent[] = [ ...Array.from(mockMessages.values()).flat(), { diff --git a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts index dc6ebe4a707..4341fdaaa3b 100644 --- a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts +++ b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts @@ -379,6 +379,92 @@ test("reopening the same entity link reapplies its workspace state", async ({ await expect(issueHeading).toBeVisible(); }); +test("definitively deleted message links open their channel and remain copyable", async ({ + page, +}) => { + const deletedMessageId = "d".repeat(64); + const channelId = "9dae0116-799b-5071-a0a8-fdd30a91a35d"; + const link = `buzz://message?channel=${channelId}&id=${deletedMessageId}`; + await installMockBridge(page, { deletedEventIds: [deletedMessageId] }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("channel-general").click(); + await page + .getByTestId("message-input") + .fill(`Deleted link \`reference\` ${link}`); + await page.getByTestId("send-message").click(); + + const linkMessage = page + .getByTestId("message-row") + .filter({ hasText: "Deleted link" }) + .last(); + const deletedLink = linkMessage.getByLabel( + "Deleted message in channel random", + ); + await expect(deletedLink).toHaveText("random"); + await expect(deletedLink).toHaveClass(/buzz-link-deleted/); + const inlineCode = linkMessage + .locator("code") + .filter({ hasText: "reference" }); + await expect(inlineCode).toBeVisible(); + await expect + .poll(async () => { + const [deletedStyles, codeStyles] = await Promise.all([ + deletedLink.evaluate((element) => { + const styles = getComputedStyle(element); + return [styles.backgroundColor, styles.color]; + }), + inlineCode.evaluate((element) => { + const styles = getComputedStyle(element); + return [styles.backgroundColor, styles.color]; + }), + ]); + return JSON.stringify(deletedStyles) === JSON.stringify(codeStyles); + }) + .toBe(true); + await expect(deletedLink).toHaveJSProperty("tagName", "BUTTON"); + const deletedColors = await deletedLink.evaluate((element) => { + const styles = getComputedStyle(element); + return [styles.backgroundColor, styles.color]; + }); + await deletedLink.hover(); + await expect + .poll(() => + deletedLink.evaluate((element) => { + const styles = getComputedStyle(element); + return [styles.backgroundColor, styles.color]; + }), + ) + .toEqual(deletedColors); + + await expect(page.getByRole("tooltip")).toHaveText("Message deleted"); + await deletedLink.click({ button: "right" }); + const linkMenu = page.locator("[data-buzz-link-context-menu]"); + await expect( + linkMenu.getByRole("button", { name: "Open link" }), + ).toBeVisible(); + await linkMenu.getByRole("button", { name: "Copy link" }).click(); + await expect + .poll(() => + page.evaluate(() => { + return ( + window as Window & { + __BUZZ_E2E_COMMAND_LOG__?: Array<{ + command: string; + payload: { text?: string }; + }>; + } + ).__BUZZ_E2E_COMMAND_LOG__?.findLast( + ({ command }) => command === "copy_text_to_clipboard", + )?.payload.text; + }), + ) + .toBe(link); + + await deletedLink.click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect(page).toHaveURL(new RegExp(`#/channels/${channelId}$`)); +}); + test("cold-start entity links drain after the React listener mounts", async ({ page, }) => { From 53631f91c0dcd995260d5dca4879936c70c52983 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 18 Aug 2026 08:01:01 -0700 Subject: [PATCH 03/27] fix(desktop): soften tooltip and disabled chip colors Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/agents/ui/RestartDiffBadge.tsx | 2 +- .../channels/ui/AddChannelBotTeamsSection.tsx | 2 +- .../src/shared/styles/globals/markdown.css | 4 +- desktop/src/shared/styles/globals/theme.css | 3 ++ .../shared/ui/markdown/ChannelDeepLink.tsx | 2 +- .../shared/ui/markdown/MessageLinkPill.tsx | 2 +- .../src/shared/ui/markdown/entityLinks.tsx | 2 +- desktop/src/shared/ui/tooltip.tsx | 2 +- desktop/tailwind.config.js | 4 ++ .../e2e/entity-link-recipient-cards.spec.ts | 37 ++++++++++++++++++- 10 files changed, 50 insertions(+), 10 deletions(-) diff --git a/desktop/src/features/agents/ui/RestartDiffBadge.tsx b/desktop/src/features/agents/ui/RestartDiffBadge.tsx index 1bdb781226f..4acfa858e8a 100644 --- a/desktop/src/features/agents/ui/RestartDiffBadge.tsx +++ b/desktop/src/features/agents/ui/RestartDiffBadge.tsx @@ -180,7 +180,7 @@ export function RestartDiffBadge({

Config changed since last start:

-

+

{autoRestartEnabled ? AUTO_RESTART_ON_BLURB : AUTO_RESTART_OFF_BLURB}

diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx index 512ee6899fb..a5d98f3dce4 100644 --- a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx @@ -143,7 +143,7 @@ export function AddChannelBotTeamsSection({

{team.name}

{team.description ? ( -

+

{team.description}

) : null} diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index b9c70e2db1f..0e0fa9de293 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -89,8 +89,8 @@ .message-markdown .mention-chip.buzz-link-deleted, .message-markdown .mention-chip.buzz-link-deleted:hover { - background: hsl(var(--muted)); - color: hsl(var(--muted-foreground)); + background: hsl(var(--disabled)); + color: hsl(var(--disabled-foreground)); } .message-markdown .mention-chip.inbox-channel-chip { diff --git a/desktop/src/shared/styles/globals/theme.css b/desktop/src/shared/styles/globals/theme.css index 5fd2593c79c..c7c5b6b5be1 100644 --- a/desktop/src/shared/styles/globals/theme.css +++ b/desktop/src/shared/styles/globals/theme.css @@ -14,6 +14,9 @@ --secondary-foreground: 234 16.02% 35.49%; --muted: 223 15.91% 82.75%; --muted-foreground: 233 12.8% 41.37%; + /* Unavailable content stays visible but semantically recedes. */ + --disabled: var(--muted); + --disabled-foreground: var(--muted-foreground); --huddle-drawer-surface: 0 0% 0%; --huddle-control-surface: 0 0% 20%; --huddle-control-hover-surface: 0 0% 24%; diff --git a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx index 8dcb2560198..ad01ffa4932 100644 --- a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx +++ b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx @@ -73,7 +73,7 @@ function ChannelMetadataTooltip({ ) : null} {footer} diff --git a/desktop/src/shared/ui/markdown/entityLinks.tsx b/desktop/src/shared/ui/markdown/entityLinks.tsx index b5e00fa6464..02a3a13dcbd 100644 --- a/desktop/src/shared/ui/markdown/entityLinks.tsx +++ b/desktop/src/shared/ui/markdown/entityLinks.tsx @@ -66,7 +66,7 @@ function EntityMetadataTooltip({ {metadata?.description ? ` · ${metadata.description}` : null} {footer} diff --git a/desktop/src/shared/ui/tooltip.tsx b/desktop/src/shared/ui/tooltip.tsx index 6af9e481887..6fa58919732 100644 --- a/desktop/src/shared/ui/tooltip.tsx +++ b/desktop/src/shared/ui/tooltip.tsx @@ -34,7 +34,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-tooltip-content-transform-origin)", + "z-50 overflow-hidden rounded-md bg-secondary px-3 py-1.5 text-xs text-secondary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-tooltip-content-transform-origin)", className, )} {...props} diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js index 07d00b0db92..572d2704067 100644 --- a/desktop/tailwind.config.js +++ b/desktop/tailwind.config.js @@ -105,6 +105,10 @@ export default { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))", }, + disabled: { + DEFAULT: "hsl(var(--disabled))", + foreground: "hsl(var(--disabled-foreground))", + }, destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))", diff --git a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts index 4341fdaaa3b..bb9718de431 100644 --- a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts +++ b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts @@ -124,6 +124,21 @@ test("agent-style message with bare buzz:// links renders entity cards without s await expect( prTooltip.locator('[data-buzz-tooltip-metadata-type=""]'), ).toHaveText("Pull request · relay-tools"); + const tooltipSemanticColors = await prTooltip.evaluate((element) => { + const styles = getComputedStyle(element); + const probe = document.createElement("span"); + probe.style.backgroundColor = "hsl(var(--secondary))"; + probe.style.color = "hsl(var(--secondary-foreground))"; + document.body.append(probe); + const semanticStyles = getComputedStyle(probe); + const result = { + actual: [styles.backgroundColor, styles.color], + expected: [semanticStyles.backgroundColor, semanticStyles.color], + }; + probe.remove(); + return result; + }); + expect(tooltipSemanticColors.actual).toEqual(tooltipSemanticColors.expected); await expect(prChip).toContainText(`relay-tools · ${PR_SUBJECT}`); const issueChip = row.getByRole("button", { @@ -422,10 +437,28 @@ test("definitively deleted message links open their channel and remain copyable" }) .toBe(true); await expect(deletedLink).toHaveJSProperty("tagName", "BUTTON"); - const deletedColors = await deletedLink.evaluate((element) => { + const deletedSemanticColors = await deletedLink.evaluate((element) => { const styles = getComputedStyle(element); - return [styles.backgroundColor, styles.color]; + const rootStyles = getComputedStyle(document.documentElement); + const probe = document.createElement("span"); + probe.style.backgroundColor = "hsl(var(--disabled))"; + probe.style.color = "hsl(var(--disabled-foreground))"; + document.body.append(probe); + const semanticStyles = getComputedStyle(probe); + const result = { + actual: [styles.backgroundColor, styles.color], + expected: [semanticStyles.backgroundColor, semanticStyles.color], + tokens: [ + rootStyles.getPropertyValue("--disabled").trim(), + rootStyles.getPropertyValue("--disabled-foreground").trim(), + ], + }; + probe.remove(); + return result; }); + expect(deletedSemanticColors.actual).toEqual(deletedSemanticColors.expected); + expect(deletedSemanticColors.tokens.every(Boolean)).toBe(true); + const deletedColors = deletedSemanticColors.actual; await deletedLink.hover(); await expect .poll(() => From 277fea55e17021819d2c698c8330a4ba466edccd Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 18 Aug 2026 10:20:41 -0700 Subject: [PATCH 04/27] fix(desktop): keep tooltip metadata on one line Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../shared/ui/markdown/ChannelDeepLink.tsx | 2 +- .../shared/ui/markdown/MessageLinkPill.tsx | 2 +- .../src/shared/ui/markdown/entityLinks.tsx | 2 +- .../e2e/entity-link-recipient-cards.spec.ts | 8 ++++--- desktop/tests/e2e/navigation.spec.ts | 22 ++++++++++++++----- 5 files changed, 24 insertions(+), 12 deletions(-) diff --git a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx index ad01ffa4932..11cac7db8fd 100644 --- a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx +++ b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx @@ -73,7 +73,7 @@ function ChannelMetadataTooltip({ ) : null} {footer} diff --git a/desktop/src/shared/ui/markdown/entityLinks.tsx b/desktop/src/shared/ui/markdown/entityLinks.tsx index 02a3a13dcbd..abda58d8401 100644 --- a/desktop/src/shared/ui/markdown/entityLinks.tsx +++ b/desktop/src/shared/ui/markdown/entityLinks.tsx @@ -66,7 +66,7 @@ function EntityMetadataTooltip({ {metadata?.description ? ` · ${metadata.description}` : null} {footer} diff --git a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts index bb9718de431..e7a90200eb3 100644 --- a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts +++ b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts @@ -121,9 +121,11 @@ test("agent-style message with bare buzz:// links renders entity cards without s await expect( prTooltip.locator('[data-buzz-tooltip-metadata-content=""]'), ).toContainText(PR_SUBJECT); - await expect( - prTooltip.locator('[data-buzz-tooltip-metadata-type=""]'), - ).toHaveText("Pull request · relay-tools"); + const prFooter = prTooltip.locator('[data-buzz-tooltip-metadata-type=""]'); + await expect(prFooter).toHaveText("Pull request · relay-tools"); + await expect(prFooter).toHaveCSS("white-space", "nowrap"); + await expect(prFooter).toHaveCSS("overflow", "hidden"); + await expect(prFooter).toHaveCSS("text-overflow", "ellipsis"); const tooltipSemanticColors = await prTooltip.evaluate((element) => { const styles = getComputedStyle(element); const probe = document.createElement("span"); diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index b01e2b55039..e91513bbc3d 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -481,9 +481,15 @@ test("message links to visible root messages open the thread panel", async ({ await expect( messageTooltip.locator('[data-buzz-tooltip-metadata-content=""]'), ).toHaveClass(/line-clamp-2/); - await expect( - messageTooltip.locator('[data-buzz-tooltip-metadata-type=""]'), - ).toHaveText(/#general · .+ · (just now|\d+[mhdw] ago)/); + const messageFooter = messageTooltip.locator( + '[data-buzz-tooltip-metadata-type=""]', + ); + await expect(messageFooter).toHaveText( + /#general · .+ · (just now|\d+[mhdw] ago)/, + ); + await expect(messageFooter).toHaveCSS("white-space", "nowrap"); + await expect(messageFooter).toHaveCSS("overflow", "hidden"); + await expect(messageFooter).toHaveCSS("text-overflow", "ellipsis"); const messageChipBox = await rootThreadLink.boundingBox(); const messageTooltipBox = await messageTooltip.boundingBox(); if (!messageChipBox || !messageTooltipBox) { @@ -523,9 +529,13 @@ test("message links to visible root messages open the thread panel", async ({ await expect( channelTooltip.locator('[data-buzz-tooltip-metadata-content=""]'), ).toHaveText("Off-topic, fun stuff"); - await expect( - channelTooltip.locator('[data-buzz-tooltip-metadata-type=""]'), - ).toHaveText("Public channel"); + const channelFooter = channelTooltip.locator( + '[data-buzz-tooltip-metadata-type=""]', + ); + await expect(channelFooter).toHaveText("Public channel"); + await expect(channelFooter).toHaveCSS("white-space", "nowrap"); + await expect(channelFooter).toHaveCSS("overflow", "hidden"); + await expect(channelFooter).toHaveCSS("text-overflow", "ellipsis"); await rootThreadLink.hover(); await rootThreadLink.click({ button: "right" }); From 2985d292d0d1503c07e515c1839ec56914462eea Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 18 Aug 2026 11:16:28 -0700 Subject: [PATCH 05/27] test(desktop): prove DM tooltip metadata stays inline Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/tests/e2e/navigation.spec.ts | 53 ++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index e91513bbc3d..c1d6a607fa0 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -577,6 +577,59 @@ test("message links to visible root messages open the thread panel", async ({ ); }); +test("direct-message tooltip metadata stays on one physical line", async ({ + page, +}) => { + const dmChannelId = "f48efb06-0c93-5025-aac9-2e646bb6bfa8"; + const dmMessageId = "mock-dm-link-one-line"; + + await page.goto("/"); + await page.getByTestId("channel-alice-tyler").click(); + await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); + await page.evaluate((id) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "alice-tyler", + content: "DM source message", + id, + }); + }, dmMessageId); + + await page.getByTestId("channel-general").click(); + await page + .getByTestId("message-input") + .fill(`DM link buzz://message?channel=${dmChannelId}&id=${dmMessageId}`); + await page.getByTestId("send-message").click(); + + const dmLink = page + .getByTestId("message-row") + .filter({ hasText: "DM link" }) + .last() + .getByRole("button", { name: "Open message in channel alice-tyler" }); + await expect(dmLink).toHaveText("alice-tyler · DM source message"); + await dmLink.hover(); + + const footer = page + .getByRole("tooltip") + .locator('[data-buzz-tooltip-metadata-type=""]'); + await expect(footer).toContainText("Direct message with alice-tyler"); + await expect(footer).toHaveCSS("white-space", "nowrap"); + await expect(footer).toHaveCSS("overflow", "hidden"); + await expect(footer).toHaveCSS("text-overflow", "ellipsis"); + await expect + .poll(() => + footer.evaluate((element) => { + const lineHeight = Number.parseFloat( + getComputedStyle(element).lineHeight, + ); + return { + fitsOneLine: element.scrollHeight <= Math.ceil(lineHeight), + heightIsClipped: element.clientHeight === element.scrollHeight, + }; + }), + ) + .toEqual({ fitsOneLine: true, heightIsClipped: true }); +}); + test("message links omit tooltips when preview metadata is unavailable", async ({ page, }) => { From 6157244b9b4cae8a18f06ede1ea17633ab08735d Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 18 Aug 2026 12:14:04 -0700 Subject: [PATCH 06/27] fix(desktop-messages): fade deleted message chips Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/shared/styles/globals/markdown.css | 2 +- desktop/tests/e2e/entity-link-recipient-cards.spec.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index 0e0fa9de293..3318cdffc43 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -90,7 +90,7 @@ .message-markdown .mention-chip.buzz-link-deleted, .message-markdown .mention-chip.buzz-link-deleted:hover { background: hsl(var(--disabled)); - color: hsl(var(--disabled-foreground)); + color: hsl(var(--disabled-foreground) / 0.7); } .message-markdown .mention-chip.inbox-channel-chip { diff --git a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts index e7a90200eb3..7cf4514d9cb 100644 --- a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts +++ b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts @@ -435,16 +435,19 @@ test("definitively deleted message links open their channel and remain copyable" return [styles.backgroundColor, styles.color]; }), ]); - return JSON.stringify(deletedStyles) === JSON.stringify(codeStyles); + return { + sharesDisabledSurface: deletedStyles[0] === codeStyles[0], + usesFadedForeground: deletedStyles[1] !== codeStyles[1], + }; }) - .toBe(true); + .toEqual({ sharesDisabledSurface: true, usesFadedForeground: true }); await expect(deletedLink).toHaveJSProperty("tagName", "BUTTON"); const deletedSemanticColors = await deletedLink.evaluate((element) => { const styles = getComputedStyle(element); const rootStyles = getComputedStyle(document.documentElement); const probe = document.createElement("span"); probe.style.backgroundColor = "hsl(var(--disabled))"; - probe.style.color = "hsl(var(--disabled-foreground))"; + probe.style.color = "hsl(var(--disabled-foreground) / 0.7)"; document.body.append(probe); const semanticStyles = getComputedStyle(probe); const result = { From 0ff61b2de6e16c0c814be451e4d3ab24135f8fd4 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 18 Aug 2026 12:41:20 -0700 Subject: [PATCH 07/27] fix(desktop-messages): show project context in entity tooltips Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/shared/ui/markdown.test.mjs | 25 +++--- desktop/src/shared/ui/markdown.tsx | 11 ++- .../src/shared/ui/markdown/entityLinks.tsx | 83 +++++++++++++++++-- .../e2e/entity-link-recipient-cards.spec.ts | 20 +++-- 4 files changed, 111 insertions(+), 28 deletions(-) diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 462de011cea..e7822e6e8b9 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; // These are copied here to avoid importing from .ts files that depend on // React (which isn't resolvable outside the bundler). Same pattern as @@ -1080,17 +1081,21 @@ test("bare Buzz permalinks render cohesive icon-prefixed chips", () => { }); const html = renderToStaticMarkup( React.createElement( - MarkdownRuntimeContext.Provider, - { - value: { - channels: [{ id: channelId, name: "engineering" }], - onOpenChannel: () => {}, - onOpenEntityLink: () => {}, - onOpenMessageLink: () => {}, - relayOrigin: null, + QueryClientProvider, + { client: new QueryClient() }, + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + channels: [{ id: channelId, name: "engineering" }], + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + }, }, - }, - markdown, + markdown, + ), ), ); diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 2bc4f232b04..747100cee49 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -55,6 +55,7 @@ import { SyntaxHighlightedCode, } from "./markdown/CodeBlock"; import { + EntityLinkAnchor, renderEntityLinkAnchor, useEntityCardOpenHandlers, useOpenEntityLink, @@ -1666,13 +1667,11 @@ export function createMarkdownComponents( const href = String(children ?? ""); if (!parseEntityLink(href).ok) return {href}; - return renderEntityLinkAnchor({ - children: href, + return React.createElement( + EntityLinkAnchor, + { href, interactive, onOpenEntityLink, relayOrigin }, href, - interactive, - onOpenEntityLink, - relayOrigin, - }); + ); }, "message-link": function MarkdownMessageLink({ children, diff --git a/desktop/src/shared/ui/markdown/entityLinks.tsx b/desktop/src/shared/ui/markdown/entityLinks.tsx index abda58d8401..ccdd17a6294 100644 --- a/desktop/src/shared/ui/markdown/entityLinks.tsx +++ b/desktop/src/shared/ui/markdown/entityLinks.tsx @@ -1,6 +1,8 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useProjectsQuery } from "@/features/projects/hooks"; +import type { Project } from "@/features/projects/projectModels"; import { entityLinkProjectRouteId, isEntityLink, @@ -30,6 +32,8 @@ function EntityMetadataTooltip({ fallback, footer, href, + link, + projects, }: { children: ( metadata: LinkPreviewMetadata | null | undefined, @@ -37,6 +41,8 @@ function EntityMetadataTooltip({ fallback: string; footer: string; href: string; + link: ParsedEntityLink; + projects: Project[] | undefined; }) { const [metadata, setMetadata] = React.useState< LinkPreviewMetadata | null | undefined @@ -50,7 +56,32 @@ function EntityMetadataTooltip({ cancelled = true; }; }, [href]); - const context = metadata?.title.trim() || fallback; + const repositoryAddress = + link.type === "issue" || link.type === "pr" + ? `30617:${link.owner}:${link.dtag}` + : null; + const containingProject = repositoryAddress + ? projects?.find((project) => + project.repositoryAddresses.includes(repositoryAddress), + ) + : null; + const resolvedTitle = metadata?.title.trim(); + const chipRepeatsTitle = + Boolean(resolvedTitle) && (link.type === "issue" || link.type === "pr"); + const projectName = containingProject?.name.trim(); + const projectDescription = containingProject?.description.trim(); + const projectContext = projectName + ? projectDescription && projectDescription !== projectName + ? `${projectName} · ${projectDescription}` + : projectName + : null; + const context = projectContext + ? projectContext + : chipRepeatsTitle + ? metadata?.description + : [resolvedTitle || fallback, metadata?.description] + .filter((value): value is string => Boolean(value)) + .join(" · "); const chip = children(metadata); return ( @@ -61,12 +92,16 @@ function EntityMetadataTooltip({ className="max-w-72 p-2 text-left" side="top" > - - {context} - {metadata?.description ? ` · ${metadata.description}` : null} - + {context ? ( + + {context} + + ) : null} {footer} @@ -190,6 +225,38 @@ function resolveEntityHref( * the href is not a valid entity link so the caller can fall through to its * default anchor. */ +export function EntityLinkAnchor({ + children, + href, + onOpenEntityLink, + relayOrigin, + interactive = true, + asChip = true, +}: { + children?: React.ReactNode; + href: string; + onOpenEntityLink: (link: ParsedEntityLink) => void; + relayOrigin: string | null; + interactive?: boolean; + asChip?: boolean; +}): React.ReactElement | null { + const { data: projects } = useProjectsQuery(); + return renderEntityLinkAnchor({ + children, + href, + onOpenEntityLink, + relayOrigin, + interactive, + asChip, + projects, + }); +} + +/** + * Pure rendering boundary retained for static-markup tests and non-provider + * callers. The normal Markdown path uses `EntityLinkAnchor` above so the + * authoritative Projects read model can enrich issue/PR tooltips. + */ export function renderEntityLinkAnchor({ children, href, @@ -197,6 +264,7 @@ export function renderEntityLinkAnchor({ relayOrigin, interactive = true, asChip = true, + projects, }: { children: React.ReactNode; href: string | undefined; @@ -204,6 +272,7 @@ export function renderEntityLinkAnchor({ relayOrigin: string | null; interactive?: boolean; asChip?: boolean; + projects?: Project[]; }): React.ReactElement | null { if (!href) return null; @@ -254,6 +323,8 @@ export function renderEntityLinkAnchor({ fallback={presentation.label} footer={presentation.tooltipFooter} href={canonicalHref} + link={parsed.value} + projects={projects} > {chip} diff --git a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts index 7cf4514d9cb..a37c63c359c 100644 --- a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts +++ b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts @@ -118,9 +118,13 @@ test("agent-style message with bare buzz:// links renders entity cards without s await expect(prChip).not.toHaveAttribute("title"); await prChip.hover(); const prTooltip = page.getByRole("tooltip"); - await expect( - prTooltip.locator('[data-buzz-tooltip-metadata-content=""]'), - ).toContainText(PR_SUBJECT); + const prContext = prTooltip.locator( + '[data-buzz-tooltip-metadata-content=""]', + ); + await expect(prContext).toHaveText( + "buzz · The complete Buzz community platform.", + ); + await expect(prContext).not.toContainText(PR_SUBJECT); const prFooter = prTooltip.locator('[data-buzz-tooltip-metadata-type=""]'); await expect(prFooter).toHaveText("Pull request · relay-tools"); await expect(prFooter).toHaveCSS("white-space", "nowrap"); @@ -150,9 +154,13 @@ test("agent-style message with bare buzz:// links renders entity cards without s await expect(issueChip).toHaveClass(/max-w-64/); await issueChip.hover(); const issueTooltip = page.getByRole("tooltip"); - await expect( - issueTooltip.locator('[data-buzz-tooltip-metadata-content=""]'), - ).toContainText(ISSUE_SUBJECT); + const issueContext = issueTooltip.locator( + '[data-buzz-tooltip-metadata-content=""]', + ); + await expect(issueContext).toHaveText( + "buzz · The complete Buzz community platform.", + ); + await expect(issueContext).not.toContainText(ISSUE_SUBJECT); await expect( issueTooltip.locator('[data-buzz-tooltip-metadata-type=""]'), ).toHaveText("Issue · relay-tools"); From 632b1bd3b0637fd05ed8eb6d636b0b079cba3e22 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 18 Aug 2026 14:41:24 -0700 Subject: [PATCH 08/27] fix(desktop-messages): preserve unresolved message navigation Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../shared/ui/markdown/ChannelDeepLink.tsx | 2 +- .../shared/ui/markdown/MessageLinkPill.tsx | 30 +---- .../src/shared/ui/markdown/entityLinks.tsx | 12 +- .../ui/markdown/useMessageLinkMetadata.ts | 40 ++----- .../e2e/entity-link-recipient-cards.spec.ts | 112 +++--------------- 5 files changed, 43 insertions(+), 153 deletions(-) diff --git a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx index 11cac7db8fd..10fd4f67cfb 100644 --- a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx +++ b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx @@ -73,7 +73,7 @@ function ChannelMetadataTooltip({ ) : null} ; }) { - if (metadata.state.kind === "deleted") { - return ( - - - {children} - Message deleted - - - ); - } if (metadata.state.kind !== "ready" || !metadata.state.snippet.trim()) { return children; } @@ -97,7 +87,7 @@ function MessageLinkMetadataTooltip({ {content} {footer} @@ -115,7 +105,6 @@ export function MessageLinkPill({ href, interactive, link, - onOpenChannel, onOpenMessageLink, threadExcerpt, variant = "default", @@ -129,7 +118,6 @@ export function MessageLinkPill({ const shouldLoadMetadata = channelReadable && interactive && variant === "default"; const metadata = useMessageLinkMetadata(link, shouldLoadMetadata); - const isDeleted = metadata.state.kind === "deleted"; const metadataPending = shouldLoadMetadata && (metadata.state.kind === "idle" || metadata.state.kind === "loading"); @@ -162,20 +150,10 @@ export function MessageLinkPill({ data-message-link="" href={permalink} icon="message" - aria-label={ - isDeleted - ? `Deleted message in channel ${channelLabel}` - : `Open message in channel ${channelLabel}` - } - className={cn("max-w-64", isDeleted && "buzz-link-deleted")} + aria-label={`Open message in channel ${channelLabel}`} + className="max-w-64" interactive={interactive} - onOpenLink={() => { - if (isDeleted) { - onOpenChannel(link.channelId); - return; - } - onOpenMessageLink(link); - }} + onOpenLink={() => onOpenMessageLink(link)} > {channelLabel} diff --git a/desktop/src/shared/ui/markdown/entityLinks.tsx b/desktop/src/shared/ui/markdown/entityLinks.tsx index ccdd17a6294..5064bf2a7df 100644 --- a/desktop/src/shared/ui/markdown/entityLinks.tsx +++ b/desktop/src/shared/ui/markdown/entityLinks.tsx @@ -44,18 +44,20 @@ function EntityMetadataTooltip({ link: ParsedEntityLink; projects: Project[] | undefined; }) { - const [metadata, setMetadata] = React.useState< - LinkPreviewMetadata | null | undefined - >(undefined); + const [resolved, setResolved] = React.useState<{ + href: string; + metadata: LinkPreviewMetadata | null; + } | null>(null); React.useEffect(() => { let cancelled = false; void loadBuzzEntityMetadata(href).then((value) => { - if (!cancelled) setMetadata(value); + if (!cancelled) setResolved({ href, metadata: value }); }); return () => { cancelled = true; }; }, [href]); + const metadata = resolved?.href === href ? resolved.metadata : undefined; const repositoryAddress = link.type === "issue" || link.type === "pr" ? `30617:${link.owner}:${link.dtag}` @@ -101,7 +103,7 @@ function EntityMetadataTooltip({ ) : null} {footer} diff --git a/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts b/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts index 66f3955bdce..e9f509dff79 100644 --- a/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts +++ b/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts @@ -7,18 +7,8 @@ import { getUserProfile } from "@/shared/api/tauriProfiles"; import { truncatePubkey } from "@/shared/lib/pubkey"; const MESSAGE_METADATA_RETRY_DELAY_MS = 750; -const EVENT_NOT_FOUND_MESSAGE = "event not found"; const PREVIEWABLE_MESSAGE_KINDS = new Set([9, 40002, 45001, 45003]); -function isEventNotFoundError(error: unknown): boolean { - if (typeof error === "string") { - return error.includes(EVENT_NOT_FOUND_MESSAGE); - } - return ( - error instanceof Error && error.message.includes(EVENT_NOT_FOUND_MESSAGE) - ); -} - function waitForMessageMetadataRetry(): Promise { return new Promise((resolve) => { window.setTimeout(resolve, MESSAGE_METADATA_RETRY_DELAY_MS); @@ -28,8 +18,7 @@ function waitForMessageMetadataRetry(): Promise { async function getMessageLinkEvent(messageId: string) { try { return await getEventById(messageId); - } catch (error) { - if (isEventNotFoundError(error)) throw error; + } catch { await waitForMessageMetadataRetry(); return getEventById(messageId); } @@ -44,12 +33,10 @@ type MessageLinkMetadataState = | { kind: "idle" } | { kind: "loading" } | ({ kind: "ready" } & MessageLinkMetadata) - | { kind: "deleted" } | { kind: "unavailable" }; type CachedMessageLinkMetadata = | ({ kind: "ready" } & MessageLinkMetadata) - | { kind: "deleted" } | { kind: "unavailable" }; const metadataCache = new Map>(); @@ -84,11 +71,7 @@ function fetchMetadata( snippet: summarizeMessageLinkContent(event.content), }; }) - .catch((error) => - isEventNotFoundError(error) - ? ({ kind: "deleted" } as const) - : ({ kind: "unavailable" } as const), - ); + .catch(() => ({ kind: "unavailable" }) as const); metadataCache.set(key, request); void request.then((result) => { if (result.kind === "unavailable" && metadataCache.get(key) === request) { @@ -103,13 +86,14 @@ export function useMessageLinkMetadata( link: ParsedMessageLink, channelReadable: boolean, ): { state: MessageLinkMetadataState } { - const [state, setState] = React.useState({ - kind: "idle", - }); + const [resolved, setResolved] = React.useState<{ + key: string; + state: CachedMessageLinkMetadata; + } | null>(null); const requestId = React.useRef(0); + const key = `${link.channelId}:${link.messageId}`; React.useEffect(() => { requestId.current += 1; - setState({ kind: "idle" }); if (!channelReadable) return; const currentRequest = requestId.current; @@ -117,15 +101,17 @@ export function useMessageLinkMetadata( channelId: link.channelId, messageId: link.messageId, }; - setState({ kind: "loading" }); void fetchMetadata(currentLink).then((metadata) => { if (requestId.current === currentRequest) { - setState(metadata); + setResolved({ key, state: metadata }); } }); return () => { requestId.current += 1; }; - }, [channelReadable, link.channelId, link.messageId]); - return { state }; + }, [channelReadable, key, link.channelId, link.messageId]); + if (!channelReadable) return { state: { kind: "idle" } }; + return { + state: resolved?.key === key ? resolved.state : { kind: "loading" }, + }; } diff --git a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts index a37c63c359c..b9ce5817231 100644 --- a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts +++ b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts @@ -133,8 +133,8 @@ test("agent-style message with bare buzz:// links renders entity cards without s const tooltipSemanticColors = await prTooltip.evaluate((element) => { const styles = getComputedStyle(element); const probe = document.createElement("span"); - probe.style.backgroundColor = "hsl(var(--secondary))"; - probe.style.color = "hsl(var(--secondary-foreground))"; + probe.style.backgroundColor = "hsl(var(--primary))"; + probe.style.color = "hsl(var(--primary-foreground))"; document.body.append(probe); const semanticStyles = getComputedStyle(probe); const result = { @@ -404,111 +404,35 @@ test("reopening the same entity link reapplies its workspace state", async ({ await expect(issueHeading).toBeVisible(); }); -test("definitively deleted message links open their channel and remain copyable", async ({ +test("missing message links remain unavailable and preserve exact-message navigation", async ({ page, }) => { - const deletedMessageId = "d".repeat(64); + const missingMessageId = "d".repeat(64); const channelId = "9dae0116-799b-5071-a0a8-fdd30a91a35d"; - const link = `buzz://message?channel=${channelId}&id=${deletedMessageId}`; - await installMockBridge(page, { deletedEventIds: [deletedMessageId] }); + const link = `buzz://message?channel=${channelId}&id=${missingMessageId}`; + await installMockBridge(page, { deletedEventIds: [missingMessageId] }); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByTestId("channel-general").click(); - await page - .getByTestId("message-input") - .fill(`Deleted link \`reference\` ${link}`); + await page.getByTestId("message-input").fill(`Missing link ${link}`); await page.getByTestId("send-message").click(); const linkMessage = page .getByTestId("message-row") - .filter({ hasText: "Deleted link" }) + .filter({ hasText: "Missing link" }) .last(); - const deletedLink = linkMessage.getByLabel( - "Deleted message in channel random", - ); - await expect(deletedLink).toHaveText("random"); - await expect(deletedLink).toHaveClass(/buzz-link-deleted/); - const inlineCode = linkMessage - .locator("code") - .filter({ hasText: "reference" }); - await expect(inlineCode).toBeVisible(); - await expect - .poll(async () => { - const [deletedStyles, codeStyles] = await Promise.all([ - deletedLink.evaluate((element) => { - const styles = getComputedStyle(element); - return [styles.backgroundColor, styles.color]; - }), - inlineCode.evaluate((element) => { - const styles = getComputedStyle(element); - return [styles.backgroundColor, styles.color]; - }), - ]); - return { - sharesDisabledSurface: deletedStyles[0] === codeStyles[0], - usesFadedForeground: deletedStyles[1] !== codeStyles[1], - }; - }) - .toEqual({ sharesDisabledSurface: true, usesFadedForeground: true }); - await expect(deletedLink).toHaveJSProperty("tagName", "BUTTON"); - const deletedSemanticColors = await deletedLink.evaluate((element) => { - const styles = getComputedStyle(element); - const rootStyles = getComputedStyle(document.documentElement); - const probe = document.createElement("span"); - probe.style.backgroundColor = "hsl(var(--disabled))"; - probe.style.color = "hsl(var(--disabled-foreground) / 0.7)"; - document.body.append(probe); - const semanticStyles = getComputedStyle(probe); - const result = { - actual: [styles.backgroundColor, styles.color], - expected: [semanticStyles.backgroundColor, semanticStyles.color], - tokens: [ - rootStyles.getPropertyValue("--disabled").trim(), - rootStyles.getPropertyValue("--disabled-foreground").trim(), - ], - }; - probe.remove(); - return result; + const missingLink = linkMessage.getByRole("button", { + name: "Open message in channel random", }); - expect(deletedSemanticColors.actual).toEqual(deletedSemanticColors.expected); - expect(deletedSemanticColors.tokens.every(Boolean)).toBe(true); - const deletedColors = deletedSemanticColors.actual; - await deletedLink.hover(); - await expect - .poll(() => - deletedLink.evaluate((element) => { - const styles = getComputedStyle(element); - return [styles.backgroundColor, styles.color]; - }), - ) - .toEqual(deletedColors); - - await expect(page.getByRole("tooltip")).toHaveText("Message deleted"); - await deletedLink.click({ button: "right" }); - const linkMenu = page.locator("[data-buzz-link-context-menu]"); - await expect( - linkMenu.getByRole("button", { name: "Open link" }), - ).toBeVisible(); - await linkMenu.getByRole("button", { name: "Copy link" }).click(); - await expect - .poll(() => - page.evaluate(() => { - return ( - window as Window & { - __BUZZ_E2E_COMMAND_LOG__?: Array<{ - command: string; - payload: { text?: string }; - }>; - } - ).__BUZZ_E2E_COMMAND_LOG__?.findLast( - ({ command }) => command === "copy_text_to_clipboard", - )?.payload.text; - }), - ) - .toBe(link); + await expect(missingLink).toHaveText("random"); + await expect(missingLink).not.toHaveClass(/buzz-link-deleted/); + await missingLink.hover(); + await expect(page.getByRole("tooltip")).toHaveCount(0); - await deletedLink.click(); + await missingLink.click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); - await expect(page).toHaveURL(new RegExp(`#/channels/${channelId}$`)); + await expect(page).toHaveURL( + new RegExp(`#/channels/${channelId}\\?messageId=${missingMessageId}$`), + ); }); test("cold-start entity links drain after the React listener mounts", async ({ From bffcba3d55e3d8f51940af82c5d1cc85160d03c2 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 18 Aug 2026 14:41:32 -0700 Subject: [PATCH 09/27] fix(desktop-tooltips): restore shared primary surface Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/features/agents/ui/RestartDiffBadge.tsx | 2 +- .../src/features/channels/ui/AddChannelBotTeamsSection.tsx | 2 +- desktop/src/shared/styles/globals/markdown.css | 6 ------ desktop/src/shared/styles/globals/theme.css | 3 --- desktop/src/shared/ui/tooltip.tsx | 2 +- desktop/tailwind.config.js | 4 ---- 6 files changed, 3 insertions(+), 16 deletions(-) diff --git a/desktop/src/features/agents/ui/RestartDiffBadge.tsx b/desktop/src/features/agents/ui/RestartDiffBadge.tsx index 4acfa858e8a..1bdb781226f 100644 --- a/desktop/src/features/agents/ui/RestartDiffBadge.tsx +++ b/desktop/src/features/agents/ui/RestartDiffBadge.tsx @@ -180,7 +180,7 @@ export function RestartDiffBadge({

Config changed since last start:

-

+

{autoRestartEnabled ? AUTO_RESTART_ON_BLURB : AUTO_RESTART_OFF_BLURB}

diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx index a5d98f3dce4..512ee6899fb 100644 --- a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx @@ -143,7 +143,7 @@ export function AddChannelBotTeamsSection({

{team.name}

{team.description ? ( -

+

{team.description}

) : null} diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index 3318cdffc43..7d0a238c179 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -87,12 +87,6 @@ color: hsl(var(--primary)); } -.message-markdown .mention-chip.buzz-link-deleted, -.message-markdown .mention-chip.buzz-link-deleted:hover { - background: hsl(var(--disabled)); - color: hsl(var(--disabled-foreground) / 0.7); -} - .message-markdown .mention-chip.inbox-channel-chip { background: hsl(var(--muted)); color: hsl(var(--muted-foreground) / 0.82); diff --git a/desktop/src/shared/styles/globals/theme.css b/desktop/src/shared/styles/globals/theme.css index c7c5b6b5be1..5fd2593c79c 100644 --- a/desktop/src/shared/styles/globals/theme.css +++ b/desktop/src/shared/styles/globals/theme.css @@ -14,9 +14,6 @@ --secondary-foreground: 234 16.02% 35.49%; --muted: 223 15.91% 82.75%; --muted-foreground: 233 12.8% 41.37%; - /* Unavailable content stays visible but semantically recedes. */ - --disabled: var(--muted); - --disabled-foreground: var(--muted-foreground); --huddle-drawer-surface: 0 0% 0%; --huddle-control-surface: 0 0% 20%; --huddle-control-hover-surface: 0 0% 24%; diff --git a/desktop/src/shared/ui/tooltip.tsx b/desktop/src/shared/ui/tooltip.tsx index 6fa58919732..6af9e481887 100644 --- a/desktop/src/shared/ui/tooltip.tsx +++ b/desktop/src/shared/ui/tooltip.tsx @@ -34,7 +34,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 overflow-hidden rounded-md bg-secondary px-3 py-1.5 text-xs text-secondary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-tooltip-content-transform-origin)", + "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-tooltip-content-transform-origin)", className, )} {...props} diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js index 572d2704067..07d00b0db92 100644 --- a/desktop/tailwind.config.js +++ b/desktop/tailwind.config.js @@ -105,10 +105,6 @@ export default { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))", }, - disabled: { - DEFAULT: "hsl(var(--disabled))", - foreground: "hsl(var(--disabled-foreground))", - }, destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))", From eaa0260b7025870054471bd1b8df3d2f837e3258 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 18 Aug 2026 15:14:48 -0700 Subject: [PATCH 10/27] fix(desktop-messages): mute unavailable link chips Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/shared/styles/globals/markdown.css | 6 ++++ .../shared/ui/markdown/MessageLinkPill.tsx | 5 ++- .../src/shared/ui/markdown/entityLinks.tsx | 3 +- .../e2e/entity-link-recipient-cards.spec.ts | 35 ++++++++++++++++++- 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index 7d0a238c179..9c42b8298af 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -87,6 +87,12 @@ color: hsl(var(--primary)); } +.message-markdown .mention-chip.buzz-link-unavailable, +.message-markdown .mention-chip.buzz-link-unavailable:hover { + background: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground) / 0.7); +} + .message-markdown .mention-chip.inbox-channel-chip { background: hsl(var(--muted)); color: hsl(var(--muted-foreground) / 0.82); diff --git a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx index 884c820a8c9..630fdf79fc5 100644 --- a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx +++ b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx @@ -151,7 +151,10 @@ export function MessageLinkPill({ href={permalink} icon="message" aria-label={`Open message in channel ${channelLabel}`} - className="max-w-64" + className={cn( + "max-w-64", + metadata.state.kind === "unavailable" && "buzz-link-unavailable", + )} interactive={interactive} onOpenLink={() => onOpenMessageLink(link)} > diff --git a/desktop/src/shared/ui/markdown/entityLinks.tsx b/desktop/src/shared/ui/markdown/entityLinks.tsx index 5064bf2a7df..a23a46bd582 100644 --- a/desktop/src/shared/ui/markdown/entityLinks.tsx +++ b/desktop/src/shared/ui/markdown/entityLinks.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useProjectsQuery } from "@/features/projects/hooks"; import type { Project } from "@/features/projects/projectModels"; +import { cn } from "@/shared/lib/cn"; import { entityLinkProjectRouteId, isEntityLink, @@ -312,7 +313,7 @@ export function renderEntityLinkAnchor({ href={href} icon={presentation.icon} aria-label={presentation.ariaLabel} - className="max-w-64" + className={cn("max-w-64", metadata === null && "buzz-link-unavailable")} interactive={interactive} onOpenLink={() => onOpenEntityLink(parsed.value)} > diff --git a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts index b9ce5817231..bab2e867a37 100644 --- a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts +++ b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts @@ -200,6 +200,22 @@ test("agent-style message with bare buzz:// links renders entity cards without s const missingRepoChip = row.getByRole("button", { name: "Open repository missing-repo", }); + await expect(missingRepoChip).toHaveClass(/buzz-link-unavailable/); + const missingRepoColors = await missingRepoChip.evaluate((element) => { + const styles = getComputedStyle(element); + const probe = document.createElement("span"); + probe.style.backgroundColor = "hsl(var(--secondary))"; + probe.style.color = "hsl(var(--secondary-foreground) / 0.7)"; + document.body.append(probe); + const semanticStyles = getComputedStyle(probe); + const result = { + actual: [styles.backgroundColor, styles.color], + expected: [semanticStyles.backgroundColor, semanticStyles.color], + }; + probe.remove(); + return result; + }); + expect(missingRepoColors.actual).toEqual(missingRepoColors.expected); // Failed metadata falls back to the chip's stable identity instead of // removing the tooltip or leaving a type-only footer. await missingRepoChip.hover(); @@ -424,7 +440,24 @@ test("missing message links remain unavailable and preserve exact-message naviga name: "Open message in channel random", }); await expect(missingLink).toHaveText("random"); - await expect(missingLink).not.toHaveClass(/buzz-link-deleted/); + await expect(missingLink).toHaveClass(/buzz-link-unavailable/); + await expect + .poll(() => + missingLink.evaluate((element) => { + const styles = getComputedStyle(element); + const probe = document.createElement("span"); + probe.style.backgroundColor = "hsl(var(--secondary))"; + probe.style.color = "hsl(var(--secondary-foreground) / 0.7)"; + document.body.append(probe); + const semanticStyles = getComputedStyle(probe); + const result = + styles.backgroundColor === semanticStyles.backgroundColor && + styles.color === semanticStyles.color; + probe.remove(); + return result; + }), + ) + .toBe(true); await missingLink.hover(); await expect(page.getByRole("tooltip")).toHaveCount(0); From de57b65044dd604e4daeb5270f6801c9f5c8ce1c Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 18 Aug 2026 17:19:32 -0700 Subject: [PATCH 11/27] fix(desktop-messages): explain unavailable message links Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/shared/ui/markdown/MessageLinkPill.tsx | 10 ++++++++++ desktop/tests/e2e/navigation.spec.ts | 11 ++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx index 630fdf79fc5..636f7729b89 100644 --- a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx +++ b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx @@ -68,6 +68,16 @@ function MessageLinkMetadataTooltip({ footer: string; metadata: ReturnType; }) { + if (metadata.state.kind === "unavailable") { + return ( + + + {children} + Message unavailable + + + ); + } if (metadata.state.kind !== "ready" || !metadata.state.snippet.trim()) { return children; } diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index c1d6a607fa0..13f5c343892 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -630,7 +630,7 @@ test("direct-message tooltip metadata stays on one physical line", async ({ .toEqual({ fitsOneLine: true, heightIsClipped: true }); }); -test("message links omit tooltips when preview metadata is unavailable", async ({ +test("message links explain when preview metadata is unavailable", async ({ page, }) => { await page.goto("/"); @@ -648,12 +648,13 @@ test("message links omit tooltips when preview metadata is unavailable", async ( .getByTestId("message-row") .filter({ hasText: "Missing preview" }) .last(); - const missingMessageLink = linkMessage.getByRole("button", { - name: "Open message in channel general", - }); + const missingMessageLink = linkMessage.locator("button[data-message-link]"); + await expect(missingMessageLink).toHaveAccessibleName( + "Open message in channel general", + ); await expect(missingMessageLink).toHaveText("general"); await missingMessageLink.hover(); - await expect(page.getByRole("tooltip")).toHaveCount(0); + await expect(page.getByRole("tooltip")).toHaveText("Message unavailable"); }); test("message links reopen a closed thread when the same messageId is already in the URL", async ({ From 80fd6e2164c42277b19f1cd5c6191e398021a373 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 18 Aug 2026 23:03:13 -0700 Subject: [PATCH 12/27] fix(desktop): dismiss tooltips when pointer leaves trigger Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/shared/ui/tooltip.tsx | 6 ++++-- desktop/tests/e2e/navigation.spec.ts | 12 +++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/desktop/src/shared/ui/tooltip.tsx b/desktop/src/shared/ui/tooltip.tsx index 6af9e481887..14656785170 100644 --- a/desktop/src/shared/ui/tooltip.tsx +++ b/desktop/src/shared/ui/tooltip.tsx @@ -21,7 +21,9 @@ const TooltipProvider = ({ /> ); -const Tooltip = TooltipPrimitive.Root; +const Tooltip = (props: React.ComponentProps) => ( + +); const TooltipTrigger = TooltipPrimitive.Trigger; @@ -34,7 +36,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-tooltip-content-transform-origin)", + "pointer-events-none z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-tooltip-content-transform-origin)", className, )} {...props} diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index 13f5c343892..e8330800b2d 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -654,7 +654,17 @@ test("message links explain when preview metadata is unavailable", async ({ ); await expect(missingMessageLink).toHaveText("general"); await missingMessageLink.hover(); - await expect(page.getByRole("tooltip")).toHaveText("Message unavailable"); + const unavailableTooltip = page.getByRole("tooltip"); + await expect(unavailableTooltip).toHaveText("Message unavailable"); + await expect(unavailableTooltip).toHaveCSS("pointer-events", "none"); + + const tooltipBox = await unavailableTooltip.boundingBox(); + if (!tooltipBox) throw new Error("Unavailable tooltip bounds missing"); + await page.mouse.move( + tooltipBox.x + tooltipBox.width / 2, + tooltipBox.y + tooltipBox.height / 2, + ); + await expect(unavailableTooltip).toHaveCount(0); }); test("message links reopen a closed thread when the same messageId is already in the URL", async ({ From b4eef23ffb3a8235f1b9104ec7fef1733753a5c3 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 18 Aug 2026 23:37:12 -0700 Subject: [PATCH 13/27] fix(desktop): wrap metadata chips inline Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/shared/styles/globals/markdown.css | 38 ++++++++++ desktop/src/shared/ui/markdown.test.mjs | 22 ++++-- .../src/shared/ui/markdown/BuzzLinkChip.tsx | 70 ++++++++++++++++--- .../shared/ui/markdown/ChannelDeepLink.tsx | 3 + .../src/shared/ui/markdown/entityLinks.tsx | 5 +- desktop/src/shared/ui/mentionChip.ts | 3 + .../e2e/entity-link-recipient-cards.spec.ts | 6 +- desktop/tests/e2e/navigation.spec.ts | 9 ++- 8 files changed, 138 insertions(+), 18 deletions(-) diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index 9c42b8298af..f39de74c522 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -87,6 +87,19 @@ color: hsl(var(--primary)); } +.message-markdown .mention-chip.wrapping-inline-chip { + display: inline; + line-height: calc( + 1em + + var(--inline-chip-padding-block-start) + + var(--inline-chip-padding-block-end) + + 0.25rem + ); + overflow-wrap: anywhere; + text-align: left; + white-space: normal; +} + .message-markdown .mention-chip.buzz-link-unavailable, .message-markdown .mention-chip.buzz-link-unavailable:hover { background: hsl(var(--secondary)); @@ -143,6 +156,7 @@ .message-markdown.inbox-preview-markdown .inline-code-chip, .message-markdown.inbox-preview-markdown :not(pre) > code { display: inline; + line-height: 1; overflow: visible; overflow-wrap: normal; text-overflow: clip; @@ -172,6 +186,30 @@ transform: translateY(-50%); } +.message-markdown .wrapping-inline-chip.inline-chip-with-icon { + padding-left: var(--inline-chip-padding-inline); +} + +.message-markdown .wrapping-inline-chip.inline-chip-with-icon::before { + display: none; +} + +.message-markdown .inline-chip-leading-fragment.inline-chip-with-icon { + position: relative; + display: inline-flex; + align-items: center; + padding-left: calc( + var(--inline-chip-icon-size) + + var(--inline-chip-icon-gap) + ); + white-space: nowrap; +} + +.message-markdown .inline-chip-leading-fragment.inline-chip-with-icon::before { + display: block; + left: 0; +} + .message-markdown .inline-chip-icon-message::before { mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z' fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E") diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index e7822e6e8b9..a0aa5d7e57d 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -1099,18 +1099,20 @@ test("bare Buzz permalinks render cohesive icon-prefixed chips", () => { ), ); + const visibleText = html.replace(/<[^>]+>/g, ""); assert.equal((html.match(/data-buzz-link=""/g) ?? []).length, 6); assert.equal((html.match(/inline-chip-icon-message/g) ?? []).length, 2); - assert.equal((html.match(/>engineeringengineering]*>ebuzz-world · c3b589fabuzz-world { @@ -1209,7 +1211,9 @@ test("channel references replace the authored hash with the channel icon", () => ); assert.match(html, /inline-chip-icon-channel/); - assert.match(html, />engineering]*>e]+>/g, ""), /engineering/); assert.doesNotMatch(html, />#engineering { }); const html = renderToStaticMarkup(el); assert.match(html, /data-buzz-link=""/); - assert.match(html, /