# NousResearch/hermes-agent · Mermaid diagram rendering in markdown previews

## Open now

- Nothing open on GitHub.

These issues and pull requests concern adding or fixing Mermaid diagram rendering within markdown file previews and assistant markdown output on the desktop app. The work spans both a reported gap and proposed feature implementations for inline diagram support.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #40630. Verify it addresses your case.
- **Recommended:** read PR #40630 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #38654: Markdown file preview does not render Mermaid diagrams in Desktop

## Prior & competing PRs

- **#40630: feat(desktop): render Mermaid code blocks in markdown file preview**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `apps/desktop/src/app/chat/right-rail/preview-file.tsx`
- **#52935: feat(desktop): inline rich embeds, diagrams & alerts in assistant markdown**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `apps/desktop/electron/embed-referer.cjs`, `apps/desktop/electron/main.cjs`, `apps/desktop/package.json`, `apps/desktop/src/app/settings/appearance-settings.tsx`, `apps/desktop/src/components/assistant-ui/embeds/alert.test.tsx`, `apps/desktop/src/components/assistant-ui/embeds/alert.tsx`, `apps/desktop/src/components/assistant-ui/embeds/embed-consent.tsx`, `apps/desktop/src/components/assistant-ui/embeds/embed-size.ts`, `apps/desktop/src/components/assistant-ui/embeds/escape-html.ts`, `apps/desktop/src/components/assistant-ui/embeds/fail.tsx`, `apps/desktop/src/components/assistant-ui/embeds/frame-embed.tsx`, `apps/desktop/src/components/assistant-ui/embeds/index.ts`, `apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.tsx`, `apps/desktop/src/components/assistant-ui/embeds/providers/detect.test.ts`, `apps/desktop/src/components/assistant-ui/embeds/providers/index.ts`, `apps/desktop/src/components/assistant-ui/embeds/providers/instagram.ts`, `apps/desktop/src/components/assistant-ui/embeds/providers/maps.ts`, `apps/desktop/src/components/assistant-ui/embeds/providers/pinterest.ts`, `apps/desktop/src/components/assistant-ui/embeds/providers/spotify.ts`, `apps/desktop/src/components/assistant-ui/embeds/providers/tiktok.ts` (+25 more)
  - fix hunks:
  - `apps/desktop/electron/embed-referer.cjs`

    ```diff
    @@ -0,0 +1,48 @@
    +'use strict'
    +
    +const { session } = require('electron')
    +
    +const EMBED_SESSION_PARTITION = 'persist:hermes-embed'
    +const EMBED_REFERER = 'https://www.youtube.com/'
    +const YOUTUBE_REFERER_HOST_RE =
    +  /(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i
    +
    +function installEmbedRefererForSession(embedSession) {
    +  if (!embedSession) {
    +    return
    +  }
    +
    +  embedSession.webRequest.onBeforeSendHeaders((details, callback) => {
    +    let host = ''
    +
    +    try {
    +      host = new URL(details.url).hostname
    +    } catch {
    +      host = ''
    +    }
    +
    +    if (!YOUTUBE_REFERER_HOST_RE.test(host)) {
    +      callback({ requestHeaders: details.requestHeaders })
    +      return
    +    }
    +
    +    const headers = { ...details.requestHeaders }
    +
    +    if (!headers.Referer && !headers.referer) {
    +      headers.Referer = EMBED_REFERER
    +    }
    +
    +    callback({ requestHeaders: headers })
    +  })
    +}
    +
    +/** Stamp Referer on YouTube requests in the embed webview partition only. */
    +function installEmbedReferer() {
    +  try {
    +    installEmbedRefererForSession(session.fromPartition(EMBED_SESSION_PARTITION))
    +  } catch {
    +    // Non-fatal: embeds still render; YouTube may show referer errors.
    +  }
    +}
    +
    +module.exports = { installEmbedReferer }
    ```
  - `apps/desktop/electron/main.cjs`

    ```diff
    @@ -24,6 +24,7 @@ const https = require('node:https')
     const path = require('node:path')
     const { pathToFileURL } = require('node:url')
     const { execFileSync, spawn } = require('node:child_process')
    +const { installEmbedReferer } = require('./embed-referer.cjs')
     const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
     const { runBootstrap } = require('./bootstrap-runner.cjs')
     const {
    ```

    ```diff
    @@ -7447,6 +7448,7 @@ app.whenReady().then(() => {
       }
       installMediaPermissions()
       registerMediaProtocol()
    +  installEmbedReferer()
       registerDeepLinkProtocol()
       ensureWslWindowsFonts()
       configureSpellChecker()
    ```
  - `apps/desktop/package.json`

    ```diff
    @@ -81,11 +81,13 @@
         "clsx": "^2.1.1",
         "cmdk": "^1.1.1",
         "dnd-core": "^14.0.1",
    +    "dompurify": "^3.4.11",
         "hast-util-from-html-isomorphic": "^2.0.0",
         "hast-util-to-text": "^4.0.2",
         "ignore": "^7.0.5",
         "katex": "^0.16.45",
         "leva": "^0.10.1",
    +    "mermaid": "^11.15.0",
         "motion": "^12.38.0",
         "nanostores": "^1.3.0",
         "node-pty": "1.1.0",
    ```
  - `apps/desktop/src/app/settings/appearance-settings.tsx`

    ```diff
    @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
     import { useEffect, useState } from 'react'
     
     import { LanguageSwitcher } from '@/components/language-switcher'
    +import { Button } from '@/components/ui/button'
     import { SegmentedControl } from '@/components/ui/segmented-control'
     import type { DesktopMarketplaceSearchItem } from '@/global'
     import { useI18n } from '@/i18n'
    ```

    ```diff
    @@ -10,6 +11,7 @@ import { triggerHaptic } from '@/lib/haptics'
     import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
     import { selectableCardClass } from '@/lib/selectable-card'
     import { cn } from '@/lib/utils'
    +import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent'
     import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
     import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
     import { $translucency, setTranslucency } from '@/store/translucency'
    ```

    ```diff
    @@ -215,6 +217,8 @@ export function AppearanceSettings() {
       const { t, isSavingLocale } = useI18n()
       const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()
       const toolViewMode = useStore($toolViewMode)
    +  const embedMode = useStore($embedMode)
    +  const embedAllowed = useStore($embedAllowed)
       const translucency = useStore($translucency)
       const profiles = useStore($profiles)
       const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
    ```

    ```diff
    @@ -266,6 +270,12 @@ export function AppearanceSettings() {
         { id: 'technical', label: a.technical }
       ] as const
     
    +  const embedOptions = [
    +    { id: 'ask', label: a.embedsAsk },
    +    { id: 'always', label: a.embedsAlways },
    +    { id: 'off', label: a.embedsOff }
    +  ] as const satisfies readonly { id: EmbedMode; label: string }[]
    +
       return (
         <SettingsContent>
           <div>
    ```

    ```diff
    @@ -425,6 +435,35 @@ export function AppearanceSettings() {
                 description={a.toolViewDesc}
                 title={a.toolViewTitle}
               />
    +
    +          <ListRow
    +            action={
    +              <div className="flex flex-col items-end gap-1.5">
    +                <SegmentedControl
    +                  onChange={id => {
    +                    triggerHaptic('selection')
    +                    setEmbedMode(id)
    +                  }}
    +                  options={embedOptions}
    +                  value={embedMode}
    +                />
    +                {embedAllowed.length > 0 && (
    +                  <Button
    +                    onClick={() => {
    +                      triggerHaptic('selection')
    +                      clearEmbedAllowed()
    +                    }}
    +                    size="inline"
    +                    variant="text"
    +                  >
    +                    {a.embedsReset(embedAllowed.length)}
    +                  </Button>
    +                )}
    +              </div>
    +            }
    +            description={a.embedsDesc}
    +            title={a.embedsTitle}
    +          />
             </div>
           </div>
     
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/alert.test.tsx`

    ```diff
    @@ -0,0 +1,39 @@
    +import { createElement } from 'react'
    +import { describe, expect, it } from 'vitest'
    +
    +import { extractAlert } from './alert'
    +
    +describe('extractAlert', () => {
    +  it('detects each GFM alert kind from the leading marker', () => {
    +    for (const [marker, type] of [
    +      ['[!NOTE]', 'note'],
    +      ['[!TIP]', 'tip'],
    +      ['[!IMPORTANT]', 'important'],
    +      ['[!WARNING]', 'warning'],
    +      ['[!CAUTION]', 'caution']
    +    ] as const) {
    +      const node = createElement('p', null, `${marker}\nBody text`)
    +      const result = extractAlert(node)
    +
    +      expect(result?.type).toBe(type)
    +    }
    +  })
    +
    +  it('is case-insensitive on the marker', () => {
    +    expect(extractAlert(createElement('p', null, '[!note] hi'))?.type).toBe('note')
    +  })
    +
    +  it('returns null for a plain blockquote', () => {
    +    expect(extractAlert(createElement('p', null, 'just a quote'))).toBeNull()
    +    expect(extractAlert('no marker here')).toBeNull()
    +  })
    +
    +  it('strips the marker token from the body', () => {
    +    const result = extractAlert(createElement('p', null, '[!WARNING]\nDanger ahead'))
    +
    +    expect(result).not.toBeNull()
    +    // The marker must not survive into the rendered body.
    +    expect(JSON.stringify(result?.body)).not.toContain('[!WARNING]')
    +    expect(JSON.stringify(result?.body)).toContain('Danger ahead')
    +  })
    +})
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/alert.tsx`

    ```diff
    @@ -0,0 +1,125 @@
    +import { cloneElement, isValidElement, type ReactNode } from 'react'
    +
    +import { AlertCircle, AlertTriangle, type IconComponent, Info, Zap } from '@/lib/icons'
    +import { cn } from '@/lib/utils'
    +
    +export type AlertType = 'caution' | 'important' | 'note' | 'tip' | 'warning'
    +
    +interface AlertStyle {
    +  accent: string
    +  icon: IconComponent
    +  label: string
    +}
    +
    +// GitHub's five alert kinds, mapped to our icon set + a tinted accent.
    +const ALERT_STYLES: Record<AlertType, AlertStyle> = {
    +  caution: { accent: 'text-rose-600 dark:text-rose-400', icon: AlertTriangle, label: 'Caution' },
    +  important: { accent: 'text-violet-600 dark:text-violet-400', icon: AlertCircle, label: 'Important' },
    +  note: { accent: 'text-blue-600 dark:text-blue-400', icon: Info, label: 'Note' },
    +  tip: { accent: 'text-emerald-600 dark:text-emerald-400', icon: Zap, label: 'Tip' },
    +  warning: { accent: 'text-amber-600 dark:text-amber-400', icon: AlertTriangle, label: 'Warning' }
    +}
    +
    +const MARKER_RE = /^\s*\[!(note|tip|important|warning|caution)\]\s*\n?/i
    +
    +function firstText(node: ReactNode): string {
    +  if (typeof node === 'string') {
    +    return node
    +  }
    +
    +  if (typeof node === 'number') {
    +    return String(node)
    +  }
    +
    +  if (Array.isArray(node)) {
    +    for (const child of node) {
    +      const text = firstText(child)
    +
    +      if (text.trim()) {
    +        return text
    +      }
    +    }
    +
    +    return ''
    +  }
    +
    +  if (isValidElement(node)) {
    +    return firstText((node.props as { children?: ReactNode }).children)
    +  }
    +
    +  return ''
    +}
    +
    +// Remove the leading `[!TYPE]` token from the first text node that carries it,
    +// leaving the rest of the blockquote body intact. One-shot via the `state` flag.
    +function stripMarker(node: ReactNode, state: { done: boolean }): ReactNode {
    +  if (state.done) {
    +    return node
    +  }
    +
    +  if (typeof node === 'string') {
    +    const replaced = node.replace(MARKER_RE, '')
    +
    +    if (replaced !== node) {
    +      state.done = true
    +
    +      return replaced
    +    }
    +
    +    return node
    +  }
    +
    +  if (Array.isArray(node)) {
    +    return node.map((child, index) => <Fragmentless key={index} node={stripMarker(child, state)} />)
    +  }
    +
    +  if (isValidElement(node)) {
    +    const children = (node.props as { children?: ReactNode }).children
    +
    +    if (children == null) {
    +      return node
    +    }
    +
    +    return cloneElement(node, undefined, stripMarker(children, state))
    +  }
    +
    +  return node
    +}
    +
    +// Tiny helper so the array branch can return keyed nodes without wrapping
    +// strings in extra elements (React renders the raw node).
    +function Fragmentless({ node }: { node: ReactNode }) {
    +  return <>{node}</>
    +}
    +
    +/**
    + * Detect a GitHub-style alert blockquote (`> [!NOTE]`). Returns the alert kind
    + * and the body with the marker stripped, or null for a plain blockquote.
    + */
    +export function extractAlert(children: ReactNode): { body: ReactNode; type: AlertType } | null {
    +  const match = firstText(children).match(MARKER_RE)
    +
    +  if (!match) {
    +    return null
    +  }
    +
    +  return { body: stripMarker(children, { done: false }), type: match[1].toLowerCase() as AlertType }
    +}
    +
    +export function MarkdownAlert({ children, type }: { children: ReactNode; type: AlertType }) {
    +  const style = ALERT_STYLES[type]
    +  const Icon = style.icon
    +
    +  return (
    +    <div
    +      className="my-2 rounded-lg border border-border bg-muted/25 px-3 py-2 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
    +      data-slot="aui_markdown-alert"
    +    >
    +      <div className={cn('mb-1 flex items-center gap-1.5 text-[0.8125rem] font-semibold', style.accent)}>
    +        <Icon className="size-4 shrink-0" />
    +        {style.label}
    +      </div>
    +      {children}
    +    </div>
    +  )
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/embed-consent.tsx`

    ```diff
    @@ -0,0 +1,55 @@
    +'use client'
    +
    +import { type CSSProperties, useState } from 'react'
    +
    +import { SplitButton } from '@/components/ui/split-button'
    +import { Play } from '@/lib/icons'
    +import { allowProvider } from '@/store/embed-consent'
    +
    +import type { EmbedDescriptor } from './providers/types'
    +
    +// Privacy placeholder shown before an embed reaches out to a third party. Sized
    +// to the embed's footprint (no layout shift). The split control mirrors the
    +// commit button: primary "Load" (this embed) with a caret for "Always allow
    +// <service>" (persisted). Global off lives in Appearance settings.
    +export function EmbedFacade({ descriptor, onLoad }: { descriptor: EmbedDescriptor; onLoad: () => void }) {
    +  const [choice, setChoice] = useState('once')
    +
    +  const style: CSSProperties = descriptor.aspectRatio
    +    ? { aspectRatio: descriptor.aspectRatio }
    +    : { height: descriptor.height ?? 320 }
    +
    +  const actions = [
    +    { id: 'once', label: `Load ${descriptor.label}` },
    +    { id: 'always', label: `Always allow ${descriptor.label}` }
    +  ]
    +
    +  return (
    +    <span
    +      className="flex size-full flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary)/30"
    +      style={style}
    +    >
    +      <SplitButton
    +        actions={actions}
    +        onTrigger={id => (id === 'always' ? allowProvider(descriptor.provider) : onLoad())}
    +        onValueChange={setChoice}
    +        primaryIcon={<Play className="size-3 translate-x-px fill-current" />}
    +        value={choice}
    +      />
    +      <span className="text-[0.6875rem] text-(--ui-text-tertiary)">{hostOf(descriptor)}</span>
    +    </span>
    +  )
    +}
    +
    +function hostOf(descriptor: EmbedDescriptor): string {
    +  // x.com posts often arrive as twitter.com links — show the current brand.
    +  if (descriptor.provider === 'twitter') {
    +    return 'x.com'
    +  }
    +
    +  try {
    +    return new URL(descriptor.sourceUrl).hostname.replace(/^www\./, '')
    +  } catch {
    +    return descriptor.label
    +  }
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/embed-size.ts`

    ```diff
    @@ -0,0 +1,4 @@
    +// Shared height cap for inline embeds. Ratio embeds cap their width off this in
    +// UrlEmbed so height follows the aspect ratio; fenced renderers (mermaid, svg)
    +// reuse it directly. Pure CSS — no measuring.
    +export const EMBED_MAX_H = '33dvh'
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/escape-html.ts`

    ```diff
    @@ -0,0 +1,3 @@
    +export function escapeHtml(value: string): string {
    +  return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/fail.tsx`

    ```diff
    @@ -0,0 +1,7 @@
    +export function EmbedFail({ label }: { label: string }) {
    +  return (
    +    <span className="grid min-h-32 w-full place-items-center p-4">
    +      <span className="text-xs font-medium text-(--ui-red)">Failed to load {label} embed</span>
    +    </span>
    +  )
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/frame-embed.tsx`

    ```diff
    @@ -0,0 +1,55 @@
    +'use client'
    +
    +import { type CSSProperties } from 'react'
    +
    +import type { FrameEmbed } from './providers/types'
    +import { ScrollGate } from './scroll-gate'
    +import { useIsDark } from './use-is-dark'
    +
    +const ALLOW = 'autoplay; encrypted-media; picture-in-picture; clipboard-write; fullscreen'
    +
    +// Plain iframes (not webviews): a non-scrollable cross-origin iframe lets the
    +// wheel chain to the transcript instead of capturing it. Maps are the one
    +// exception — they're interactive, so a ScrollGate blocks them until ⌘ is held.
    +export default function FrameEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {
    +  const isDark = useIsDark()
    +  const isMap = descriptor.provider === 'googlemaps' || descriptor.provider === 'openstreetmap'
    +  // color-scheme makes the iframe's default (unpainted) backdrop follow the
    +  // theme instead of flashing white at the corners / during load.
    +  const colorScheme = isDark ? 'dark' : 'light'
    +
    +  const style: CSSProperties = descriptor.aspectRatio
    +    ? { aspectRatio: descriptor.aspectRatio, colorScheme }
    +    : { colorScheme, height: descriptor.height }
    +
    +  if (isMap) {
    +    return (
    +      <div className="relative w-full overflow-hidden" style={style}>
    +        <iframe
    +          allow={ALLOW}
    +          className="absolute inset-0 size-full border-0 bg-transparent"
    +          loading="lazy"
    +          referrerPolicy="strict-origin-when-cross-origin"
    +          src={descriptor.embedUrl}
    +          style={{ colorScheme }}
    +          title={`${descriptor.label} embed`}
    +        />
    +        <ScrollGate />
    +      </div>
    +    )
    +  }
    +
    +  return (
    +    <iframe
    +      allow={ALLOW}
    +      allowFullScreen
    +      className="block w-full border-0 bg-transparent"
    +      loading="lazy"
    +      referrerPolicy="strict-origin-when-cross-origin"
    +      scrolling="no"
    +      src={descriptor.embedUrl}
    +      style={style}
    +      title={`${descriptor.label} embed`}
    +    />
    +  )
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/index.ts`

    ```diff
    @@ -0,0 +1,5 @@
    +export { extractAlert, MarkdownAlert } from './alert'
    +export type { EmbedDescriptor } from './providers'
    +export { detectEmbed, isEmbeddableUrl } from './providers'
    +export { RICH_FENCE_LANGUAGES, RichCodeBlock } from './registry'
    +export { UrlEmbed } from './url-embed'
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.tsx`

    ```diff
    @@ -0,0 +1,113 @@
    +'use client'
    +
    +import mermaid from 'mermaid'
    +import { useEffect, useState } from 'react'
    +
    +import { Zoomable } from '@/components/ui/zoomable'
    +import { copySvgAsPng } from '@/lib/svg-image'
    +import { cn } from '@/lib/utils'
    +
    +import type { RichFenceProps } from './types'
    +import { useIsDark } from './use-is-dark'
    +
    +let lastTheme: 'dark' | 'default' | null = null
    +
    +// Re-initialise only on first use / theme flip. `securityLevel: 'strict'` makes
    +// mermaid sanitise label HTML and drop click handlers, so the rendered SVG is
    +// safe to inject.
    +function ensureInit(dark: boolean) {
    +  const theme = dark ? 'dark' : 'default'
    +
    +  if (theme === lastTheme) {
    +    return
    +  }
    +
    +  mermaid.initialize({ fontFamily: 'inherit', securityLevel: 'strict', startOnLoad: false, theme })
    +  lastTheme = theme
    +}
    +
    +function SourcePreview({ code, muted }: { code: string; muted?: boolean }) {
    +  return (
    +    <pre
    +      className={cn(
    +        'overflow-auto p-3 font-mono text-[0.7rem] leading-relaxed whitespace-pre-wrap wrap-anywhere',
    +        muted ? 'text-muted-foreground/70' : 'text-foreground/90'
    +      )}
    +    >
    +      {code}
    +    </pre>
    +  )
    +}
    +
    +// Lazy chunk (pulls in mermaid). Renders ```mermaid fences as diagrams; shows
    +// the source while the message streams (partial syntax throws) and falls back
    +// to source on parse failure.
    +export default function MermaidRenderer({ code, streaming }: RichFenceProps) {
    +  const isDark = useIsDark()
    +  const [svg, setSvg] = useState('')
    +  const [failed, setFailed] = useState(false)
    +
    +  useEffect(() => {
    +    if (streaming) {
    +      return
    +    }
    +
    +    let cancelled = false
    +
    +    setFailed(false)
    +
    +    void (async () => {
    +      try {
    +        ensureInit(isDark)
    +        const id = `mmd-${Math.random().toString(36).slice(2)}`
    +        const result = await mermaid.render(id, code)
    +
    +        if (!cancelled) {
    +          setSvg(result.svg)
    +        }
    +      } catch {
    +        if (!cancelled) {
    +          setFailed(true)
    +          setSvg('')
    +        }
    +      }
    +    })()
    +
    +    return () => {
    +      cancelled = true
    +    }
    +  }, [code, isDark, streaming])
    +
    +  if (streaming) {
    +    return <SourcePreview code={code} muted />
    +  }
    +
    +  if (failed) {
    +    return <SourcePreview code={code} />
    +  }
    +
    +  if (!svg) {
    +    return <SourcePreview code={code} muted />
    +  }
    +
    +  // Click to open the diagram full-screen with pan/zoom + copy-as-PNG. The
    +  // overlay keeps the diagram's natural width (capped to the viewport) so it
    +  // renders before any zoom; the inline version stays capped at 33dvh.
    +  return (
    +    <Zoomable
    +      label="Open diagram"
    +      onCopy={() => copySvgAsPng(svg)}
    +      overlay={
    +        <div
    +          className="[&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-h-[80vh] [&_svg]:max-w-[85vw]"
    +          dangerouslySetInnerHTML={{ __html: svg }}
    +        />
    +      }
    +    >
    +      <div
    +        className="overflow-hidden p-3 [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-h-[33dvh] [&_svg]:max-w-full"
    +        dangerouslySetInnerHTML={{ __html: svg }}
    +      />
    +    </Zoomable>
    +  )
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/providers/detect.test.ts`

    ```diff
    @@ -0,0 +1,133 @@
    +import { describe, expect, it } from 'vitest'
    +
    +import type { FrameEmbed, TweetEmbed } from './types'
    +
    +import { detectEmbed, isEmbeddableUrl } from './index'
    +
    +function frame(url: string): FrameEmbed {
    +  const descriptor = detectEmbed(url)
    +
    +  if (!descriptor || descriptor.renderer !== 'frame') {
    +    throw new Error(`expected a frame embed for ${url}`)
    +  }
    +
    +  return descriptor
    +}
    +
    +describe('detectEmbed — YouTube', () => {
    +  it.each([
    +    'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
    +    'https://youtu.be/dQw4w9WgXcQ',
    +    'https://www.youtube.com/shorts/dQw4w9WgXcQ',
    +    'https://m.youtube.com/watch?v=dQw4w9WgXcQ',
    +    'https://www.youtube.com/embed/dQw4w9WgXcQ',
    +    'https://www.youtube.com/live/dQw4w9WgXcQ'
    +  ])('resolves %s to the privacy-enhanced embed of the same id', url => {
    +    const embed = frame(url)
    +
    +    expect(embed.provider).toBe('youtube')
    +    expect(embed.id).toBe('youtube:dQw4w9WgXcQ')
    +    expect(embed.embedUrl).toContain('youtube-nocookie.com/embed/dQw4w9WgXcQ')
    +  })
    +
    +  it('carries a start time from t/start through to the embed', () => {
    +    expect(frame('https://youtu.be/dQw4w9WgXcQ?t=90').embedUrl).toContain('start=90')
    +    expect(frame('https://youtu.be/dQw4w9WgXcQ?t=1m30s').embedUrl).toContain('start=90')
    +  })
    +
    +  it('rejects ids that are not 11 chars', () => {
    +    expect(detectEmbed('https://www.youtube.com/watch?v=short')).toBeNull()
    +  })
    +})
    +
    +describe('detectEmbed — other frame providers', () => {
    +  it('resolves Vimeo numeric ids across path shapes', () => {
    +    expect(frame('https://vimeo.com/76979871').embedUrl).toBe('https://player.vimeo.com/video/76979871')
    +    expect(frame('https://vimeo.com/channels/staffpicks/76979871').id).toBe('vimeo:76979871')
    +  })
    +
    +  it('resolves Instagram posts and reels', () => {
    +    expect(frame('https://www.instagram.com/p/CabcDEF123/').embedUrl).toBe(
    +      'https://www.instagram.com/p/CabcDEF123/embed'
    +    )
    +    expect(frame('https://www.instagram.com/reel/CabcDEF123/').embedUrl).toContain('/reel/CabcDEF123/embed')
    +    expect(frame('https://www.instagram.com/reels/CabcDEF123/').embedUrl).toContain('/reel/CabcDEF123/embed')
    +  })
    +
    +  it('resolves Pinterest pins across locale hosts', () => {
    +    expect(frame('https://www.pinterest.com/pin/1234567890/').embedUrl).toBe(
    +      'https://assets.pinterest.com/ext/embed.html?id=1234567890'
    +    )
    +    expect(frame('https://fr.pinterest.com/pin/1234567890/').provider).toBe('pinterest')
    +  })
    +
    +  it('resolves TikTok videos to the official player', () => {
    +    expect(frame('https://www.tiktok.com/@user/video/7212345678901234567').embedUrl).toBe(
    +      'https://www.tiktok.com/player/v1/7212345678901234567'
    +    )
    +  })
    +
    +  it('resolves Spotify tracks, collections, and locale-prefixed urls', () => {
    +    expect(frame('https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT').embedUrl).toBe(
    +      'https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT'
    +    )
    +    expect(frame('https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M').provider).toBe('spotify')
    +    expect(frame('https://open.spotify.com/intl-de/album/1DFixLWuPkv3KT3TnV35m3').id).toBe(
    +      'spotify:album:1DFixLWuPkv3KT3TnV35m3'
    +    )
    +    expect(detectEmbed('https://open.spotify.com/track/')).toBeNull()
    +  })
    +})
    +
    +describe('detectEmbed — maps', () => {
    +  it('resolves Google Maps coordinates with zoom', () => {
    +    const embed = frame('https://www.google.com/maps/@40.7128,-74.0060,12z')
    +
    +    expect(embed.provider).toBe('googlemaps')
    +    expect(embed.embedUrl).toContain('output=embed')
    +    expect(embed.embedUrl).toContain('q=40.7128%2C-74.006')
    +    expect(embed.embedUrl).toContain('z=12')
    +  })
    +
    +  it('resolves a Google Maps place name', () => {
    +    expect(frame('https://www.google.com/maps/place/Eiffel+Tower/').embedUrl).toContain('q=Eiffel+Tower')
    +  })
    +
    +  it('resolves OpenStreetMap fragment state to a bbox embed', () => {
    +    const embed = frame('https://www.openstreetmap.org/#map=12/40.7128/-74.0060')
    +
    +    expect(embed.provider).toBe('openstreetmap')
    +    expect(embed.embedUrl).toContain('export/embed.html')
    +    expect(embed.embedUrl).toContain('marker=40.7128%2C-74.006')
    +    expect(embed.embedUrl).toContain('bbox=')
    +  })
    +})
    +
    +describe('detectEmbed — Twitter/X', () => {
    +  it('resolves twitter.com and x.com status urls to a tweet descriptor', () => {
    +    for (const url of ['https://twitter.com/jack/status/20', 'https://x.com/jack/status/20']) {
    +      const descriptor = detectEmbed(url)
    +
    +      expect(descriptor?.renderer).toBe('tweet')
    +      expect((descriptor as TweetEmbed).tweetId).toBe('20')
    +    }
    +  })
    +})
    +
    +describe('detectEmbed — non-matches', () => {
    +  it.each([
    +    'https://example.com/watch?v=dQw4w9WgXcQ',
    +    'https://github.com/NousResearch/hermes',
    +    'not-a-url',
    +    'ftp://youtube.com/watch?v=dQw4w9WgXcQ',
    +    'mailto:[redacted:email]'
    +  ])('returns null for %s', url => {
    +    expect(detectEmbed(url)).toBeNull()
    +    expect(isEmbeddableUrl(url)).toBe(false)
    +  })
    +
    +  it('handles empty input without throwing', () => {
    +    expect(detectEmbed(undefined)).toBeNull()
    +    expect(detectEmbed('')).toBeNull()
    +  })
    +})
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/providers/index.ts`

    ```diff
    @@ -0,0 +1,54 @@
    +import { instagram } from './instagram'
    +import { maps } from './maps'
    +import { pinterest } from './pinterest'
    +import { spotify } from './spotify'
    +import { tiktok } from './tiktok'
    +import { twitter } from './twitter'
    +import type { EmbedDescriptor, EmbedMatcher } from './types'
    +import { vimeo } from './vimeo'
    +import { youtube } from './youtube'
    +
    +export type { EmbedDescriptor, EmbedProvider, EmbedRenderer, FrameEmbed, TweetEmbed } from './types'
    +
    +// All provider hosts are disjoint, so order is irrelevant — first match wins.
    +const MATCHERS: EmbedMatcher[] = [youtube, vimeo, instagram, pinterest, tiktok, twitter, spotify, maps]
    +
    +function parseUrl(raw: string): URL | null {
    +  try {
    +    const url = new URL(raw)
    +
    +    return url.protocol === 'http:' || url.protocol === 'https:' ? url : null
    +  } catch {
    +    return null
    +  }
    +}
    +
    +/**
    + * Resolve a URL to a rich-embed descriptor, or null when no provider matches.
    + * Pure and synchronous — safe to call during render.
    + */
    +export function detectEmbed(rawUrl: string | null | undefined): EmbedDescriptor | null {
    +  if (!rawUrl) {
    +    return null
    +  }
    +
    +  const url = parseUrl(rawUrl)
    +
    +  if (!url) {
    +    return null
    +  }
    +
    +  for (const match of MATCHERS) {
    +    const descriptor = match(url)
    +
    +    if (descriptor) {
    +      return descriptor
    +    }
    +  }
    +
    +  return null
    +}
    +
    +export function isEmbeddableUrl(rawUrl: string | null | undefined): boolean {
    +  return detectEmbed(rawUrl) !== null
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/providers/instagram.ts`

    ```diff
    @@ -0,0 +1,26 @@
    +import { bareHost, type EmbedMatcher } from './types'
    +
    +export const instagram: EmbedMatcher = url => {
    +  if (bareHost(url.hostname) !== 'instagram.com') {
    +    return null
    +  }
    +
    +  const [typeRaw, code] = url.pathname.split('/').filter(Boolean)
    +  const type = typeRaw === 'reels' ? 'reel' : typeRaw
    +
    +  if (!code || !['p', 'reel', 'tv'].includes(type || '') || !/^[A-Za-z0-9_-]+$/.test(code)) {
    +    return null
    +  }
    +
    +  return {
    +    embedUrl: `https://www.instagram.com/${type}/${code}/embed`,
    +    // Placeholder height for content-visibility; embed.js self-sizes in-document.
    +    height: 450,
    +    id: `instagram:${code}`,
    +    label: 'Instagram',
    +    maxWidth: 400,
    +    provider: 'instagram',
    +    renderer: 'frame',
    +    sourceUrl: url.toString()
    +  }
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/providers/maps.ts`

    ```diff
    @@ -0,0 +1,95 @@
    +import { bareHost, type EmbedMatcher, type FrameEmbed } from './types'
    +
    +// `@lat,lng` (optionally `,<zoom>z`) as it appears in Google Maps URLs.
    +const LATLNG_RE = /@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)(?:,(\d+(?:\.\d+)?)z)?/
    +
    +function googleMapsEmbed(url: URL): FrameEmbed | null {
    +  const host = bareHost(url.hostname)
    +
    +  if (host !== 'google.com' && host !== 'maps.google.com' && !host.startsWith('google.')) {
    +    return null
    +  }
    +
    +  const isMapsPath = host.startsWith('maps.') || url.pathname.startsWith('/maps')
    +
    +  if (!isMapsPath) {
    +    return null
    +  }
    +
    +  // Prefer explicit coordinates; then a `q=` query; then a `/place/<name>`.
    +  const coords = url.pathname.match(LATLNG_RE)
    +  const placeName = url.pathname.match(/\/place\/([^/@]+)/)
    +  const query = url.searchParams.get('q') || url.searchParams.get('query')
    +  let q = ''
    +  let zoom = ''
    +
    +  if (coords) {
    +    q = `${coords[1]},${coords[2]}`
    +    zoom = coords[3] ? String(Math.round(Number(coords[3]))) : ''
    +  } else if (query) {
    +    q = query
    +  } else if (placeName) {
    +    q = decodeURIComponent(placeName[1].replace(/\+/g, ' '))
    +  }
    +
    +  if (!q) {
    +    return null
    +  }
    +
    +  // `output=embed` is the long-standing keyless Maps embed surface.
    +  const params = new URLSearchParams({ output: 'embed', q })
    +
    +  if (zoom) {
    +    params.set('z', zoom)
    +  }
    +
    +  return {
    +    aspectRatio: 16 / 10,
    +    embedUrl: `https://maps.google.com/maps?${params.toString()}`,
    +    id: `googlemaps:${q}${zoom ? `@${zoom}` : ''}`,
    +    label: 'Google Maps',
    +    maxWidth: 640,
    +    provider: 'googlemaps',
    +    renderer: 'frame',
    +    sourceUrl: url.toString()
    +  }
    +}
    +
    +function openStreetMapEmbed(url: URL): FrameEmbed | null {
    +  if (bareHost(url.hostname) !== 'openstreetmap.org') {
    +    return null
    +  }
    +
    +  // State lives in the fragment: `#map=<zoom>/<lat>/<lng>`.
    +  const match = url.hash.match(/map=(\d+(?:\.\d+)?)\/(-?\d+(?:\.\d+)?)\/(-?\d+(?:\.\d+)?)/)
    +
    +  if (!match) {
    +    return null
    +  }
    +
    +  const zoom = Number(match[1])
    +  const lat = Number(match[2])
    +  const lng = Number(match[3])
    +  // Degrees spanned at this zoom; halved for the bbox half-extent.
    +  const lonDelta = 360 / 2 ** zoom
    +  const latDelta = lonDelta / 2
    +
    +  const bbox = [lng - lonDelta / 2, lat - latDelta / 2, lng + lonDelta / 2, lat + latDelta / 2]
    +    .map(value => value.toFixed(5))
    +    .join(',')
    +
    +  const params = new URLSearchParams({ bbox, layer: 'mapnik', marker: `${lat},${lng}` })
    +
    +  return {
    +    aspectRatio: 16 / 10,
    +    embedUrl: `https://www.openstreetmap.org/export/embed.html?${params.toString()}`,
    +    id: `openstreetmap:${lat},${lng}@${zoom}`,
    +    label: 'OpenStreetMap',
    +    maxWidth: 640,
    +    provider: 'openstreetmap',
    +    renderer: 'frame',
    +    sourceUrl: url.toString()
    +  }
    +}
    +
    +export const maps: EmbedMatcher = url => googleMapsEmbed(url) || openStreetMapEmbed(url)
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/providers/pinterest.ts`

    ```diff
    @@ -0,0 +1,28 @@
    +import { bareHost, type EmbedMatcher } from './types'
    +
    +export const pinterest: EmbedMatcher = url => {
    +  // Pinterest runs many locale TLDs (pinterest.co.uk, fr.pinterest.com, ...).
    +  if (!bareHost(url.hostname).includes('pinterest.')) {
    +    return null
    +  }
    +
    +  const segments = url.pathname.split('/').filter(Boolean)
    +
    +  if (segments[0] !== 'pin' || !/^\d+$/.test(segments[1] || '')) {
    +    return null
    +  }
    +
    +  const id = segments[1]
    +
    +  return {
    +    embedUrl: `https://assets.pinterest.com/ext/embed.html?id=${id}`,
    +    // Pinterest's "small" pin size — the default card is too dominant inline.
    +    height: 380,
    +    id: `pinterest:${id}`,
    +    label: 'Pinterest',
    +    maxWidth: 236,
    +    provider: 'pinterest',
    +    renderer: 'frame',
    +    sourceUrl: url.toString()
    +  }
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/providers/spotify.ts`

    ```diff
    @@ -0,0 +1,34 @@
    +import { bareHost, type EmbedMatcher } from './types'
    +
    +// Spotify's embed has only two layouts: compact (≤152) and full (352). Any
    +// in-between height renders the compact player and pads the rest with grey, so
    +// we snap to the compact size for every type — tight, no dead space.
    +const COMPACT_HEIGHT = 152
    +const EMBED_TYPES = new Set(['album', 'artist', 'episode', 'playlist', 'show', 'track'])
    +
    +export const spotify: EmbedMatcher = url => {
    +  if (bareHost(url.hostname) !== 'open.spotify.com') {
    +    return null
    +  }
    +
    +  // Drop an optional locale prefix (`/intl-de/track/...`).
    +  const segments = url.pathname.split('/').filter(Boolean)
    +  const start = segments[0]?.startsWith('intl-') ? 1 : 0
    +  const type = segments[start] || ''
    +  const id = segments[start + 1] || ''
    +
    +  if (!EMBED_TYPES.has(type) || !/^[A-Za-z0-9]+$/.test(id)) {
    +    return null
    +  }
    +
    +  return {
    +    embedUrl: `https://open.spotify.com/embed/${type}/${id}`,
    +    height: COMPACT_HEIGHT,
    +    id: `spotify:${type}:${id}`,
    +    label: 'Spotify',
    +    maxWidth: 480,
    +    provider: 'spotify',
    +    renderer: 'frame',
    +    sourceUrl: url.toString()
    +  }
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/providers/tiktok.ts`

    ```diff
    @@ -0,0 +1,28 @@
    +import { bareHost, type EmbedMatcher } from './types'
    +
    +export const tiktok: EmbedMatcher = url => {
    +  if (bareHost(url.hostname) !== 'tiktok.com') {
    +    return null
    +  }
    +
    +  const segments = url.pathname.split('/').filter(Boolean)
    +  const videoIndex = segments.indexOf('video')
    +  const id = videoIndex >= 0 ? segments[videoIndex + 1] : ''
    +
    +  if (!/^\d+$/.test(id || '')) {
    +    return null
    +  }
    +
    +  return {
    +    // The official player is a clean dark video iframe (no white blockquote
    +    // chrome), so it goes through the plain-iframe frame path, sized 9:16.
    +    aspectRatio: 9 / 16,
    +    embedUrl: `https://www.tiktok.com/player/v1/${id}`,
    +    id: `tiktok:${id}`,
    +    label: 'TikTok',
    +    maxWidth: 365,
    +    provider: 'tiktok',
    +    renderer: 'frame',
    +    sourceUrl: url.toString()
    +  }
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/providers/twitter.ts`

    ```diff
    @@ -0,0 +1,27 @@
    +import { bareHost, type EmbedMatcher } from './types'
    +
    +export const twitter: EmbedMatcher = url => {
    +  const host = bareHost(url.hostname)
    +
    +  if (host !== 'twitter.com' && host !== 'x.com') {
    +    return null
    +  }
    +
    +  const segments = url.pathname.split('/').filter(Boolean)
    +  const statusIndex = segments.indexOf('status')
    +  const id = statusIndex >= 0 ? segments[statusIndex + 1] : ''
    +
    +  if (!/^\d+$/.test(id || '')) {
    +    return null
    +  }
    +
    +  return {
    +    id: `twitter:${id}`,
    +    label: 'X',
    +    maxWidth: 480,
    +    provider: 'twitter',
    +    renderer: 'tweet',
    +    sourceUrl: url.toString(),
    +    tweetId: id
    +  }
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/providers/types.ts`

    ```diff
    @@ -0,0 +1,60 @@
    +// Embed provider model. Detection is pure, synchronous, and dependency-free so
    +// it is safe to run during render and trivial to unit-test. Rendering lives in
    +// the lazy renderers (see ../registry.tsx) keyed off `renderer`.
    +
    +export type EmbedProvider =
    +  | 'googlemaps'
    +  | 'instagram'
    +  | 'openstreetmap'
    +  | 'pinterest'
    +  | 'spotify'
    +  | 'tiktok'
    +  | 'twitter'
    +  | 'vimeo'
    +  | 'youtube'
    +
    +/** Which lazy renderer materialises the descriptor. */
    +export type EmbedRenderer = 'frame' | 'tweet'
    +
    +interface EmbedLayout {
    +  /** Frame aspect ratio (width / height). For video/maps. */
    +  aspectRatio?: number
    +  /** Fixed pixel height for non-ratio embeds (Instagram, Pinterest, Spotify). */
    +  height?: number
    +  /** Max rendered width in px; falls back to the conversation column. */
    +  maxWidth?: number
    +}
    +
    +interface BaseEmbed extends EmbedLayout {
    +  /** Stable id for React keys / dedupe. */
    +  id: string
    +  /** Human-facing provider name (e.g. "YouTube"). */
    +  label: string
    +  provider: EmbedProvider
    +  renderer: EmbedRenderer
    +  /** Canonical URL opened in the system browser from the card. */
    +  sourceUrl: string
    +}
    +
    +/** A provider whose embed is a single iframe URL (video, post, map, ...). */
    +export interface FrameEmbed extends BaseEmbed {
    +  /** URL loaded inside the iframe. */
    +  embedUrl: string
    +  renderer: 'frame'
    +}
    +
    +/** Twitter/X ships no iframe URL — only a widget script (see social-embed.tsx). */
    +export interface TweetEmbed extends BaseEmbed {
    +  renderer: 'tweet'
    +  tweetId: string
    +}
    +
    +export type EmbedDescriptor = FrameEmbed | TweetEmbed
    +
    +/** A provider matcher. Receives a parsed http(s) URL; returns null if unmatched. */
    +export type EmbedMatcher = (url: URL) => EmbedDescriptor | null
    +
    +/** Strip a leading `www.`/`m.`/`mobile.` so host checks read cleanly. */
    +export function bareHost(host: string): string {
    +  return host.replace(/^(?:www|m|mobile)\./i, '').toLowerCase()
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/providers/vimeo.ts`

    ```diff
    @@ -0,0 +1,32 @@
    +import { bareHost, type EmbedMatcher } from './types'
    +
    +export const vimeo: EmbedMatcher = url => {
    +  const host = bareHost(url.hostname)
    +
    +  if (host !== 'vimeo.com' && host !== 'player.vimeo.com') {
    +    return null
    +  }
    +
    +  // The clip id is the last all-digits segment, covering vimeo.com/123,
    +  // /channels/x/123, /groups/x/videos/123, and player/video/123.
    +  const id = url.pathname
    +    .split('/')
    +    .filter(Boolean)
    +    .reverse()
    +    .find(segment => /^\d+$/.test(segment))
    +
    +  if (!id) {
    +    return null
    +  }
    +
    +  return {
    +    aspectRatio: 16 / 9,
    +    embedUrl: `https://player.vimeo.com/video/${id}`,
    +    id: `vimeo:${id}`,
    +    label: 'Vimeo',
    +    maxWidth: 640,
    +    provider: 'vimeo',
    +    renderer: 'frame',
    +    sourceUrl: url.toString()
    +  }
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/providers/youtube.ts`

    ```diff
    @@ -0,0 +1,65 @@
    +import { bareHost, type EmbedMatcher } from './types'
    +
    +const YOUTUBE_ID_RE = /^[A-Za-z0-9_-]{11}$/
    +
    +// `t`/`start` accept either raw seconds ("90") or the "1m30s" form.
    +function startSeconds(value: string | null): number | undefined {
    +  if (!value) {
    +    return undefined
    +  }
    +
    +  if (/^\d+$/.test(value)) {
    +    return Number(value)
    +  }
    +
    +  const match = value.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/)
    +
    +  if (!match || !match[0]) {
    +    return undefined
    +  }
    +
    +  const seconds = Number(match[1] || 0) * 3600 + Number(match[2] || 0) * 60 + Number(match[3] || 0)
    +
    +  return seconds > 0 ? seconds : undefined
    +}
    +
    +export const youtube: EmbedMatcher = url => {
    +  const host = bareHost(url.hostname)
    +  const segments = url.pathname.split('/').filter(Boolean)
    +  let id = ''
    +
    +  if (host === 'youtu.be') {
    +    id = segments[0] || ''
    +  } else if (host === 'youtube.com' || host === 'youtube-nocookie.com') {
    +    if (segments[0] === 'watch') {
    +      id = url.searchParams.get('v') || ''
    +    } else if (['embed', 'shorts', 'live', 'v'].includes(segments[0] || '')) {
    +      id = segments[1] || ''
    +    }
    +  } else {
    +    return null
    +  }
    +
    +  if (!YOUTUBE_ID_RE.test(id)) {
    +    return null
    +  }
    +
    +  const params = new URLSearchParams({ modestbranding: '1', rel: '0' })
    +
    +  const start = startSeconds(url.searchParams.get('t') || url.searchParams.get('start'))
    +
    +  if (start) {
    +    params.set('start', String(start))
    +  }
    +
    +  return {
    +    aspectRatio: 16 / 9,
    +    embedUrl: `https://www.youtube-nocookie.com/embed/${id}?${params.toString()}`,
    +    id: `youtube:${id}`,
    +    label: 'YouTube',
    +    maxWidth: 640,
    +    provider: 'youtube',
    +    renderer: 'frame',
    +    sourceUrl: url.toString()
    +  }
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/registry.tsx`

    ```diff
    @@ -0,0 +1,39 @@
    +'use client'
    +
    +import { type ComponentType, lazy, type LazyExoticComponent, type ReactNode, Suspense } from 'react'
    +
    +import { RichBoundary } from './rich-boundary'
    +import type { RichFenceProps } from './types'
    +
    +// Root renderer for fenced code blocks: a language → lazy-renderer table. Each
    +// renderer is its own split chunk (mermaid pulls in the mermaid lib, svg pulls
    +// in DOMPurify), loaded only when a block of that language actually appears.
    +const LAZY_FENCE: Record<string, LazyExoticComponent<ComponentType<RichFenceProps>>> = {
    +  mermaid: lazy(() => import('./mermaid-embed')),
    +  svg: lazy(() => import('./svg-embed'))
    +}
    +
    +export const RICH_FENCE_LANGUAGES: ReadonlySet<string> = new Set(Object.keys(LAZY_FENCE))
    +
    +interface RichCodeBlockProps extends RichFenceProps {
    +  /** Rendered for unhandled languages, while the chunk loads, and on failure
    +   *  (typically the normal syntax-highlighted code block). */
    +  fallback: ReactNode
    +  language?: string
    +}
    +
    +export function RichCodeBlock({ code, fallback, language, streaming }: RichCodeBlockProps) {
    +  const Renderer = language ? LAZY_FENCE[language.toLowerCase()] : undefined
    +
    +  if (!Renderer) {
    +    return <>{fallback}</>
    +  }
    +
    +  return (
    +    <RichBoundary fallback={fallback} resetKey={code}>
    +      <Suspense fallback={fallback}>
    +        <Renderer code={code} streaming={streaming} />
    +      </Suspense>
    +    </RichBoundary>
    +  )
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/rich-boundary.tsx`

    ```diff
    @@ -0,0 +1,34 @@
    +import { Component, type ReactNode } from 'react'
    +
    +interface Props {
    +  children: ReactNode
    +  /** Rendered in place of the subtree when a render throws. */
    +  fallback: ReactNode
    +  /** Changing this clears a caught error (e.g. new source for a re-parse). */
    +  resetKey?: string
    +}
    +
    +/**
    + * Local boundary for rich renderers (Mermaid parse throws, malformed SVG, a
    + * provider widget blowing up). A failed embed must never blank the transcript —
    + * we show the `fallback` (typically the raw source) and recover when `resetKey`
    + * changes. Unlike MessageRenderBoundary this swallows ALL render errors, because
    + * the blast radius is one self-contained block, not the message tree.
    + */
    +export class RichBoundary extends Component<Props, { failed: boolean }> {
    +  state = { failed: false }
    +
    +  static getDerivedStateFromError() {
    +    return { failed: true }
    +  }
    +
    +  componentDidUpdate(prev: Props) {
    +    if (this.state.failed && prev.resetKey !== this.props.resetKey) {
    +      this.setState({ failed: false })
    +    }
    +  }
    +
    +  render() {
    +    return this.state.failed ? this.props.fallback : this.props.children
    +  }
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/scroll-gate.tsx`

    ```diff
    @@ -0,0 +1,33 @@
    +'use client'
    +
    +import { useEffect, useState } from 'react'
    +
    +import { cn } from '@/lib/utils'
    +
    +/** Block wheel until ⌘/Ctrl so map embeds don't hijack transcript scroll. */
    +export function ScrollGate() {
    +  const [active, setActive] = useState(false)
    +
    +  useEffect(() => {
    +    const sync = (event: KeyboardEvent) => setActive(event.metaKey || event.ctrlKey)
    +    const clear = () => setActive(false)
    +
    +    window.addEventListener('keydown', sync)
    +    window.addEventListener('keyup', sync)
    +    window.addEventListener('blur', clear)
    +
    +    return () => {
    +      window.removeEventListener('keydown', sync)
    +      window.removeEventListener('keyup', sync)
    +      window.removeEventListener('blur', clear)
    +    }
    +  }, [])
    +
    +  return (
    +    <div className={cn('group/gate absolute inset-0', active ? 'pointer-events-none' : 'pointer-events-auto')}>
    +      <span className="pointer-events-none absolute bottom-2 left-2 rounded-md bg-black/55 px-1.5 py-0.5 text-[0.625rem] font-medium text-white opacity-0 transition-opacity group-hover/embed:opacity-100">
    +        Hold ⌘ to zoom
    +      </span>
    +    </div>
    +  )
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/social-embed.tsx`

    ```diff
    @@ -0,0 +1,131 @@
    +'use client'
    +
    +import { useEffect, useRef } from 'react'
    +
    +import { escapeHtml } from './escape-html'
    +import type { EmbedDescriptor } from './providers/types'
    +import { useIsDark } from './use-is-dark'
    +
    +// The provider embed scripts need a REAL origin to run (they touch
    +// cookies/storage/postMessage), so — exactly like react-social-media-embed — we
    +// render the official blockquote in this document and let the script swap it for
    +// a correctly-sized iframe. A sandboxed srcDoc iframe gives a null origin and
    +// the scripts silently bail (white / 2px). The container is height:auto, so it
    +// grows to whatever the provider renders. No measuring, no forced height.
    +type EmbedWindow = Window &
    +  typeof globalThis & {
    +    instgrm?: { Embeds?: { process?: () => void } }
    +    twttr?: { widgets?: { load?: (el?: HTMLElement) => void } }
    +  }
    +
    +const SCRIPT: Record<string, { id: string; src: string }> = {
    +  instagram: { id: 'hermes-ig-embed', src: 'https://www.instagram.com/embed.js' },
    +  tiktok: { id: 'hermes-tt-embed', src: 'https://www.tiktok.com/embed.js' },
    +  twitter: { id: 'hermes-tw-embed', src: 'https://platform.twitter.com/widgets.js' }
    +}
    +
    +const PROCESS_DELAYS_MS = [0, 300, 800, 1600, 3000]
    +
    +function markup(descriptor: EmbedDescriptor, theme: 'dark' | 'light'): string {
    +  const url = escapeHtml(descriptor.sourceUrl)
    +
    +  switch (descriptor.provider) {
    +    case 'instagram':
    +      return `<blockquote class="instagram-media" data-instgrm-permalink="${url}" data-instgrm-version="14" style="margin:0;width:100%;min-width:0;max-width:100%"></blockquote>`
    +    case 'tiktok': {
    +      const id = escapeHtml(descriptor.id.replace(/^tiktok:/, ''))
    +
    +      return `<blockquote class="tiktok-embed" cite="${url}" data-video-id="${id}" style="margin:0;max-width:100%"><section></section></blockquote>`
    +    }
    +
    +    case 'twitter':
    +      // data-chrome="transparent" drops the card background so the themed page
    +      // shows through instead of a white box.
    +      return `<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}" data-chrome="transparent"><a href="${url}"></a></blockquote>`
    +
    +    default:
    +      return ''
    +  }
    +}
    +
    +function loadScript(provider: string): Promise<void> {
    +  const { id, src } = SCRIPT[provider]
    +
    +  // TikTok exposes no re-process API; its script rescans the document each time
    +  // it runs, so we re-inject it. The others are loaded once and reused.
    +  if (provider === 'tiktok') {
    +    document.getElementById(id)?.remove()
    +  } else if (document.getElementById(id)) {
    +    return Promise.resolve()
    +  }
    +
    +  return new Promise(resolve => {
    +    const script = document.createElement('script')
    +
    +    script.async = true
    +    script.id = id
    +    script.onload = () => resolve()
    +    script.onerror = () => resolve()
    +    script.src = src
    +    document.body.appendChild(script)
    +  })
    +}
    +
    +function processEmbed(provider: string, container: HTMLElement): void {
    +  const win = window as EmbedWindow
    +
    +  if (provider === 'instagram') {
    +    win.instgrm?.Embeds?.process?.()
    +  } else if (provider === 'twitter') {
    +    win.twttr?.widgets?.load?.(container)
    +  }
    +  // TikTok auto-scans on (re)injection — no manual process call.
    +}
    +
    +export default function SocialEmbedRenderer({ descriptor }: { descriptor: EmbedDescriptor }) {
    +  const isDark = useIsDark()
    +  const ref = useRef<HTMLDivElement | null>(null)
    +
    +  useEffect(() => {
    +    const container = ref.current
    +
    +    if (!container) {
    +      return
    +    }
    +
    +    let cancelled = false
    +    const timers: number[] = []
    +
    +    container.innerHTML = markup(descriptor, isDark ? 'dark' : 'light')
    +
    +    void loadScript(descriptor.provider).then(() => {
    +      // The script renders asynchronously; nudge a few times so the embed
    +      // settles whether the script was cached or freshly fetched.
    +      for (const delay of PROCESS_DELAYS_MS) {
    +        timers.push(window.setTimeout(() => !cancelled && processEmbed(descriptor.provider, container), delay))
    +      }
    +    })
    +
    +    return () => {
    +      cancelled = true
    +
    +      for (const timer of timers) {
    +        clearTimeout(timer)
    +      }
    +
    +      container.innerHTML = ''
    +    }
    +  }, [descriptor, isDark])
    +
    +  // The white corner/box on tweets is a color-scheme MISMATCH: when the iframe's
    +  // resolved scheme differs from ours, the browser paints an opaque (white)
    +  // Canvas behind it. Twitter's embed resolves to `light`, so we force the iframe
    +  // to `light` to match — no mismatch, no Canvas — and data-chrome=transparent
    +  // then lets the dark page show through. (Confirmed: mkdocs-material #6889.)
    +  return (
    +    <div
    +      className="w-full [&_.instagram-media]:!min-w-0 [&_iframe]:!m-0 [&_iframe]:!max-w-full [&_iframe]:[color-scheme:light]"
    +      ref={ref}
    +    />
    +  )
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/spotify-embed.tsx`

    ```diff
    @@ -0,0 +1,45 @@
    +'use client'
    +
    +import { type CSSProperties, useMemo } from 'react'
    +
    +import type { FrameEmbed } from './providers/types'
    +import { useIsDark } from './use-is-dark'
    +
    +const ALLOW = 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture'
    +
    +// Spotify paints a white backdrop behind its card; theme=0 gives the dark
    +// player and the card wrapper's overflow-hidden clips the corners.
    +function spotifySrc(embedUrl: string, isDark: boolean): string {
    +  const url = new URL(embedUrl)
    +
    +  url.searchParams.set('utm_source', 'generator')
    +
    +  if (isDark) {
    +    url.searchParams.set('theme', '0')
    +  }
    +
    +  return url.toString()
    +}
    +
    +export default function SpotifyEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {
    +  const isDark = useIsDark()
    +  const src = useMemo(() => spotifySrc(descriptor.embedUrl, isDark), [descriptor.embedUrl, isDark])
    +
    +  // Match the iframe's own (light) scheme — a `dark` mismatch makes the browser
    +  // paint an opaque white Canvas behind it. theme=0 still gives the dark player.
    +  const style: CSSProperties = {
    +    colorScheme: 'light',
    +    height: descriptor.height
    +  }
    +
    +  return (
    +    <iframe
    +      allow={ALLOW}
    +      className="block w-full border-0 bg-transparent"
    +      loading="lazy"
    +      src={src}
    +      style={style}
    +      title="Spotify embed"
    +    />
    +  )
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/svg-embed.tsx`

    ```diff
    @@ -0,0 +1,32 @@
    +'use client'
    +
    +import DOMPurify from 'dompurify'
    +import { useMemo } from 'react'
    +
    +import type { RichFenceProps } from './types'
    +
    +// Lazy chunk (pulls in DOMPurify). Renders a ```svg fence as an image after
    +// hard-sanitising it: the svg profile strips scripts, event handlers, and
    +// foreignObject, so untrusted model output can't execute.
    +export default function SvgRenderer({ code }: RichFenceProps) {
    +  const clean = useMemo(
    +    () =>
    +      DOMPurify.sanitize(code, {
    +        USE_PROFILES: { svg: true, svgFilters: true }
    +      }),
    +    [code]
    +  )
    +
    +  if (!clean.trim()) {
    +    return null
    +  }
    +
    +  // Left-aligned, capped on both axes so a large intrinsic SVG scales down
    +  // (preserving ratio) instead of filling the column or centering.
    +  return (
    +    <div
    +      className="my-2 [&_svg]:block [&_svg]:h-auto [&_svg]:w-auto [&_svg]:max-h-[33dvh] [&_svg]:max-w-full"
    +      dangerouslySetInnerHTML={{ __html: clean }}
    +    />
    +  )
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/types.ts`

    ```diff
    @@ -0,0 +1,8 @@
    +// Shared prop contract for fenced-block renderers (mermaid, svg). Kept in its
    +// own module so renderers and the registry can both import it without a cycle.
    +export interface RichFenceProps {
    +  code: string
    +  /** True while the surrounding message is still streaming. Renderers that can
    +   *  throw on partial input (e.g. mermaid) defer until this is false. */
    +  streaming?: boolean
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/url-embed.tsx`

    ```diff
    @@ -0,0 +1,84 @@
    +'use client'
    +
    +import { useStore } from '@nanostores/react'
    +import { type CSSProperties, lazy, Suspense, useState } from 'react'
    +
    +import { PrettyLink } from '@/lib/external-link'
    +import { $embedAllowed, $embedMode } from '@/store/embed-consent'
    +
    +import { EmbedFacade } from './embed-consent'
    +import { EMBED_MAX_H } from './embed-size'
    +import { EmbedFail } from './fail'
    +import type { EmbedDescriptor } from './providers/types'
    +import { RichBoundary } from './rich-boundary'
    +
    +const FrameEmbedRenderer = lazy(() => import('./frame-embed'))
    +const SocialEmbedRenderer = lazy(() => import('./social-embed'))
    +const SpotifyEmbedRenderer = lazy(() => import('./spotify-embed'))
    +const YouTubeEmbedRenderer = lazy(() => import('./youtube-embed'))
    +
    +function intrinsicHeight(descriptor: EmbedDescriptor): number {
    +  if (descriptor.aspectRatio) {
    +    return Math.round((descriptor.maxWidth ?? 640) / descriptor.aspectRatio)
    +  }
    +
    +  return descriptor.height ?? 320
    +}
    +
    +function LazyRenderer({ descriptor }: { descriptor: EmbedDescriptor }) {
    +  // X and Instagram load their official blockquote script in-document. The tweet
    +  // check also narrows the union to FrameEmbed for the iframe renderers below.
    +  if (descriptor.renderer === 'tweet' || descriptor.provider === 'instagram') {
    +    return <SocialEmbedRenderer descriptor={descriptor} />
    +  }
    +
    +  if (descriptor.provider === 'youtube') {
    +    return <YouTubeEmbedRenderer descriptor={descriptor} />
    +  }
    +
    +  if (descriptor.provider === 'spotify') {
    +    return <SpotifyEmbedRenderer descriptor={descriptor} />
    +  }
    +
    +  return <FrameEmbedRenderer descriptor={descriptor} />
    +}
    +
    +export function UrlEmbed({ descriptor }: { descriptor: EmbedDescriptor }) {
    +  const mode = useStore($embedMode)
    +  const allowed = useStore($embedAllowed)
    +  const [loaded, setLoaded] = useState(false)
    +
    +  // Privacy gate: don't reach out to the provider until consented. `off` keeps
    +  // it a plain link; otherwise the placeholder shows until "Load" (this embed)
    +  // or "Always allow" / global `always` permits the fetch.
    +  if (mode === 'off') {
    +    return <PrettyLink className="wrap-anywhere" href={descriptor.sourceUrl} />
    +  }
    +
    +  const consented = mode === 'always' || loaded || allowed.includes(descriptor.provider)
    +  const aspect = descriptor.aspectRatio
    +
    +  // Ratio embeds cap WIDTH off the ratio so height tops out at the cap while
    +  // scaling. Non-ratio embeds own their own height (measured / fixed).
    +  const style: CSSProperties = {
    +    containIntrinsicSize: `auto ${intrinsicHeight(descriptor)}px`,
    +    contentVisibility: 'auto',
    +    ...(aspect
    +      ? { width: `min(${descriptor.maxWidth ?? 640}px, 100%, calc(${EMBED_MAX_H} * ${aspect}))` }
    +      : { width: descriptor.maxWidth ? `min(${descriptor.maxWidth}px, 100%)` : '100%' })
    +  }
    +
    +  return (
    +    <span className="group/embed my-2 block overflow-hidden rounded-lg" data-slot="aui_embed-card" style={style}>
    +      <RichBoundary fallback={<EmbedFail label={descriptor.label} />} resetKey={descriptor.id}>
    +        {consented ? (
    +          <Suspense fallback={null}>
    +            <LazyRenderer descriptor={descriptor} />
    +          </Suspense>
    +        ) : (
    +          <EmbedFacade descriptor={descriptor} onLoad={() => setLoaded(true)} />
    +        )}
    +      </RichBoundary>
    +    </span>
    +  )
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/use-is-dark.ts`

    ```diff
    @@ -0,0 +1,24 @@
    +import { useEffect, useState } from 'react'
    +
    +// Tracks the app's dark/light mode off the `dark` class on <html> (set by
    +// themes/context.tsx). Embeds that theme their own content (tweets) read this.
    +export function useIsDark(): boolean {
    +  const [dark, setDark] = useState(
    +    () => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
    +  )
    +
    +  useEffect(() => {
    +    if (typeof document === 'undefined') {
    +      return
    +    }
    +
    +    const root = document.documentElement
    +    const observer = new MutationObserver(() => setDark(root.classList.contains('dark')))
    +
    +    observer.observe(root, { attributeFilter: ['class'], attributes: true })
    +
    +    return () => observer.disconnect()
    +  }, [])
    +
    +  return dark
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/embeds/youtube-embed.tsx`

    ```diff
    @@ -0,0 +1,47 @@
    +'use client'
    +
    +import { useMemo } from 'react'
    +
    +import type { FrameEmbed } from './providers/types'
    +import { useIsDark } from './use-is-dark'
    +
    +const YOUTUBE_ALLOW =
    +  'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen'
    +
    +function youtubeSrc(embedUrl: string): string {
    +  const url = new URL(embedUrl)
    +
    +  // Only pass origin when it is an HTTP(S) origin; custom schemes (app://,
    +  // file://) can make the player reject otherwise embeddable videos.
    +  if (
    +    typeof window !== 'undefined' &&
    +    (window.location.protocol === 'http:' || window.location.protocol === 'https:') &&
    +    window.location.origin &&
    +    window.location.origin !== 'null'
    +  ) {
    +    url.searchParams.set('origin', window.location.origin)
    +  }
    +
    +  return url.toString()
    +}
    +
    +// Keep this as a plain iframe and let YouTube render its native player/error UI.
    +export default function YouTubeEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {
    +  const isDark = useIsDark()
    +  const src = useMemo(() => youtubeSrc(descriptor.embedUrl), [descriptor.embedUrl])
    +
    +  // Width is capped to the ratio by UrlEmbed, so aspect-video sizes height ≤ cap.
    +  return (
    +    <iframe
    +      allow={YOUTUBE_ALLOW}
    +      allowFullScreen
    +      className="block aspect-video w-full border-0 bg-transparent"
    +      loading="lazy"
    +      referrerPolicy="strict-origin-when-cross-origin"
    +      scrolling="no"
    +      src={src}
    +      style={{ colorScheme: isDark ? 'dark' : 'light' }}
    +      title="YouTube embed"
    +    />
    +  )
    +}
    ```
  - `apps/desktop/src/components/assistant-ui/markdown-text.tsx`

    ```diff
    @@ -40,6 +40,8 @@ import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
     import { tailBoundedRemend } from '@/lib/remend-tail'
     import { cn } from '@/lib/utils'
     
    +import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds'
    +
     // Math rendering plugin (KaTeX). Configured once at module scope — the
     // plugin is stateless beyond its internal cache so re-creating per-render
     // would needlessly thrash. We use a memoizing wrapper around rehype-katex
    ```

    ```diff
    @@ -270,6 +272,17 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a
       }
     
       const text = childrenToText(children)
    +
    +  // Bare autolink → inline rich embed when a provider matches. Labeled links
    +  // (`[watch](url)`) stay plain. Desktop only (webview / iframe renderers).
    +  if (window.hermesDesktop && text && normalizeExternalUrl(text) === target) {
    +    const embed = detectEmbed(target)
    +
    +    if (embed) {
    +      return <UrlEmbed descriptor={embed} />
    +    }
    +  }
    +
       const fallbackLabel = text && normalizeExternalUrl(text) !== target ? text : undefined
     
       return (
    ```

    ```diff
    @@ -535,13 +548,25 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
             // owning per-line text direction. Inline code carries `dir="ltr"`
             // (see the `code` override) so it doesn't vote here either, same
             // contract as the CSS isolate.
    -        blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
    -          <blockquote
    -            className={cn('border-s-2 border-border ps-3 text-muted-foreground italic', className)}
    -            dir="auto"
    -            {...props}
    -          />
    -        ),
    +        // A `> [!NOTE]`/`[!WARNING]`/... blockquote renders as a GFM alert
    +        // callout; everything else stays a plain quote.
    +        blockquote: ({ children, className, ...props }: ComponentProps<'blockquote'>) => {
    +          const alert = extractAlert(children)
    +
    +          if (alert) {
    +            return <MarkdownAlert type={alert.type}>{alert.body}</MarkdownAlert>
    +          }
    +
    +          return (
    +            <blockquote
    +              className={cn('border-s-2 border-border ps-3 text-muted-foreground italic', className)}
    +              dir="auto"
    +              {...props}
    +            >
    +              {children}
    +            </blockquote>
    +          )
    +        },
             ul: ({ className, ...props }: ComponentProps<'ul'>) => (
               <ul className={cn('my-1 gap-0', className)} dir="auto" {...props} />
             ),
    ```

    ```diff
    @@ -578,7 +603,16 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
               <td className={cn('px-2.5 py-1.5 align-top text-[0.8125rem] leading-snug', className)} {...props} />
             ),
             img: MarkdownImage,
    -        SyntaxHighlighter: (props: SyntaxHighlighterProps) => <SyntaxHighlighter {...props} defer={isStreaming} />
    +        // ```mermaid / ```svg fences route to their lazy renderers; every other
    +        // language falls back to the Shiki-highlighted code block.
    +        SyntaxHighlighter: (props: SyntaxHighlighterProps) => (
    +          <RichCodeBlock
    +            code={props.code}
    +            fallback={<SyntaxHighlighter {...props} defer={isStreaming} />}
    +            language={props.language}
    +            streaming={isStreaming}
    +          />
    +        )
           }) as StreamdownTextComponents,
         [isStreaming]
       )
    ```
  - `apps/desktop/src/components/ui/use-zoom-pan.ts`

    ```diff
    @@ -0,0 +1,97 @@
    +import {
    +  type CSSProperties,
    +  type PointerEvent as ReactPointerEvent,
    +  type WheelEvent as ReactWheelEvent,
    +  useCallback,
    +  useRef,
    +  useState
    +} from 'react'
    +
    +interface Transform {
    +  scale: number
    +  x: number
    +  y: number
    +}
    +
    +const MIN_SCALE = 0.25
    +const MAX_SCALE = 8
    +const WHEEL_STEP = 1.1
    +const BUTTON_STEP = 1.25
    +
    +const clamp = (scale: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale))
    +
    +/**
    + * Headless pan/zoom transform. Wheel zooms toward the cursor, drag pans, buttons
    + * zoom toward centre. Returns the transform style plus the surface handlers, so
    + * any content (SVG, image, canvas) can be made pan/zoomable.
    + */
    +export function useZoomPan() {
    +  const [transform, setTransform] = useState<Transform>({ scale: 1, x: 0, y: 0 })
    +  const drag = useRef<{ x: number; y: number } | null>(null)
    +  const [panning, setPanning] = useState(false)
    +
    +  // Zoom toward (cx, cy), measured from the surface centre, keeping that point fixed.
    +  const zoomAt = useCallback((factor: number, cx = 0, cy = 0) => {
    +    setTransform(prev => {
    +      const scale = clamp(prev.scale * factor)
    +      const k = scale / prev.scale
    +
    +      return { scale, x: cx - k * (cx - prev.x), y: cy - k * (cy - prev.y) }
    +    })
    +  }, [])
    +
    +  const onWheel = useCallback(
    +    (event: ReactWheelEvent) => {
    +      event.preventDefault()
    +      const rect = event.currentTarget.getBoundingClientRect()
    +      const cx = event.clientX - rect.left - rect.width / 2
    +      const cy = event.clientY - rect.top - rect.height / 2
    +
    +      zoomAt(event.deltaY < 0 ? WHEEL_STEP : 1 / WHEEL_STEP, cx, cy)
    +    },
    +    [zoomAt]
    +  )
    +
    +  const onPointerDown = useCallback((event: ReactPointerEvent) => {
    +    event.currentTarget.setPointerCapture(event.pointerId)
    +    setTransform(prev => {
    +      drag.current = { x: event.clientX - prev.x, y: event.clientY - prev.y }
    +
    +      return prev
    +    })
    +    setPanning(true)
    +  }, [])
    +
    +  const onPointerMove = useCallback((event: ReactPointerEvent) => {
    +    if (!drag.current) {
    +      return
    +    }
    +
    +    const start = drag.current
    +
    +    setTransform(prev => ({ ...prev, x: event.clientX - start.x, y: event.clientY - start.y }))
    +  }, [])
    +
    +  const endPan = useCallback(() => {
    +    drag.current = null
    +    setPanning(false)
    +  }, [])
    +
    +  const reset = useCallback(() => setTransform({ scale: 1, x: 0, y: 0 }), [])
    +  const zoomIn = useCallback(() => zoomAt(BUTTON_STEP), [zoomAt])
    +  const zoomOut = useCallback(() => zoomAt(1 / BUTTON_STEP), [zoomAt])
    +
    +  const style: CSSProperties = {
    +    transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`
    +  }
    +
    +  return {
    +    panning,
    +    reset,
    +    scale: transform.scale,
    +    stageProps: { onPointerDown, onPointerLeave: endPan, onPointerMove, onPointerUp: endPan, onWheel },
    +    style,
    +    zoomIn,
    +    zoomOut
    +  }
    +}
    ```
  - `apps/desktop/src/components/ui/zoomable.tsx`

    ```diff
    @@ -0,0 +1,172 @@
    +'use client'
    +
    +import { type ReactNode, useEffect, useState } from 'react'
    +
    +import { Dialog, DialogContent } from '@/components/ui/dialog'
    +import { Check, Copy, Maximize, RefreshCw, X, ZoomIn, ZoomOut } from '@/lib/icons'
    +import { cn } from '@/lib/utils'
    +
    +import { useZoomPan } from './use-zoom-pan'
    +
    +interface ZoomableProps {
    +  /** Inline content; also the default full-view content. */
    +  children: ReactNode
    +  /** Full-view content, if it should differ from the inline version. */
    +  overlay?: ReactNode
    +  /** Copy/export action shown in the viewer toolbar. */
    +  onCopy?: () => Promise<void> | void
    +  /** Accessible label for the expand affordance. */
    +  label?: string
    +  className?: string
    +}
    +
    +/**
    + * Generic click-to-expand viewer: renders inline content with a hover "expand"
    + * affordance, then opens a full overlay where the content can be panned/zoomed
    + * (see useZoomPan) and optionally copied. Content-agnostic — wrap a diagram,
    + * image, or any node.
    + */
    +export function Zoomable({ children, overlay, onCopy, label = 'Open full view', className }: ZoomableProps) {
    +  const [open, setOpen] = useState(false)
    +
    +  return (
    +    <>
    +      <div className={cn('group/zoomable relative', className)}>
    +        {/* The whole content is the trigger — click anywhere to open, like an image. */}
    +        <button
    +          className="block w-full cursor-zoom-in text-left"
    +          onClick={() => setOpen(true)}
    +          title={label}
    +          type="button"
    +        >
    +          {children}
    +        </button>
    +        <span
    +          aria-hidden
    +          className="pointer-events-none absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity group-hover/zoomable:opacity-100"
    +        >
    +          <Maximize className="size-4" />
    +        </span>
    +      </div>
    +      {open && (
    +        <ZoomPanViewer onCopy={onCopy} onOpenChange={setOpen} open={open}>
    +          {overlay ?? children}
    +        </ZoomPanViewer>
    +      )}
    +    </>
    +  )
    +}
    +
    +function ZoomPanViewer({
    +  children,
    +  onCopy,
    +  onOpenChange,
    +  open
    +}: {
    +  children: ReactNode
    +  onCopy?: () => Promise<void> | void
    +  onOpenChange: (open: boolean) => void
    +  open: boolean
    +}) {
    +  const { panning, reset, stageProps, style, zoomIn, zoomOut } = useZoomPan()
    +
    +  useEffect(() => {
    +    if (open) {
    +      reset()
    +    }
    +  }, [open, reset])
    +
    +  return (
    +    <Dialog onOpenChange={onOpenChange} open={open}>
    +      <DialogContent
    +        className="flex h-[85vh] w-[90vw] max-w-[90vw] flex-col gap-0 overflow-hidden p-0"
    +        showCloseButton={false}
    +      >
    +        <div
    +          className={cn(
    +            'relative flex-1 touch-none select-none overflow-hidden',
    +            panning ? 'cursor-grabbing' : 'cursor-grab'
    +          )}
    +          {...stageProps}
    +        >
    +          <div className="absolute inset-0 grid place-items-center">
    +            <div className="origin-center" style={style}>
    +              {children}
    +            </div>
    +          </div>
    +        </div>
    +        <Toolbar onClose={() => onOpenChange(false)} onCopy={onCopy} reset={reset} zoomIn={zoomIn} zoomOut={zoomOut} />
    +      </DialogContent>
    +    </Dialog>
    +  )
    +}
    +
    +function Toolbar({
    +  onClose,
    +  onCopy,
    +  reset,
    +  zoomIn,
    +  zoomOut
    +}: {
    +  onClose: () => void
    +  onCopy?: () => Promise<void> | void
    +  reset: () => void
    +  zoomIn: () => void
    +  zoomOut: () => void
    +}) {
    +  const [copied, setCopied] = useState(false)
    +
    +  const copy = async () => {
    +    if (!onCopy) {
    +      return
    +    }
    +
    +    await onCopy()
    +    setCopied(true)
    +    window.setTimeout(() => setCopied(false), 1500)
    +  }
    +
    +  return (
    +    <div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border/70 bg-background/85 p-1 shadow-sm backdrop-blur">
    +      <ToolbarButton label="Zoom out" onClick={zoomOut}>
    +        <ZoomOut className="size-4" />
    +      </ToolbarButton>
    +      <ToolbarButton label="Reset" onClick={reset}>
    +        <RefreshCw className="size-4" />
    +      </ToolbarButton>
    +      <ToolbarButton label="Zoom in" onClick={zoomIn}>
    +        <ZoomIn className="size-4" />
    +      </ToolbarButton>
    +      {onCopy && (
    +        <>
    +          <Divider />
    +          <ToolbarButton label={copied ? 'Copied' : 'Copy'} onClick={() => void copy()}>
    +            {copied ? <Check className="size-4" /> : <Copy className="size-4" />}
    +          </ToolbarButton>
    +        </>
    +      )}
    +      <Divider />
    +      <ToolbarButton label="Close" onClick={onClose}>
    +        <X className="size-4" />
    +      </ToolbarButton>
    +    </div>
    +  )
    +}
    +
    +function Divider() {
    +  return <span className="mx-0.5 h-5 w-px bg-border" />
    +}
    +
    +function ToolbarButton({ children, label, onClick }: { children: ReactNode; label: string; onClick: () => void }) {
    +  return (
    +    <button
    +      aria-label={label}
    +      className="grid size-8 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
    +      onClick={onClick}
    +      title={label}
    +      type="button"
    +    >
    +      {children}
    +    </button>
    +  )
    +}
    ```
  - `apps/desktop/src/i18n/en.ts`

    ```diff
    @@ -376,6 +376,13 @@ export const en: Translations = {
           toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.',
           translucencyTitle: 'Window Translucency',
           translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.',
    +      embedsTitle: 'Inline Embeds',
    +      embedsDesc:
    +        'Rich previews load from third-party sites (YouTube, X, …). Ask shows a placeholder until you allow each one; Always loads them automatically; Off keeps plain links.',
    +      embedsAsk: 'Ask',
    +      embedsAlways: 'Always',
    +      embedsOff: 'Off',
    +      embedsReset: (count: number) => `Reset ${count} allowed ${count === 1 ? 'service' : 'services'}`,
           product: 'Product',
           productDesc: 'Human-friendly tool activity with concise summaries.',
           technical: 'Technical',
    ```
  - `apps/desktop/src/i18n/ja.ts`

    ```diff
    @@ -286,6 +286,13 @@ export const ja = defineLocale({
           toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。',
           translucencyTitle: 'ウィンドウの透過',
           translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。',
    +      embedsTitle: 'インライン埋め込み',
    +      embedsDesc:
    +        'リッチプレビューは第三者サイト（YouTube、X など）から読み込まれます。確認は許可するまでプレースホルダーを表示し、常には自動で読み込み、オフはリンクのままにします。',
    +      embedsAsk: '確認',
    +      embedsAlways: '常に',
    +      embedsOff: 'オフ',
    +      embedsReset: (count: number) => `許可した${count}件のサービスをリセット`,
           product: 'プロダクト',
           productDesc: '読みやすいツール活動と簡潔な要約を表示します。',
           technical: 'テクニカル',
    ```
  - `apps/desktop/src/i18n/types.ts`

    ```diff
    @@ -301,6 +301,12 @@ export interface Translations {
           toolViewDesc: string
           translucencyTitle: string
           translucencyDesc: string
    +      embedsTitle: string
    +      embedsDesc: string
    +      embedsAsk: string
    +      embedsAlways: string
    +      embedsOff: string
    +      embedsReset: (count: number) => string
           product: string
           productDesc: string
           technical: string
    ```
  - `apps/desktop/src/i18n/zh-hant.ts`

    ```diff
    @@ -278,6 +278,12 @@ export const zhHant = defineLocale({
           toolViewDesc: '產品模式會隱藏原始工具 payload；技術模式會顯示完整輸入/輸出。',
           translucencyTitle: '視窗透明',
           translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
    +      embedsTitle: '內嵌預覽',
    +      embedsDesc: '豐富預覽會從第三方網站（YouTube、X 等）載入。詢問會在你允許前顯示佔位符；一律會自動載入；關閉則保留純連結。',
    +      embedsAsk: '詢問',
    +      embedsAlways: '一律',
    +      embedsOff: '關閉',
    +      embedsReset: (count: number) => `重設 ${count} 個已允許的服務`,
           product: '產品',
           productDesc: '易讀的工具活動與精簡摘要。',
           technical: '技術',
    ```
  - `apps/desktop/src/i18n/zh.ts`

    ```diff
    @@ -369,6 +369,12 @@ export const zh: Translations = {
           toolViewDesc: '产品模式隐藏原始工具数据；技术模式显示完整输入/输出。',
           translucencyTitle: '窗口透明',
           translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
    +      embedsTitle: '内嵌预览',
    +      embedsDesc: '富预览会从第三方网站（YouTube、X 等）加载。询问会在你允许前显示占位符；总是会自动加载；关闭则保留纯链接。',
    +      embedsAsk: '询问',
    +      embedsAlways: '总是',
    +      embedsOff: '关闭',
    +      embedsReset: (count: number) => `重置 ${count} 个已允许的服务`,
           product: '产品',
           productDesc: '易读的工具活动与简洁摘要。',
           technical: '技术',
    ```
  - `apps/desktop/src/lib/icons.ts`

    ```diff
    @@ -56,6 +56,7 @@ import {
       IconLock as Lock,
       IconLogin as LogIn,
       IconMail as Mail,
    +  IconMaximize as Maximize,
       IconMessageCircle as MessageCircle,
       IconMessage2 as MessageSquareText,
       IconMicrophone as Mic,
    ```

    ```diff
    @@ -104,7 +105,9 @@ import {
       IconX as X,
       IconX as XIcon,
       IconBolt as Zap,
    -  IconBoltFilled as ZapFilled
    +  IconBoltFilled as ZapFilled,
    +  IconZoomIn as ZoomIn,
    +  IconZoomOut as ZoomOut
     } from '@tabler/icons-react'
     
     export {
    ```

    ```diff
    @@ -165,6 +168,7 @@ export {
       Lock,
       LogIn,
       Mail,
    +  Maximize,
       MessageCircle,
       MessageSquareText,
       Mic,
    ```

    ```diff
    @@ -213,7 +217,9 @@ export {
       X,
       XIcon,
       Zap,
    -  ZapFilled
    +  ZapFilled,
    +  ZoomIn,
    +  ZoomOut
     }
     
     export type { Icon as IconComponent } from '@tabler/icons-react'
    ```
  - `apps/desktop/src/lib/svg-image.ts`

    ```diff
    @@ -0,0 +1,56 @@
    +// Rasterise an SVG string to PNG and copy it to the clipboard. Self-contained
    +// SVGs only (inline styles) — mermaid output qualifies. Falls back to copying
    +// the SVG markup as text where image clipboard writes aren't permitted.
    +
    +function svgSize(svg: string): { height: number; width: number } {
    +  const el = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement
    +  const width = parseFloat(el.getAttribute('width') || '')
    +  const height = parseFloat(el.getAttribute('height') || '')
    +
    +  if (width && height) {
    +    return { height, width }
    +  }
    +
    +  const [, , vbW, vbH] = (el.getAttribute('viewBox') || '').split(/[\s,]+/).map(Number)
    +
    +  return vbW && vbH ? { height: vbH, width: vbW } : { height: 600, width: 800 }
    +}
    +
    +export function svgToPngBlob(svg: string, scale = 2): Promise<Blob> {
    +  const { height, width } = svgSize(svg)
    +
    +  return new Promise((resolve, reject) => {
    +    const image = new Image()
    +
    +    image.onload = () => {
    +      const canvas = document.createElement('canvas')
    +      canvas.width = Math.max(1, Math.round(width * scale))
    +      canvas.height = Math.max(1, Math.round(height * scale))
    +
    +      const ctx = canvas.getContext('2d')
    +
    +      if (!ctx) {
    +        reject(new Error('no 2d context'))
    +
    +        return
    +      }
    +
    +      ctx.scale(scale, scale)
    +      ctx.drawImage(image, 0, 0, width, height)
    +      canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('toBlob failed'))), 'image/png')
    +    }
    +
    +    image.onerror = () => reject(new Error('svg load failed'))
    +    image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
    +  })
    +}
    +
    +export async function copySvgAsPng(svg: string): Promise<void> {
    +  try {
    +    const blob = await svgToPngBlob(svg)
    +
    +    await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
    +  } catch {
    +    await navigator.clipboard.writeText(svg)
    +  }
    +}
    ```
  - `apps/desktop/src/store/embed-consent.ts`

    ```diff
    @@ -0,0 +1,37 @@
    +import { type Codec, Codecs, persistentAtom } from '@/lib/persisted'
    +
    +// Privacy gate for inline embeds. Loading an embed reaches out to a third party
    +// (IP, referrer, cookies), so by default we render a placeholder until the user
    +// consents — per embed ("Load once") or per service ("Always allow YouTube").
    +// Mirrors the tool-approval model, but purely client-side (the renderer is what
    +// makes the request) so it never touches the gateway/config.yaml.
    +export type EmbedMode = 'always' | 'ask' | 'off'
    +
    +const MODE_KEY = 'hermes.desktop.embed-mode'
    +const ALLOWED_KEY = 'hermes.desktop.embed-allowed'
    +
    +const modeCodec: Codec<EmbedMode> = {
    +  decode: raw => (raw === 'always' || raw === 'off' ? raw : 'ask'),
    +  encode: value => value
    +}
    +
    +/** Global default: ask (placeholder), always (auto-load), off (plain link). */
    +export const $embedMode = persistentAtom<EmbedMode>(MODE_KEY, 'ask', modeCodec)
    +/** Providers granted a standing "always allow" (e.g. `youtube`, `twitter`). */
    +export const $embedAllowed = persistentAtom<string[]>(ALLOWED_KEY, [], Codecs.stringArray)
    +
    +export function allowProvider(provider: string) {
    +  const current = $embedAllowed.get()
    +
    +  if (!current.includes(provider)) {
    +    $embedAllowed.set([...current, provider])
    +  }
    +}
    +
    +export function setEmbedMode(mode: EmbedMode) {
    +  $embedMode.set(mode)
    +}
    +
    +export function clearEmbedAllowed() {
    +  $embedAllowed.set([])
    +}
    ```
  - `package-lock.json`

    ```diff
    @@ -95,11 +95,13 @@
             "clsx": "^2.1.1",
             "cmdk": "^1.1.1",
             "dnd-core": "^14.0.1",
    +        "dompurify": "^3.4.11",
             "hast-util-from-html-isomorphic": "^2.0.0",
             "hast-util-to-text": "^4.0.2",
             "ignore": "^7.0.5",
             "katex": "^0.16.45",
             "leva": "^0.10.1",
    +        "mermaid": "^11.15.0",
             "motion": "^12.38.0",
             "nanostores": "^1.3.0",
             "node-pty": "1.1.0",
    ```
- **#40531: feat(desktop): render Mermaid diagrams in markdown file preview**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#40630`, `#52935`

## Fix status

- Merged to main in PR #40630 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-07`.
- Subsystem: `apps/desktop`
