File size: 4,373 Bytes
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
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<string, unknown>;
};

function post(type: string, extra: Record<string, unknown>): 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<string, unknown> }
      | 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<string, unknown> | 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}`;
}