From c818f0f81ec0963b6710c95f1ac1f0c912742ce1 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Sun, 28 Jun 2026 12:39:01 +0100
Subject: [PATCH 1/3] Add rich link previews

---
 .../src-tauri/src/commands/link_preview.rs    | 301 +++++++++++++++
 desktop/src-tauri/src/commands/mod.rs         |   2 +
 desktop/src-tauri/src/lib.rs                  |   1 +
 .../messages/lib/linkInteractionExtension.ts  |  28 +-
 .../messages/lib/useRichTextEditor.ts         |  60 ++-
 desktop/src/shared/lib/linkPreview.test.mjs   | 186 +++++++++
 desktop/src/shared/lib/linkPreview.ts         | 363 ++++++++++++++++++
 .../src/shared/lib/useResolvedLinkPreviews.ts |  92 +++++
 desktop/src/shared/styles/globals.css         | 119 ++++++
 desktop/src/shared/ui/attachment.tsx          | 196 ++++++++++
 .../src/shared/ui/link-preview-attachment.tsx | 158 ++++++++
 desktop/src/shared/ui/markdown.tsx            |  47 ++-
 12 files changed, 1547 insertions(+), 6 deletions(-)
 create mode 100644 desktop/src-tauri/src/commands/link_preview.rs
 create mode 100644 desktop/src/shared/lib/linkPreview.test.mjs
 create mode 100644 desktop/src/shared/lib/linkPreview.ts
 create mode 100644 desktop/src/shared/lib/useResolvedLinkPreviews.ts
 create mode 100644 desktop/src/shared/ui/attachment.tsx
 create mode 100644 desktop/src/shared/ui/link-preview-attachment.tsx

diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs
new file mode 100644
index 0000000000..5cc9c01d33
--- /dev/null
+++ b/desktop/src-tauri/src/commands/link_preview.rs
@@ -0,0 +1,301 @@
+use std::time::Duration;
+
+use futures_util::StreamExt;
+use reqwest::{
+    header::{ACCEPT, CONTENT_TYPE, USER_AGENT},
+    redirect::Policy,
+};
+use url::Url;
+
+const MAX_TITLE_FETCH_BYTES: usize = 256 * 1024;
+const TITLE_FETCH_TIMEOUT: Duration = Duration::from_secs(4);
+
+#[tauri::command]
+pub async fn fetch_link_preview_title(href: String) -> Result<Option<String>, String> {
+    let url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?;
+    if !is_supported_google_link(&url) {
+        return Ok(None);
+    }
+
+    let client = reqwest::Client::builder()
+        .redirect(Policy::none())
+        .pool_idle_timeout(Duration::from_secs(10))
+        .pool_max_idle_per_host(1)
+        .build()
+        .map_err(|error| format!("link preview title client failed: {error}"))?;
+
+    let request = client
+        .get(url.as_str())
+        .header(
+            ACCEPT,
+            "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+        )
+        .header(USER_AGENT, "Buzz Desktop link preview");
+
+    let response = tokio::time::timeout(TITLE_FETCH_TIMEOUT, request.send())
+        .await
+        .map_err(|_| "link preview title request timed out".to_string())?
+        .map_err(|error| format!("link preview title request failed: {error}"))?;
+
+    if !response.status().is_success() {
+        return Ok(None);
+    }
+
+    let is_html = response
+        .headers()
+        .get(CONTENT_TYPE)
+        .and_then(|value| value.to_str().ok())
+        .map(|value| value.to_ascii_lowercase().contains("text/html"))
+        .unwrap_or(true);
+    if !is_html {
+        return Ok(None);
+    }
+
+    let body = read_limited_text(response).await?;
+    Ok(extract_google_title(&body))
+}
+
+fn is_supported_google_link(url: &Url) -> bool {
+    if url.scheme() != "https" {
+        return false;
+    }
+
+    let Some(host) = url.host_str().map(|host| host.to_ascii_lowercase()) else {
+        return false;
+    };
+    let segments = url
+        .path_segments()
+        .map(|segments| segments.collect::<Vec<_>>())
+        .unwrap_or_default();
+
+    match host.trim_start_matches("www.") {
+        "docs.google.com" => {
+            matches!(
+                segments.as_slice(),
+                ["document", "d", _, ..]
+                    | ["spreadsheets", "d", _, ..]
+                    | ["presentation", "d", _, ..]
+            )
+        }
+        "drive.google.com" => {
+            matches!(segments.as_slice(), ["file", "d", _, ..])
+                || matches!(segments.as_slice(), ["drive", "folders", _, ..])
+                || (segments.first() == Some(&"open")
+                    && url.query_pairs().any(|(key, _)| key == "id"))
+        }
+        _ => false,
+    }
+}
+
+async fn read_limited_text(response: reqwest::Response) -> Result<String, String> {
+    let mut stream = response.bytes_stream();
+    let mut bytes = Vec::new();
+
+    while let Some(chunk) = stream.next().await {
+        let chunk = chunk.map_err(|error| format!("reading title response failed: {error}"))?;
+        if bytes.len() + chunk.len() > MAX_TITLE_FETCH_BYTES {
+            let remaining = MAX_TITLE_FETCH_BYTES.saturating_sub(bytes.len());
+            bytes.extend_from_slice(&chunk[..remaining]);
+            break;
+        }
+        bytes.extend_from_slice(&chunk);
+    }
+
+    Ok(String::from_utf8_lossy(&bytes).into_owned())
+}
+
+fn extract_google_title(html: &str) -> Option<String> {
+    extract_meta_title(html)
+        .or_else(|| extract_title_tag(html))
+        .and_then(|title| normalize_google_title(&title))
+}
+
+fn extract_meta_title(html: &str) -> Option<String> {
+    let lower = html.to_ascii_lowercase();
+    let mut search_from = 0;
+
+    while let Some(relative_start) = lower[search_from..].find("<meta") {
+        let start = search_from + relative_start;
+        let Some(relative_end) = lower[start..].find('>') else {
+            break;
+        };
+        let end = start + relative_end + 1;
+        let tag = &html[start..end];
+        let lower_tag = &lower[start..end];
+
+        if lower_tag.contains("og:title") || lower_tag.contains("twitter:title") {
+            if let Some(content) = attr_value(tag, "content") {
+                return Some(content);
+            }
+        }
+
+        search_from = end;
+    }
+
+    None
+}
+
+fn extract_title_tag(html: &str) -> Option<String> {
+    let lower = html.to_ascii_lowercase();
+    let start = lower.find("<title")?;
+    let content_start = start + lower[start..].find('>')? + 1;
+    let content_end = content_start + lower[content_start..].find("</title>")?;
+    Some(html[content_start..content_end].to_string())
+}
+
+fn attr_value(tag: &str, attr: &str) -> Option<String> {
+    let lower = tag.to_ascii_lowercase();
+    let attr = attr.to_ascii_lowercase();
+    let mut search_from = 0;
+
+    while let Some(relative_start) = lower[search_from..].find(&attr) {
+        let name_start = search_from + relative_start;
+        let name_end = name_start + attr.len();
+        let before = lower[..name_start].chars().last();
+        let after = lower[name_end..].chars().next();
+        let has_name_boundary = !matches!(before, Some(c) if c.is_ascii_alphanumeric() || c == '-' || c == '_')
+            && !matches!(after, Some(c) if c.is_ascii_alphanumeric() || c == '-' || c == '_');
+
+        if has_name_boundary {
+            let lower_rest = &lower[name_end..];
+            let equals_offset = lower_rest.find('=')?;
+            let value_start = name_end + equals_offset + 1;
+            let value = tag[value_start..].trim_start();
+            let quote = value.chars().next()?;
+
+            if quote == '"' || quote == '\'' {
+                let value_body = &value[quote.len_utf8()..];
+                let value_end = value_body.find(quote)?;
+                return Some(decode_html_entities(&value_body[..value_end]));
+            }
+
+            let value_end = value
+                .find(|c: char| c.is_ascii_whitespace() || c == '>')
+                .unwrap_or(value.len());
+            return Some(decode_html_entities(&value[..value_end]));
+        }
+
+        search_from = name_end;
+    }
+
+    None
+}
+
+fn normalize_google_title(raw_title: &str) -> Option<String> {
+    let mut title = decode_html_entities(raw_title)
+        .split_whitespace()
+        .collect::<Vec<_>>()
+        .join(" ");
+
+    for suffix in [
+        " - Google Docs",
+        " - Google Sheets",
+        " - Google Slides",
+        " - Google Drive",
+    ] {
+        if let Some(stripped) = title.strip_suffix(suffix) {
+            title = stripped.trim().to_string();
+            break;
+        }
+    }
+
+    match title.as_str() {
+        ""
+        | "Document"
+        | "Spreadsheet"
+        | "Presentation"
+        | "Drive file"
+        | "Drive folder"
+        | "Google Docs"
+        | "Google Sheets"
+        | "Google Slides"
+        | "Google Drive"
+        | "Sign in - Google Accounts" => None,
+        _ => Some(title.chars().take(180).collect()),
+    }
+}
+
+fn decode_html_entities(value: &str) -> String {
+    let mut decoded = value
+        .replace("&amp;", "&")
+        .replace("&quot;", "\"")
+        .replace("&#39;", "'")
+        .replace("&#x27;", "'")
+        .replace("&apos;", "'")
+        .replace("&lt;", "<")
+        .replace("&gt;", ">");
+
+    while let Some(start) = decoded.find("&#") {
+        let Some(relative_end) = decoded[start..].find(';') else {
+            break;
+        };
+        let end = start + relative_end + 1;
+        let entity = &decoded[start + 2..end - 1];
+        let parsed = if let Some(hex) = entity
+            .strip_prefix('x')
+            .or_else(|| entity.strip_prefix('X'))
+        {
+            u32::from_str_radix(hex, 16).ok()
+        } else {
+            entity.parse::<u32>().ok()
+        };
+
+        let Some(ch) = parsed.and_then(char::from_u32) else {
+            break;
+        };
+        decoded.replace_range(start..end, &ch.to_string());
+    }
+
+    decoded
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{extract_google_title, is_supported_google_link};
+    use url::Url;
+
+    #[test]
+    fn title_prefers_open_graph_title() {
+        let html = r#"
+          <html>
+            <head>
+              <meta property="og:title" content="Composer links &amp; previews - Google Docs">
+              <title>Fallback - Google Docs</title>
+            </head>
+          </html>
+        "#;
+
+        assert_eq!(
+            extract_google_title(html).as_deref(),
+            Some("Composer links & previews")
+        );
+    }
+
+    #[test]
+    fn title_ignores_generic_google_titles() {
+        assert_eq!(
+            extract_google_title("<title>Sign in - Google Accounts</title>"),
+            None
+        );
+        assert_eq!(extract_google_title("<title>Google Docs</title>"), None);
+    }
+
+    #[test]
+    fn supported_urls_are_google_file_links_only() {
+        assert!(is_supported_google_link(
+            &Url::parse("https://docs.google.com/document/d/abc/edit").unwrap()
+        ));
+        assert!(is_supported_google_link(
+            &Url::parse("https://docs.google.com/spreadsheets/d/abc/edit").unwrap()
+        ));
+        assert!(is_supported_google_link(
+            &Url::parse("https://drive.google.com/file/d/abc/view").unwrap()
+        ));
+        assert!(!is_supported_google_link(
+            &Url::parse("https://example.com/document/d/abc/edit").unwrap()
+        ));
+        assert!(!is_supported_google_link(
+            &Url::parse("http://docs.google.com/document/d/abc/edit").unwrap()
+        ));
+    }
+}
diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index e8a2756ebf..7a6a771d4a 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -11,6 +11,7 @@ mod export_util;
 mod identity;
 mod identity_archive;
 mod legacy_storage;
+mod link_preview;
 mod media;
 mod media_download;
 mod media_transcode;
@@ -41,6 +42,7 @@ pub use engrams::*;
 pub use identity::*;
 pub use identity_archive::*;
 pub use legacy_storage::*;
+pub use link_preview::*;
 pub use media::*;
 pub use media_download::*;
 #[cfg(feature = "mesh-llm")]
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 82b5caa0c6..96795e3d86 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -445,6 +445,7 @@ pub fn run() {
             get_relay_ws_url,
             get_relay_http_url,
             get_media_proxy_port,
+            fetch_link_preview_title,
             discover_acp_providers,
             install_acp_runtime,
             discover_managed_agent_prereqs,
diff --git a/desktop/src/features/messages/lib/linkInteractionExtension.ts b/desktop/src/features/messages/lib/linkInteractionExtension.ts
index 033f603a18..30e4ac7a8c 100644
--- a/desktop/src/features/messages/lib/linkInteractionExtension.ts
+++ b/desktop/src/features/messages/lib/linkInteractionExtension.ts
@@ -14,6 +14,15 @@ type LinkInteractionExtensionOptions = {
 
 export const linkInteractionKey = new PluginKey("linkInteraction");
 
+const PASTED_LINK_AT_END_RE =
+  /(?:^|\s)((?:https?:\/\/|www\.)[^\s]+|(?:github\.com|linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s]+)$/i;
+
+function isPasteEndingWithLink(text: string): boolean {
+  const trimmedEnd = text.trimEnd();
+  if (!trimmedEnd || trimmedEnd.length !== text.length) return false;
+  return PASTED_LINK_AT_END_RE.test(trimmedEnd);
+}
+
 /**
  * Centralises composer link interactions that depend on ProseMirror editor
  * state: click interception, click-vs-drag selection preservation, and active
@@ -27,6 +36,8 @@ export function createLinkInteractionExtension({
     name: "linkInteraction",
 
     addProseMirrorPlugins() {
+      let suppressSelectionCardUntil = 0;
+
       return [
         new Plugin({
           key: linkInteractionKey,
@@ -42,11 +53,22 @@ export function createLinkInteractionExtension({
                 ) {
                   return;
                 }
-                notifyLinkSelection(nextState, getSelectionChangeHandler);
+                notifyLinkSelection(nextState, getSelectionChangeHandler, {
+                  suppressLinkedSelection:
+                    Date.now() < suppressSelectionCardUntil,
+                });
               },
             };
           },
           props: {
+            handlePaste(_view, event) {
+              const pastedText =
+                event.clipboardData?.getData("text/plain") ?? "";
+              if (isPasteEndingWithLink(pastedText)) {
+                suppressSelectionCardUntil = Date.now() + 500;
+              }
+              return false;
+            },
             handleDOMEvents: {
               // Native anchor default can still win in the WebView before
               // ProseMirror's semantic click hook runs, so intercept editor
@@ -98,6 +120,7 @@ export function createLinkInteractionExtension({
 function notifyLinkSelection(
   state: EditorState,
   getSelectionChangeHandler: LinkInteractionExtensionOptions["getSelectionChangeHandler"],
+  options?: { suppressLinkedSelection?: boolean },
 ) {
   const handler = getSelectionChangeHandler();
   if (!handler) return;
@@ -105,7 +128,8 @@ function notifyLinkSelection(
     handler(null);
     return;
   }
-  handler(resolveLinkAt(state, state.selection.from));
+  const info = resolveLinkAt(state, state.selection.from);
+  handler(options?.suppressLinkedSelection && info ? null : info);
 }
 
 function handleLinkClick({
diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts
index 97784317d7..ccabc04645 100644
--- a/desktop/src/features/messages/lib/useRichTextEditor.ts
+++ b/desktop/src/features/messages/lib/useRichTextEditor.ts
@@ -6,7 +6,7 @@ import StarterKit from "@tiptap/starter-kit";
 import Placeholder from "@tiptap/extension-placeholder";
 import Link from "@tiptap/extension-link";
 import { Extension, type KeyboardShortcutCommand } from "@tiptap/core";
-import { Selection, TextSelection } from "@tiptap/pm/state";
+import { Plugin, Selection, TextSelection } from "@tiptap/pm/state";
 
 import { isMacPlatform } from "@/shared/lib/platform";
 import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
@@ -94,6 +94,63 @@ export type RichTextEditorOptions = {
   onLinkSelectionChange?: (info: LinkSelectionInfo | null) => void;
 };
 
+const PASTED_LINK_AT_END_RE =
+  /(?:^|\s)((?:https?:\/\/|www\.)[^\s]+|(?:github\.com|linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s]+)$/i;
+
+function shouldAppendSpaceAfterPaste(text: string): boolean {
+  const trimmedEnd = text.trimEnd();
+  if (!trimmedEnd || trimmedEnd.length !== text.length) return false;
+  return PASTED_LINK_AT_END_RE.test(trimmedEnd);
+}
+
+const LinkPasteTrailingSpace = Extension.create({
+  name: "linkPasteTrailingSpace",
+
+  addProseMirrorPlugins() {
+    return [
+      new Plugin({
+        props: {
+          handlePaste(view, event) {
+            const pastedText = event.clipboardData?.getData("text/plain") ?? "";
+            if (!shouldAppendSpaceAfterPaste(pastedText)) return false;
+
+            window.setTimeout(() => {
+              if (!view.dom.isConnected) return;
+              const { state } = view;
+              if (!state.selection.empty) return;
+
+              const from = state.selection.from;
+              if (from < state.doc.content.size) {
+                const nextText = state.doc.textBetween(
+                  from,
+                  Math.min(state.doc.content.size, from + 1),
+                  "\n",
+                  "\n",
+                );
+                if (/^\s$/.test(nextText)) return;
+              }
+
+              let transaction = state.tr.insertText(" ", from, from);
+              const linkMark = state.schema.marks.link;
+              if (linkMark) {
+                transaction = transaction.removeMark(from, from + 1, linkMark);
+              }
+              transaction = transaction.setSelection(
+                TextSelection.create(transaction.doc, from + 1),
+              );
+              transaction.setStoredMarks([]);
+              view.dispatch(transaction.scrollIntoView());
+              view.focus();
+            }, 0);
+
+            return false;
+          },
+        },
+      }),
+    ];
+  },
+});
+
 /**
  * Creates and manages a Tiptap editor configured for Markdown output.
  *
@@ -342,6 +399,7 @@ export function useRichTextEditor({
             class: "text-primary underline underline-offset-4 cursor-text",
           },
         }),
+        LinkPasteTrailingSpace,
         createLinkInteractionExtension({
           getEditLinkHandler: () => onEditLinkRef.current,
           getSelectionChangeHandler: () => onLinkSelectionChangeRef.current,
diff --git a/desktop/src/shared/lib/linkPreview.test.mjs b/desktop/src/shared/lib/linkPreview.test.mjs
new file mode 100644
index 0000000000..52361d6f32
--- /dev/null
+++ b/desktop/src/shared/lib/linkPreview.test.mjs
@@ -0,0 +1,186 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+  extractSupportedLinkPreviews,
+  isSupportedLinkAutolinkLabel,
+  parseSupportedLinkPreview,
+} from "./linkPreview.ts";
+
+test("parseSupportedLinkPreview parses GitHub pull request URLs", () => {
+  assert.deepEqual(
+    parseSupportedLinkPreview("https://github.com/block/sprout/pull/1234"),
+    {
+      kind: "github-pull-request",
+      href: "https://github.com/block/sprout/pull/1234",
+      provider: "GitHub",
+      title: "block/sprout #1234",
+      typeLabel: "PR",
+    },
+  );
+});
+
+test("parseSupportedLinkPreview parses GitHub repository URLs", () => {
+  assert.deepEqual(
+    parseSupportedLinkPreview("https://github.com/block/sprout"),
+    {
+      kind: "github-repository",
+      href: "https://github.com/block/sprout",
+      provider: "GitHub",
+      title: "block/sprout",
+      typeLabel: "repo",
+    },
+  );
+});
+
+test("parseSupportedLinkPreview trims markdown punctuation around GitHub URLs", () => {
+  assert.deepEqual(
+    parseSupportedLinkPreview("https://github.com/block/sprout/pull/1234)."),
+    {
+      kind: "github-pull-request",
+      href: "https://github.com/block/sprout/pull/1234",
+      provider: "GitHub",
+      title: "block/sprout #1234",
+      typeLabel: "PR",
+    },
+  );
+});
+
+test("parseSupportedLinkPreview ignores unsupported GitHub URLs", () => {
+  assert.equal(
+    parseSupportedLinkPreview("https://github.com/block/sprout/tree/main"),
+    null,
+  );
+});
+
+test("parseSupportedLinkPreview parses Linear issue URLs", () => {
+  assert.deepEqual(
+    parseSupportedLinkPreview(
+      "https://linear.app/buzz/issue/BUG-321/fix-link-previews",
+    ),
+    {
+      kind: "linear-issue",
+      href: "https://linear.app/buzz/issue/BUG-321/fix-link-previews",
+      provider: "Linear",
+      title: "BUG-321",
+      typeLabel: "issue",
+    },
+  );
+});
+
+test("parseSupportedLinkPreview normalizes Linear issue URL variants", () => {
+  assert.deepEqual(
+    parseSupportedLinkPreview("linear.app/buzz/issue/a-7/fix-link-previews"),
+    {
+      kind: "linear-issue",
+      href: "https://linear.app/buzz/issue/a-7/fix-link-previews",
+      provider: "Linear",
+      title: "A-7",
+      typeLabel: "issue",
+    },
+  );
+});
+
+test("parseSupportedLinkPreview parses Google app URLs", () => {
+  assert.deepEqual(
+    [
+      "https://drive.google.com/file/d/abc123/view",
+      "https://drive.google.com/drive/folders/folder123",
+      "https://docs.google.com/document/d/doc123/edit",
+      "https://docs.google.com/spreadsheets/d/sheet123/edit",
+      "https://docs.google.com/presentation/d/slides123/edit",
+    ].map((href) => parseSupportedLinkPreview(href)?.kind),
+    [
+      "google-drive-file",
+      "google-drive-folder",
+      "google-docs-document",
+      "google-sheets-spreadsheet",
+      "google-slides-presentation",
+    ],
+  );
+});
+
+test("extractSupportedLinkPreviews returns unique supported links in order", () => {
+  assert.deepEqual(
+    extractSupportedLinkPreviews(
+      [
+        "See github.com/block/sprout/pull/1",
+        "and https://linear.app/buzz/issue/BUG-2/fix-preview",
+        "then https://github.com/block/sprout/pull/1 again.",
+        "plus https://docs.google.com/document/d/doc123/edit",
+      ].join(" "),
+    ).map((preview) => preview.title),
+    ["block/sprout #1", "BUG-2", "Document"],
+  );
+});
+
+test("extractSupportedLinkPreviews handles markdown link serialization", () => {
+  assert.deepEqual(
+    extractSupportedLinkPreviews(
+      "[https://github.com/block/sprout/pull/44](https://github.com/block/sprout/pull/44)",
+    ).map((preview) => preview.title),
+    ["block/sprout #44"],
+  );
+});
+
+test("extractSupportedLinkPreviews uses useful markdown labels as titles", () => {
+  assert.deepEqual(
+    extractSupportedLinkPreviews(
+      "[Composer attachment polish](https://docs.google.com/document/d/doc123/edit)",
+    ),
+    [
+      {
+        kind: "google-docs-document",
+        href: "https://docs.google.com/document/d/doc123/edit",
+        provider: "Google Docs",
+        title: "Composer attachment polish",
+        typeLabel: "document",
+      },
+    ],
+  );
+});
+
+test("extractSupportedLinkPreviews includes multiple supported Google links", () => {
+  assert.deepEqual(
+    extractSupportedLinkPreviews(
+      [
+        "https://docs.google.com/document/d/doc123/edit",
+        "https://docs.google.com/spreadsheets/d/sheet123/edit",
+        "https://docs.google.com/presentation/d/slides123/edit",
+      ].join(" "),
+    ).map((preview) => preview.kind),
+    [
+      "google-docs-document",
+      "google-sheets-spreadsheet",
+      "google-slides-presentation",
+    ],
+  );
+});
+
+test("extractSupportedLinkPreviews skips URLs inside inline and fenced code", () => {
+  assert.deepEqual(
+    extractSupportedLinkPreviews(
+      [
+        "`https://github.com/block/sprout/pull/1`",
+        "```",
+        "https://linear.app/buzz/issue/BUG-2/fix-preview",
+        "```",
+        "https://github.com/block/sprout/pull/3",
+      ].join("\n"),
+    ).map((preview) => preview.title),
+    ["block/sprout #3"],
+  );
+});
+
+test("isSupportedLinkAutolinkLabel matches normalized bare URL labels", () => {
+  const preview = parseSupportedLinkPreview("github.com/block/sprout/pull/5");
+  assert.ok(preview);
+  assert.equal(
+    isSupportedLinkAutolinkLabel(
+      "https://github.com/block/sprout/pull/5",
+      preview,
+    ),
+    true,
+  );
+  assert.equal(isSupportedLinkAutolinkLabel("review this", preview), false);
+});
diff --git a/desktop/src/shared/lib/linkPreview.ts b/desktop/src/shared/lib/linkPreview.ts
new file mode 100644
index 0000000000..e06d859b3f
--- /dev/null
+++ b/desktop/src/shared/lib/linkPreview.ts
@@ -0,0 +1,363 @@
+export type SupportedLinkPreviewKind =
+  | "github-pull-request"
+  | "github-issue"
+  | "github-repository"
+  | "linear-issue"
+  | "google-drive-file"
+  | "google-drive-folder"
+  | "google-docs-document"
+  | "google-sheets-spreadsheet"
+  | "google-slides-presentation";
+
+export type SupportedLinkPreview = {
+  kind: SupportedLinkPreviewKind;
+  href: string;
+  provider:
+    | "GitHub"
+    | "Linear"
+    | "Google Drive"
+    | "Google Docs"
+    | "Google Sheets"
+    | "Google Slides";
+  title: string;
+  typeLabel:
+    | "PR"
+    | "issue"
+    | "repo"
+    | "file"
+    | "folder"
+    | "document"
+    | "spreadsheet"
+    | "presentation";
+};
+
+const SUPPORTED_URL_RE =
+  /\b(?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+/gi;
+const MARKDOWN_SUPPORTED_LINK_RE =
+  /!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi;
+const MAX_PREVIEWS = 8;
+
+function stripCodeBlocks(content: string): string {
+  return content
+    .replace(/```[\s\S]*?```/g, " ")
+    .replace(/~~~[\s\S]*?~~~/g, " ")
+    .replace(/`[^`\n]*`/g, " ");
+}
+
+function countChar(value: string, char: string): number {
+  let count = 0;
+  for (const current of value) {
+    if (current === char) count += 1;
+  }
+  return count;
+}
+
+function trimUrlCandidate(candidate: string): string {
+  let value = candidate.replace(/[.,!?;:]+$/g, "");
+
+  const pairs: Array<[close: string, open: string]> = [
+    [")", "("],
+    ["]", "["],
+    ["}", "{"],
+  ];
+
+  let changed = true;
+  while (changed) {
+    changed = false;
+    for (const [close, open] of pairs) {
+      if (
+        value.endsWith(close) &&
+        countChar(value, close) > countChar(value, open)
+      ) {
+        value = value.slice(0, -1);
+        changed = true;
+      }
+    }
+  }
+
+  return value;
+}
+
+function safeDecode(value: string): string {
+  try {
+    return decodeURIComponent(value);
+  } catch {
+    return value;
+  }
+}
+
+function normalizeHostname(parsed: URL): string {
+  return parsed.hostname.toLowerCase().replace(/^www\./, "");
+}
+
+function createPreview(
+  kind: SupportedLinkPreviewKind,
+  parsed: URL,
+  provider: SupportedLinkPreview["provider"],
+  typeLabel: SupportedLinkPreview["typeLabel"],
+  title: string,
+): SupportedLinkPreview {
+  return {
+    kind,
+    href: parsed.href,
+    provider,
+    title,
+    typeLabel,
+  };
+}
+
+function parseGithubLink(parsed: URL): SupportedLinkPreview | null {
+  if (normalizeHostname(parsed) !== "github.com") {
+    return null;
+  }
+
+  const segments = parsed.pathname.split("/").filter(Boolean).map(safeDecode);
+  const [owner, repo, resource, number] = segments;
+  if (!owner || !repo) return null;
+
+  const repoLabel = `${owner}/${repo}`;
+  if (resource === undefined) {
+    return createPreview(
+      "github-repository",
+      parsed,
+      "GitHub",
+      "repo",
+      repoLabel,
+    );
+  }
+
+  if (/^\d+$/.test(number ?? "")) {
+    if (resource === "pull") {
+      return createPreview(
+        "github-pull-request",
+        parsed,
+        "GitHub",
+        "PR",
+        `${repoLabel} #${number}`,
+      );
+    }
+
+    if (resource === "issues") {
+      return createPreview(
+        "github-issue",
+        parsed,
+        "GitHub",
+        "issue",
+        `${repoLabel} #${number}`,
+      );
+    }
+  }
+
+  return null;
+}
+
+function parseLinearIssue(parsed: URL): SupportedLinkPreview | null {
+  if (normalizeHostname(parsed) !== "linear.app") {
+    return null;
+  }
+
+  const segments = parsed.pathname.split("/").filter(Boolean).map(safeDecode);
+  const issueSegmentIndex = segments.findIndex(
+    (segment) => segment.toLowerCase() === "issue",
+  );
+  const workspace = segments[0];
+  const issueId = segments[issueSegmentIndex + 1]?.toUpperCase();
+
+  if (
+    !workspace ||
+    issueSegmentIndex < 1 ||
+    !issueId ||
+    !/^[A-Z][A-Z0-9]*-\d+$/.test(issueId)
+  ) {
+    return null;
+  }
+
+  return createPreview("linear-issue", parsed, "Linear", "issue", issueId);
+}
+
+function parseGoogleDriveLink(parsed: URL): SupportedLinkPreview | null {
+  if (normalizeHostname(parsed) !== "drive.google.com") {
+    return null;
+  }
+
+  const segments = parsed.pathname.split("/").filter(Boolean);
+  const folderSegmentIndex = segments.findIndex(
+    (segment) => segment.toLowerCase() === "folders",
+  );
+
+  if (folderSegmentIndex >= 0 && segments[folderSegmentIndex + 1]) {
+    return createPreview(
+      "google-drive-folder",
+      parsed,
+      "Google Drive",
+      "folder",
+      "Drive folder",
+    );
+  }
+
+  if (
+    (segments[0] === "file" && segments[1] === "d" && segments[2]) ||
+    (segments[0] === "open" && parsed.searchParams.has("id"))
+  ) {
+    return createPreview(
+      "google-drive-file",
+      parsed,
+      "Google Drive",
+      "file",
+      "Drive file",
+    );
+  }
+
+  return null;
+}
+
+function parseGoogleDocsLink(parsed: URL): SupportedLinkPreview | null {
+  if (normalizeHostname(parsed) !== "docs.google.com") {
+    return null;
+  }
+
+  const segments = parsed.pathname.split("/").filter(Boolean);
+  const [resource, dSegment, id] = segments;
+  if (dSegment !== "d" || !id) return null;
+
+  if (resource === "document") {
+    return createPreview(
+      "google-docs-document",
+      parsed,
+      "Google Docs",
+      "document",
+      "Document",
+    );
+  }
+
+  if (resource === "spreadsheets") {
+    return createPreview(
+      "google-sheets-spreadsheet",
+      parsed,
+      "Google Sheets",
+      "spreadsheet",
+      "Spreadsheet",
+    );
+  }
+
+  if (resource === "presentation") {
+    return createPreview(
+      "google-slides-presentation",
+      parsed,
+      "Google Slides",
+      "presentation",
+      "Presentation",
+    );
+  }
+
+  return null;
+}
+
+/** Parse a supported external URL into a compact preview. */
+export function parseSupportedLinkPreview(
+  href: string,
+): SupportedLinkPreview | null {
+  let parsed: URL;
+  try {
+    const candidate = trimUrlCandidate(href);
+    parsed = new URL(
+      /^https?:\/\//i.test(candidate) ? candidate : `https://${candidate}`,
+    );
+  } catch {
+    return null;
+  }
+
+  if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
+    return null;
+  }
+
+  return (
+    parseGithubLink(parsed) ??
+    parseLinearIssue(parsed) ??
+    parseGoogleDriveLink(parsed) ??
+    parseGoogleDocsLink(parsed)
+  );
+}
+
+export function isSupportedLinkAutolinkLabel(
+  label: string,
+  preview: SupportedLinkPreview,
+): boolean {
+  return parseSupportedLinkPreview(label)?.href === preview.href;
+}
+
+function titleFromMarkdownLabel(
+  label: string,
+  preview: SupportedLinkPreview,
+): string | null {
+  const title = label.replace(/\s+/g, " ").trim();
+  if (!title || isSupportedLinkAutolinkLabel(title, preview)) {
+    return null;
+  }
+  return title;
+}
+
+function withTitle(
+  preview: SupportedLinkPreview,
+  title: string | null,
+): SupportedLinkPreview {
+  return title ? { ...preview, title } : preview;
+}
+
+type LinkPreviewCandidate = {
+  href: string;
+  index: number;
+  label?: string;
+  order: number;
+};
+
+/** Extract supported link previews from message text, preserving first-seen order. */
+export function extractSupportedLinkPreviews(
+  content: string,
+): SupportedLinkPreview[] {
+  const previews: SupportedLinkPreview[] = [];
+  const seen = new Set<string>();
+  const searchable = stripCodeBlocks(content);
+  const candidates: LinkPreviewCandidate[] = [];
+  let order = 0;
+
+  for (const match of searchable.matchAll(MARKDOWN_SUPPORTED_LINK_RE)) {
+    if (match[0]?.startsWith("!")) continue;
+    candidates.push({
+      href: match[2],
+      index: match.index ?? 0,
+      label: match[1],
+      order,
+    });
+    order += 1;
+  }
+
+  for (const match of searchable.matchAll(SUPPORTED_URL_RE)) {
+    candidates.push({
+      href: match[0],
+      index: match.index ?? 0,
+      order,
+    });
+    order += 1;
+  }
+
+  candidates.sort((a, b) => a.index - b.index || a.order - b.order);
+
+  for (const candidate of candidates) {
+    const preview = parseSupportedLinkPreview(candidate.href);
+    if (!preview || seen.has(preview.href)) continue;
+
+    seen.add(preview.href);
+    previews.push(
+      withTitle(
+        preview,
+        candidate.label
+          ? titleFromMarkdownLabel(candidate.label, preview)
+          : null,
+      ),
+    );
+    if (previews.length >= MAX_PREVIEWS) break;
+  }
+
+  return previews;
+}
diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts
new file mode 100644
index 0000000000..9773c33945
--- /dev/null
+++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts
@@ -0,0 +1,92 @@
+import * as React from "react";
+
+import { invokeTauri } from "@/shared/api/tauri";
+
+import type { SupportedLinkPreview } from "./linkPreview";
+
+const GOOGLE_FALLBACK_TITLES = new Set([
+  "Drive file",
+  "Drive folder",
+  "Document",
+  "Spreadsheet",
+  "Presentation",
+]);
+
+const titleCache = new Map<string, Promise<string | null> | string | null>();
+
+function fetchLinkPreviewTitle(href: string): Promise<string | null> {
+  return invokeTauri<string | null>("fetch_link_preview_title", { href });
+}
+
+function shouldResolveTitle(preview: SupportedLinkPreview): boolean {
+  return (
+    preview.kind.startsWith("google-") &&
+    GOOGLE_FALLBACK_TITLES.has(preview.title)
+  );
+}
+
+function cacheTitle(href: string): Promise<string | null> {
+  const cached = titleCache.get(href);
+  if (cached instanceof Promise) return cached;
+  if (cached !== undefined) return Promise.resolve(cached);
+
+  const promise = fetchLinkPreviewTitle(href)
+    .then((title) => {
+      titleCache.set(href, title);
+      return title;
+    })
+    .catch(() => {
+      titleCache.set(href, null);
+      return null;
+    });
+  titleCache.set(href, promise);
+  return promise;
+}
+
+export function useResolvedLinkPreviews(
+  previews: SupportedLinkPreview[],
+): SupportedLinkPreview[] {
+  const [resolvedTitles, setResolvedTitles] = React.useState<
+    Record<string, string>
+  >({});
+
+  React.useEffect(() => {
+    let cancelled = false;
+    const pending = previews.filter(shouldResolveTitle);
+    if (pending.length === 0) return undefined;
+
+    for (const preview of pending) {
+      const cached = titleCache.get(preview.href);
+      if (typeof cached === "string" && cached) {
+        setResolvedTitles((current) =>
+          current[preview.href] === cached
+            ? current
+            : { ...current, [preview.href]: cached },
+        );
+        continue;
+      }
+
+      void cacheTitle(preview.href).then((title) => {
+        if (cancelled || !title) return;
+        setResolvedTitles((current) =>
+          current[preview.href] === title
+            ? current
+            : { ...current, [preview.href]: title },
+        );
+      });
+    }
+
+    return () => {
+      cancelled = true;
+    };
+  }, [previews]);
+
+  return React.useMemo(
+    () =>
+      previews.map((preview) => {
+        const title = resolvedTitles[preview.href];
+        return title ? { ...preview, title } : preview;
+      }),
+    [previews, resolvedTitles],
+  );
+}
diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index e416856888..b2ee0beaff 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -879,6 +879,73 @@
   cursor: text;
 }
 
+.rich-text-composer .tiptap a[href*="linear.app"],
+.message-markdown .linear-link {
+  --buzz-linear-color: #5e6ad2;
+}
+
+.dark .rich-text-composer .tiptap a[href*="linear.app"],
+.dark .message-markdown .linear-link {
+  --buzz-linear-color: #828fff;
+}
+
+.rich-text-composer .tiptap a[href*="linear.app"],
+.message-markdown .linear-link {
+  color: var(--buzz-linear-color);
+}
+
+.rich-text-composer .tiptap a[href*="linear.app"]:hover,
+.message-markdown .linear-link:hover {
+  color: var(--buzz-linear-color);
+}
+
+.rich-text-composer .tiptap a[href*="github.com"] {
+  --buzz-composer-link-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E");
+  --buzz-composer-link-icon-color: hsl(var(--foreground));
+}
+
+.rich-text-composer .tiptap a[href*="linear.app"] {
+  --buzz-composer-link-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z'/%3E%3C/svg%3E");
+  --buzz-composer-link-icon-color: var(--buzz-linear-color);
+}
+
+.rich-text-composer .tiptap a[href*="drive.google.com"] {
+  --buzz-composer-link-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12.01 1.485c-2.082 0-3.754.02-3.743.047.01.02 1.708 3.001 3.774 6.62l3.76 6.574h3.76c2.081 0 3.753-.02 3.742-.047-.005-.02-1.708-3.001-3.775-6.62l-3.76-6.574zm-4.76 1.73a789.828 789.861 0 0 0-3.63 6.319L0 15.868l1.89 3.298 1.885 3.297 3.62-6.335 3.618-6.33-1.88-3.287C8.1 4.704 7.255 3.22 7.25 3.214zm2.259 12.653-.203.348c-.114.198-.96 1.672-1.88 3.287a423.93 423.948 0 0 1-1.698 2.97c-.01.026 3.24.042 7.222.042h7.244l1.796-3.157c.992-1.734 1.85-3.23 1.906-3.323l.104-.167h-7.249z'/%3E%3C/svg%3E");
+  --buzz-composer-link-icon-color: #4285f4;
+}
+
+.rich-text-composer .tiptap a[href*="docs.google.com/document"] {
+  --buzz-composer-link-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M14.727 6.727H14V0H4.91c-.905 0-1.637.732-1.637 1.636v20.728c0 .904.732 1.636 1.636 1.636h14.182c.904 0 1.636-.732 1.636-1.636V6.727h-6zm-.545 10.455H7.09v-1.364h7.09v1.364zm2.727-3.273H7.091v-1.364h9.818v1.364zm0-3.273H7.091V9.273h9.818v1.363zM14.727 6h6l-6-6v6z'/%3E%3C/svg%3E");
+  --buzz-composer-link-icon-color: #4285f4;
+}
+
+.rich-text-composer .tiptap a[href*="docs.google.com/spreadsheets"] {
+  --buzz-composer-link-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M11.318 12.545H7.91v-1.909h3.41v1.91zM14.728 0v6h6l-6-6zm1.363 10.636h-3.41v1.91h3.41v-1.91zm0 3.273h-3.41v1.91h3.41v-1.91zM20.727 6.5v15.864c0 .904-.732 1.636-1.636 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.318v6.5h6.5zm-3.273 2.773H6.545v7.909h10.91v-7.91zm-6.136 4.636H7.91v1.91h3.41v-1.91z'/%3E%3C/svg%3E");
+  --buzz-composer-link-icon-color: #34a853;
+}
+
+.rich-text-composer .tiptap a[href*="docs.google.com/presentation"] {
+  --buzz-composer-link-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M16.09 15.273H7.91v-4.637h8.18v4.637zm1.728-8.523h2.91v15.614c0 .904-.733 1.636-1.637 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.068v6.75h3.841zm-.363 2.523H6.545v7.363h10.91V9.273zm-2.728-5.979V6h6.001l-6-6v3.294z'/%3E%3C/svg%3E");
+  --buzz-composer-link-icon-color: #fbbc04;
+}
+
+.rich-text-composer .tiptap a[href*="github.com"]::before,
+.rich-text-composer .tiptap a[href*="linear.app"]::before,
+.rich-text-composer .tiptap a[href*="drive.google.com"]::before,
+.rich-text-composer .tiptap a[href*="docs.google.com/document"]::before,
+.rich-text-composer .tiptap a[href*="docs.google.com/spreadsheets"]::before,
+.rich-text-composer .tiptap a[href*="docs.google.com/presentation"]::before {
+  background-color: var(--buzz-composer-link-icon-color, currentColor);
+  content: "";
+  display: inline-block;
+  height: 0.875em;
+  margin-right: 0.25em;
+  mask: var(--buzz-composer-link-icon) center / contain no-repeat;
+  -webkit-mask: var(--buzz-composer-link-icon) center / contain no-repeat;
+  transform: translateY(0.1em);
+  width: 0.875em;
+}
+
 .rich-text-composer .tiptap hr {
   border-color: hsl(var(--border) / 0.8);
   margin: 0.5rem 0;
@@ -901,6 +968,58 @@
   --agent-icon-gap: 0.125rem;
 }
 
+.message-markdown p:empty {
+  display: none;
+}
+
+.message-markdown [data-link-preview] {
+  --buzz-link-preview-icon-bg: hsl(var(--muted));
+  --buzz-link-preview-icon-color: hsl(var(--foreground));
+}
+
+.dark .message-markdown [data-link-preview] {
+  --buzz-link-preview-icon-bg: #24292f;
+}
+
+.message-markdown [data-link-preview="github-issue"],
+.message-markdown [data-link-preview="github-pull-request"],
+.message-markdown [data-link-preview="github-repository"] {
+  --buzz-link-preview-icon-color: #24292f;
+}
+
+.dark .message-markdown [data-link-preview="github-issue"],
+.dark .message-markdown [data-link-preview="github-pull-request"],
+.dark .message-markdown [data-link-preview="github-repository"] {
+  --buzz-link-preview-icon-color: #fff;
+}
+
+.message-markdown [data-link-preview="linear-issue"] {
+  --buzz-link-preview-icon-color: #5e6ad2;
+}
+
+.dark .message-markdown [data-link-preview="linear-issue"] {
+  --buzz-link-preview-icon-color: #828fff;
+}
+
+.message-markdown [data-link-preview="google-drive-file"],
+.message-markdown [data-link-preview="google-drive-folder"],
+.message-markdown [data-link-preview="google-docs-document"] {
+  --buzz-link-preview-icon-color: #4285f4;
+}
+
+.message-markdown [data-link-preview="google-sheets-spreadsheet"] {
+  --buzz-link-preview-icon-color: #34a853;
+}
+
+.message-markdown [data-link-preview="google-slides-presentation"] {
+  --buzz-link-preview-icon-color: #fbbc04;
+}
+
+.message-markdown .link-preview-media {
+  background-color: var(--buzz-link-preview-icon-bg);
+  color: var(--buzz-link-preview-icon-color);
+}
+
 .message-markdown .mention-chip,
 .message-markdown .inline-code-chip,
 .message-markdown :not(pre) > code {
diff --git a/desktop/src/shared/ui/attachment.tsx b/desktop/src/shared/ui/attachment.tsx
new file mode 100644
index 0000000000..b3765415d3
--- /dev/null
+++ b/desktop/src/shared/ui/attachment.tsx
@@ -0,0 +1,196 @@
+import type * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+
+import { cn } from "@/shared/lib/cn";
+import { Button, type ButtonProps } from "@/shared/ui/button";
+
+type AttachmentProps = React.ComponentProps<"div"> & {
+  orientation?: "horizontal" | "vertical";
+  size?: "default" | "sm" | "xs";
+  state?: "idle" | "uploading" | "processing" | "error" | "done";
+};
+
+function Attachment({
+  className,
+  orientation = "horizontal",
+  size = "default",
+  state = "done",
+  ...props
+}: AttachmentProps) {
+  return (
+    <div
+      className={cn(
+        "group/attachment relative flex min-w-0 gap-3 overflow-hidden rounded-lg border border-border/70 bg-muted/30 text-left transition-colors",
+        "hover:border-border hover:bg-muted/50 data-[state=error]:border-destructive/40 data-[state=error]:bg-destructive/10",
+        "focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
+        orientation === "horizontal" && "items-center",
+        orientation === "vertical" && "flex-col",
+        size === "default" && "px-3 py-2.5",
+        size === "sm" && "px-2.5 py-2",
+        size === "xs" && "gap-2 px-2 py-1.5",
+        className,
+      )}
+      data-orientation={orientation}
+      data-slot="attachment"
+      data-state={state}
+      {...props}
+    />
+  );
+}
+
+type AttachmentMediaProps = React.ComponentProps<"div"> & {
+  variant?: "icon" | "image";
+};
+
+function AttachmentMedia({
+  className,
+  variant = "icon",
+  ...props
+}: AttachmentMediaProps) {
+  return (
+    <div
+      className={cn(
+        "flex shrink-0 items-center justify-center overflow-hidden rounded-md bg-background text-muted-foreground",
+        variant === "icon" && "h-9 w-9",
+        variant === "image" && "aspect-square h-12 w-12",
+        "[&_svg]:h-4 [&_svg]:w-4",
+        className,
+      )}
+      data-slot="attachment-media"
+      data-variant={variant}
+      {...props}
+    />
+  );
+}
+
+function AttachmentContent({
+  className,
+  ...props
+}: React.ComponentProps<"div">) {
+  return (
+    <div
+      className={cn("min-w-0 flex-1", className)}
+      data-slot="attachment-content"
+      {...props}
+    />
+  );
+}
+
+function AttachmentTitle({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      className={cn(
+        "truncate text-sm font-semibold leading-5 text-foreground group-data-[state=processing]/attachment:animate-pulse group-data-[state=uploading]/attachment:animate-pulse",
+        className,
+      )}
+      data-slot="attachment-title"
+      {...props}
+    />
+  );
+}
+
+function AttachmentDescription({
+  className,
+  ...props
+}: React.ComponentProps<"div">) {
+  return (
+    <div
+      className={cn(
+        "truncate text-xs leading-4 text-muted-foreground",
+        className,
+      )}
+      data-slot="attachment-description"
+      {...props}
+    />
+  );
+}
+
+function AttachmentActions({
+  className,
+  ...props
+}: React.ComponentProps<"div">) {
+  return (
+    <div
+      className={cn(
+        "relative z-20 flex shrink-0 items-center gap-1",
+        className,
+      )}
+      data-slot="attachment-actions"
+      {...props}
+    />
+  );
+}
+
+type AttachmentActionProps = ButtonProps & {
+  asChild?: boolean;
+};
+
+function AttachmentAction({
+  className,
+  size = "icon-xs",
+  variant = "ghost",
+  ...props
+}: AttachmentActionProps) {
+  return (
+    <Button
+      className={cn(
+        "relative z-20 text-muted-foreground hover:bg-muted hover:text-foreground",
+        className,
+      )}
+      data-slot="attachment-action"
+      size={size}
+      variant={variant}
+      {...props}
+    />
+  );
+}
+
+type AttachmentTriggerProps = React.ComponentProps<"button"> & {
+  asChild?: boolean;
+};
+
+function AttachmentTrigger({
+  asChild,
+  className,
+  type = "button",
+  ...props
+}: AttachmentTriggerProps) {
+  const Comp = asChild ? Slot : "button";
+
+  return (
+    <Comp
+      className={cn(
+        "absolute inset-0 z-10 rounded-lg focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
+        className,
+      )}
+      data-slot="attachment-trigger"
+      type={asChild ? undefined : type}
+      {...props}
+    />
+  );
+}
+
+function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      className={cn(
+        "flex min-w-0 gap-2 overflow-x-auto overscroll-x-contain pb-1",
+        className,
+      )}
+      data-slot="attachment-group"
+      {...props}
+    />
+  );
+}
+
+export {
+  Attachment,
+  AttachmentAction,
+  AttachmentActions,
+  AttachmentContent,
+  AttachmentDescription,
+  AttachmentGroup,
+  AttachmentMedia,
+  AttachmentTitle,
+  AttachmentTrigger,
+};
diff --git a/desktop/src/shared/ui/link-preview-attachment.tsx b/desktop/src/shared/ui/link-preview-attachment.tsx
new file mode 100644
index 0000000000..5d6b80767a
--- /dev/null
+++ b/desktop/src/shared/ui/link-preview-attachment.tsx
@@ -0,0 +1,158 @@
+import { ExternalLink } from "lucide-react";
+
+import type { SupportedLinkPreview } from "@/shared/lib/linkPreview";
+import { cn } from "@/shared/lib/cn";
+import {
+  Attachment,
+  AttachmentActions,
+  AttachmentContent,
+  AttachmentMedia,
+  AttachmentTitle,
+  AttachmentTrigger,
+} from "@/shared/ui/attachment";
+
+function LinearLogo({ className }: { className?: string }) {
+  return (
+    <svg
+      aria-hidden="true"
+      className={className}
+      fill="currentColor"
+      viewBox="0 0 24 24"
+    >
+      <path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
+    </svg>
+  );
+}
+
+function GitHubLogo({ className }: { className?: string }) {
+  return (
+    <svg
+      aria-hidden="true"
+      className={className}
+      fill="currentColor"
+      viewBox="0 0 24 24"
+    >
+      <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
+    </svg>
+  );
+}
+
+function GoogleDriveLogo({ className }: { className?: string }) {
+  return (
+    <svg
+      aria-hidden="true"
+      className={className}
+      fill="currentColor"
+      viewBox="0 0 24 24"
+    >
+      <path d="M12.01 1.485c-2.082 0-3.754.02-3.743.047.01.02 1.708 3.001 3.774 6.62l3.76 6.574h3.76c2.081 0 3.753-.02 3.742-.047-.005-.02-1.708-3.001-3.775-6.62l-3.76-6.574zm-4.76 1.73a789.828 789.861 0 0 0-3.63 6.319L0 15.868l1.89 3.298 1.885 3.297 3.62-6.335 3.618-6.33-1.88-3.287C8.1 4.704 7.255 3.22 7.25 3.214zm2.259 12.653-.203.348c-.114.198-.96 1.672-1.88 3.287a423.93 423.948 0 0 1-1.698 2.97c-.01.026 3.24.042 7.222.042h7.244l1.796-3.157c.992-1.734 1.85-3.23 1.906-3.323l.104-.167h-7.249z" />
+    </svg>
+  );
+}
+
+function GoogleDocsLogo({ className }: { className?: string }) {
+  return (
+    <svg
+      aria-hidden="true"
+      className={className}
+      fill="currentColor"
+      viewBox="0 0 24 24"
+    >
+      <path d="M14.727 6.727H14V0H4.91c-.905 0-1.637.732-1.637 1.636v20.728c0 .904.732 1.636 1.636 1.636h14.182c.904 0 1.636-.732 1.636-1.636V6.727h-6zm-.545 10.455H7.09v-1.364h7.09v1.364zm2.727-3.273H7.091v-1.364h9.818v1.364zm0-3.273H7.091V9.273h9.818v1.363zM14.727 6h6l-6-6v6z" />
+    </svg>
+  );
+}
+
+function GoogleSheetsLogo({ className }: { className?: string }) {
+  return (
+    <svg
+      aria-hidden="true"
+      className={className}
+      fill="currentColor"
+      viewBox="0 0 24 24"
+    >
+      <path d="M11.318 12.545H7.91v-1.909h3.41v1.91zM14.728 0v6h6l-6-6zm1.363 10.636h-3.41v1.91h3.41v-1.91zm0 3.273h-3.41v1.91h3.41v-1.91zM20.727 6.5v15.864c0 .904-.732 1.636-1.636 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.318v6.5h6.5zm-3.273 2.773H6.545v7.909h10.91v-7.91zm-6.136 4.636H7.91v1.91h3.41v-1.91z" />
+    </svg>
+  );
+}
+
+function GoogleSlidesLogo({ className }: { className?: string }) {
+  return (
+    <svg
+      aria-hidden="true"
+      className={className}
+      fill="currentColor"
+      viewBox="0 0 24 24"
+    >
+      <path d="M16.09 15.273H7.91v-4.637h8.18v4.637zm1.728-8.523h2.91v15.614c0 .904-.733 1.636-1.637 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.068v6.75h3.841zm-.363 2.523H6.545v7.363h10.91V9.273zm-2.728-5.979V6h6.001l-6-6v3.294z" />
+    </svg>
+  );
+}
+
+function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) {
+  switch (preview.kind) {
+    case "github-issue":
+    case "github-pull-request":
+    case "github-repository":
+      return <GitHubLogo className="h-4 w-4" />;
+    case "linear-issue":
+      return <LinearLogo className="h-4 w-4" />;
+    case "google-drive-file":
+    case "google-drive-folder":
+      return <GoogleDriveLogo className="h-4 w-4" />;
+    case "google-docs-document":
+      return <GoogleDocsLogo className="h-4 w-4" />;
+    case "google-sheets-spreadsheet":
+      return <GoogleSheetsLogo className="h-4 w-4" />;
+    case "google-slides-presentation":
+      return <GoogleSlidesLogo className="h-4 w-4" />;
+  }
+}
+
+export function LinkPreviewAttachment({
+  className,
+  preview,
+}: {
+  className?: string;
+  preview: SupportedLinkPreview;
+}) {
+  return (
+    <Attachment
+      className={cn(
+        "w-80 max-w-full shrink-0 no-underline shadow-none",
+        className,
+      )}
+      data-link-preview={preview.kind}
+    >
+      <AttachmentMedia className="link-preview-media">
+        <LinkPreviewLogo preview={preview} />
+      </AttachmentMedia>
+      <AttachmentContent>
+        <div className="truncate text-xs font-medium leading-4 text-muted-foreground">
+          {preview.provider}
+          <span aria-hidden="true"> · </span>
+          {preview.typeLabel}
+        </div>
+        <AttachmentTitle>{preview.title}</AttachmentTitle>
+      </AttachmentContent>
+      <AttachmentActions>
+        <ExternalLink
+          aria-hidden="true"
+          className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover/attachment:opacity-100 group-focus-within/attachment:opacity-100"
+        />
+      </AttachmentActions>
+      <AttachmentTrigger asChild>
+        <a
+          aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
+          href={preview.href}
+          rel="noreferrer"
+          target="_blank"
+        >
+          <span className="sr-only">
+            Open {preview.provider} {preview.typeLabel}: {preview.title}
+          </span>
+        </a>
+      </AttachmentTrigger>
+    </Attachment>
+  );
+}
diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index 3c9f2bb4b1..8d3951d910 100644
--- a/desktop/src/shared/ui/markdown.tsx
+++ b/desktop/src/shared/ui/markdown.tsx
@@ -32,6 +32,12 @@ import type { Channel } from "@/shared/api/types";
 import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
 import { copyCodeBlockToClipboard } from "@/shared/lib/codeBlockClipboard";
 import { cn } from "@/shared/lib/cn";
