{"repo":"NousResearch/hermes-agent","public_id":"38654","display_label":"Mermaid diagram rendering in markdown previews","summary":"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.","subsystem":"apps/desktop","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":40630,"merged_at":"2026-07-07T09:39:19+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-07T09:39:19+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":38654,"pr_number":40630,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-07T09:39:19+00:00"},{"issue_number":38654,"pr_number":52935,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-26T18:36:44+00:00"}],"members":[{"kind":"issue","number":38654,"title":"Markdown file preview does not render Mermaid diagrams in Desktop","url":null,"state":"closed"},{"kind":"pr","number":40531,"title":"feat(desktop): render Mermaid diagrams in markdown file preview","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#40630","number":40630},{"kind":"pr","value":"#52935","number":52935}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":40630,"title":"feat(desktop): render Mermaid code blocks in markdown file preview","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/src/app/chat/right-rail/preview-file.tsx"]},{"kind":"pr","number":52935,"title":"feat(desktop): inline rich embeds, diagrams & alerts in assistant markdown","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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","apps/desktop/src/components/assistant-ui/embeds/providers/twitter.ts","apps/desktop/src/components/assistant-ui/embeds/providers/types.ts","apps/desktop/src/components/assistant-ui/embeds/providers/vimeo.ts","apps/desktop/src/components/assistant-ui/embeds/providers/youtube.ts","apps/desktop/src/components/assistant-ui/embeds/registry.tsx","apps/desktop/src/components/assistant-ui/embeds/rich-boundary.tsx","apps/desktop/src/components/assistant-ui/embeds/scroll-gate.tsx","apps/desktop/src/components/assistant-ui/embeds/social-embed.tsx","apps/desktop/src/components/assistant-ui/embeds/spotify-embed.tsx","apps/desktop/src/components/assistant-ui/embeds/svg-embed.tsx","apps/desktop/src/components/assistant-ui/embeds/types.ts","apps/desktop/src/components/assistant-ui/embeds/url-embed.tsx","apps/desktop/src/components/assistant-ui/embeds/use-is-dark.ts","apps/desktop/src/components/assistant-ui/embeds/youtube-embed.tsx","apps/desktop/src/components/assistant-ui/markdown-text.tsx","apps/desktop/src/components/ui/use-zoom-pan.ts","apps/desktop/src/components/ui/zoomable.tsx","apps/desktop/src/i18n/en.ts","apps/desktop/src/i18n/ja.ts","apps/desktop/src/i18n/types.ts","apps/desktop/src/i18n/zh-hant.ts","apps/desktop/src/i18n/zh.ts","apps/desktop/src/lib/icons.ts","apps/desktop/src/lib/svg-image.ts","apps/desktop/src/store/embed-consent.ts"],"fix_hunks":[{"file":"apps/desktop/electron/embed-referer.cjs","hunks":["@@ -0,0 +1,48 @@\n+'use strict'\n+\n+const { session } = require('electron')\n+\n+const EMBED_SESSION_PARTITION = 'persist:hermes-embed'\n+const EMBED_REFERER = 'https://www.youtube.com/'\n+const YOUTUBE_REFERER_HOST_RE =\n+  /(^|\\.)(youtube\\.com|youtube-nocookie\\.com|googlevideo\\.com|ytimg\\.com|youtubei\\.googleapis\\.com)$/i\n+\n+function installEmbedRefererForSession(embedSession) {\n+  if (!embedSession) {\n+    return\n+  }\n+\n+  embedSession.webRequest.onBeforeSendHeaders((details, callback) => {\n+    let host = ''\n+\n+    try {\n+      host = new URL(details.url).hostname\n+    } catch {\n+      host = ''\n+    }\n+\n+    if (!YOUTUBE_REFERER_HOST_RE.test(host)) {\n+      callback({ requestHeaders: details.requestHeaders })\n+      return\n+    }\n+\n+    const headers = { ...details.requestHeaders }\n+\n+    if (!headers.Referer && !headers.referer) {\n+      headers.Referer = EMBED_REFERER\n+    }\n+\n+    callback({ requestHeaders: headers })\n+  })\n+}\n+\n+/** Stamp Referer on YouTube requests in the embed webview partition only. */\n+function installEmbedReferer() {\n+  try {\n+    installEmbedRefererForSession(session.fromPartition(EMBED_SESSION_PARTITION))\n+  } catch {\n+    // Non-fatal: embeds still render; YouTube may show referer errors.\n+  }\n+}\n+\n+module.exports = { installEmbedReferer }"]},{"file":"apps/desktop/electron/main.cjs","hunks":["@@ -24,6 +24,7 @@ const https = require('node:https')\n const path = require('node:path')\n const { pathToFileURL } = require('node:url')\n const { execFileSync, spawn } = require('node:child_process')\n+const { installEmbedReferer } = require('./embed-referer.cjs')\n const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')\n const { runBootstrap } = require('./bootstrap-runner.cjs')\n const {","@@ -7447,6 +7448,7 @@ app.whenReady().then(() => {\n   }\n   installMediaPermissions()\n   registerMediaProtocol()\n+  installEmbedReferer()\n   registerDeepLinkProtocol()\n   ensureWslWindowsFonts()\n   configureSpellChecker()"]},{"file":"apps/desktop/package.json","hunks":["@@ -81,11 +81,13 @@\n     \"clsx\": \"^2.1.1\",\n     \"cmdk\": \"^1.1.1\",\n     \"dnd-core\": \"^14.0.1\",\n+    \"dompurify\": \"^3.4.11\",\n     \"hast-util-from-html-isomorphic\": \"^2.0.0\",\n     \"hast-util-to-text\": \"^4.0.2\",\n     \"ignore\": \"^7.0.5\",\n     \"katex\": \"^0.16.45\",\n     \"leva\": \"^0.10.1\",\n+    \"mermaid\": \"^11.15.0\",\n     \"motion\": \"^12.38.0\",\n     \"nanostores\": \"^1.3.0\",\n     \"node-pty\": \"1.1.0\","]},{"file":"apps/desktop/src/app/settings/appearance-settings.tsx","hunks":["@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'\n import { useEffect, useState } from 'react'\n \n import { LanguageSwitcher } from '@/components/language-switcher'\n+import { Button } from '@/components/ui/button'\n import { SegmentedControl } from '@/components/ui/segmented-control'\n import type { DesktopMarketplaceSearchItem } from '@/global'\n import { useI18n } from '@/i18n'","@@ -10,6 +11,7 @@ import { triggerHaptic } from '@/lib/haptics'\n import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'\n import { selectableCardClass } from '@/lib/selectable-card'\n import { cn } from '@/lib/utils'\n+import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent'\n import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'\n import { $toolViewMode, setToolViewMode } from '@/store/tool-view'\n import { $translucency, setTranslucency } from '@/store/translucency'","@@ -215,6 +217,8 @@ export function AppearanceSettings() {\n   const { t, isSavingLocale } = useI18n()\n   const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()\n   const toolViewMode = useStore($toolViewMode)\n+  const embedMode = useStore($embedMode)\n+  const embedAllowed = useStore($embedAllowed)\n   const translucency = useStore($translucency)\n   const profiles = useStore($profiles)\n   const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))","@@ -266,6 +270,12 @@ export function AppearanceSettings() {\n     { id: 'technical', label: a.technical }\n   ] as const\n \n+  const embedOptions = [\n+    { id: 'ask', label: a.embedsAsk },\n+    { id: 'always', label: a.embedsAlways },\n+    { id: 'off', label: a.embedsOff }\n+  ] as const satisfies readonly { id: EmbedMode; label: string }[]\n+\n   return (\n     <SettingsContent>\n       <div>","@@ -425,6 +435,35 @@ export function AppearanceSettings() {\n             description={a.toolViewDesc}\n             title={a.toolViewTitle}\n           />\n+\n+          <ListRow\n+            action={\n+              <div className=\"flex flex-col items-end gap-1.5\">\n+                <SegmentedControl\n+                  onChange={id => {\n+                    triggerHaptic('selection')\n+                    setEmbedMode(id)\n+                  }}\n+                  options={embedOptions}\n+                  value={embedMode}\n+                />\n+                {embedAllowed.length > 0 && (\n+                  <Button\n+                    onClick={() => {\n+                      triggerHaptic('selection')\n+                      clearEmbedAllowed()\n+                    }}\n+                    size=\"inline\"\n+                    variant=\"text\"\n+                  >\n+                    {a.embedsReset(embedAllowed.length)}\n+                  </Button>\n+                )}\n+              </div>\n+            }\n+            description={a.embedsDesc}\n+            title={a.embedsTitle}\n+          />\n         </div>\n       </div>\n "]},{"file":"apps/desktop/src/components/assistant-ui/embeds/alert.test.tsx","hunks":["@@ -0,0 +1,39 @@\n+import { createElement } from 'react'\n+import { describe, expect, it } from 'vitest'\n+\n+import { extractAlert } from './alert'\n+\n+describe('extractAlert', () => {\n+  it('detects each GFM alert kind from the leading marker', () => {\n+    for (const [marker, type] of [\n+      ['[!NOTE]', 'note'],\n+      ['[!TIP]', 'tip'],\n+      ['[!IMPORTANT]', 'important'],\n+      ['[!WARNING]', 'warning'],\n+      ['[!CAUTION]', 'caution']\n+    ] as const) {\n+      const node = createElement('p', null, `${marker}\\nBody text`)\n+      const result = extractAlert(node)\n+\n+      expect(result?.type).toBe(type)\n+    }\n+  })\n+\n+  it('is case-insensitive on the marker', () => {\n+    expect(extractAlert(createElement('p', null, '[!note] hi'))?.type).toBe('note')\n+  })\n+\n+  it('returns null for a plain blockquote', () => {\n+    expect(extractAlert(createElement('p', null, 'just a quote'))).toBeNull()\n+    expect(extractAlert('no marker here')).toBeNull()\n+  })\n+\n+  it('strips the marker token from the body', () => {\n+    const result = extractAlert(createElement('p', null, '[!WARNING]\\nDanger ahead'))\n+\n+    expect(result).not.toBeNull()\n+    // The marker must not survive into the rendered body.\n+    expect(JSON.stringify(result?.body)).not.toContain('[!WARNING]')\n+    expect(JSON.stringify(result?.body)).toContain('Danger ahead')\n+  })\n+})"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/alert.tsx","hunks":["@@ -0,0 +1,125 @@\n+import { cloneElement, isValidElement, type ReactNode } from 'react'\n+\n+import { AlertCircle, AlertTriangle, type IconComponent, Info, Zap } from '@/lib/icons'\n+import { cn } from '@/lib/utils'\n+\n+export type AlertType = 'caution' | 'important' | 'note' | 'tip' | 'warning'\n+\n+interface AlertStyle {\n+  accent: string\n+  icon: IconComponent\n+  label: string\n+}\n+\n+// GitHub's five alert kinds, mapped to our icon set + a tinted accent.\n+const ALERT_STYLES: Record<AlertType, AlertStyle> = {\n+  caution: { accent: 'text-rose-600 dark:text-rose-400', icon: AlertTriangle, label: 'Caution' },\n+  important: { accent: 'text-violet-600 dark:text-violet-400', icon: AlertCircle, label: 'Important' },\n+  note: { accent: 'text-blue-600 dark:text-blue-400', icon: Info, label: 'Note' },\n+  tip: { accent: 'text-emerald-600 dark:text-emerald-400', icon: Zap, label: 'Tip' },\n+  warning: { accent: 'text-amber-600 dark:text-amber-400', icon: AlertTriangle, label: 'Warning' }\n+}\n+\n+const MARKER_RE = /^\\s*\\[!(note|tip|important|warning|caution)\\]\\s*\\n?/i\n+\n+function firstText(node: ReactNode): string {\n+  if (typeof node === 'string') {\n+    return node\n+  }\n+\n+  if (typeof node === 'number') {\n+    return String(node)\n+  }\n+\n+  if (Array.isArray(node)) {\n+    for (const child of node) {\n+      const text = firstText(child)\n+\n+      if (text.trim()) {\n+        return text\n+      }\n+    }\n+\n+    return ''\n+  }\n+\n+  if (isValidElement(node)) {\n+    return firstText((node.props as { children?: ReactNode }).children)\n+  }\n+\n+  return ''\n+}\n+\n+// Remove the leading `[!TYPE]` token from the first text node that carries it,\n+// leaving the rest of the blockquote body intact. One-shot via the `state` flag.\n+function stripMarker(node: ReactNode, state: { done: boolean }): ReactNode {\n+  if (state.done) {\n+    return node\n+  }\n+\n+  if (typeof node === 'string') {\n+    const replaced = node.replace(MARKER_RE, '')\n+\n+    if (replaced !== node) {\n+      state.done = true\n+\n+      return replaced\n+    }\n+\n+    return node\n+  }\n+\n+  if (Array.isArray(node)) {\n+    return node.map((child, index) => <Fragmentless key={index} node={stripMarker(child, state)} />)\n+  }\n+\n+  if (isValidElement(node)) {\n+    const children = (node.props as { children?: ReactNode }).children\n+\n+    if (children == null) {\n+      return node\n+    }\n+\n+    return cloneElement(node, undefined, stripMarker(children, state))\n+  }\n+\n+  return node\n+}\n+\n+// Tiny helper so the array branch can return keyed nodes without wrapping\n+// strings in extra elements (React renders the raw node).\n+function Fragmentless({ node }: { node: ReactNode }) {\n+  return <>{node}</>\n+}\n+\n+/**\n+ * Detect a GitHub-style alert blockquote (`> [!NOTE]`). Returns the alert kind\n+ * and the body with the marker stripped, or null for a plain blockquote.\n+ */\n+export function extractAlert(children: ReactNode): { body: ReactNode; type: AlertType } | null {\n+  const match = firstText(children).match(MARKER_RE)\n+\n+  if (!match) {\n+    return null\n+  }\n+\n+  return { body: stripMarker(children, { done: false }), type: match[1].toLowerCase() as AlertType }\n+}\n+\n+export function MarkdownAlert({ children, type }: { children: ReactNode; type: AlertType }) {\n+  const style = ALERT_STYLES[type]\n+  const Icon = style.icon\n+\n+  return (\n+    <div\n+      className=\"my-2 rounded-lg border border-border bg-muted/25 px-3 py-2 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0\"\n+      data-slot=\"aui_markdown-alert\"\n+    >\n+      <div className={cn('mb-1 flex items-center gap-1.5 text-[0.8125rem] font-semibold', style.accent)}>\n+        <Icon className=\"size-4 shrink-0\" />\n+        {style.label}\n+      </div>\n+      {children}\n+    </div>\n+  )\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/embed-consent.tsx","hunks":["@@ -0,0 +1,55 @@\n+'use client'\n+\n+import { type CSSProperties, useState } from 'react'\n+\n+import { SplitButton } from '@/components/ui/split-button'\n+import { Play } from '@/lib/icons'\n+import { allowProvider } from '@/store/embed-consent'\n+\n+import type { EmbedDescriptor } from './providers/types'\n+\n+// Privacy placeholder shown before an embed reaches out to a third party. Sized\n+// to the embed's footprint (no layout shift). The split control mirrors the\n+// commit button: primary \"Load\" (this embed) with a caret for \"Always allow\n+// <service>\" (persisted). Global off lives in Appearance settings.\n+export function EmbedFacade({ descriptor, onLoad }: { descriptor: EmbedDescriptor; onLoad: () => void }) {\n+  const [choice, setChoice] = useState('once')\n+\n+  const style: CSSProperties = descriptor.aspectRatio\n+    ? { aspectRatio: descriptor.aspectRatio }\n+    : { height: descriptor.height ?? 320 }\n+\n+  const actions = [\n+    { id: 'once', label: `Load ${descriptor.label}` },\n+    { id: 'always', label: `Always allow ${descriptor.label}` }\n+  ]\n+\n+  return (\n+    <span\n+      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\"\n+      style={style}\n+    >\n+      <SplitButton\n+        actions={actions}\n+        onTrigger={id => (id === 'always' ? allowProvider(descriptor.provider) : onLoad())}\n+        onValueChange={setChoice}\n+        primaryIcon={<Play className=\"size-3 translate-x-px fill-current\" />}\n+        value={choice}\n+      />\n+      <span className=\"text-[0.6875rem] text-(--ui-text-tertiary)\">{hostOf(descriptor)}</span>\n+    </span>\n+  )\n+}\n+\n+function hostOf(descriptor: EmbedDescriptor): string {\n+  // x.com posts often arrive as twitter.com links — show the current brand.\n+  if (descriptor.provider === 'twitter') {\n+    return 'x.com'\n+  }\n+\n+  try {\n+    return new URL(descriptor.sourceUrl).hostname.replace(/^www\\./, '')\n+  } catch {\n+    return descriptor.label\n+  }\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/embed-size.ts","hunks":["@@ -0,0 +1,4 @@\n+// Shared height cap for inline embeds. Ratio embeds cap their width off this in\n+// UrlEmbed so height follows the aspect ratio; fenced renderers (mermaid, svg)\n+// reuse it directly. Pure CSS — no measuring.\n+export const EMBED_MAX_H = '33dvh'"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/escape-html.ts","hunks":["@@ -0,0 +1,3 @@\n+export function escapeHtml(value: string): string {\n+  return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;')\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/fail.tsx","hunks":["@@ -0,0 +1,7 @@\n+export function EmbedFail({ label }: { label: string }) {\n+  return (\n+    <span className=\"grid min-h-32 w-full place-items-center p-4\">\n+      <span className=\"text-xs font-medium text-(--ui-red)\">Failed to load {label} embed</span>\n+    </span>\n+  )\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/frame-embed.tsx","hunks":["@@ -0,0 +1,55 @@\n+'use client'\n+\n+import { type CSSProperties } from 'react'\n+\n+import type { FrameEmbed } from './providers/types'\n+import { ScrollGate } from './scroll-gate'\n+import { useIsDark } from './use-is-dark'\n+\n+const ALLOW = 'autoplay; encrypted-media; picture-in-picture; clipboard-write; fullscreen'\n+\n+// Plain iframes (not webviews): a non-scrollable cross-origin iframe lets the\n+// wheel chain to the transcript instead of capturing it. Maps are the one\n+// exception — they're interactive, so a ScrollGate blocks them until ⌘ is held.\n+export default function FrameEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {\n+  const isDark = useIsDark()\n+  const isMap = descriptor.provider === 'googlemaps' || descriptor.provider === 'openstreetmap'\n+  // color-scheme makes the iframe's default (unpainted) backdrop follow the\n+  // theme instead of flashing white at the corners / during load.\n+  const colorScheme = isDark ? 'dark' : 'light'\n+\n+  const style: CSSProperties = descriptor.aspectRatio\n+    ? { aspectRatio: descriptor.aspectRatio, colorScheme }\n+    : { colorScheme, height: descriptor.height }\n+\n+  if (isMap) {\n+    return (\n+      <div className=\"relative w-full overflow-hidden\" style={style}>\n+        <iframe\n+          allow={ALLOW}\n+          className=\"absolute inset-0 size-full border-0 bg-transparent\"\n+          loading=\"lazy\"\n+          referrerPolicy=\"strict-origin-when-cross-origin\"\n+          src={descriptor.embedUrl}\n+          style={{ colorScheme }}\n+          title={`${descriptor.label} embed`}\n+        />\n+        <ScrollGate />\n+      </div>\n+    )\n+  }\n+\n+  return (\n+    <iframe\n+      allow={ALLOW}\n+      allowFullScreen\n+      className=\"block w-full border-0 bg-transparent\"\n+      loading=\"lazy\"\n+      referrerPolicy=\"strict-origin-when-cross-origin\"\n+      scrolling=\"no\"\n+      src={descriptor.embedUrl}\n+      style={style}\n+      title={`${descriptor.label} embed`}\n+    />\n+  )\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/index.ts","hunks":["@@ -0,0 +1,5 @@\n+export { extractAlert, MarkdownAlert } from './alert'\n+export type { EmbedDescriptor } from './providers'\n+export { detectEmbed, isEmbeddableUrl } from './providers'\n+export { RICH_FENCE_LANGUAGES, RichCodeBlock } from './registry'\n+export { UrlEmbed } from './url-embed'"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.tsx","hunks":["@@ -0,0 +1,113 @@\n+'use client'\n+\n+import mermaid from 'mermaid'\n+import { useEffect, useState } from 'react'\n+\n+import { Zoomable } from '@/components/ui/zoomable'\n+import { copySvgAsPng } from '@/lib/svg-image'\n+import { cn } from '@/lib/utils'\n+\n+import type { RichFenceProps } from './types'\n+import { useIsDark } from './use-is-dark'\n+\n+let lastTheme: 'dark' | 'default' | null = null\n+\n+// Re-initialise only on first use / theme flip. `securityLevel: 'strict'` makes\n+// mermaid sanitise label HTML and drop click handlers, so the rendered SVG is\n+// safe to inject.\n+function ensureInit(dark: boolean) {\n+  const theme = dark ? 'dark' : 'default'\n+\n+  if (theme === lastTheme) {\n+    return\n+  }\n+\n+  mermaid.initialize({ fontFamily: 'inherit', securityLevel: 'strict', startOnLoad: false, theme })\n+  lastTheme = theme\n+}\n+\n+function SourcePreview({ code, muted }: { code: string; muted?: boolean }) {\n+  return (\n+    <pre\n+      className={cn(\n+        'overflow-auto p-3 font-mono text-[0.7rem] leading-relaxed whitespace-pre-wrap wrap-anywhere',\n+        muted ? 'text-muted-foreground/70' : 'text-foreground/90'\n+      )}\n+    >\n+      {code}\n+    </pre>\n+  )\n+}\n+\n+// Lazy chunk (pulls in mermaid). Renders ```mermaid fences as diagrams; shows\n+// the source while the message streams (partial syntax throws) and falls back\n+// to source on parse failure.\n+export default function MermaidRenderer({ code, streaming }: RichFenceProps) {\n+  const isDark = useIsDark()\n+  const [svg, setSvg] = useState('')\n+  const [failed, setFailed] = useState(false)\n+\n+  useEffect(() => {\n+    if (streaming) {\n+      return\n+    }\n+\n+    let cancelled = false\n+\n+    setFailed(false)\n+\n+    void (async () => {\n+      try {\n+        ensureInit(isDark)\n+        const id = `mmd-${Math.random().toString(36).slice(2)}`\n+        const result = await mermaid.render(id, code)\n+\n+        if (!cancelled) {\n+          setSvg(result.svg)\n+        }\n+      } catch {\n+        if (!cancelled) {\n+          setFailed(true)\n+          setSvg('')\n+        }\n+      }\n+    })()\n+\n+    return () => {\n+      cancelled = true\n+    }\n+  }, [code, isDark, streaming])\n+\n+  if (streaming) {\n+    return <SourcePreview code={code} muted />\n+  }\n+\n+  if (failed) {\n+    return <SourcePreview code={code} />\n+  }\n+\n+  if (!svg) {\n+    return <SourcePreview code={code} muted />\n+  }\n+\n+  // Click to open the diagram full-screen with pan/zoom + copy-as-PNG. The\n+  // overlay keeps the diagram's natural width (capped to the viewport) so it\n+  // renders before any zoom; the inline version stays capped at 33dvh.\n+  return (\n+    <Zoomable\n+      label=\"Open diagram\"\n+      onCopy={() => copySvgAsPng(svg)}\n+      overlay={\n+        <div\n+          className=\"[&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-h-[80vh] [&_svg]:max-w-[85vw]\"\n+          dangerouslySetInnerHTML={{ __html: svg }}\n+        />\n+      }\n+    >\n+      <div\n+        className=\"overflow-hidden p-3 [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-h-[33dvh] [&_svg]:max-w-full\"\n+        dangerouslySetInnerHTML={{ __html: svg }}\n+      />\n+    </Zoomable>\n+  )\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/providers/detect.test.ts","hunks":["@@ -0,0 +1,133 @@\n+import { describe, expect, it } from 'vitest'\n+\n+import type { FrameEmbed, TweetEmbed } from './types'\n+\n+import { detectEmbed, isEmbeddableUrl } from './index'\n+\n+function frame(url: string): FrameEmbed {\n+  const descriptor = detectEmbed(url)\n+\n+  if (!descriptor || descriptor.renderer !== 'frame') {\n+    throw new Error(`expected a frame embed for ${url}`)\n+  }\n+\n+  return descriptor\n+}\n+\n+describe('detectEmbed — YouTube', () => {\n+  it.each([\n+    'https://www.youtube.com/watch?v=dQw4w9WgXcQ',\n+    'https://youtu.be/dQw4w9WgXcQ',\n+    'https://www.youtube.com/shorts/dQw4w9WgXcQ',\n+    'https://m.youtube.com/watch?v=dQw4w9WgXcQ',\n+    'https://www.youtube.com/embed/dQw4w9WgXcQ',\n+    'https://www.youtube.com/live/dQw4w9WgXcQ'\n+  ])('resolves %s to the privacy-enhanced embed of the same id', url => {\n+    const embed = frame(url)\n+\n+    expect(embed.provider).toBe('youtube')\n+    expect(embed.id).toBe('youtube:dQw4w9WgXcQ')\n+    expect(embed.embedUrl).toContain('youtube-nocookie.com/embed/dQw4w9WgXcQ')\n+  })\n+\n+  it('carries a start time from t/start through to the embed', () => {\n+    expect(frame('https://youtu.be/dQw4w9WgXcQ?t=90').embedUrl).toContain('start=90')\n+    expect(frame('https://youtu.be/dQw4w9WgXcQ?t=1m30s').embedUrl).toContain('start=90')\n+  })\n+\n+  it('rejects ids that are not 11 chars', () => {\n+    expect(detectEmbed('https://www.youtube.com/watch?v=short')).toBeNull()\n+  })\n+})\n+\n+describe('detectEmbed — other frame providers', () => {\n+  it('resolves Vimeo numeric ids across path shapes', () => {\n+    expect(frame('https://vimeo.com/76979871').embedUrl).toBe('https://player.vimeo.com/video/76979871')\n+    expect(frame('https://vimeo.com/channels/staffpicks/76979871').id).toBe('vimeo:76979871')\n+  })\n+\n+  it('resolves Instagram posts and reels', () => {\n+    expect(frame('https://www.instagram.com/p/CabcDEF123/').embedUrl).toBe(\n+      'https://www.instagram.com/p/CabcDEF123/embed'\n+    )\n+    expect(frame('https://www.instagram.com/reel/CabcDEF123/').embedUrl).toContain('/reel/CabcDEF123/embed')\n+    expect(frame('https://www.instagram.com/reels/CabcDEF123/').embedUrl).toContain('/reel/CabcDEF123/embed')\n+  })\n+\n+  it('resolves Pinterest pins across locale hosts', () => {\n+    expect(frame('https://www.pinterest.com/pin/1234567890/').embedUrl).toBe(\n+      'https://assets.pinterest.com/ext/embed.html?id=1234567890'\n+    )\n+    expect(frame('https://fr.pinterest.com/pin/1234567890/').provider).toBe('pinterest')\n+  })\n+\n+  it('resolves TikTok videos to the official player', () => {\n+    expect(frame('https://www.tiktok.com/@user/video/7212345678901234567').embedUrl).toBe(\n+      'https://www.tiktok.com/player/v1/7212345678901234567'\n+    )\n+  })\n+\n+  it('resolves Spotify tracks, collections, and locale-prefixed urls', () => {\n+    expect(frame('https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT').embedUrl).toBe(\n+      'https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT'\n+    )\n+    expect(frame('https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M').provider).toBe('spotify')\n+    expect(frame('https://open.spotify.com/intl-de/album/1DFixLWuPkv3KT3TnV35m3').id).toBe(\n+      'spotify:album:1DFixLWuPkv3KT3TnV35m3'\n+    )\n+    expect(detectEmbed('https://open.spotify.com/track/')).toBeNull()\n+  })\n+})\n+\n+describe('detectEmbed — maps', () => {\n+  it('resolves Google Maps coordinates with zoom', () => {\n+    const embed = frame('https://www.google.com/maps/@40.7128,-74.0060,12z')\n+\n+    expect(embed.provider).toBe('googlemaps')\n+    expect(embed.embedUrl).toContain('output=embed')\n+    expect(embed.embedUrl).toContain('q=40.7128%2C-74.006')\n+    expect(embed.embedUrl).toContain('z=12')\n+  })\n+\n+  it('resolves a Google Maps place name', () => {\n+    expect(frame('https://www.google.com/maps/place/Eiffel+Tower/').embedUrl).toContain('q=Eiffel+Tower')\n+  })\n+\n+  it('resolves OpenStreetMap fragment state to a bbox embed', () => {\n+    const embed = frame('https://www.openstreetmap.org/#map=12/40.7128/-74.0060')\n+\n+    expect(embed.provider).toBe('openstreetmap')\n+    expect(embed.embedUrl).toContain('export/embed.html')\n+    expect(embed.embedUrl).toContain('marker=40.7128%2C-74.006')\n+    expect(embed.embedUrl).toContain('bbox=')\n+  })\n+})\n+\n+describe('detectEmbed — Twitter/X', () => {\n+  it('resolves twitter.com and x.com status urls to a tweet descriptor', () => {\n+    for (const url of ['https://twitter.com/jack/status/20', 'https://x.com/jack/status/20']) {\n+      const descriptor = detectEmbed(url)\n+\n+      expect(descriptor?.renderer).toBe('tweet')\n+      expect((descriptor as TweetEmbed).tweetId).toBe('20')\n+    }\n+  })\n+})\n+\n+describe('detectEmbed — non-matches', () => {\n+  it.each([\n+    'https://example.com/watch?v=dQw4w9WgXcQ',\n+    'https://github.com/NousResearch/hermes',\n+    'not-a-url',\n+    'ftp://youtube.com/watch?v=dQw4w9WgXcQ',\n+    'mailto:[redacted:email]'\n+  ])('returns null for %s', url => {\n+    expect(detectEmbed(url)).toBeNull()\n+    expect(isEmbeddableUrl(url)).toBe(false)\n+  })\n+\n+  it('handles empty input without throwing', () => {\n+    expect(detectEmbed(undefined)).toBeNull()\n+    expect(detectEmbed('')).toBeNull()\n+  })\n+})"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/providers/index.ts","hunks":["@@ -0,0 +1,54 @@\n+import { instagram } from './instagram'\n+import { maps } from './maps'\n+import { pinterest } from './pinterest'\n+import { spotify } from './spotify'\n+import { tiktok } from './tiktok'\n+import { twitter } from './twitter'\n+import type { EmbedDescriptor, EmbedMatcher } from './types'\n+import { vimeo } from './vimeo'\n+import { youtube } from './youtube'\n+\n+export type { EmbedDescriptor, EmbedProvider, EmbedRenderer, FrameEmbed, TweetEmbed } from './types'\n+\n+// All provider hosts are disjoint, so order is irrelevant — first match wins.\n+const MATCHERS: EmbedMatcher[] = [youtube, vimeo, instagram, pinterest, tiktok, twitter, spotify, maps]\n+\n+function parseUrl(raw: string): URL | null {\n+  try {\n+    const url = new URL(raw)\n+\n+    return url.protocol === 'http:' || url.protocol === 'https:' ? url : null\n+  } catch {\n+    return null\n+  }\n+}\n+\n+/**\n+ * Resolve a URL to a rich-embed descriptor, or null when no provider matches.\n+ * Pure and synchronous — safe to call during render.\n+ */\n+export function detectEmbed(rawUrl: string | null | undefined): EmbedDescriptor | null {\n+  if (!rawUrl) {\n+    return null\n+  }\n+\n+  const url = parseUrl(rawUrl)\n+\n+  if (!url) {\n+    return null\n+  }\n+\n+  for (const match of MATCHERS) {\n+    const descriptor = match(url)\n+\n+    if (descriptor) {\n+      return descriptor\n+    }\n+  }\n+\n+  return null\n+}\n+\n+export function isEmbeddableUrl(rawUrl: string | null | undefined): boolean {\n+  return detectEmbed(rawUrl) !== null\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/providers/instagram.ts","hunks":["@@ -0,0 +1,26 @@\n+import { bareHost, type EmbedMatcher } from './types'\n+\n+export const instagram: EmbedMatcher = url => {\n+  if (bareHost(url.hostname) !== 'instagram.com') {\n+    return null\n+  }\n+\n+  const [typeRaw, code] = url.pathname.split('/').filter(Boolean)\n+  const type = typeRaw === 'reels' ? 'reel' : typeRaw\n+\n+  if (!code || !['p', 'reel', 'tv'].includes(type || '') || !/^[A-Za-z0-9_-]+$/.test(code)) {\n+    return null\n+  }\n+\n+  return {\n+    embedUrl: `https://www.instagram.com/${type}/${code}/embed`,\n+    // Placeholder height for content-visibility; embed.js self-sizes in-document.\n+    height: 450,\n+    id: `instagram:${code}`,\n+    label: 'Instagram',\n+    maxWidth: 400,\n+    provider: 'instagram',\n+    renderer: 'frame',\n+    sourceUrl: url.toString()\n+  }\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/providers/maps.ts","hunks":["@@ -0,0 +1,95 @@\n+import { bareHost, type EmbedMatcher, type FrameEmbed } from './types'\n+\n+// `@lat,lng` (optionally `,<zoom>z`) as it appears in Google Maps URLs.\n+const LATLNG_RE = /@(-?\\d+(?:\\.\\d+)?),(-?\\d+(?:\\.\\d+)?)(?:,(\\d+(?:\\.\\d+)?)z)?/\n+\n+function googleMapsEmbed(url: URL): FrameEmbed | null {\n+  const host = bareHost(url.hostname)\n+\n+  if (host !== 'google.com' && host !== 'maps.google.com' && !host.startsWith('google.')) {\n+    return null\n+  }\n+\n+  const isMapsPath = host.startsWith('maps.') || url.pathname.startsWith('/maps')\n+\n+  if (!isMapsPath) {\n+    return null\n+  }\n+\n+  // Prefer explicit coordinates; then a `q=` query; then a `/place/<name>`.\n+  const coords = url.pathname.match(LATLNG_RE)\n+  const placeName = url.pathname.match(/\\/place\\/([^/@]+)/)\n+  const query = url.searchParams.get('q') || url.searchParams.get('query')\n+  let q = ''\n+  let zoom = ''\n+\n+  if (coords) {\n+    q = `${coords[1]},${coords[2]}`\n+    zoom = coords[3] ? String(Math.round(Number(coords[3]))) : ''\n+  } else if (query) {\n+    q = query\n+  } else if (placeName) {\n+    q = decodeURIComponent(placeName[1].replace(/\\+/g, ' '))\n+  }\n+\n+  if (!q) {\n+    return null\n+  }\n+\n+  // `output=embed` is the long-standing keyless Maps embed surface.\n+  const params = new URLSearchParams({ output: 'embed', q })\n+\n+  if (zoom) {\n+    params.set('z', zoom)\n+  }\n+\n+  return {\n+    aspectRatio: 16 / 10,\n+    embedUrl: `https://maps.google.com/maps?${params.toString()}`,\n+    id: `googlemaps:${q}${zoom ? `@${zoom}` : ''}`,\n+    label: 'Google Maps',\n+    maxWidth: 640,\n+    provider: 'googlemaps',\n+    renderer: 'frame',\n+    sourceUrl: url.toString()\n+  }\n+}\n+\n+function openStreetMapEmbed(url: URL): FrameEmbed | null {\n+  if (bareHost(url.hostname) !== 'openstreetmap.org') {\n+    return null\n+  }\n+\n+  // State lives in the fragment: `#map=<zoom>/<lat>/<lng>`.\n+  const match = url.hash.match(/map=(\\d+(?:\\.\\d+)?)\\/(-?\\d+(?:\\.\\d+)?)\\/(-?\\d+(?:\\.\\d+)?)/)\n+\n+  if (!match) {\n+    return null\n+  }\n+\n+  const zoom = Number(match[1])\n+  const lat = Number(match[2])\n+  const lng = Number(match[3])\n+  // Degrees spanned at this zoom; halved for the bbox half-extent.\n+  const lonDelta = 360 / 2 ** zoom\n+  const latDelta = lonDelta / 2\n+\n+  const bbox = [lng - lonDelta / 2, lat - latDelta / 2, lng + lonDelta / 2, lat + latDelta / 2]\n+    .map(value => value.toFixed(5))\n+    .join(',')\n+\n+  const params = new URLSearchParams({ bbox, layer: 'mapnik', marker: `${lat},${lng}` })\n+\n+  return {\n+    aspectRatio: 16 / 10,\n+    embedUrl: `https://www.openstreetmap.org/export/embed.html?${params.toString()}`,\n+    id: `openstreetmap:${lat},${lng}@${zoom}`,\n+    label: 'OpenStreetMap',\n+    maxWidth: 640,\n+    provider: 'openstreetmap',\n+    renderer: 'frame',\n+    sourceUrl: url.toString()\n+  }\n+}\n+\n+export const maps: EmbedMatcher = url => googleMapsEmbed(url) || openStreetMapEmbed(url)"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/providers/pinterest.ts","hunks":["@@ -0,0 +1,28 @@\n+import { bareHost, type EmbedMatcher } from './types'\n+\n+export const pinterest: EmbedMatcher = url => {\n+  // Pinterest runs many locale TLDs (pinterest.co.uk, fr.pinterest.com, ...).\n+  if (!bareHost(url.hostname).includes('pinterest.')) {\n+    return null\n+  }\n+\n+  const segments = url.pathname.split('/').filter(Boolean)\n+\n+  if (segments[0] !== 'pin' || !/^\\d+$/.test(segments[1] || '')) {\n+    return null\n+  }\n+\n+  const id = segments[1]\n+\n+  return {\n+    embedUrl: `https://assets.pinterest.com/ext/embed.html?id=${id}`,\n+    // Pinterest's \"small\" pin size — the default card is too dominant inline.\n+    height: 380,\n+    id: `pinterest:${id}`,\n+    label: 'Pinterest',\n+    maxWidth: 236,\n+    provider: 'pinterest',\n+    renderer: 'frame',\n+    sourceUrl: url.toString()\n+  }\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/providers/spotify.ts","hunks":["@@ -0,0 +1,34 @@\n+import { bareHost, type EmbedMatcher } from './types'\n+\n+// Spotify's embed has only two layouts: compact (≤152) and full (352). Any\n+// in-between height renders the compact player and pads the rest with grey, so\n+// we snap to the compact size for every type — tight, no dead space.\n+const COMPACT_HEIGHT = 152\n+const EMBED_TYPES = new Set(['album', 'artist', 'episode', 'playlist', 'show', 'track'])\n+\n+export const spotify: EmbedMatcher = url => {\n+  if (bareHost(url.hostname) !== 'open.spotify.com') {\n+    return null\n+  }\n+\n+  // Drop an optional locale prefix (`/intl-de/track/...`).\n+  const segments = url.pathname.split('/').filter(Boolean)\n+  const start = segments[0]?.startsWith('intl-') ? 1 : 0\n+  const type = segments[start] || ''\n+  const id = segments[start + 1] || ''\n+\n+  if (!EMBED_TYPES.has(type) || !/^[A-Za-z0-9]+$/.test(id)) {\n+    return null\n+  }\n+\n+  return {\n+    embedUrl: `https://open.spotify.com/embed/${type}/${id}`,\n+    height: COMPACT_HEIGHT,\n+    id: `spotify:${type}:${id}`,\n+    label: 'Spotify',\n+    maxWidth: 480,\n+    provider: 'spotify',\n+    renderer: 'frame',\n+    sourceUrl: url.toString()\n+  }\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/providers/tiktok.ts","hunks":["@@ -0,0 +1,28 @@\n+import { bareHost, type EmbedMatcher } from './types'\n+\n+export const tiktok: EmbedMatcher = url => {\n+  if (bareHost(url.hostname) !== 'tiktok.com') {\n+    return null\n+  }\n+\n+  const segments = url.pathname.split('/').filter(Boolean)\n+  const videoIndex = segments.indexOf('video')\n+  const id = videoIndex >= 0 ? segments[videoIndex + 1] : ''\n+\n+  if (!/^\\d+$/.test(id || '')) {\n+    return null\n+  }\n+\n+  return {\n+    // The official player is a clean dark video iframe (no white blockquote\n+    // chrome), so it goes through the plain-iframe frame path, sized 9:16.\n+    aspectRatio: 9 / 16,\n+    embedUrl: `https://www.tiktok.com/player/v1/${id}`,\n+    id: `tiktok:${id}`,\n+    label: 'TikTok',\n+    maxWidth: 365,\n+    provider: 'tiktok',\n+    renderer: 'frame',\n+    sourceUrl: url.toString()\n+  }\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/providers/twitter.ts","hunks":["@@ -0,0 +1,27 @@\n+import { bareHost, type EmbedMatcher } from './types'\n+\n+export const twitter: EmbedMatcher = url => {\n+  const host = bareHost(url.hostname)\n+\n+  if (host !== 'twitter.com' && host !== 'x.com') {\n+    return null\n+  }\n+\n+  const segments = url.pathname.split('/').filter(Boolean)\n+  const statusIndex = segments.indexOf('status')\n+  const id = statusIndex >= 0 ? segments[statusIndex + 1] : ''\n+\n+  if (!/^\\d+$/.test(id || '')) {\n+    return null\n+  }\n+\n+  return {\n+    id: `twitter:${id}`,\n+    label: 'X',\n+    maxWidth: 480,\n+    provider: 'twitter',\n+    renderer: 'tweet',\n+    sourceUrl: url.toString(),\n+    tweetId: id\n+  }\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/providers/types.ts","hunks":["@@ -0,0 +1,60 @@\n+// Embed provider model. Detection is pure, synchronous, and dependency-free so\n+// it is safe to run during render and trivial to unit-test. Rendering lives in\n+// the lazy renderers (see ../registry.tsx) keyed off `renderer`.\n+\n+export type EmbedProvider =\n+  | 'googlemaps'\n+  | 'instagram'\n+  | 'openstreetmap'\n+  | 'pinterest'\n+  | 'spotify'\n+  | 'tiktok'\n+  | 'twitter'\n+  | 'vimeo'\n+  | 'youtube'\n+\n+/** Which lazy renderer materialises the descriptor. */\n+export type EmbedRenderer = 'frame' | 'tweet'\n+\n+interface EmbedLayout {\n+  /** Frame aspect ratio (width / height). For video/maps. */\n+  aspectRatio?: number\n+  /** Fixed pixel height for non-ratio embeds (Instagram, Pinterest, Spotify). */\n+  height?: number\n+  /** Max rendered width in px; falls back to the conversation column. */\n+  maxWidth?: number\n+}\n+\n+interface BaseEmbed extends EmbedLayout {\n+  /** Stable id for React keys / dedupe. */\n+  id: string\n+  /** Human-facing provider name (e.g. \"YouTube\"). */\n+  label: string\n+  provider: EmbedProvider\n+  renderer: EmbedRenderer\n+  /** Canonical URL opened in the system browser from the card. */\n+  sourceUrl: string\n+}\n+\n+/** A provider whose embed is a single iframe URL (video, post, map, ...). */\n+export interface FrameEmbed extends BaseEmbed {\n+  /** URL loaded inside the iframe. */\n+  embedUrl: string\n+  renderer: 'frame'\n+}\n+\n+/** Twitter/X ships no iframe URL — only a widget script (see social-embed.tsx). */\n+export interface TweetEmbed extends BaseEmbed {\n+  renderer: 'tweet'\n+  tweetId: string\n+}\n+\n+export type EmbedDescriptor = FrameEmbed | TweetEmbed\n+\n+/** A provider matcher. Receives a parsed http(s) URL; returns null if unmatched. */\n+export type EmbedMatcher = (url: URL) => EmbedDescriptor | null\n+\n+/** Strip a leading `www.`/`m.`/`mobile.` so host checks read cleanly. */\n+export function bareHost(host: string): string {\n+  return host.replace(/^(?:www|m|mobile)\\./i, '').toLowerCase()\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/providers/vimeo.ts","hunks":["@@ -0,0 +1,32 @@\n+import { bareHost, type EmbedMatcher } from './types'\n+\n+export const vimeo: EmbedMatcher = url => {\n+  const host = bareHost(url.hostname)\n+\n+  if (host !== 'vimeo.com' && host !== 'player.vimeo.com') {\n+    return null\n+  }\n+\n+  // The clip id is the last all-digits segment, covering vimeo.com/123,\n+  // /channels/x/123, /groups/x/videos/123, and player/video/123.\n+  const id = url.pathname\n+    .split('/')\n+    .filter(Boolean)\n+    .reverse()\n+    .find(segment => /^\\d+$/.test(segment))\n+\n+  if (!id) {\n+    return null\n+  }\n+\n+  return {\n+    aspectRatio: 16 / 9,\n+    embedUrl: `https://player.vimeo.com/video/${id}`,\n+    id: `vimeo:${id}`,\n+    label: 'Vimeo',\n+    maxWidth: 640,\n+    provider: 'vimeo',\n+    renderer: 'frame',\n+    sourceUrl: url.toString()\n+  }\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/providers/youtube.ts","hunks":["@@ -0,0 +1,65 @@\n+import { bareHost, type EmbedMatcher } from './types'\n+\n+const YOUTUBE_ID_RE = /^[A-Za-z0-9_-]{11}$/\n+\n+// `t`/`start` accept either raw seconds (\"90\") or the \"1m30s\" form.\n+function startSeconds(value: string | null): number | undefined {\n+  if (!value) {\n+    return undefined\n+  }\n+\n+  if (/^\\d+$/.test(value)) {\n+    return Number(value)\n+  }\n+\n+  const match = value.match(/^(?:(\\d+)h)?(?:(\\d+)m)?(?:(\\d+)s)?$/)\n+\n+  if (!match || !match[0]) {\n+    return undefined\n+  }\n+\n+  const seconds = Number(match[1] || 0) * 3600 + Number(match[2] || 0) * 60 + Number(match[3] || 0)\n+\n+  return seconds > 0 ? seconds : undefined\n+}\n+\n+export const youtube: EmbedMatcher = url => {\n+  const host = bareHost(url.hostname)\n+  const segments = url.pathname.split('/').filter(Boolean)\n+  let id = ''\n+\n+  if (host === 'youtu.be') {\n+    id = segments[0] || ''\n+  } else if (host === 'youtube.com' || host === 'youtube-nocookie.com') {\n+    if (segments[0] === 'watch') {\n+      id = url.searchParams.get('v') || ''\n+    } else if (['embed', 'shorts', 'live', 'v'].includes(segments[0] || '')) {\n+      id = segments[1] || ''\n+    }\n+  } else {\n+    return null\n+  }\n+\n+  if (!YOUTUBE_ID_RE.test(id)) {\n+    return null\n+  }\n+\n+  const params = new URLSearchParams({ modestbranding: '1', rel: '0' })\n+\n+  const start = startSeconds(url.searchParams.get('t') || url.searchParams.get('start'))\n+\n+  if (start) {\n+    params.set('start', String(start))\n+  }\n+\n+  return {\n+    aspectRatio: 16 / 9,\n+    embedUrl: `https://www.youtube-nocookie.com/embed/${id}?${params.toString()}`,\n+    id: `youtube:${id}`,\n+    label: 'YouTube',\n+    maxWidth: 640,\n+    provider: 'youtube',\n+    renderer: 'frame',\n+    sourceUrl: url.toString()\n+  }\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/registry.tsx","hunks":["@@ -0,0 +1,39 @@\n+'use client'\n+\n+import { type ComponentType, lazy, type LazyExoticComponent, type ReactNode, Suspense } from 'react'\n+\n+import { RichBoundary } from './rich-boundary'\n+import type { RichFenceProps } from './types'\n+\n+// Root renderer for fenced code blocks: a language → lazy-renderer table. Each\n+// renderer is its own split chunk (mermaid pulls in the mermaid lib, svg pulls\n+// in DOMPurify), loaded only when a block of that language actually appears.\n+const LAZY_FENCE: Record<string, LazyExoticComponent<ComponentType<RichFenceProps>>> = {\n+  mermaid: lazy(() => import('./mermaid-embed')),\n+  svg: lazy(() => import('./svg-embed'))\n+}\n+\n+export const RICH_FENCE_LANGUAGES: ReadonlySet<string> = new Set(Object.keys(LAZY_FENCE))\n+\n+interface RichCodeBlockProps extends RichFenceProps {\n+  /** Rendered for unhandled languages, while the chunk loads, and on failure\n+   *  (typically the normal syntax-highlighted code block). */\n+  fallback: ReactNode\n+  language?: string\n+}\n+\n+export function RichCodeBlock({ code, fallback, language, streaming }: RichCodeBlockProps) {\n+  const Renderer = language ? LAZY_FENCE[language.toLowerCase()] : undefined\n+\n+  if (!Renderer) {\n+    return <>{fallback}</>\n+  }\n+\n+  return (\n+    <RichBoundary fallback={fallback} resetKey={code}>\n+      <Suspense fallback={fallback}>\n+        <Renderer code={code} streaming={streaming} />\n+      </Suspense>\n+    </RichBoundary>\n+  )\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/rich-boundary.tsx","hunks":["@@ -0,0 +1,34 @@\n+import { Component, type ReactNode } from 'react'\n+\n+interface Props {\n+  children: ReactNode\n+  /** Rendered in place of the subtree when a render throws. */\n+  fallback: ReactNode\n+  /** Changing this clears a caught error (e.g. new source for a re-parse). */\n+  resetKey?: string\n+}\n+\n+/**\n+ * Local boundary for rich renderers (Mermaid parse throws, malformed SVG, a\n+ * provider widget blowing up). A failed embed must never blank the transcript —\n+ * we show the `fallback` (typically the raw source) and recover when `resetKey`\n+ * changes. Unlike MessageRenderBoundary this swallows ALL render errors, because\n+ * the blast radius is one self-contained block, not the message tree.\n+ */\n+export class RichBoundary extends Component<Props, { failed: boolean }> {\n+  state = { failed: false }\n+\n+  static getDerivedStateFromError() {\n+    return { failed: true }\n+  }\n+\n+  componentDidUpdate(prev: Props) {\n+    if (this.state.failed && prev.resetKey !== this.props.resetKey) {\n+      this.setState({ failed: false })\n+    }\n+  }\n+\n+  render() {\n+    return this.state.failed ? this.props.fallback : this.props.children\n+  }\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/scroll-gate.tsx","hunks":["@@ -0,0 +1,33 @@\n+'use client'\n+\n+import { useEffect, useState } from 'react'\n+\n+import { cn } from '@/lib/utils'\n+\n+/** Block wheel until ⌘/Ctrl so map embeds don't hijack transcript scroll. */\n+export function ScrollGate() {\n+  const [active, setActive] = useState(false)\n+\n+  useEffect(() => {\n+    const sync = (event: KeyboardEvent) => setActive(event.metaKey || event.ctrlKey)\n+    const clear = () => setActive(false)\n+\n+    window.addEventListener('keydown', sync)\n+    window.addEventListener('keyup', sync)\n+    window.addEventListener('blur', clear)\n+\n+    return () => {\n+      window.removeEventListener('keydown', sync)\n+      window.removeEventListener('keyup', sync)\n+      window.removeEventListener('blur', clear)\n+    }\n+  }, [])\n+\n+  return (\n+    <div className={cn('group/gate absolute inset-0', active ? 'pointer-events-none' : 'pointer-events-auto')}>\n+      <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\">\n+        Hold ⌘ to zoom\n+      </span>\n+    </div>\n+  )\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/social-embed.tsx","hunks":["@@ -0,0 +1,131 @@\n+'use client'\n+\n+import { useEffect, useRef } from 'react'\n+\n+import { escapeHtml } from './escape-html'\n+import type { EmbedDescriptor } from './providers/types'\n+import { useIsDark } from './use-is-dark'\n+\n+// The provider embed scripts need a REAL origin to run (they touch\n+// cookies/storage/postMessage), so — exactly like react-social-media-embed — we\n+// render the official blockquote in this document and let the script swap it for\n+// a correctly-sized iframe. A sandboxed srcDoc iframe gives a null origin and\n+// the scripts silently bail (white / 2px). The container is height:auto, so it\n+// grows to whatever the provider renders. No measuring, no forced height.\n+type EmbedWindow = Window &\n+  typeof globalThis & {\n+    instgrm?: { Embeds?: { process?: () => void } }\n+    twttr?: { widgets?: { load?: (el?: HTMLElement) => void } }\n+  }\n+\n+const SCRIPT: Record<string, { id: string; src: string }> = {\n+  instagram: { id: 'hermes-ig-embed', src: 'https://www.instagram.com/embed.js' },\n+  tiktok: { id: 'hermes-tt-embed', src: 'https://www.tiktok.com/embed.js' },\n+  twitter: { id: 'hermes-tw-embed', src: 'https://platform.twitter.com/widgets.js' }\n+}\n+\n+const PROCESS_DELAYS_MS = [0, 300, 800, 1600, 3000]\n+\n+function markup(descriptor: EmbedDescriptor, theme: 'dark' | 'light'): string {\n+  const url = escapeHtml(descriptor.sourceUrl)\n+\n+  switch (descriptor.provider) {\n+    case 'instagram':\n+      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>`\n+    case 'tiktok': {\n+      const id = escapeHtml(descriptor.id.replace(/^tiktok:/, ''))\n+\n+      return `<blockquote class=\"tiktok-embed\" cite=\"${url}\" data-video-id=\"${id}\" style=\"margin:0;max-width:100%\"><section></section></blockquote>`\n+    }\n+\n+    case 'twitter':\n+      // data-chrome=\"transparent\" drops the card background so the themed page\n+      // shows through instead of a white box.\n+      return `<blockquote class=\"twitter-tweet\" data-dnt=\"true\" data-theme=\"${theme}\" data-chrome=\"transparent\"><a href=\"${url}\"></a></blockquote>`\n+\n+    default:\n+      return ''\n+  }\n+}\n+\n+function loadScript(provider: string): Promise<void> {\n+  const { id, src } = SCRIPT[provider]\n+\n+  // TikTok exposes no re-process API; its script rescans the document each time\n+  // it runs, so we re-inject it. The others are loaded once and reused.\n+  if (provider === 'tiktok') {\n+    document.getElementById(id)?.remove()\n+  } else if (document.getElementById(id)) {\n+    return Promise.resolve()\n+  }\n+\n+  return new Promise(resolve => {\n+    const script = document.createElement('script')\n+\n+    script.async = true\n+    script.id = id\n+    script.onload = () => resolve()\n+    script.onerror = () => resolve()\n+    script.src = src\n+    document.body.appendChild(script)\n+  })\n+}\n+\n+function processEmbed(provider: string, container: HTMLElement): void {\n+  const win = window as EmbedWindow\n+\n+  if (provider === 'instagram') {\n+    win.instgrm?.Embeds?.process?.()\n+  } else if (provider === 'twitter') {\n+    win.twttr?.widgets?.load?.(container)\n+  }\n+  // TikTok auto-scans on (re)injection — no manual process call.\n+}\n+\n+export default function SocialEmbedRenderer({ descriptor }: { descriptor: EmbedDescriptor }) {\n+  const isDark = useIsDark()\n+  const ref = useRef<HTMLDivElement | null>(null)\n+\n+  useEffect(() => {\n+    const container = ref.current\n+\n+    if (!container) {\n+      return\n+    }\n+\n+    let cancelled = false\n+    const timers: number[] = []\n+\n+    container.innerHTML = markup(descriptor, isDark ? 'dark' : 'light')\n+\n+    void loadScript(descriptor.provider).then(() => {\n+      // The script renders asynchronously; nudge a few times so the embed\n+      // settles whether the script was cached or freshly fetched.\n+      for (const delay of PROCESS_DELAYS_MS) {\n+        timers.push(window.setTimeout(() => !cancelled && processEmbed(descriptor.provider, container), delay))\n+      }\n+    })\n+\n+    return () => {\n+      cancelled = true\n+\n+      for (const timer of timers) {\n+        clearTimeout(timer)\n+      }\n+\n+      container.innerHTML = ''\n+    }\n+  }, [descriptor, isDark])\n+\n+  // The white corner/box on tweets is a color-scheme MISMATCH: when the iframe's\n+  // resolved scheme differs from ours, the browser paints an opaque (white)\n+  // Canvas behind it. Twitter's embed resolves to `light`, so we force the iframe\n+  // to `light` to match — no mismatch, no Canvas — and data-chrome=transparent\n+  // then lets the dark page show through. (Confirmed: mkdocs-material #6889.)\n+  return (\n+    <div\n+      className=\"w-full [&_.instagram-media]:!min-w-0 [&_iframe]:!m-0 [&_iframe]:!max-w-full [&_iframe]:[color-scheme:light]\"\n+      ref={ref}\n+    />\n+  )\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/spotify-embed.tsx","hunks":["@@ -0,0 +1,45 @@\n+'use client'\n+\n+import { type CSSProperties, useMemo } from 'react'\n+\n+import type { FrameEmbed } from './providers/types'\n+import { useIsDark } from './use-is-dark'\n+\n+const ALLOW = 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture'\n+\n+// Spotify paints a white backdrop behind its card; theme=0 gives the dark\n+// player and the card wrapper's overflow-hidden clips the corners.\n+function spotifySrc(embedUrl: string, isDark: boolean): string {\n+  const url = new URL(embedUrl)\n+\n+  url.searchParams.set('utm_source', 'generator')\n+\n+  if (isDark) {\n+    url.searchParams.set('theme', '0')\n+  }\n+\n+  return url.toString()\n+}\n+\n+export default function SpotifyEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {\n+  const isDark = useIsDark()\n+  const src = useMemo(() => spotifySrc(descriptor.embedUrl, isDark), [descriptor.embedUrl, isDark])\n+\n+  // Match the iframe's own (light) scheme — a `dark` mismatch makes the browser\n+  // paint an opaque white Canvas behind it. theme=0 still gives the dark player.\n+  const style: CSSProperties = {\n+    colorScheme: 'light',\n+    height: descriptor.height\n+  }\n+\n+  return (\n+    <iframe\n+      allow={ALLOW}\n+      className=\"block w-full border-0 bg-transparent\"\n+      loading=\"lazy\"\n+      src={src}\n+      style={style}\n+      title=\"Spotify embed\"\n+    />\n+  )\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/svg-embed.tsx","hunks":["@@ -0,0 +1,32 @@\n+'use client'\n+\n+import DOMPurify from 'dompurify'\n+import { useMemo } from 'react'\n+\n+import type { RichFenceProps } from './types'\n+\n+// Lazy chunk (pulls in DOMPurify). Renders a ```svg fence as an image after\n+// hard-sanitising it: the svg profile strips scripts, event handlers, and\n+// foreignObject, so untrusted model output can't execute.\n+export default function SvgRenderer({ code }: RichFenceProps) {\n+  const clean = useMemo(\n+    () =>\n+      DOMPurify.sanitize(code, {\n+        USE_PROFILES: { svg: true, svgFilters: true }\n+      }),\n+    [code]\n+  )\n+\n+  if (!clean.trim()) {\n+    return null\n+  }\n+\n+  // Left-aligned, capped on both axes so a large intrinsic SVG scales down\n+  // (preserving ratio) instead of filling the column or centering.\n+  return (\n+    <div\n+      className=\"my-2 [&_svg]:block [&_svg]:h-auto [&_svg]:w-auto [&_svg]:max-h-[33dvh] [&_svg]:max-w-full\"\n+      dangerouslySetInnerHTML={{ __html: clean }}\n+    />\n+  )\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/types.ts","hunks":["@@ -0,0 +1,8 @@\n+// Shared prop contract for fenced-block renderers (mermaid, svg). Kept in its\n+// own module so renderers and the registry can both import it without a cycle.\n+export interface RichFenceProps {\n+  code: string\n+  /** True while the surrounding message is still streaming. Renderers that can\n+   *  throw on partial input (e.g. mermaid) defer until this is false. */\n+  streaming?: boolean\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/url-embed.tsx","hunks":["@@ -0,0 +1,84 @@\n+'use client'\n+\n+import { useStore } from '@nanostores/react'\n+import { type CSSProperties, lazy, Suspense, useState } from 'react'\n+\n+import { PrettyLink } from '@/lib/external-link'\n+import { $embedAllowed, $embedMode } from '@/store/embed-consent'\n+\n+import { EmbedFacade } from './embed-consent'\n+import { EMBED_MAX_H } from './embed-size'\n+import { EmbedFail } from './fail'\n+import type { EmbedDescriptor } from './providers/types'\n+import { RichBoundary } from './rich-boundary'\n+\n+const FrameEmbedRenderer = lazy(() => import('./frame-embed'))\n+const SocialEmbedRenderer = lazy(() => import('./social-embed'))\n+const SpotifyEmbedRenderer = lazy(() => import('./spotify-embed'))\n+const YouTubeEmbedRenderer = lazy(() => import('./youtube-embed'))\n+\n+function intrinsicHeight(descriptor: EmbedDescriptor): number {\n+  if (descriptor.aspectRatio) {\n+    return Math.round((descriptor.maxWidth ?? 640) / descriptor.aspectRatio)\n+  }\n+\n+  return descriptor.height ?? 320\n+}\n+\n+function LazyRenderer({ descriptor }: { descriptor: EmbedDescriptor }) {\n+  // X and Instagram load their official blockquote script in-document. The tweet\n+  // check also narrows the union to FrameEmbed for the iframe renderers below.\n+  if (descriptor.renderer === 'tweet' || descriptor.provider === 'instagram') {\n+    return <SocialEmbedRenderer descriptor={descriptor} />\n+  }\n+\n+  if (descriptor.provider === 'youtube') {\n+    return <YouTubeEmbedRenderer descriptor={descriptor} />\n+  }\n+\n+  if (descriptor.provider === 'spotify') {\n+    return <SpotifyEmbedRenderer descriptor={descriptor} />\n+  }\n+\n+  return <FrameEmbedRenderer descriptor={descriptor} />\n+}\n+\n+export function UrlEmbed({ descriptor }: { descriptor: EmbedDescriptor }) {\n+  const mode = useStore($embedMode)\n+  const allowed = useStore($embedAllowed)\n+  const [loaded, setLoaded] = useState(false)\n+\n+  // Privacy gate: don't reach out to the provider until consented. `off` keeps\n+  // it a plain link; otherwise the placeholder shows until \"Load\" (this embed)\n+  // or \"Always allow\" / global `always` permits the fetch.\n+  if (mode === 'off') {\n+    return <PrettyLink className=\"wrap-anywhere\" href={descriptor.sourceUrl} />\n+  }\n+\n+  const consented = mode === 'always' || loaded || allowed.includes(descriptor.provider)\n+  const aspect = descriptor.aspectRatio\n+\n+  // Ratio embeds cap WIDTH off the ratio so height tops out at the cap while\n+  // scaling. Non-ratio embeds own their own height (measured / fixed).\n+  const style: CSSProperties = {\n+    containIntrinsicSize: `auto ${intrinsicHeight(descriptor)}px`,\n+    contentVisibility: 'auto',\n+    ...(aspect\n+      ? { width: `min(${descriptor.maxWidth ?? 640}px, 100%, calc(${EMBED_MAX_H} * ${aspect}))` }\n+      : { width: descriptor.maxWidth ? `min(${descriptor.maxWidth}px, 100%)` : '100%' })\n+  }\n+\n+  return (\n+    <span className=\"group/embed my-2 block overflow-hidden rounded-lg\" data-slot=\"aui_embed-card\" style={style}>\n+      <RichBoundary fallback={<EmbedFail label={descriptor.label} />} resetKey={descriptor.id}>\n+        {consented ? (\n+          <Suspense fallback={null}>\n+            <LazyRenderer descriptor={descriptor} />\n+          </Suspense>\n+        ) : (\n+          <EmbedFacade descriptor={descriptor} onLoad={() => setLoaded(true)} />\n+        )}\n+      </RichBoundary>\n+    </span>\n+  )\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/use-is-dark.ts","hunks":["@@ -0,0 +1,24 @@\n+import { useEffect, useState } from 'react'\n+\n+// Tracks the app's dark/light mode off the `dark` class on <html> (set by\n+// themes/context.tsx). Embeds that theme their own content (tweets) read this.\n+export function useIsDark(): boolean {\n+  const [dark, setDark] = useState(\n+    () => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')\n+  )\n+\n+  useEffect(() => {\n+    if (typeof document === 'undefined') {\n+      return\n+    }\n+\n+    const root = document.documentElement\n+    const observer = new MutationObserver(() => setDark(root.classList.contains('dark')))\n+\n+    observer.observe(root, { attributeFilter: ['class'], attributes: true })\n+\n+    return () => observer.disconnect()\n+  }, [])\n+\n+  return dark\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/embeds/youtube-embed.tsx","hunks":["@@ -0,0 +1,47 @@\n+'use client'\n+\n+import { useMemo } from 'react'\n+\n+import type { FrameEmbed } from './providers/types'\n+import { useIsDark } from './use-is-dark'\n+\n+const YOUTUBE_ALLOW =\n+  'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen'\n+\n+function youtubeSrc(embedUrl: string): string {\n+  const url = new URL(embedUrl)\n+\n+  // Only pass origin when it is an HTTP(S) origin; custom schemes (app://,\n+  // file://) can make the player reject otherwise embeddable videos.\n+  if (\n+    typeof window !== 'undefined' &&\n+    (window.location.protocol === 'http:' || window.location.protocol === 'https:') &&\n+    window.location.origin &&\n+    window.location.origin !== 'null'\n+  ) {\n+    url.searchParams.set('origin', window.location.origin)\n+  }\n+\n+  return url.toString()\n+}\n+\n+// Keep this as a plain iframe and let YouTube render its native player/error UI.\n+export default function YouTubeEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {\n+  const isDark = useIsDark()\n+  const src = useMemo(() => youtubeSrc(descriptor.embedUrl), [descriptor.embedUrl])\n+\n+  // Width is capped to the ratio by UrlEmbed, so aspect-video sizes height ≤ cap.\n+  return (\n+    <iframe\n+      allow={YOUTUBE_ALLOW}\n+      allowFullScreen\n+      className=\"block aspect-video w-full border-0 bg-transparent\"\n+      loading=\"lazy\"\n+      referrerPolicy=\"strict-origin-when-cross-origin\"\n+      scrolling=\"no\"\n+      src={src}\n+      style={{ colorScheme: isDark ? 'dark' : 'light' }}\n+      title=\"YouTube embed\"\n+    />\n+  )\n+}"]},{"file":"apps/desktop/src/components/assistant-ui/markdown-text.tsx","hunks":["@@ -40,6 +40,8 @@ import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'\n import { tailBoundedRemend } from '@/lib/remend-tail'\n import { cn } from '@/lib/utils'\n \n+import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds'\n+\n // Math rendering plugin (KaTeX). Configured once at module scope — the\n // plugin is stateless beyond its internal cache so re-creating per-render\n // would needlessly thrash. We use a memoizing wrapper around rehype-katex","@@ -270,6 +272,17 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a\n   }\n \n   const text = childrenToText(children)\n+\n+  // Bare autolink → inline rich embed when a provider matches. Labeled links\n+  // (`[watch](url)`) stay plain. Desktop only (webview / iframe renderers).\n+  if (window.hermesDesktop && text && normalizeExternalUrl(text) === target) {\n+    const embed = detectEmbed(target)\n+\n+    if (embed) {\n+      return <UrlEmbed descriptor={embed} />\n+    }\n+  }\n+\n   const fallbackLabel = text && normalizeExternalUrl(text) !== target ? text : undefined\n \n   return (","@@ -535,13 +548,25 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex\n         // owning per-line text direction. Inline code carries `dir=\"ltr\"`\n         // (see the `code` override) so it doesn't vote here either, same\n         // contract as the CSS isolate.\n-        blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (\n-          <blockquote\n-            className={cn('border-s-2 border-border ps-3 text-muted-foreground italic', className)}\n-            dir=\"auto\"\n-            {...props}\n-          />\n-        ),\n+        // A `> [!NOTE]`/`[!WARNING]`/... blockquote renders as a GFM alert\n+        // callout; everything else stays a plain quote.\n+        blockquote: ({ children, className, ...props }: ComponentProps<'blockquote'>) => {\n+          const alert = extractAlert(children)\n+\n+          if (alert) {\n+            return <MarkdownAlert type={alert.type}>{alert.body}</MarkdownAlert>\n+          }\n+\n+          return (\n+            <blockquote\n+              className={cn('border-s-2 border-border ps-3 text-muted-foreground italic', className)}\n+              dir=\"auto\"\n+              {...props}\n+            >\n+              {children}\n+            </blockquote>\n+          )\n+        },\n         ul: ({ className, ...props }: ComponentProps<'ul'>) => (\n           <ul className={cn('my-1 gap-0', className)} dir=\"auto\" {...props} />\n         ),","@@ -578,7 +603,16 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex\n           <td className={cn('px-2.5 py-1.5 align-top text-[0.8125rem] leading-snug', className)} {...props} />\n         ),\n         img: MarkdownImage,\n-        SyntaxHighlighter: (props: SyntaxHighlighterProps) => <SyntaxHighlighter {...props} defer={isStreaming} />\n+        // ```mermaid / ```svg fences route to their lazy renderers; every other\n+        // language falls back to the Shiki-highlighted code block.\n+        SyntaxHighlighter: (props: SyntaxHighlighterProps) => (\n+          <RichCodeBlock\n+            code={props.code}\n+            fallback={<SyntaxHighlighter {...props} defer={isStreaming} />}\n+            language={props.language}\n+            streaming={isStreaming}\n+          />\n+        )\n       }) as StreamdownTextComponents,\n     [isStreaming]\n   )"]},{"file":"apps/desktop/src/components/ui/use-zoom-pan.ts","hunks":["@@ -0,0 +1,97 @@\n+import {\n+  type CSSProperties,\n+  type PointerEvent as ReactPointerEvent,\n+  type WheelEvent as ReactWheelEvent,\n+  useCallback,\n+  useRef,\n+  useState\n+} from 'react'\n+\n+interface Transform {\n+  scale: number\n+  x: number\n+  y: number\n+}\n+\n+const MIN_SCALE = 0.25\n+const MAX_SCALE = 8\n+const WHEEL_STEP = 1.1\n+const BUTTON_STEP = 1.25\n+\n+const clamp = (scale: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale))\n+\n+/**\n+ * Headless pan/zoom transform. Wheel zooms toward the cursor, drag pans, buttons\n+ * zoom toward centre. Returns the transform style plus the surface handlers, so\n+ * any content (SVG, image, canvas) can be made pan/zoomable.\n+ */\n+export function useZoomPan() {\n+  const [transform, setTransform] = useState<Transform>({ scale: 1, x: 0, y: 0 })\n+  const drag = useRef<{ x: number; y: number } | null>(null)\n+  const [panning, setPanning] = useState(false)\n+\n+  // Zoom toward (cx, cy), measured from the surface centre, keeping that point fixed.\n+  const zoomAt = useCallback((factor: number, cx = 0, cy = 0) => {\n+    setTransform(prev => {\n+      const scale = clamp(prev.scale * factor)\n+      const k = scale / prev.scale\n+\n+      return { scale, x: cx - k * (cx - prev.x), y: cy - k * (cy - prev.y) }\n+    })\n+  }, [])\n+\n+  const onWheel = useCallback(\n+    (event: ReactWheelEvent) => {\n+      event.preventDefault()\n+      const rect = event.currentTarget.getBoundingClientRect()\n+      const cx = event.clientX - rect.left - rect.width / 2\n+      const cy = event.clientY - rect.top - rect.height / 2\n+\n+      zoomAt(event.deltaY < 0 ? WHEEL_STEP : 1 / WHEEL_STEP, cx, cy)\n+    },\n+    [zoomAt]\n+  )\n+\n+  const onPointerDown = useCallback((event: ReactPointerEvent) => {\n+    event.currentTarget.setPointerCapture(event.pointerId)\n+    setTransform(prev => {\n+      drag.current = { x: event.clientX - prev.x, y: event.clientY - prev.y }\n+\n+      return prev\n+    })\n+    setPanning(true)\n+  }, [])\n+\n+  const onPointerMove = useCallback((event: ReactPointerEvent) => {\n+    if (!drag.current) {\n+      return\n+    }\n+\n+    const start = drag.current\n+\n+    setTransform(prev => ({ ...prev, x: event.clientX - start.x, y: event.clientY - start.y }))\n+  }, [])\n+\n+  const endPan = useCallback(() => {\n+    drag.current = null\n+    setPanning(false)\n+  }, [])\n+\n+  const reset = useCallback(() => setTransform({ scale: 1, x: 0, y: 0 }), [])\n+  const zoomIn = useCallback(() => zoomAt(BUTTON_STEP), [zoomAt])\n+  const zoomOut = useCallback(() => zoomAt(1 / BUTTON_STEP), [zoomAt])\n+\n+  const style: CSSProperties = {\n+    transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`\n+  }\n+\n+  return {\n+    panning,\n+    reset,\n+    scale: transform.scale,\n+    stageProps: { onPointerDown, onPointerLeave: endPan, onPointerMove, onPointerUp: endPan, onWheel },\n+    style,\n+    zoomIn,\n+    zoomOut\n+  }\n+}"]},{"file":"apps/desktop/src/components/ui/zoomable.tsx","hunks":["@@ -0,0 +1,172 @@\n+'use client'\n+\n+import { type ReactNode, useEffect, useState } from 'react'\n+\n+import { Dialog, DialogContent } from '@/components/ui/dialog'\n+import { Check, Copy, Maximize, RefreshCw, X, ZoomIn, ZoomOut } from '@/lib/icons'\n+import { cn } from '@/lib/utils'\n+\n+import { useZoomPan } from './use-zoom-pan'\n+\n+interface ZoomableProps {\n+  /** Inline content; also the default full-view content. */\n+  children: ReactNode\n+  /** Full-view content, if it should differ from the inline version. */\n+  overlay?: ReactNode\n+  /** Copy/export action shown in the viewer toolbar. */\n+  onCopy?: () => Promise<void> | void\n+  /** Accessible label for the expand affordance. */\n+  label?: string\n+  className?: string\n+}\n+\n+/**\n+ * Generic click-to-expand viewer: renders inline content with a hover \"expand\"\n+ * affordance, then opens a full overlay where the content can be panned/zoomed\n+ * (see useZoomPan) and optionally copied. Content-agnostic — wrap a diagram,\n+ * image, or any node.\n+ */\n+export function Zoomable({ children, overlay, onCopy, label = 'Open full view', className }: ZoomableProps) {\n+  const [open, setOpen] = useState(false)\n+\n+  return (\n+    <>\n+      <div className={cn('group/zoomable relative', className)}>\n+        {/* The whole content is the trigger — click anywhere to open, like an image. */}\n+        <button\n+          className=\"block w-full cursor-zoom-in text-left\"\n+          onClick={() => setOpen(true)}\n+          title={label}\n+          type=\"button\"\n+        >\n+          {children}\n+        </button>\n+        <span\n+          aria-hidden\n+          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\"\n+        >\n+          <Maximize className=\"size-4\" />\n+        </span>\n+      </div>\n+      {open && (\n+        <ZoomPanViewer onCopy={onCopy} onOpenChange={setOpen} open={open}>\n+          {overlay ?? children}\n+        </ZoomPanViewer>\n+      )}\n+    </>\n+  )\n+}\n+\n+function ZoomPanViewer({\n+  children,\n+  onCopy,\n+  onOpenChange,\n+  open\n+}: {\n+  children: ReactNode\n+  onCopy?: () => Promise<void> | void\n+  onOpenChange: (open: boolean) => void\n+  open: boolean\n+}) {\n+  const { panning, reset, stageProps, style, zoomIn, zoomOut } = useZoomPan()\n+\n+  useEffect(() => {\n+    if (open) {\n+      reset()\n+    }\n+  }, [open, reset])\n+\n+  return (\n+    <Dialog onOpenChange={onOpenChange} open={open}>\n+      <DialogContent\n+        className=\"flex h-[85vh] w-[90vw] max-w-[90vw] flex-col gap-0 overflow-hidden p-0\"\n+        showCloseButton={false}\n+      >\n+        <div\n+          className={cn(\n+            'relative flex-1 touch-none select-none overflow-hidden',\n+            panning ? 'cursor-grabbing' : 'cursor-grab'\n+          )}\n+          {...stageProps}\n+        >\n+          <div className=\"absolute inset-0 grid place-items-center\">\n+            <div className=\"origin-center\" style={style}>\n+              {children}\n+            </div>\n+          </div>\n+        </div>\n+        <Toolbar onClose={() => onOpenChange(false)} onCopy={onCopy} reset={reset} zoomIn={zoomIn} zoomOut={zoomOut} />\n+      </DialogContent>\n+    </Dialog>\n+  )\n+}\n+\n+function Toolbar({\n+  onClose,\n+  onCopy,\n+  reset,\n+  zoomIn,\n+  zoomOut\n+}: {\n+  onClose: () => void\n+  onCopy?: () => Promise<void> | void\n+  reset: () => void\n+  zoomIn: () => void\n+  zoomOut: () => void\n+}) {\n+  const [copied, setCopied] = useState(false)\n+\n+  const copy = async () => {\n+    if (!onCopy) {\n+      return\n+    }\n+\n+    await onCopy()\n+    setCopied(true)\n+    window.setTimeout(() => setCopied(false), 1500)\n+  }\n+\n+  return (\n+    <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\">\n+      <ToolbarButton label=\"Zoom out\" onClick={zoomOut}>\n+        <ZoomOut className=\"size-4\" />\n+      </ToolbarButton>\n+      <ToolbarButton label=\"Reset\" onClick={reset}>\n+        <RefreshCw className=\"size-4\" />\n+      </ToolbarButton>\n+      <ToolbarButton label=\"Zoom in\" onClick={zoomIn}>\n+        <ZoomIn className=\"size-4\" />\n+      </ToolbarButton>\n+      {onCopy && (\n+        <>\n+          <Divider />\n+          <ToolbarButton label={copied ? 'Copied' : 'Copy'} onClick={() => void copy()}>\n+            {copied ? <Check className=\"size-4\" /> : <Copy className=\"size-4\" />}\n+          </ToolbarButton>\n+        </>\n+      )}\n+      <Divider />\n+      <ToolbarButton label=\"Close\" onClick={onClose}>\n+        <X className=\"size-4\" />\n+      </ToolbarButton>\n+    </div>\n+  )\n+}\n+\n+function Divider() {\n+  return <span className=\"mx-0.5 h-5 w-px bg-border\" />\n+}\n+\n+function ToolbarButton({ children, label, onClick }: { children: ReactNode; label: string; onClick: () => void }) {\n+  return (\n+    <button\n+      aria-label={label}\n+      className=\"grid size-8 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n+      onClick={onClick}\n+      title={label}\n+      type=\"button\"\n+    >\n+      {children}\n+    </button>\n+  )\n+}"]},{"file":"apps/desktop/src/i18n/en.ts","hunks":["@@ -376,6 +376,13 @@ export const en: Translations = {\n       toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.',\n       translucencyTitle: 'Window Translucency',\n       translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.',\n+      embedsTitle: 'Inline Embeds',\n+      embedsDesc:\n+        '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.',\n+      embedsAsk: 'Ask',\n+      embedsAlways: 'Always',\n+      embedsOff: 'Off',\n+      embedsReset: (count: number) => `Reset ${count} allowed ${count === 1 ? 'service' : 'services'}`,\n       product: 'Product',\n       productDesc: 'Human-friendly tool activity with concise summaries.',\n       technical: 'Technical',"]},{"file":"apps/desktop/src/i18n/ja.ts","hunks":["@@ -286,6 +286,13 @@ export const ja = defineLocale({\n       toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。',\n       translucencyTitle: 'ウィンドウの透過',\n       translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。',\n+      embedsTitle: 'インライン埋め込み',\n+      embedsDesc:\n+        'リッチプレビューは第三者サイト（YouTube、X など）から読み込まれます。確認は許可するまでプレースホルダーを表示し、常には自動で読み込み、オフはリンクのままにします。',\n+      embedsAsk: '確認',\n+      embedsAlways: '常に',\n+      embedsOff: 'オフ',\n+      embedsReset: (count: number) => `許可した${count}件のサービスをリセット`,\n       product: 'プロダクト',\n       productDesc: '読みやすいツール活動と簡潔な要約を表示します。',\n       technical: 'テクニカル',"]},{"file":"apps/desktop/src/i18n/types.ts","hunks":["@@ -301,6 +301,12 @@ export interface Translations {\n       toolViewDesc: string\n       translucencyTitle: string\n       translucencyDesc: string\n+      embedsTitle: string\n+      embedsDesc: string\n+      embedsAsk: string\n+      embedsAlways: string\n+      embedsOff: string\n+      embedsReset: (count: number) => string\n       product: string\n       productDesc: string\n       technical: string"]},{"file":"apps/desktop/src/i18n/zh-hant.ts","hunks":["@@ -278,6 +278,12 @@ export const zhHant = defineLocale({\n       toolViewDesc: '產品模式會隱藏原始工具 payload；技術模式會顯示完整輸入/輸出。',\n       translucencyTitle: '視窗透明',\n       translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',\n+      embedsTitle: '內嵌預覽',\n+      embedsDesc: '豐富預覽會從第三方網站（YouTube、X 等）載入。詢問會在你允許前顯示佔位符；一律會自動載入；關閉則保留純連結。',\n+      embedsAsk: '詢問',\n+      embedsAlways: '一律',\n+      embedsOff: '關閉',\n+      embedsReset: (count: number) => `重設 ${count} 個已允許的服務`,\n       product: '產品',\n       productDesc: '易讀的工具活動與精簡摘要。',\n       technical: '技術',"]},{"file":"apps/desktop/src/i18n/zh.ts","hunks":["@@ -369,6 +369,12 @@ export const zh: Translations = {\n       toolViewDesc: '产品模式隐藏原始工具数据；技术模式显示完整输入/输出。',\n       translucencyTitle: '窗口透明',\n       translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',\n+      embedsTitle: '内嵌预览',\n+      embedsDesc: '富预览会从第三方网站（YouTube、X 等）加载。询问会在你允许前显示占位符；总是会自动加载；关闭则保留纯链接。',\n+      embedsAsk: '询问',\n+      embedsAlways: '总是',\n+      embedsOff: '关闭',\n+      embedsReset: (count: number) => `重置 ${count} 个已允许的服务`,\n       product: '产品',\n       productDesc: '易读的工具活动与简洁摘要。',\n       technical: '技术',"]},{"file":"apps/desktop/src/lib/icons.ts","hunks":["@@ -56,6 +56,7 @@ import {\n   IconLock as Lock,\n   IconLogin as LogIn,\n   IconMail as Mail,\n+  IconMaximize as Maximize,\n   IconMessageCircle as MessageCircle,\n   IconMessage2 as MessageSquareText,\n   IconMicrophone as Mic,","@@ -104,7 +105,9 @@ import {\n   IconX as X,\n   IconX as XIcon,\n   IconBolt as Zap,\n-  IconBoltFilled as ZapFilled\n+  IconBoltFilled as ZapFilled,\n+  IconZoomIn as ZoomIn,\n+  IconZoomOut as ZoomOut\n } from '@tabler/icons-react'\n \n export {","@@ -165,6 +168,7 @@ export {\n   Lock,\n   LogIn,\n   Mail,\n+  Maximize,\n   MessageCircle,\n   MessageSquareText,\n   Mic,","@@ -213,7 +217,9 @@ export {\n   X,\n   XIcon,\n   Zap,\n-  ZapFilled\n+  ZapFilled,\n+  ZoomIn,\n+  ZoomOut\n }\n \n export type { Icon as IconComponent } from '@tabler/icons-react'"]},{"file":"apps/desktop/src/lib/svg-image.ts","hunks":["@@ -0,0 +1,56 @@\n+// Rasterise an SVG string to PNG and copy it to the clipboard. Self-contained\n+// SVGs only (inline styles) — mermaid output qualifies. Falls back to copying\n+// the SVG markup as text where image clipboard writes aren't permitted.\n+\n+function svgSize(svg: string): { height: number; width: number } {\n+  const el = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement\n+  const width = parseFloat(el.getAttribute('width') || '')\n+  const height = parseFloat(el.getAttribute('height') || '')\n+\n+  if (width && height) {\n+    return { height, width }\n+  }\n+\n+  const [, , vbW, vbH] = (el.getAttribute('viewBox') || '').split(/[\\s,]+/).map(Number)\n+\n+  return vbW && vbH ? { height: vbH, width: vbW } : { height: 600, width: 800 }\n+}\n+\n+export function svgToPngBlob(svg: string, scale = 2): Promise<Blob> {\n+  const { height, width } = svgSize(svg)\n+\n+  return new Promise((resolve, reject) => {\n+    const image = new Image()\n+\n+    image.onload = () => {\n+      const canvas = document.createElement('canvas')\n+      canvas.width = Math.max(1, Math.round(width * scale))\n+      canvas.height = Math.max(1, Math.round(height * scale))\n+\n+      const ctx = canvas.getContext('2d')\n+\n+      if (!ctx) {\n+        reject(new Error('no 2d context'))\n+\n+        return\n+      }\n+\n+      ctx.scale(scale, scale)\n+      ctx.drawImage(image, 0, 0, width, height)\n+      canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('toBlob failed'))), 'image/png')\n+    }\n+\n+    image.onerror = () => reject(new Error('svg load failed'))\n+    image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`\n+  })\n+}\n+\n+export async function copySvgAsPng(svg: string): Promise<void> {\n+  try {\n+    const blob = await svgToPngBlob(svg)\n+\n+    await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])\n+  } catch {\n+    await navigator.clipboard.writeText(svg)\n+  }\n+}"]},{"file":"apps/desktop/src/store/embed-consent.ts","hunks":["@@ -0,0 +1,37 @@\n+import { type Codec, Codecs, persistentAtom } from '@/lib/persisted'\n+\n+// Privacy gate for inline embeds. Loading an embed reaches out to a third party\n+// (IP, referrer, cookies), so by default we render a placeholder until the user\n+// consents — per embed (\"Load once\") or per service (\"Always allow YouTube\").\n+// Mirrors the tool-approval model, but purely client-side (the renderer is what\n+// makes the request) so it never touches the gateway/config.yaml.\n+export type EmbedMode = 'always' | 'ask' | 'off'\n+\n+const MODE_KEY = 'hermes.desktop.embed-mode'\n+const ALLOWED_KEY = 'hermes.desktop.embed-allowed'\n+\n+const modeCodec: Codec<EmbedMode> = {\n+  decode: raw => (raw === 'always' || raw === 'off' ? raw : 'ask'),\n+  encode: value => value\n+}\n+\n+/** Global default: ask (placeholder), always (auto-load), off (plain link). */\n+export const $embedMode = persistentAtom<EmbedMode>(MODE_KEY, 'ask', modeCodec)\n+/** Providers granted a standing \"always allow\" (e.g. `youtube`, `twitter`). */\n+export const $embedAllowed = persistentAtom<string[]>(ALLOWED_KEY, [], Codecs.stringArray)\n+\n+export function allowProvider(provider: string) {\n+  const current = $embedAllowed.get()\n+\n+  if (!current.includes(provider)) {\n+    $embedAllowed.set([...current, provider])\n+  }\n+}\n+\n+export function setEmbedMode(mode: EmbedMode) {\n+  $embedMode.set(mode)\n+}\n+\n+export function clearEmbedAllowed() {\n+  $embedAllowed.set([])\n+}"]},{"file":"package-lock.json","hunks":["@@ -95,11 +95,13 @@\n         \"clsx\": \"^2.1.1\",\n         \"cmdk\": \"^1.1.1\",\n         \"dnd-core\": \"^14.0.1\",\n+        \"dompurify\": \"^3.4.11\",\n         \"hast-util-from-html-isomorphic\": \"^2.0.0\",\n         \"hast-util-to-text\": \"^4.0.2\",\n         \"ignore\": \"^7.0.5\",\n         \"katex\": \"^0.16.45\",\n         \"leva\": \"^0.10.1\",\n+        \"mermaid\": \"^11.15.0\",\n         \"motion\": \"^12.38.0\",\n         \"nanostores\": \"^1.3.0\",\n         \"node-pty\": \"1.1.0\",\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}