From 3ce2fbceb4725a54170b0384b8e5db61d7bca370 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Tue, 18 Aug 2026 00:05:51 +0530 Subject: [PATCH 01/11] fix: add paste to devtools --- packages/devtools/README.md | 2 + packages/devtools/package.json | 7 + packages/devtools/src/OpenUIDevtools.test.ts | 227 +++++++++- packages/devtools/src/OpenUIDevtools.tsx | 145 ++++++- .../devtools/src/ReactLangStreamEventRow.tsx | 53 ++- packages/devtools/src/libraryRegistry.ts | 49 +++ packages/devtools/src/paste/HelpDialog.tsx | 105 +++++ packages/devtools/src/paste/PasteModal.tsx | 127 ++++++ packages/devtools/src/paste/PasteUI.tsx | 339 +++++++++++++++ packages/devtools/src/paste/StreamToolbar.tsx | 154 +++++++ packages/devtools/src/paste/chunker.ts | 51 +++ packages/devtools/src/paste/eject.ts | 53 +++ packages/devtools/src/paste/index.ts | 3 + packages/devtools/src/paste/mockTools.ts | 31 ++ .../devtools/src/paste/panels/JsonPanel.tsx | 40 ++ .../devtools/src/paste/panels/RenderPanel.tsx | 132 ++++++ .../src/paste/panels/StreamTimeline.tsx | 93 ++++ .../devtools/src/paste/panels/TreePanel.tsx | 119 ++++++ .../src/paste/panels/ValidationPanel.tsx | 75 ++++ packages/devtools/src/paste/parse.ts | 72 ++++ packages/devtools/src/paste/styles.ts | 398 ++++++++++++++++++ packages/devtools/src/paste/types.ts | 60 +++ packages/devtools/src/paste/usePlayback.ts | 262 ++++++++++++ packages/devtools/src/paste/useReactLang.ts | 37 ++ packages/devtools/src/paste/useValidation.ts | 27 ++ packages/lang-core/src/index.ts | 2 + packages/lang-core/src/version.ts | 7 + packages/react-lang/src/exports.ts | 1 + packages/react-lang/src/library.test.ts | 89 ++++ packages/react-lang/src/library.ts | 7 +- packages/react-lang/src/publishLibrary.ts | 55 +++ pnpm-lock.yaml | 3 + 32 files changed, 2810 insertions(+), 15 deletions(-) create mode 100644 packages/devtools/src/libraryRegistry.ts create mode 100644 packages/devtools/src/paste/HelpDialog.tsx create mode 100644 packages/devtools/src/paste/PasteModal.tsx create mode 100644 packages/devtools/src/paste/PasteUI.tsx create mode 100644 packages/devtools/src/paste/StreamToolbar.tsx create mode 100644 packages/devtools/src/paste/chunker.ts create mode 100644 packages/devtools/src/paste/eject.ts create mode 100644 packages/devtools/src/paste/index.ts create mode 100644 packages/devtools/src/paste/mockTools.ts create mode 100644 packages/devtools/src/paste/panels/JsonPanel.tsx create mode 100644 packages/devtools/src/paste/panels/RenderPanel.tsx create mode 100644 packages/devtools/src/paste/panels/StreamTimeline.tsx create mode 100644 packages/devtools/src/paste/panels/TreePanel.tsx create mode 100644 packages/devtools/src/paste/panels/ValidationPanel.tsx create mode 100644 packages/devtools/src/paste/parse.ts create mode 100644 packages/devtools/src/paste/styles.ts create mode 100644 packages/devtools/src/paste/types.ts create mode 100644 packages/devtools/src/paste/usePlayback.ts create mode 100644 packages/devtools/src/paste/useReactLang.ts create mode 100644 packages/devtools/src/paste/useValidation.ts create mode 100644 packages/lang-core/src/version.ts create mode 100644 packages/react-lang/src/library.test.ts create mode 100644 packages/react-lang/src/publishLibrary.ts diff --git a/packages/devtools/README.md b/packages/devtools/README.md index dfdb8aec7..32c6ca3e4 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -21,6 +21,8 @@ The widget renders nothing in production builds (`NODE_ENV === "production"`) un `@openuidev/react-lang` ships with this package and auto-mounts the widget in development — no manual `` needed. Mounting it manually still works (e.g. to customize props): only one instance ever renders, and a manually mounted instance takes precedence over the auto-mounted one. +In development, `createLibrary()` registers the live library with the widget. A row in the drawer opens **OpenUI Paste** — an editor against that library (host CSS included), with Render / Validation / Tree / JSON / Stream panels and simulated stream playback. Eject moves the same view into a separate window. + ## Props | Prop | Default | Description | diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 468753a1b..5935fa88f 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -41,9 +41,15 @@ }, "peerDependencies": { "@openuidev/observability": "workspace:^", + "@openuidev/react-lang": "workspace:^", "react": "catalog:", "react-dom": "catalog:" }, + "peerDependenciesMeta": { + "@openuidev/react-lang": { + "optional": true + } + }, "keywords": [ "openui", "devtools", @@ -64,6 +70,7 @@ "author": "engineering@thesys.dev", "devDependencies": { "@openuidev/observability": "workspace:^", + "@openuidev/react-lang": "workspace:^", "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", diff --git a/packages/devtools/src/OpenUIDevtools.test.ts b/packages/devtools/src/OpenUIDevtools.test.ts index 0f4a4bf61..c8fe670ca 100644 --- a/packages/devtools/src/OpenUIDevtools.test.ts +++ b/packages/devtools/src/OpenUIDevtools.test.ts @@ -2,17 +2,77 @@ import { observability, toErrorInfo } from "@openuidev/observability"; import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { OpenUIDevtools, type OpenUIDevtoolsProps } from "./index"; -// React's act() requires this flag to flush effects/state synchronously in tests. -(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +vi.mock("@openuidev/react-lang", async () => { + const { createElement: el } = await import("react"); + const parse = (src: string) => ({ + root: /\broot\s*=/.test(src) + ? { type: "element" as const, typeName: "Card", props: {}, partial: false } + : null, + meta: { + incomplete: false, + unresolved: [] as string[], + orphaned: [] as string[], + statementCount: src.trim() ? 1 : 0, + errors: [] as unknown[], + }, + }); + return { + LANG_CORE_VERSION: "9.9.9", + Renderer: (props: { response: string | null }) => + el("div", { "data-testid": "openui-renderer" }, props.response ?? ""), + createParser: () => ({ parse }), + createStreamingParser: () => { + let buf = ""; + return { + push: (chunk: string) => { + buf += chunk; + return parse(buf); + }, + getResult: () => parse(buf), + }; + }, + }; +}); + +const LIBRARIES_KEY = Symbol.for("openui.devtools.libraries"); + +function seedLibrary(): void { + ( + globalThis as { + [LIBRARIES_KEY]?: { + key: string; + library: { + root: string; + components: Record; + toJSONSchema: () => unknown; + }; + }[]; + } + )[LIBRARIES_KEY] = [ + { + key: "Card", + library: { + root: "Card", + components: { Card: {} }, + toJSONSchema: () => ({ $defs: { Card: { type: "object", properties: {} } } }), + }, + }, + ]; +} + +function clearLibraries(): void { + delete (globalThis as { [LIBRARIES_KEY]?: unknown })[LIBRARIES_KEY]; +} let container: HTMLDivElement; let root: Root; beforeEach(() => { window.localStorage.clear(); + clearLibraries(); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -45,6 +105,11 @@ function buttonByText(text: string): HTMLButtonElement | undefined { HTMLButtonElement | undefined; } +function openPasteButton(): HTMLButtonElement | undefined { + return container.querySelector('button[aria-label="Open OpenUI Paste"]') ?? + undefined; +} + function checkboxLabeled(text: string): HTMLInputElement { const label = [...container.querySelectorAll("label")].find((el) => el.textContent?.includes(text), @@ -265,6 +330,32 @@ describe("OpenUIDevtools", () => { expect(toggle().textContent).toContain("1"); }); + it("opens a stream response in OpenUI Paste", () => { + seedLibrary(); + render({ enabled: true, errorsOnly: false }); + act(() => + observability.info({ + kind: "react-lang:stream", + id: "stream-1", + phase: "settled", + response: 'root = Card("from stream")', + parser: { statementCount: 1, orphaned: [] }, + errors: [], + }), + ); + + click( + container.querySelector( + 'button[aria-label="Toggle OpenUI Lang stream details"]', + )!, + ); + click(container.querySelector('button[aria-label="Open in Paste"]')!); + + const editor = container.querySelector('textarea[aria-label="OpenUI Lang"]'); + expect(container.querySelector('[aria-label="OpenUI Paste"]')).not.toBeNull(); + expect(editor?.value).toBe('root = Card("from stream")'); + }); + it("hides provisional errors while the stream is still running", () => { render({ enabled: true, errorsOnly: false }); @@ -383,4 +474,134 @@ describe("OpenUIDevtools", () => { expect(container.textContent).not.toContain("OpenUI Lang stream"); expect(container.textContent).toContain("No events captured yet."); }); + + it("disables OpenUI Paste until a library is registered", () => { + render({ enabled: true }); + expect(openPasteButton()?.disabled).toBe(true); + }); + + it("opens OpenUI Paste from a late-mounted registry entry", () => { + seedLibrary(); + render({ enabled: true }); + const paste = openPasteButton(); + expect(paste?.disabled).toBe(false); + click(paste!); + expect(container.querySelector('[aria-label="OpenUI Paste"]')).not.toBeNull(); + expect(container.querySelector('textarea[aria-label="OpenUI Lang"]')).not.toBeNull(); + }); + + it("shows paste panels and stream controls", () => { + seedLibrary(); + render({ enabled: true }); + click(openPasteButton()!); + expect(container.querySelector('[aria-label="Playback controls"]')).not.toBeNull(); + expect(container.querySelector('button[aria-label="Stream"]')).not.toBeNull(); + const tabs = container.querySelector('[role="tablist"]')?.textContent ?? ""; + expect(tabs).toContain("Render"); + expect(tabs).toContain("Validation"); + expect(tabs).toContain("Tree"); + expect(tabs).toContain("JSON"); + expect(tabs).toContain("Stream"); + }); + + it("shows the installed lang-core version in OpenUI Paste", async () => { + seedLibrary(); + render({ enabled: true }); + click(openPasteButton()!); + await act(async () => { + await Promise.resolve(); + }); + expect(container.textContent).toContain("lang-core 9.9.9"); + }); + + it("switches to the validation panel", async () => { + seedLibrary(); + render({ enabled: true }); + click(openPasteButton()!); + await act(async () => { + await Promise.resolve(); + }); + const validation = [...container.querySelectorAll('[role="tab"]')].find((tab) => + tab.textContent?.startsWith("Validation"), + ); + click(validation!); + expect(container.textContent).toContain("Paste some OpenUI Lang to validate it."); + }); + + it("does not list library registration pings as events", () => { + render({ enabled: true, errorsOnly: false }); + act(() => + observability.info({ + kind: "react-lang:library", + root: "Card", + components: ["Card"], + message: "Library registered (root: Card)", + }), + ); + click(toggle()); + expect(container.textContent).not.toContain("Library registered"); + expect(container.textContent).toContain("No events captured yet."); + }); + + it("ejects OpenUI Paste into a separate window", () => { + seedLibrary(); + const popupDoc = document.implementation.createHTMLDocument("paste"); + const popup = { + document: popupDoc, + focus: vi.fn(), + close: vi.fn(), + closed: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + const open = vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window); + + render({ enabled: true }); + click(openPasteButton()!); + click(container.querySelector('button[aria-label="Open OpenUI Paste in a new window"]')!); + + expect(open).toHaveBeenCalled(); + expect(container.querySelector('[aria-label="OpenUI Paste"]')).toBeNull(); + expect(popupDoc.getElementById("openui-paste-root")).not.toBeNull(); + expect(popupDoc.body.textContent).toContain("OpenUI Paste"); + open.mockRestore(); + }); + + it("focuses the ejected window when OpenUI Paste is clicked again", () => { + seedLibrary(); + const popupDoc = document.implementation.createHTMLDocument("paste"); + const popup = { + document: popupDoc, + focus: vi.fn(), + close: vi.fn(), + closed: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + const open = vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window); + + render({ enabled: true }); + click(openPasteButton()!); + click(container.querySelector('button[aria-label="Open OpenUI Paste in a new window"]')!); + popup.focus.mockClear(); + + click(openPasteButton()!); + + expect(popup.focus).toHaveBeenCalled(); + expect(container.querySelector('[aria-label="OpenUI Paste"]')).toBeNull(); + open.mockRestore(); + }); + + it("stays in the modal when the popup is blocked", () => { + seedLibrary(); + const open = vi.spyOn(window, "open").mockReturnValue(null); + + render({ enabled: true }); + click(openPasteButton()!); + click(container.querySelector('button[aria-label="Open OpenUI Paste in a new window"]')!); + + expect(container.querySelector('[aria-label="OpenUI Paste"]')).not.toBeNull(); + expect(container.textContent).toContain("Allow popups for this origin"); + open.mockRestore(); + }); }); diff --git a/packages/devtools/src/OpenUIDevtools.tsx b/packages/devtools/src/OpenUIDevtools.tsx index 1b673d92e..11d2877f6 100644 --- a/packages/devtools/src/OpenUIDevtools.tsx +++ b/packages/devtools/src/OpenUIDevtools.tsx @@ -7,10 +7,13 @@ import { } from "@openuidev/observability"; import { ArrowLeft, Check, Copy, WrapText, X } from "lucide-react"; import { useEffect, useState, type CSSProperties } from "react"; +import { createPortal } from "react-dom"; +import { addOrReplaceEvent } from "./eventBuffer"; +import { isLibraryEvent, useRegisteredLibraries } from "./libraryRegistry"; +import { openPasteWindow, pasteMountNode, PasteModal, PasteUI } from "./paste"; import { getQuotaError, QuotaErrorRow } from "./QuotaErrorRow"; import { getReactLangStreamDetail, ReactLangStreamEventRow } from "./ReactLangStreamEventRow"; import { ShiroLogo } from "./ShiroLogo"; -import { addOrReplaceEvent } from "./eventBuffer"; import { useDevtoolsSingleton } from "./singleton"; import { useDevtoolsConfig } from "./useDevtoolsConfig"; @@ -60,6 +63,11 @@ export function OpenUIDevtools({ const [selected, setSelected] = useState(null); const [wrapStack, setWrapStack] = useState(false); const [copied, setCopied] = useState(false); + const [pasteOpen, setPasteOpen] = useState(false); + const [popup, setPopup] = useState(null); + const [popupBlocked, setPopupBlocked] = useState(false); + const [code, setCode] = useState(""); + const libraries = useRegisteredLibraries(); const { config, setConfig, configRef } = useDevtoolsConfig({ autoOpen: autoOpenOnError, onlyErrors: errorsOnly, @@ -70,22 +78,31 @@ export function OpenUIDevtools({ useEffect(() => { if (!isEnabled) return; return observability.listenAll((event) => { + if (isLibraryEvent(event)) return; setEvents((prev) => addOrReplaceEvent(prev, event, maxEvents)); if (event.level === "error" && configRef.current.autoOpen) setOpen(true); }); }, [isEnabled, maxEvents, configRef]); - // Escape steps back: stack view → list, list → closed. + // Escape steps back: paste → stack view → list → closed. useEffect(() => { - if (!open) return; + if (!open && !pasteOpen) return; const onKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; - if (selected) setSelected(null); + if (pasteOpen) setPasteOpen(false); + else if (selected) setSelected(null); else setOpen(false); }; document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); - }, [open, selected]); + }, [open, selected, pasteOpen]); + + useEffect(() => { + if (!popup) return; + const onGone = () => setPopup(null); + popup.addEventListener("pagehide", onGone); + return () => popup.removeEventListener("pagehide", onGone); + }, [popup]); if (!isEnabled || !isSingleton) return null; @@ -115,6 +132,49 @@ export function OpenUIDevtools({ const selectedStack = selected ? (getErrorInfo(selected)?.stack ?? "") : ""; + const closePaste = () => { + if (popup) { + popup.close(); + setPopup(null); + } + setPasteOpen(false); + setPopupBlocked(false); + }; + + const ejectPaste = () => { + const next = openPasteWindow(); + if (!next) { + setPopupBlocked(true); + return; + } + setPopupBlocked(false); + setPopup(next); + setPasteOpen(false); + }; + + const openPaste = () => { + setPopupBlocked(false); + if (popup && !popup.closed) { + popup.focus(); + return; + } + if (popup) setPopup(null); + setPasteOpen(true); + }; + + const paste = ( + + ); + const popupRoot = popup ? pasteMountNode(popup) : null; + return ( <>
@@ -221,6 +281,31 @@ export function OpenUIDevtools({ Errors only
+
+ + {libraries.length === 0 + ? "Paste previews OpenUI Lang with this app’s components once a library is registered." + : "Paste OpenUI Lang to preview it with this app’s components."} + + +
{visibleEvents.length === 0 ? (
No events captured yet.
@@ -234,7 +319,18 @@ export function OpenUIDevtools({ if (quotaError) return ; const stream = getReactLangStreamDetail(event); if (stream) { - return ; + return ( + 0} + onOpenInPaste={(response) => { + setCode(response); + openPaste(); + }} + /> + ); } const error = getErrorInfo(event); @@ -284,6 +380,12 @@ export function OpenUIDevtools({ )}
+ {popupRoot ? createPortal(paste, popupRoot) : null} + {!popup ? ( + setPasteOpen(false)}> + {paste} + + ) : null} ); } @@ -480,6 +582,10 @@ const styles = { borderColor: "#18181b", color: "#ffffff", }, + textButtonDisabled: { + opacity: 0.45, + cursor: "not-allowed", + }, iconButton: { display: "inline-flex", alignItems: "center", @@ -509,6 +615,33 @@ const styles = { cursor: "pointer", accentColor: "#18181b", }, + pasteRow: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + padding: "10px 16px", + borderBottom: "1px solid #f4f4f5", + }, + pasteHint: { + color: "#52525b", + fontSize: 12, + lineHeight: 1.4, + minWidth: 0, + }, + pasteLink: { + flexShrink: 0, + border: "none", + background: "transparent", + color: "#18181b", + cursor: "pointer", + fontFamily: FONT, + fontSize: 12, + fontWeight: 600, + padding: 0, + textDecoration: "underline", + textUnderlineOffset: 2, + }, list: { overflowY: "auto", padding: 12, diff --git a/packages/devtools/src/ReactLangStreamEventRow.tsx b/packages/devtools/src/ReactLangStreamEventRow.tsx index 50f234b05..00cc2334d 100644 --- a/packages/devtools/src/ReactLangStreamEventRow.tsx +++ b/packages/devtools/src/ReactLangStreamEventRow.tsx @@ -1,5 +1,5 @@ import { type ObservabilityEvent } from "@openuidev/observability"; -import { Check, ChevronDown, ChevronRight, Copy } from "lucide-react"; +import { Check, ChevronDown, ChevronRight, ClipboardPaste, Copy } from "lucide-react"; import { useState, type CSSProperties } from "react"; export interface ReactLangStreamDetail { @@ -49,9 +49,13 @@ export function getReactLangStreamDetail(event: ObservabilityEvent): ReactLangSt export function ReactLangStreamEventRow({ event, stream, + onOpenInPaste, + canOpenInPaste = false, }: { event: ObservabilityEvent; stream: ReactLangStreamDetail; + onOpenInPaste?: (response: string) => void; + canOpenInPaste?: boolean; }) { const [expanded, setExpanded] = useState(false); const [responseCopied, setResponseCopied] = useState(false); @@ -78,6 +82,8 @@ export function ReactLangStreamEventRow({ .catch(() => {}); }; + const openInPasteDisabled = isStreaming || !stream.response || !canOpenInPaste; + return (
+
+ + +
{stream.response || "(empty response)"}
@@ -298,6 +331,12 @@ const styles = { justifyContent: "space-between", gap: 8, }, + responseActions: { + display: "flex", + alignItems: "center", + gap: 10, + flexShrink: 0, + }, copyResponseButton: { display: "inline-flex", alignItems: "center", @@ -310,6 +349,10 @@ const styles = { fontSize: 11, padding: 0, }, + copyResponseButtonDisabled: { + opacity: 0.45, + cursor: "not-allowed", + }, responseCode: { maxHeight: 260, overflow: "auto", diff --git a/packages/devtools/src/libraryRegistry.ts b/packages/devtools/src/libraryRegistry.ts new file mode 100644 index 000000000..03cef6176 --- /dev/null +++ b/packages/devtools/src/libraryRegistry.ts @@ -0,0 +1,49 @@ +import { observability, type ObservabilityEvent } from "@openuidev/observability"; +import { useEffect, useState } from "react"; + +/** + * Shared with `@openuidev/react-lang` via `Symbol.for`. Not a public API. + * Keep the string in sync with `packages/react-lang/src/publishLibrary.ts`. + */ +const DEVTOOLS_LIBRARIES_KEY = Symbol.for("openui.devtools.libraries"); + +export const LIBRARY_EVENT_KIND = "react-lang:library"; + +/** Structural slice of a `createLibrary()` result — enough to label, parse, and render. */ +export interface PasteLibrary { + id?: string; + root?: string; + components: Record; + toJSONSchema?: () => unknown; +} + +export interface RegisteredLibrary { + key: string; + library: PasteLibrary; +} + +interface RegistryStore { + [DEVTOOLS_LIBRARIES_KEY]?: RegisteredLibrary[]; +} + +export function isLibraryEvent(event: ObservabilityEvent): boolean { + return event.detail.kind === LIBRARY_EVENT_KIND; +} + +export function readRegisteredLibraries(): RegisteredLibrary[] { + return (globalThis as RegistryStore)[DEVTOOLS_LIBRARIES_KEY] ?? []; +} + +/** Live `createLibrary()` results, seeded from the stash and refreshed on ping. */ +export function useRegisteredLibraries(): RegisteredLibrary[] { + const [libraries, setLibraries] = useState(readRegisteredLibraries); + + useEffect(() => { + setLibraries(readRegisteredLibraries()); + return observability.listenAll((event) => { + if (isLibraryEvent(event)) setLibraries(readRegisteredLibraries()); + }); + }, []); + + return libraries; +} diff --git a/packages/devtools/src/paste/HelpDialog.tsx b/packages/devtools/src/paste/HelpDialog.tsx new file mode 100644 index 000000000..47d7056e9 --- /dev/null +++ b/packages/devtools/src/paste/HelpDialog.tsx @@ -0,0 +1,105 @@ +import { HelpCircle, X } from "lucide-react"; +import { useEffect, useState, type CSSProperties } from "react"; +import { pasteStyles as s } from "./styles"; + +export function HelpDialog() { + const [open, setOpen] = useState(false); + + useEffect(() => { + if (!open) return; + const onKey = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.stopPropagation(); + event.preventDefault(); + setOpen(false); + }; + document.addEventListener("keydown", onKey, true); + return () => document.removeEventListener("keydown", onKey, true); + }, [open]); + + return ( + <> + + {open ? ( +
setOpen(false)} role="presentation"> +
event.stopPropagation()} + > +
+ How to use OpenUI Paste + +
+
+

+ Paste OpenUI Lang on the left. The panels on the right show what the host app’s + parser and renderer make of it — your real createLibrary() components + and CSS, using the app’s installed lang-core (shown in the header). +

+

Panels

+
    +
  • + Render: live output. Query() / Mutation(){" "} + resolve with mocked data. +
  • +
  • + Validation: parse errors grouped by code, plus unresolved refs + and orphaned statements. +
  • +
  • + Tree: the parsed element tree, state, queries, and mutations. +
  • +
  • + JSON: the raw parse result. +
  • +
  • + Stream: a per-chunk trace of simulated LLM playback. +
  • +
+

Streaming

+

+ Stream replays the editor as if a model were emitting it. LLM-like + chunking adds jitter (reproducible via Seed). Pause and{" "} + Step chunk by chunk. +

+
+
+
+ ) : null} + + ); +} + +const helpButton: CSSProperties = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 26, + height: 26, + border: "1px solid #e4e4e7", + borderRadius: 8, + background: "#ffffff", + color: "#71717a", + cursor: "pointer", + padding: 0, +}; + +const heading: CSSProperties = { + fontSize: 13, + fontWeight: 700, + margin: "12px 0 6px", + color: "#18181b", +}; diff --git a/packages/devtools/src/paste/PasteModal.tsx b/packages/devtools/src/paste/PasteModal.tsx new file mode 100644 index 000000000..cd5ba4abc --- /dev/null +++ b/packages/devtools/src/paste/PasteModal.tsx @@ -0,0 +1,127 @@ +import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; + +const DURATION_MS = 200; +const EASE = "cubic-bezier(0.16, 1, 0.3, 1)"; + +export function PasteModal({ + open, + children, + onClose, +}: { + open: boolean; + children: ReactNode; + onClose: () => void; +}) { + const reduceMotion = usePrefersReducedMotion(); + const { present, shown } = usePresence(open, DURATION_MS, reduceMotion); + + if (!present) return null; + + return ( +
+
+
event.stopPropagation()} + > + {children} +
+
+ ); +} + +function usePresence(open: boolean, duration: number, instant: boolean) { + const [present, setPresent] = useState(open); + const [shown, setShown] = useState(false); + + if (open && !present) setPresent(true); + + useEffect(() => { + if (open) { + if (instant) { + setShown(true); + return; + } + const id = requestAnimationFrame(() => setShown(true)); + return () => cancelAnimationFrame(id); + } + + setShown(false); + if (!present) return; + if (instant) { + setPresent(false); + return; + } + const timeout = window.setTimeout(() => setPresent(false), duration); + return () => window.clearTimeout(timeout); + }, [open, duration, instant, present]); + + return { present, shown }; +} + +function usePrefersReducedMotion(): boolean { + const [reduced, setReduced] = useState(false); + + useEffect(() => { + const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)"); + if (!mq) return; + const sync = () => setReduced(mq.matches); + sync(); + mq.addEventListener("change", sync); + return () => mq.removeEventListener("change", sync); + }, []); + + return reduced; +} + +const styles = { + shell: { + position: "fixed", + inset: 0, + zIndex: 2147483647, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: 24, + }, + backdrop: { + position: "absolute", + inset: 0, + background: "rgba(24, 24, 27, 0.4)", + }, + dialog: { + position: "relative", + width: "min(1280px, calc(100vw - 32px))", + height: "min(860px, calc(100vh - 32px))", + display: "flex", + flexDirection: "column", + overflow: "hidden", + border: "1px solid #e4e4e7", + borderRadius: 16, + background: "#ffffff", + boxShadow: "0 16px 48px rgba(24, 24, 27, 0.18)", + }, +} satisfies Record; diff --git a/packages/devtools/src/paste/PasteUI.tsx b/packages/devtools/src/paste/PasteUI.tsx new file mode 100644 index 000000000..ccb5f3433 --- /dev/null +++ b/packages/devtools/src/paste/PasteUI.tsx @@ -0,0 +1,339 @@ +import { Maximize2, X } from "lucide-react"; +import { Component, useMemo, useState, type CSSProperties, type ReactNode } from "react"; +import { type RegisteredLibrary } from "../libraryRegistry"; +import type { ChunkStrategy } from "./chunker"; +import { HelpDialog } from "./HelpDialog"; +import { JsonPanel } from "./panels/JsonPanel"; +import { RenderPanel } from "./panels/RenderPanel"; +import { StreamTimeline } from "./panels/StreamTimeline"; +import { TreePanel } from "./panels/TreePanel"; +import { ValidationPanel } from "./panels/ValidationPanel"; +import { librarySchema } from "./parse"; +import { FONT, MONO, pasteStyles as paste } from "./styles"; +import { StreamToolbar, type StreamSettings } from "./StreamToolbar"; +import { usePlayback } from "./usePlayback"; +import { useReactLang } from "./useReactLang"; +import { useValidation } from "./useValidation"; + +const CHAR_STRATEGY_LIMIT = 50 * 1024; + +type Tab = "render" | "validation" | "tree" | "json" | "stream"; + +const TABS: { id: Tab; label: string }[] = [ + { id: "render", label: "Render" }, + { id: "validation", label: "Validation" }, + { id: "tree", label: "Tree" }, + { id: "json", label: "JSON" }, + { id: "stream", label: "Stream" }, +]; + +class PasteErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { + constructor(props: { children: ReactNode }) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + override componentDidUpdate(prevProps: { children: ReactNode }) { + if (this.state.error && prevProps.children !== this.props.children) { + this.setState({ error: null }); + } + } + + override render() { + if (this.state.error) { + return
{this.state.error.message}
; + } + return this.props.children; + } +} + +export interface PasteUIProps { + libraries: RegisteredLibrary[]; + code: string; + onCodeChange: (code: string) => void; + ejected?: boolean; + onEject?: () => void; + onClose: () => void; + popupBlocked?: boolean; +} + +function pickLibrary(libraries: RegisteredLibrary[], code: string): RegisteredLibrary | undefined { + const match = /^\s*root\s*=\s*([A-Za-z_][\w]*)/m.exec(code); + if (match?.[1]) { + const byRoot = libraries.find((entry) => entry.library.root === match[1]); + if (byRoot) return byRoot; + } + return libraries[0]; +} + +export function PasteUI({ + libraries, + code, + onCodeChange, + ejected = false, + onEject, + onClose, + popupBlocked = false, +}: PasteUIProps) { + const selected = pickLibrary(libraries, code); + const lang = useReactLang(); + const schema = useMemo(() => librarySchema(selected?.library), [selected]); + const rootName = selected?.library.root; + const outcome = useValidation(code, lang, schema, rootName); + const playback = usePlayback(code, lang, schema, rootName); + const [tab, setTab] = useState("render"); + const [strategy, setStrategy] = useState("llm"); + const [seed, setSeed] = useState(42); + const settings: StreamSettings = { + strategy, + onStrategyChange: setStrategy, + seed, + onSeedChange: setSeed, + }; + + const playbackActive = playback.state.status === "playing" || playback.state.status === "paused"; + const isStreaming = playback.state.status === "playing"; + const displayed = + playbackActive || playback.state.status === "done" + ? { result: playback.state.result, fatal: playback.state.fatal } + : outcome; + const renderedCode = playbackActive ? playback.state.prefix : code; + const streamDisabled = !lang || !code.trim() || schema == null; + + const changeCode = (next: string) => { + if (playback.state.status !== "idle") playback.reset(); + onCodeChange(next); + }; + + return ( +
+
+
+ OpenUI Paste + {lang?.langCoreVersion ? ( + + lang-core {lang.langCoreVersion} + + ) : null} +
+
+ + {!ejected && onEject ? ( + + ) : null} + +
+
+ {popupBlocked ? ( +
Allow popups for this origin to eject OpenUI Paste.
+ ) : null} + CHAR_STRATEGY_LIMIT} + disabled={streamDisabled} + /> +
+
+