import type { HostEvent } from "./types"; const READY = "streamlit:componentReady"; const RENDER = "streamlit:render"; const SET_VALUE = "streamlit:setComponentValue"; const SET_HEIGHT = "streamlit:setFrameHeight"; const HOST_RENDER_EVENT = "aios:grid-host-render"; type HostWindow = Window & { __AIOS_STREAMLIT_ARGS__?: Record; }; function post(type: string, extra: Record): void { window.parent.postMessage( { isStreamlitMessage: true, type, ...extra }, "*" ); } export function isStreamlitComponent(): boolean { if (typeof window === "undefined" || window.parent === window) return false; return new URLSearchParams(window.location.search).has("streamlitUrl"); } /** * Minimal Streamlit Components v1 protocol. Keeping the adapter isolated makes * the grid host-neutral and disposable when the surrounding Streamlit shell is. */ export function initializeHostBridge(): void { if (!isStreamlitComponent()) return; window.addEventListener("message", (event: MessageEvent) => { const data = event.data as | { type?: string; args?: Record } | undefined; if (!data || data.type !== RENDER) return; (window as HostWindow).__AIOS_STREAMLIT_ARGS__ = data.args ?? {}; window.dispatchEvent(new CustomEvent(HOST_RENDER_EVENT)); const height = Number(data.args?.height ?? 760); post(SET_HEIGHT, { height: Number.isFinite(height) ? height : 760 }); }); post(READY, { apiVersion: 1 }); } export function readHostArgs(): Record | null { return (window as HostWindow).__AIOS_STREAMLIT_ARGS__ ?? null; } export function subscribeHostRender(listener: () => void): () => void { window.addEventListener(HOST_RENDER_EVENT, listener); return () => window.removeEventListener(HOST_RENDER_EVENT, listener); } /** * ⚠ The component value is an EVENT LOG, not the last event (2026-07-27). * * Streamlit gives a component exactly ONE value slot, and a rerun reads whatever is in it at * that moment. Creating a field emits `field_upsert` and, 420ms later, the insertColumn * autosave emits `view_upsert` — and when the server was still busy with the first rerun, the * second write CLOBBERED the slot before anything read it. The field creation simply vanished: * no error, menu closed, column gone on the next payload. (Measured live 2026-07-27; the same * race sat latent under overlay-field creation, masked by localStorage keeping a local copy.) * * So every emit sends the RECENT WINDOW of events; the host processes each exactly once, * keyed by the event's id (which is why every HostEvent carries one). The window is bounded — * an id-deduped log needs only to be longer than the longest burst, and 24 is far past any * real interaction burst. */ const PENDING: HostEvent[] = []; /** * EXIT wave 1 (X2): where an event goes when there is no Streamlit host. * * ⚠ A REGISTRATION HOOK, NOT AN IMPORT, and the direction is the point. This * module is the STREAMLIT adapter — "disposable when the surrounding Streamlit * shell is". Importing `apiBridge` from here would make the successor path * reachable only THROUGH the doomed module and put a cycle between the two * (apiBridge needs `isStreamlitComponent`). So the successor installs itself: * `main.tsx` calls `installStandaloneBridge()` in standalone only, the embed * never does, and when hostBridge is finally deleted the sink is simply what is * left. Null until installed — an un-installed standalone drops events exactly * as it always did, which is what keeps this change inert in the embed. */ type EventSink = (event: HostEvent) => boolean; let standaloneSink: EventSink | null = null; export function setStandaloneSink(sink: EventSink | null): void { standaloneSink = sink; } export function emitHostEvent(event: HostEvent): boolean { if (!isStreamlitComponent()) return standaloneSink ? standaloneSink(event) : false; PENDING.push(event); if (PENDING.length > 24) PENDING.shift(); post(SET_VALUE, { value: { events: [...PENDING] }, dataType: "json" }); return true; } export function eventId(prefix: string): string { const cryptoId = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`; return `${prefix}:${cryptoId}`; }