+import {
+  extractSupportedLinkPreviews,
+  isSupportedLinkAutolinkLabel,
+  parseSupportedLinkPreview,
+} from "@/shared/lib/linkPreview";
+import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews";
 import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
 import rehypeImageGallery from "@/shared/lib/rehypeImageGallery";
 import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight";
@@ -43,6 +49,8 @@ import remarkMentions from "@/shared/lib/remarkMentions";
 import remarkSpoilers from "@/shared/lib/remarkSpoilers";
 import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
 import { Button } from "@/shared/ui/button";
+import { AttachmentGroup } from "@/shared/ui/attachment";
+import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment";
 import {
   INLINE_CODE_CHIP_CLASS,
   MENTION_CHIP_BASE_CLASSES,
@@ -1817,13 +1825,15 @@ function createMarkdownComponents(
         return <>{children}</>;
       }
 
+      const label = getReactNodeText(children);
+
       // Generic file attachment: a `[filename](url)` link whose href matches an
       // imeta entry with a non-image, non-video MIME. Render a download card
       // instead of a plain link. (Media uses the `img` renderer, not this path.)
       const card = resolveFileCard(
         href ? imetaByUrl?.get(href) : undefined,
         href,
-        getReactNodeText(children),
+        label,
       );
       if (card) {
         return (
@@ -1841,7 +1851,7 @@ function createMarkdownComponents(
       if (href) {
         const messageLinkTarget = resolveMessageLinkRenderTarget({
           href,
-          label: getReactNodeText(children),
+          label,
         });
         if (messageLinkTarget.kind !== "none") {
           if (messageLinkTarget.kind === "pill") {
@@ -1873,10 +1883,25 @@ function createMarkdownComponents(
         // Malformed message deep link — fall through to the default
         // anchor (renders as a normal external link).
       }
+
+      const supportedLinkPreview = href
+        ? parseSupportedLinkPreview(href)
+        : null;
+      const isLinearLink = supportedLinkPreview?.kind === "linear-issue";
+      if (
+        supportedLinkPreview &&
+        isSupportedLinkAutolinkLabel(label, supportedLinkPreview)
+      ) {
+        return null;
+      }
+
       return (
         <a
           {...props}
-          className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80"
+          className={cn(
+            "font-medium underline underline-offset-4 transition-colors",
+            isLinearLink ? "linear-link" : "text-primary hover:text-primary/80",
+          )}
           href={href}
           rel="noreferrer"
           target="_blank"
@@ -2258,6 +2283,12 @@ function MarkdownInner({
     processedContent = `${processedContent}\u200B`;
   }
 
+  const linkPreviews = React.useMemo(
+    () => (interactive ? extractSupportedLinkPreviews(content) : []),
+    [content, interactive],
+  );
+  const resolvedLinkPreviews = useResolvedLinkPreviews(linkPreviews);
+
   const markdownNode = (
     <ReactMarkdown
       components={components}
@@ -2292,6 +2323,16 @@ function MarkdownInner({
     >
       <VideoReviewMarkdownContext.Provider value={videoReviewContext}>
         {markdownNode}
+        {resolvedLinkPreviews.length > 0 ? (
+          <AttachmentGroup
+            className="max-w-full flex-wrap overflow-visible pb-0"
+            data-link-preview-list=""
+          >
+            {resolvedLinkPreviews.map((preview) => (
+              <LinkPreviewAttachment key={preview.href} preview={preview} />
+            ))}
+          </AttachmentGroup>
+        ) : null}
       </VideoReviewMarkdownContext.Provider>
     </div>
   );

From eef04bc3961c2239441b1442030cdf1804fe71d7 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Sun, 28 Jun 2026 12:51:05 +0100
Subject: [PATCH 2/3] Fix link preview comment handling

---
 desktop/src/shared/lib/linkPreview.test.mjs |  29 ++++
 desktop/src/shared/lib/linkPreview.ts       | 146 +++++++++++++++++++-
 desktop/src/shared/ui/markdown.tsx          |  18 ++-
 3 files changed, 182 insertions(+), 11 deletions(-)

diff --git a/desktop/src/shared/lib/linkPreview.test.mjs b/desktop/src/shared/lib/linkPreview.test.mjs
index 52361d6f32..9a1adcac0c 100644
--- a/desktop/src/shared/lib/linkPreview.test.mjs
+++ b/desktop/src/shared/lib/linkPreview.test.mjs
@@ -172,6 +172,35 @@ test("extractSupportedLinkPreviews skips URLs inside inline and fenced code", ()
   );
 });
 
+test("extractSupportedLinkPreviews skips links inside inline spoilers", () => {
+  assert.deepEqual(
+    extractSupportedLinkPreviews(
+      [
+        "Keep",
+        "||[roadmap](https://docs.google.com/document/d/hidden/edit)||",
+        "hidden, but show https://github.com/block/sprout/pull/7",
+      ].join(" "),
+    ).map((preview) => preview.title),
+    ["block/sprout #7"],
+  );
+});
+
+test("extractSupportedLinkPreviews skips links inside block spoilers", () => {
+  assert.deepEqual(
+    extractSupportedLinkPreviews(
+      [
+        "||",
+        "",
+        "https://linear.app/buzz/issue/BUG-99/hidden-spoiler-link",
+        "",
+        "||",
+        "https://github.com/block/sprout/pull/8",
+      ].join("\n"),
+    ).map((preview) => preview.title),
+    ["block/sprout #8"],
+  );
+});
+
 test("isSupportedLinkAutolinkLabel matches normalized bare URL labels", () => {
   const preview = parseSupportedLinkPreview("github.com/block/sprout/pull/5");
   assert.ok(preview);
diff --git a/desktop/src/shared/lib/linkPreview.ts b/desktop/src/shared/lib/linkPreview.ts
index e06d859b3f..eb3ac08dc3 100644
--- a/desktop/src/shared/lib/linkPreview.ts
+++ b/desktop/src/shared/lib/linkPreview.ts
@@ -37,11 +37,145 @@ const MARKDOWN_SUPPORTED_LINK_RE =
   /!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi;
 const MAX_PREVIEWS = 8;
 
-function stripCodeBlocks(content: string): string {
-  return content
-    .replace(/```[\s\S]*?```/g, " ")
-    .replace(/~~~[\s\S]*?~~~/g, " ")
-    .replace(/`[^`\n]*`/g, " ");
+type HiddenRange = {
+  start: number;
+  end: number;
+};
+
+function maskRanges(content: string, ranges: HiddenRange[]): string {
+  if (ranges.length === 0) return content;
+
+  const merged: HiddenRange[] = [];
+  for (const range of [...ranges].sort((a, b) => a.start - b.start)) {
+    const last = merged[merged.length - 1];
+    if (last && range.start <= last.end) {
+      last.end = Math.max(last.end, range.end);
+    } else {
+      merged.push({ ...range });
+    }
+  }
+
+  let masked = "";
+  let cursor = 0;
+  for (const range of merged) {
+    masked += content.slice(cursor, range.start);
+    masked += content.slice(range.start, range.end).replace(/[^\n]/g, " ");
+    cursor = range.end;
+  }
+
+  return masked + content.slice(cursor);
+}
+
+function isIndexInRanges(index: number, ranges: HiddenRange[]): boolean {
+  return ranges.some((range) => index >= range.start && index < range.end);
+}
+
+function overlapsRange(
+  start: number,
+  end: number,
+  ranges: HiddenRange[],
+): boolean {
+  return ranges.some((range) => start < range.end && end > range.start);
+}
+
+function collectCodeRanges(content: string): HiddenRange[] {
+  const ranges: HiddenRange[] = [];
+  for (const match of content.matchAll(/```[\s\S]*?```|~~~[\s\S]*?~~~/g)) {
+    ranges.push({
+      start: match.index ?? 0,
+      end: (match.index ?? 0) + match[0].length,
+    });
+  }
+
+  for (const match of content.matchAll(/`[^`\n]*`/g)) {
+    ranges.push({
+      start: match.index ?? 0,
+      end: (match.index ?? 0) + match[0].length,
+    });
+  }
+
+  return ranges;
+}
+
+function collectBlockSpoilerRanges(
+  content: string,
+  excludedRanges: HiddenRange[],
+): HiddenRange[] {
+  const ranges: HiddenRange[] = [];
+  let openStart: number | null = null;
+  let lineStart = 0;
+
+  while (lineStart < content.length) {
+    const newlineIndex = content.indexOf("\n", lineStart);
+    const lineEnd =
+      newlineIndex === -1 ? content.length : newlineIndex + "\n".length;
+    const line = content.slice(
+      lineStart,
+      newlineIndex === -1 ? lineEnd : newlineIndex,
+    );
+
+    if (
+      line.trim() === "||" &&
+      !overlapsRange(lineStart, lineEnd, excludedRanges)
+    ) {
+      if (openStart == null) {
+        openStart = lineStart;
+      } else {
+        ranges.push({ start: openStart, end: lineEnd });
+        openStart = null;
+      }
+    }
+
+    lineStart = lineEnd;
+  }
+
+  return ranges;
+}
+
+function collectInlineSpoilerRanges(
+  content: string,
+  excludedRanges: HiddenRange[],
+): HiddenRange[] {
+  const ranges: HiddenRange[] = [];
+  let openStart: number | null = null;
+  let index = 0;
+
+  while (index < content.length - 1) {
+    if (
+      content[index] === "|" &&
+      content[index + 1] === "|" &&
+      !isIndexInRanges(index, excludedRanges) &&
+      !isIndexInRanges(index + 1, excludedRanges)
+    ) {
+      if (openStart == null) {
+        openStart = index;
+      } else {
+        ranges.push({ start: openStart, end: index + 2 });
+        openStart = null;
+      }
+      index += 2;
+      continue;
+    }
+
+    index += 1;
+  }
+
+  return ranges;
+}
+
+function stripHiddenLinkPreviewContent(content: string): string {
+  const codeRanges = collectCodeRanges(content);
+  const blockSpoilerRanges = collectBlockSpoilerRanges(content, codeRanges);
+  const inlineSpoilerRanges = collectInlineSpoilerRanges(content, [
+    ...codeRanges,
+    ...blockSpoilerRanges,
+  ]);
+
+  return maskRanges(content, [
+    ...codeRanges,
+    ...blockSpoilerRanges,
+    ...inlineSpoilerRanges,
+  ]);
 }
 
 function countChar(value: string, char: string): number {
@@ -317,7 +451,7 @@ export function extractSupportedLinkPreviews(
 ): SupportedLinkPreview[] {
   const previews: SupportedLinkPreview[] = [];
   const seen = new Set<string>();
-  const searchable = stripCodeBlocks(content);
+  const searchable = stripHiddenLinkPreviewContent(content);
   const candidates: LinkPreviewCandidate[] = [];
   let order = 0;
 
diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index 8d3951d910..0ee4278022 100644
--- a/desktop/src/shared/ui/markdown.tsx
+++ b/desktop/src/shared/ui/markdown.tsx
@@ -210,6 +210,7 @@ type MarkdownRuntime = {
   agentMentionPubkeysByName?: Record<string, string>;
   channels: Channel[];
   imetaByUrl?: ImetaLookup;
+  linkPreviewHrefs: ReadonlySet<string>;
   mentionPubkeysByName?: Record<string, string>;
   onOpenChannel: (channelId: string) => void;
   onOpenMessageLink: (link: ParsedMessageLink) => void;
@@ -1813,7 +1814,8 @@ function createMarkdownComponents(
       </SpoilerInline>
     ),
     a: ({ children, href, ...props }) => {
-      const { imetaByUrl, onOpenMessageLink } = runtimeRef.current;
+      const { imetaByUrl, linkPreviewHrefs, onOpenMessageLink } =
+        runtimeRef.current;
       if (!interactive) {
         return <span className="font-medium text-current">{children}</span>;
       }
@@ -1890,6 +1892,7 @@ function createMarkdownComponents(
       const isLinearLink = supportedLinkPreview?.kind === "linear-issue";
       if (
         supportedLinkPreview &&
+        linkPreviewHrefs.has(supportedLinkPreview.href) &&
         isSupportedLinkAutolinkLabel(label, supportedLinkPreview)
       ) {
         return null;
@@ -2235,10 +2238,19 @@ function MarkdownInner({
     },
     [goChannel],
   );
+  const linkPreviews = React.useMemo(
+    () => (interactive ? extractSupportedLinkPreviews(content) : []),
+    [content, interactive],
+  );
+  const linkPreviewHrefs = React.useMemo(
+    () => new Set(linkPreviews.map((preview) => preview.href)),
+    [linkPreviews],
+  );
   const runtimeRef = useLatestRef<MarkdownRuntime>({
     agentMentionPubkeysByName,
     channels,
     imetaByUrl,
+    linkPreviewHrefs,
     mentionPubkeysByName,
     onOpenChannel,
     onOpenMessageLink,
@@ -2283,10 +2295,6 @@ function MarkdownInner({
     processedContent = `${processedContent}\u200B`;
   }
 
-  const linkPreviews = React.useMemo(
-    () => (interactive ? extractSupportedLinkPreviews(content) : []),
-    [content, interactive],
-  );
   const resolvedLinkPreviews = useResolvedLinkPreviews(linkPreviews);
 
   const markdownNode = (

From 3a5e1c3822e8cecced14a38874536c92c1018210 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 29 Jun 2026 08:20:33 +0100
Subject: [PATCH 3/3] Harden link preview extraction

---
 desktop/src/shared/lib/linkPreview.test.mjs | 39 ++++++++++++++++++++
 desktop/src/shared/lib/linkPreview.ts       | 41 ++++++++++++++++++---
 2 files changed, 74 insertions(+), 6 deletions(-)

diff --git a/desktop/src/shared/lib/linkPreview.test.mjs b/desktop/src/shared/lib/linkPreview.test.mjs
index 9a1adcac0c..a56b35f54a 100644
--- a/desktop/src/shared/lib/linkPreview.test.mjs
+++ b/desktop/src/shared/lib/linkPreview.test.mjs
@@ -172,6 +172,45 @@ test("extractSupportedLinkPreviews skips URLs inside inline and fenced code", ()
   );
 });
 
+test("extractSupportedLinkPreviews skips URLs inside indented code", () => {
+  assert.deepEqual(
+    extractSupportedLinkPreviews(
+      [
+        "    https://docs.google.com/document/d/hidden/edit",
+        "\tgithub.com/block/sprout/pull/4",
+        "https://github.com/block/sprout/pull/5",
+      ].join("\n"),
+    ).map((preview) => preview.title),
+    ["block/sprout #5"],
+  );
+});
+
+test("extractSupportedLinkPreviews skips markdown image link URLs", () => {
+  assert.deepEqual(
+    extractSupportedLinkPreviews(
+      [
+        "![alt](https://docs.google.com/document/d/doc123/edit)",
+        "![alt](https://github.com/block/sprout)",
+        "[Composer attachment polish](https://docs.google.com/document/d/doc456/edit)",
+      ].join("\n"),
+    ).map((preview) => preview.title),
+    ["Composer attachment polish"],
+  );
+});
+
+test("extractSupportedLinkPreviews requires bare URL boundaries", () => {
+  assert.deepEqual(
+    extractSupportedLinkPreviews(
+      [
+        "https://evil-github.com/block/sprout/pull/1",
+        "https://example.com/go/https://docs.google.com/document/d/doc123/edit",
+        "(https://github.com/block/sprout/pull/2)",
+      ].join(" "),
+    ).map((preview) => preview.title),
+    ["block/sprout #2"],
+  );
+});
+
 test("extractSupportedLinkPreviews skips links inside inline spoilers", () => {
   assert.deepEqual(
     extractSupportedLinkPreviews(
diff --git a/desktop/src/shared/lib/linkPreview.ts b/desktop/src/shared/lib/linkPreview.ts
index eb3ac08dc3..d806fb38f2 100644
--- a/desktop/src/shared/lib/linkPreview.ts
+++ b/desktop/src/shared/lib/linkPreview.ts
@@ -32,7 +32,7 @@ export type SupportedLinkPreview = {
 };
 
 const SUPPORTED_URL_RE =
-  /\b(?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+/gi;
+  /(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi;
 const MARKDOWN_SUPPORTED_LINK_RE =
   /!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi;
 const MAX_PREVIEWS = 8;
@@ -94,6 +94,27 @@ function collectCodeRanges(content: string): HiddenRange[] {
     });
   }
 
+  for (const match of content.matchAll(/^(?: {4}|\t).*(?:\n|$)/gm)) {
+    ranges.push({
+      start: match.index ?? 0,
+      end: (match.index ?? 0) + match[0].length,
+    });
+  }
+
+  return ranges;
+}
+
+function collectMarkdownImageLinkRanges(content: string): HiddenRange[] {
+  const ranges: HiddenRange[] = [];
+
+  for (const match of content.matchAll(MARKDOWN_SUPPORTED_LINK_RE)) {
+    if (!match[0]?.startsWith("!")) continue;
+    ranges.push({
+      start: match.index ?? 0,
+      end: (match.index ?? 0) + match[0].length,
+    });
+  }
+
   return ranges;
 }
 
@@ -165,14 +186,19 @@ function collectInlineSpoilerRanges(
 
 function stripHiddenLinkPreviewContent(content: string): string {
   const codeRanges = collectCodeRanges(content);
-  const blockSpoilerRanges = collectBlockSpoilerRanges(content, codeRanges);
+  const imageLinkRanges = collectMarkdownImageLinkRanges(content);
+  const nonSpoilerHiddenRanges = [...codeRanges, ...imageLinkRanges];
+  const blockSpoilerRanges = collectBlockSpoilerRanges(
+    content,
+    nonSpoilerHiddenRanges,
+  );
   const inlineSpoilerRanges = collectInlineSpoilerRanges(content, [
-    ...codeRanges,
+    ...nonSpoilerHiddenRanges,
     ...blockSpoilerRanges,
   ]);
 
   return maskRanges(content, [
-    ...codeRanges,
+    ...nonSpoilerHiddenRanges,
     ...blockSpoilerRanges,
     ...inlineSpoilerRanges,
   ]);
@@ -467,9 +493,12 @@ export function extractSupportedLinkPreviews(
   }
 
   for (const match of searchable.matchAll(SUPPORTED_URL_RE)) {
+    const prefix = match[1] ?? "";
+    const href = match[2];
+    if (!href) continue;
     candidates.push({
-      href: match[0],
-      index: match.index ?? 0,
+      href,
+      index: (match.index ?? 0) + prefix.length,
       order,
     });
     order += 1;
