419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
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:
|
|
* `<button data-hermes-send="get-price eth">ETH</button>`. */
|
|
export function intentScript(token: string): string {
|
|
return (
|
|
'<script>(function(){var t=' +
|
|
JSON.stringify(token) +
|
|
';function send(p){if(typeof p!=="string"||!p.trim())return false;' +
|
|
'parent.postMessage({type:' +
|
|
JSON.stringify(INTENT_MESSAGE_TYPE) +
|
|
',token:t,prompt:p.slice(0,' +
|
|
String(MAX_INTENT_LENGTH) +
|
|
')},"*");return true}' +
|
|
'window.hermes={send:send};' +
|
|
'addEventListener("click",function(e){var el=e.target&&e.target.closest?' +
|
|
'e.target.closest("[data-hermes-send]"):null;' +
|
|
'if(el)send(el.getAttribute("data-hermes-send")||"")},true)})()</script>'
|
|
)
|
|
}
|
|
|
|
/** 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<string, string> = {
|
|
'--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<string, string>; font: string } {
|
|
const vars: Record<string, string> = {}
|
|
|
|
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<string, string>, font: string): string {
|
|
const tokens = Object.entries(vars)
|
|
.map(([name, value]) => `${name}:${value}`)
|
|
.join(';')
|
|
|
|
const fontRule = font ? `font-family:${font};` : ''
|
|
|
|
return (
|
|
`<style>:root{${tokens}}` +
|
|
`html,body{margin:0;padding:0;background:transparent;color:var(--foreground,inherit);${fontRule}}</style>`
|
|
)
|
|
}
|
|
|
|
/** 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 (
|
|
'<script>(function(){var t=' +
|
|
JSON.stringify(token) +
|
|
';var lastH=0,lastW=0;function post(){var d=document.documentElement;var b=document.body;' +
|
|
'var h=Math.max(d?d.scrollHeight:0,b?b.scrollHeight:0);' +
|
|
'var w=0;if(b){var kids=b.children;var L=Infinity,R=0;for(var i=0;i<kids.length;i++){' +
|
|
'var r=kids[i].getBoundingClientRect();if(r.width===0&&r.height===0)continue;' +
|
|
'if(r.left<L)L=r.left;if(r.right>R)R=r.right}' +
|
|
'if(R>L)w=R-L}' +
|
|
'w=Math.ceil(w);' +
|
|
'if(Math.abs(h-lastH)>1||Math.abs(w-lastW)>1){lastH=h;lastW=w;parent.postMessage({type:' +
|
|
JSON.stringify(SIZE_MESSAGE_TYPE) +
|
|
',token:t,height:h,width:w},"*")}}' +
|
|
'if(typeof ResizeObserver==="function"){var ro=new ResizeObserver(post);' +
|
|
'ro.observe(document.documentElement);if(document.body)ro.observe(document.body)}' +
|
|
'addEventListener("load",post);post()})()</script>'
|
|
)
|
|
}
|
|
|
|
/** Assemble the srcdoc: theme prelude first (so the page's own styles win),
|
|
* then the measuring + intent scripts before `</body>` 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<Record<string, string>>
|
|
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 ? <PreviewAttachment source="explicit-link" target={file} /> : null
|
|
}
|
|
|
|
return <InlineHtmlFrame file={file} initialHeight={directiveFrameHeight(attrs.height)} streaming={streaming} />
|
|
}
|
|
|
|
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<string | null>(null)
|
|
const [failed, setFailed] = useState(false)
|
|
const [measured, setMeasured] = useState<number | null>(null)
|
|
const [contentWidth, setContentWidth] = useState<number | null>(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 <PreviewAttachment source="explicit-link" target={file} />
|
|
}
|
|
|
|
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 (
|
|
<span className="my-2 block w-full max-w-160">
|
|
{framedDoc === null ? (
|
|
<span
|
|
className="block w-full animate-pulse rounded-md bg-[color-mix(in_srgb,currentColor_4%,transparent)]"
|
|
style={{ height }}
|
|
/>
|
|
) : (
|
|
<span
|
|
className="relative block max-w-full transition-[height] duration-200"
|
|
style={{ height, width: width ?? '100%' }}
|
|
>
|
|
<iframe
|
|
className="absolute inset-0 size-full border-0 bg-transparent"
|
|
loading="lazy"
|
|
sandbox="allow-scripts"
|
|
srcDoc={framedDoc}
|
|
style={{ colorScheme: isDark ? 'dark' : 'light' }}
|
|
title={file}
|
|
/>
|
|
</span>
|
|
)}
|
|
</span>
|
|
)
|
|
}
|