diff --git a/apps/desktop/electron/embed-referer.cjs b/apps/desktop/electron/embed-referer.cjs new file mode 100644 index 00000000000..2825eda40e7 --- /dev/null +++ b/apps/desktop/electron/embed-referer.cjs @@ -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 } diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index d7184bc9743..5d859bca649 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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 { @@ -7447,6 +7448,7 @@ app.whenReady().then(() => { } installMediaPermissions() registerMediaProtocol() + installEmbedReferer() registerDeepLinkProtocol() ensureWslWindowsFonts() configureSpellChecker() diff --git a/apps/desktop/src/components/assistant-ui/embeds/alert.test.tsx b/apps/desktop/src/components/assistant-ui/embeds/alert.test.tsx new file mode 100644 index 00000000000..b003b914a89 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/embeds/alert.test.tsx @@ -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') + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/embeds/alert.tsx b/apps/desktop/src/components/assistant-ui/embeds/alert.tsx new file mode 100644 index 00000000000..d2c55e43d71 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/embeds/alert.tsx @@ -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 = { + 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) => ) + } + + 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 ( +
+
+ + {style.label} +
+ {children} +
+ ) +} diff --git a/apps/desktop/src/components/assistant-ui/embeds/frame-embed.tsx b/apps/desktop/src/components/assistant-ui/embeds/frame-embed.tsx new file mode 100644 index 00000000000..7d99c0dd7a2 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/embeds/frame-embed.tsx @@ -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 ( +
+