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 `