import { useStore } from '@nanostores/react' import { useEffect, useMemo, useState } from 'react' import { requestComposerSubmit } from '@/app/chat/composer/focus' import { useSessionView } from '@/app/chat/session-view' import { useIsDark } from '@/components/assistant-ui/embeds/use-is-dark' import { PreviewAttachment } from '@/components/chat/preview-attachment' import { readDesktopFileText } from '@/lib/desktop-fs' import { localPreviewTarget } from '@/lib/local-preview' /** * `::preview{file="…"}` — a workspace HTML file rendered LIVE inside the * assistant message. A sandboxed iframe with an opaque origin * (`sandbox="allow-scripts"`, deliberately no `allow-same-origin`): scripts * run and the widget is fully interactive, but the document cannot reach the * app, its cookies, storage, or the bridge. The doc arrives via `srcdoc` * from a bridge file read, so single-file HTML (what agents generate) is * fully live; relative sibling assets don't resolve in an opaque origin. * * SIZE IS CONTENT-DRIVEN. The opaque origin means the parent can't measure * the document, but we own the srcdoc string — an injected script posts the * content's size up via postMessage (tagged with a per-mount token). Height * tracks live within the clamp band; width adopts ONCE from the first * report, so a fixed-size widget shrink-wraps and sits left in the message * flow like an image, while a fluid page measures the full viewport and * stays column-wide. A `height="480"` attribute only sets the starting * height — measurement always wins. * * NATIVE BY DEFAULT. A theme prelude injects first: the app's resolved * theme tokens under friendly names (--foreground, --muted-foreground, * --accent, --border, --card), the app font, zero body margin/padding, and * a transparent background — so widget-shaped content reads as part of the * app. The page's own styles override all of it, so a full page keeps its * own design. * * WIDGETS TALK BACK OFF-SCREEN. `window.hermes.send(prompt)` (or declarative * `data-hermes-send` on any clickable element) routes the prompt through the * composer's send path as a user turn typed `display_kind=hidden`: the agent * wakes and the durable row exists (context, resume, audit via the DB), but * no bubble renders — the widget updating is the visible response. Token- * gated, length-capped, throttled to human speed. * * Non-HTML targets and remote gateways (no local file access) fall back to * the standard preview-attachment card rather than a broken frame. */ const MIN_HEIGHT = 120 const MAX_HEIGHT = 1200 const DEFAULT_HEIGHT = 280 /** The transcript column cap the frame renders inside (`max-w-160` = 40rem). */ const MAX_COLUMN_WIDTH = 640 /** Ignore sub-pixel/rounding churn so a vh-sized page can't oscillate. */ const RESIZE_TOLERANCE = 4 export function directiveFrameHeight(raw: string | undefined): number | null { if (!raw) { return null } const parsed = Number(raw) if (!Number.isInteger(parsed)) { return null } return Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, parsed)) } const SIZE_MESSAGE_TYPE = 'hermes-inline-preview-size' const INTENT_MESSAGE_TYPE = 'hermes-inline-preview-intent' /** Prompt length cap for a widget intent — a sentence, not a payload dump. */ const MAX_INTENT_LENGTH = 500 /** One intent per frame per second; clicks are human-speed. */ const INTENT_THROTTLE_MS = 1000 /** The script that gives the widget its ONE voice: `hermes.send(prompt)`. * Posts the prompt up tagged with the mount token; the parent validates, * throttles, and routes it through the composer as a normal user message — * the widget speaks WITH the user's voice, visibly, never silently. Also * wires `data-hermes-send` so declarative HTML works with zero script: * ``. */ export function intentScript(token: string): string { return ( '' ) } /** Parse a widget intent. Null unless it is OUR type with OUR token and a * non-empty string prompt — same trust boundary as size reports, because * this one turns into a user message. Trimmed and length-capped. */ export function intentFromMessage(data: unknown, token: string): string | null { if (typeof data !== 'object' || data === null) { return null } const message = data as { type?: unknown; token?: unknown; prompt?: unknown } if (message.type !== INTENT_MESSAGE_TYPE || message.token !== token || typeof message.prompt !== 'string') { return null } const prompt = message.prompt.trim().slice(0, MAX_INTENT_LENGTH) return prompt || null } /** Semantic tokens handed into the frame, resolved to concrete values from * the LIVE theme. Friendly names, not internal ones — this is the contract * reference HTML / skills write against (`var(--foreground)` etc.). */ const THEME_BRIDGE_TOKENS: Record = { '--foreground': '--ui-text-primary', '--muted-foreground': '--ui-text-tertiary', '--accent': '--ui-accent', '--border': '--ui-stroke-tertiary', '--card': '--ui-bg-editor' } /** Resolve the bridge tokens + app font against the current document. */ export function collectThemeBridge(): { vars: Record; font: string } { const vars: Record = {} if (typeof document !== 'undefined') { const root = getComputedStyle(document.documentElement) for (const [alias, source] of Object.entries(THEME_BRIDGE_TOKENS)) { const value = root.getPropertyValue(source).trim() if (value) { vars[alias] = value } } } const font = typeof document === 'undefined' ? '' : getComputedStyle(document.body).fontFamily return { vars, font } } /** * The style prelude that makes an inline widget read as NATIVE: the app's * resolved theme tokens as CSS vars, the app font, no margin, and a * transparent background so the widget sits directly on the chat surface. * Injected FIRST, so the page's own styles override every default here — a * full page that wants its own look keeps it. */ export function themePrelude(vars: Record, font: string): string { const tokens = Object.entries(vars) .map(([name, value]) => `${name}:${value}`) .join(';') const fontRule = font ? `font-family:${font};` : '' return ( `` ) } /** The script injected into the srcdoc that reports content size to the * parent. Runs inside the opaque origin, so postMessage is its only door — * it can say "I am N pixels" and nothing else. Height is the document * scrollHeight; width is the union of the body children's boxes (intrinsic * content width — the document itself always fills the viewport, so * scrollWidth would just echo the frame back). */ export function measurementScript(token: string): string { return ( '' ) } /** Assemble the srcdoc: theme prelude first (so the page's own styles win), * then the measuring + intent scripts before `` when present so they * run after the page's own markup, appended otherwise. */ export function withInlineChrome(doc: string, token: string, prelude: string): string { const script = measurementScript(token) + intentScript(token) const bodyClose = /<\/body\s*>/i.exec(doc) const framed = bodyClose ? doc.slice(0, bodyClose.index) + script + doc.slice(bodyClose.index) : doc + script return prelude + framed } export interface FrameSizeReport { height: number /** Intrinsic content width, 0 when unmeasurable. */ width: number } /** Parse a size report from the frame. Null unless it is OUR message type, * carries OUR token, and holds a sane finite height — anything inside the * sandbox can postMessage, so everything is validated before it moves the * layout. Height clamped to the band; width sanitized but uncapped (the * frame caps it against the column at render). */ export function frameSizeFromMessage(data: unknown, token: string): FrameSizeReport | null { if (typeof data !== 'object' || data === null) { return null } const message = data as { type?: unknown; token?: unknown; height?: unknown; width?: unknown } if (message.type !== SIZE_MESSAGE_TYPE || message.token !== token || typeof message.height !== 'number') { return null } if (!Number.isFinite(message.height) || message.height <= 0) { return null } const width = typeof message.width === 'number' && Number.isFinite(message.width) && message.width > 0 ? Math.round(message.width) : 0 return { height: Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, Math.round(message.height))), width } } const HTML_FILE_RE = /\.(?:html?|xhtml)$/i export function InlinePreviewDirective({ attrs, streaming }: { attrs: Readonly> streaming: boolean }) { const file = attrs.file ?? '' // Not renderable inline: hand the leaf to the classic card. Non-HTML has // nothing to frame. (Remote gateways used to bail here too — that predates // the mode-aware fs bridge; the frame now reads through readDesktopFileText, // which fetches over the authenticated /api/fs bridge in remote mode, so a // URL connection — including a same-machine `hermes serve` — renders live.) if (!file || !HTML_FILE_RE.test(file)) { return file ? : null } return } function InlineHtmlFrame({ file, initialHeight, streaming }: { file: string /** `height` attribute — the starting height only; measurement overrides. */ initialHeight: number | null streaming: boolean }) { const cwd = useStore(useSessionView().$cwd) const isDark = useIsDark() const [doc, setDoc] = useState(null) const [failed, setFailed] = useState(false) const [measured, setMeasured] = useState(null) const [contentWidth, setContentWidth] = useState(null) // One token per mount: the message listener only trusts reports from the // document THIS mount injected, so two previews in one transcript (or a // hostile page inventing messages) can't move each other's frames. const token = useMemo(() => Math.random().toString(36).slice(2), []) // Resolve against THIS session's cwd (the file was written by its agent). const resolved = localPreviewTarget(file, cwd || undefined) const path = resolved?.path ?? null useEffect(() => { // Wait for turn settle: mid-stream the file is often mid-write, and a // half-written srcdoc renders as garbage that never self-corrects. if (!path || streaming) { return } let alive = true void Promise.resolve(readDesktopFileText(path)) .then(result => { if (!alive) { return } if (!result || result.binary || !result.text) { setFailed(true) } else { setDoc(result.text) } }) .catch(() => alive && setFailed(true)) return () => { alive = false } }, [path, streaming]) useEffect(() => { // Human-speed gate on widget intents. A closure local, not state: it's // a rate limiter read inside the handler, never rendered. let lastIntentAt = 0 const onMessage = (event: MessageEvent) => { const intent = intentFromMessage(event.data, token) if (intent !== null) { const now = Date.now() if (now - lastIntentAt >= INTENT_THROTTLE_MS) { lastIntentAt = now // Off-screen: the prompt reaches the agent as a normal user turn // through the composer's own send path (steer/queue rules apply), // but the row is typed hidden — no bubble, no UI space. The widget // updating IS the visible response. requestComposerSubmit(intent, { target: 'active', displayKind: 'hidden' }) } return } const next = frameSizeFromMessage(event.data, token) if (next === null) { return } // Functional updates so the comparisons read current state without a // shadow ref: same-value sets bail out in React, and the tolerance // keeps a vh-sized page (which measures what it's given) from // oscillating. setMeasured(prev => Math.abs(next.height - (prev ?? initialHeight ?? DEFAULT_HEIGHT)) > RESIZE_TOLERANCE ? next.height : prev ) // Width adopts ONCE, from the first report — measured at full column // width, so it is the content's intrinsic span. Tracking width live // would feedback-loop: %-width children reflow narrower every time // the frame shrinks, spiraling toward zero. if (next.width > 0) { setContentWidth(prev => prev ?? next.width) } } window.addEventListener('message', onMessage) return () => window.removeEventListener('message', onMessage) }, [initialHeight, token]) // Resolved once per mount; theme switches remount the transcript anyway. const framedDoc = useMemo(() => { if (doc === null) { return null } const { vars, font } = collectThemeBridge() return withInlineChrome(doc, token, themePrelude(vars, font)) }, [doc, token]) if (!path || failed) { return } const height = measured ?? initialHeight ?? DEFAULT_HEIGHT // Left-aligned in the message flow, like an image: the frame is only as // wide as its content (capped at the column). Fluid pages measure the // full viewport and stay full-bleed. const width = contentWidth !== null ? Math.min(contentWidth, MAX_COLUMN_WIDTH) : undefined return ( {framedDoc === null ? ( ) : (