From 28a1805cea17be5afc16484b502185c50afbb4d7 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Sun, 3 May 2026 23:12:51 +0200 Subject: [PATCH 1/2] feat(a2ui-playground): fix URI Too Long, add simulation controls, and polish UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix "URI Too Long" error for large demos by serving demo JSONs as static files (demos/*.json) and referencing them via ?demo= instead of inlining base64url payloads in the URL. Reduces render URLs from ~11,000 chars to ~95 chars. - Add simulation controls bar (visible only for known demos): - "Simulated" info indicator with click-to-toggle tooltip explaining pre-recorded data - Speed slider (0.25x–4x) to control streaming replay speed - Fix fetch crash in Lynx for Web worker thread by having render.tsx fetch demo JSONs in the browser context instead of the Lynx worker - Use LAN IP instead of localhost for QR code URLs so phones can reach the dev server - Use messagesUrl for native preview (LynxExplorer) to keep QR codes scannable for all demos - Improve QR section: hide when no render URL, show URL + Copy button for web preview, clarify "Web Preview" vs "Native Preview" labels - Update AI Chat mock response to direct users to the Demos tab - Polish sidebar active state (border outline instead of inset bar) for both Demos and Components pages --- .../genui/a2ui-playground/lynx-src/App.tsx | 24 +- .../genui/a2ui-playground/rsbuild.config.ts | 6 + .../a2ui-playground/src/pages/AIChatPage.tsx | 12 +- .../a2ui-playground/src/pages/DemosPage.tsx | 245 +++++++++++++----- packages/genui/a2ui-playground/src/render.tsx | 26 +- packages/genui/a2ui-playground/src/styles.css | 113 +++++++- .../a2ui-playground/src/utils/renderUrl.ts | 30 ++- 7 files changed, 358 insertions(+), 98 deletions(-) diff --git a/packages/genui/a2ui-playground/lynx-src/App.tsx b/packages/genui/a2ui-playground/lynx-src/App.tsx index c81db5c17d..d8a0bc3400 100644 --- a/packages/genui/a2ui-playground/lynx-src/App.tsx +++ b/packages/genui/a2ui-playground/lynx-src/App.tsx @@ -26,7 +26,7 @@ type ActionMocks = Record; type ResponseMessages = A2uiMessage[]; -const STREAM_MESSAGE_DELAY_MS = 800; +const DEFAULT_STREAM_DELAY_MS = 800; function randomId(prefix: string) { return prefix + Date.now().toString(36) @@ -199,6 +199,17 @@ export function App() { [globalPropsData, initData], ); + // Speed multiplier from URL query (e.g. ?speed=2 → 2x faster). + const streamDelay = useMemo(() => { + const raw = (globalProps as Record | null)?.speed + ?? (rawInitData as Record | null)?.speed; + const speed = typeof raw === 'string' + ? Number(raw) + : (typeof raw === 'number' ? raw : 1); + if (!speed || speed <= 0) return DEFAULT_STREAM_DELAY_MS; + return DEFAULT_STREAM_DELAY_MS / speed; + }, [globalProps, rawInitData]); + // biome-ignore lint/suspicious/noExplicitAny: const clientRef = useRef(null); @@ -206,6 +217,7 @@ export function App() { const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call useEffect(() => { let cancelled = false; @@ -252,9 +264,7 @@ export function App() { if (cancelled) break; // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access client.processor?.processMessages?.([msg]); - await new Promise((resolve) => - setTimeout(resolve, STREAM_MESSAGE_DELAY_MS) - ); + await new Promise((resolve) => setTimeout(resolve, streamDelay)); } })(); @@ -284,9 +294,7 @@ export function App() { if (cancelled) break; // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access client.processor?.processMessages?.([msg]); - await new Promise((resolve) => - setTimeout(resolve, STREAM_MESSAGE_DELAY_MS) - ); + await new Promise((resolve) => setTimeout(resolve, streamDelay)); } }; @@ -309,7 +317,7 @@ export function App() { return () => { cancelled = true; }; - }, [effectiveData]); + }, [effectiveData, streamDelay]); return ( + AI generation is not yet connected. In the meantime, check out the{' '} + Demos{' '} + tab to see pre-recorded A2UI scenarios with simulated streaming — you can + even adjust the playback speed. + + ), }; export function AIChatPage( diff --git a/packages/genui/a2ui-playground/src/pages/DemosPage.tsx b/packages/genui/a2ui-playground/src/pages/DemosPage.tsx index 2b7e7d2899..66affcb7be 100644 --- a/packages/genui/a2ui-playground/src/pages/DemosPage.tsx +++ b/packages/genui/a2ui-playground/src/pages/DemosPage.tsx @@ -91,16 +91,35 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { const [, setRenderQrError] = useState(''); const [lynxDevQrError, setLynxDevQrError] = useState(''); const [lynxDevCopied, setLynxDevCopied] = useState(false); + const [speed, setSpeed] = useState(1); + const [showSimTooltip, setShowSimTooltip] = useState(false); const baseUrl = window.location.href.replace(/#.*$/, ''); const rspeedyDevUrl = useRspeedyDevUrl(); const lynxUrlSeqRef = useRef(0); + // For QR codes, replace localhost/127.0.0.1 with the LAN IP so phones can reach it. + const networkBaseUrl = useMemo(() => { + const u = new URL(baseUrl); + if ( + (u.hostname === 'localhost' || u.hostname === '127.0.0.1') + && rspeedyDevUrl + ) { + try { + u.hostname = new URL(rspeedyDevUrl).hostname; + } catch { /* ignore */ } + } + return u.toString(); + }, [baseUrl, rspeedyDevUrl]); + const currentScenario = useMemo( () => ALL_SCENARIOS.find((s) => s.id === scenarioId) ?? ALL_SCENARIOS[0], [scenarioId], ); + // Whether the current render is a known demo (simulated) vs. custom JSON. + const [isSimulated, setIsSimulated] = useState(true); + const doRender = useCallback( (json: string, scenario: Scenario | undefined) => { setError(''); @@ -113,9 +132,19 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { return; } const actionMocks = scenario?.actionMocks; + // Use short demo ID for known scenarios; inline payload for custom JSON. + const isKnownDemo = ALL_SCENARIOS.some((s) => s.id === scenario?.id); + setIsSimulated(isKnownDemo); const url = buildRenderUrl( - { protocol, demoUrl: DEFAULT_DEMO_URL, messages: parsed, actionMocks }, - baseUrl, + { + protocol, + demoUrl: DEFAULT_DEMO_URL, + messages: parsed, + actionMocks, + demoId: isKnownDemo ? scenario!.id : undefined, + speed, + }, + networkBaseUrl, ); setRenderUrl(url); @@ -124,9 +153,22 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { const seq = ++lynxUrlSeqRef.current; if (rspeedyDevUrl) { const uInline = new URL(rspeedyDevUrl); - uInline.searchParams.set('messages', JSON.stringify(parsed)); - if (actionMocks) { - uInline.searchParams.set('actionMocks', JSON.stringify(actionMocks)); + if (isKnownDemo) { + // Known demo: point to the static JSON served by the rsbuild dev server. + // Native Lynx supports fetch, so App.tsx will load it via messagesUrl. + const demosOrigin = new URL(networkBaseUrl).origin; + uInline.searchParams.set( + 'messagesUrl', + `${demosOrigin}/demos/${scenario!.id}.json`, + ); + } else { + uInline.searchParams.set('messages', JSON.stringify(parsed)); + if (actionMocks) { + uInline.searchParams.set( + 'actionMocks', + JSON.stringify(actionMocks), + ); + } } setLynxDevUrl(uInline.toString()); } else { @@ -174,7 +216,7 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { // "View on Device" QR is scannable. render.html already supports // messagesUrl / actionMocksUrl query params. if (messagesUrlAbs) { - const r = new URL('render.html', baseUrl); + const r = new URL('render.html', networkBaseUrl); r.searchParams.set('protocol', protocol); r.searchParams.set('demoUrl', DEFAULT_DEMO_URL); r.searchParams.set('messagesUrl', messagesUrlAbs); @@ -189,7 +231,7 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { } })(); }, - [baseUrl, protocol, rspeedyDevUrl], + [networkBaseUrl, protocol, rspeedyDevUrl, speed], ); useEffect(() => { @@ -321,6 +363,47 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { ) : null} + {isSimulated + ? ( +
+
+ + {showSimTooltip + ? ( +
+ Pre-recorded messages replayed at simulated speed. No AI + model is running. +
+ ) + : null} +
+
+ + setSpeed(Number(e.target.value))} + /> + {speed}x +
+
+ ) + : null}
{renderUrl ? @@ -335,72 +418,94 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { )}
- {/* QR Code Section */} -
-
-
-
View on Device
-
- {renderUrl - ? ( - - ) - : ( -
- No render -
- )} -
- {lynxDevUrl - ? ( -
-
-
Lynx Dev Bundle
-
- {lynxDevQrError - ? 'QR code unavailable. Open this link with LynxExplorer instead.' - : 'Scan with LynxExplorer to load the rspeedy dev bundle.'} + {/* QR Code Section — only shown when there's a render URL */} + {renderUrl || lynxDevUrl + ? ( +
+ {renderUrl + ? ( +
+
+
Web Preview
+
+ Opens in any mobile browser via Lynx for Web. +
+
+
+ {formatUrlForDisplay(renderUrl)} +
+ +
+
+
-
-
- {formatUrlForDisplay(lynxDevUrl)} + ) + : null} + {lynxDevUrl + ? ( +
+
+
Native Preview
+
+ {lynxDevQrError + ? 'URL too long for QR. Copy the link and open it in LynxExplorer.' + : 'Opens in LynxExplorer for native rendering.'} +
+
+
+ {formatUrlForDisplay(lynxDevUrl)} +
+ +
- +
-
- -
- ) - : null} -
+ ) + : null} +
+ ) + : null}
); diff --git a/packages/genui/a2ui-playground/src/render.tsx b/packages/genui/a2ui-playground/src/render.tsx index 35eee23205..9558226b5b 100644 --- a/packages/genui/a2ui-playground/src/render.tsx +++ b/packages/genui/a2ui-playground/src/render.tsx @@ -54,8 +54,9 @@ function parseInitDataFromQuery(): InitData | null { const messages = params.get('messages'); const actionMocks = params.get('actionMocks'); const actionMocksUrl = params.get('actionMocksUrl'); + const demo = params.get('demo'); - if (!protocol && !messagesUrl && !messages && !demoUrl) { + if (!protocol && !messagesUrl && !messages && !demoUrl && !demo) { return null; } @@ -135,6 +136,29 @@ function Render() { ); const lynxViewRef = useRef(null); + // Known demo: fetch the static JSON in the browser context (where fetch works) + // and pass the resolved messages as initData, avoiding fetch in Lynx's worker thread. + useEffect(() => { + const demo = new URLSearchParams(window.location.search).get('demo'); + if (!demo) return; + let cancelled = false; + void (async () => { + try { + const res = await window.fetch(`./demos/${demo}.json`); + if (!res.ok || cancelled) return; + const messages = (await res.json()) as unknown; + if (!cancelled) { + setInitData((prev) => (prev ? { ...prev, messages } : prev)); + } + } catch { + // ignore — will show empty + } + })(); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { const handleMessage = (e: MessageEvent) => { if (!isInitLynxViewMessage(e.data)) { diff --git a/packages/genui/a2ui-playground/src/styles.css b/packages/genui/a2ui-playground/src/styles.css index 8f07c49e99..3b590379f1 100644 --- a/packages/genui/a2ui-playground/src/styles.css +++ b/packages/genui/a2ui-playground/src/styles.css @@ -354,6 +354,104 @@ a { overflow: auto; } +/* ── Simulation Controls Bar ── */ +.simulationBar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 6px 16px; + border-bottom: 1px solid var(--geist-border); + background: var(--geist-surface); + flex-shrink: 0; + font-size: 12px; +} + +.simInfo { + display: flex; + align-items: center; + gap: 6px; + color: var(--geist-secondary); + position: relative; +} + +.simInfoIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + border: 1px solid var(--geist-border-hover); + font-size: 10px; + font-weight: 600; + font-style: italic; + font-family: Georgia, serif; + color: var(--geist-secondary); + cursor: help; + flex-shrink: 0; +} + +.simInfoToggle { + display: inline-flex; + align-items: center; + gap: 6px; + border: none; + background: none; + padding: 0; + cursor: pointer; + font-size: 12px; + font-family: inherit; +} + +.simInfoLabel { + font-weight: 500; + color: var(--geist-secondary); +} + +.simTooltip { + position: absolute; + top: calc(100% + 4px); + left: 16px; + z-index: 10; + width: max-content; + max-width: min(360px, calc(100vw - 48px)); + padding: 8px 12px; + border-radius: var(--geist-radius-md); + border: 1px solid var(--geist-border); + background: var(--geist-background); + box-shadow: var(--geist-shadow-md); + font-size: 12px; + line-height: 1.5; + color: var(--geist-secondary); +} + +.simSpeed { + display: flex; + align-items: center; + gap: 8px; +} + +.simSpeedLabel { + font-weight: 500; + color: var(--geist-secondary); +} + +.simSpeedSlider { + width: 80px; + height: 4px; + accent-color: var(--geist-foreground); + cursor: pointer; +} + +.simSpeedValue { + min-width: 28px; + text-align: right; + font-family: var(--geist-mono); + font-weight: 500; + color: var(--geist-foreground); +} + /* ── QR Code Section (always visible in preview panel) ── */ .previewQrSection { flex-shrink: 0; @@ -522,16 +620,16 @@ a { display: flex; flex-direction: column; gap: 2px; - padding: 12px 14px; - min-height: 44px; - border: none; + padding: 10px 12px; + min-height: 40px; + border: 1px solid transparent; border-radius: var(--geist-radius-sm); background: transparent; cursor: pointer; text-align: left; transition: background var(--geist-transition), - box-shadow var(--geist-transition), + border-color var(--geist-transition), color var(--geist-transition); width: 100%; } @@ -541,12 +639,11 @@ a { } .scenarioItem.active { + border-color: var(--geist-border-hover); background: var(--geist-surface); - box-shadow: inset 3px 0 0 var(--geist-foreground); } .scenarioItem.active .scenarioName { - font-weight: 600; color: var(--geist-foreground); } @@ -739,7 +836,7 @@ a { display: block; width: 100%; padding: 5px 12px 5px 20px; - border: none; + border: 1px solid transparent; border-radius: var(--geist-radius-sm); background: transparent; cursor: pointer; @@ -759,7 +856,7 @@ a { .compSidebarItem.active { color: var(--geist-foreground); background: var(--geist-surface); - box-shadow: inset 2px 0 0 var(--geist-foreground); + border-color: var(--geist-border-hover); } /* ── Content ── */ diff --git a/packages/genui/a2ui-playground/src/utils/renderUrl.ts b/packages/genui/a2ui-playground/src/utils/renderUrl.ts index 4ea1b9b34f..466358a1fd 100644 --- a/packages/genui/a2ui-playground/src/utils/renderUrl.ts +++ b/packages/genui/a2ui-playground/src/utils/renderUrl.ts @@ -9,23 +9,37 @@ export interface RenderInit { demoUrl: string; messages: unknown; actionMocks?: unknown; + /** When set, use a short `?demo=` param instead of inlining the payload. */ + demoId?: string; + /** Simulation speed multiplier (e.g. 0.5, 1, 2, 4). */ + speed?: number; } export function buildRenderUrl(init: RenderInit, baseUrl: string): string { const url = new URL('render.html', baseUrl); url.searchParams.set('protocol', init.protocol); url.searchParams.set('demoUrl', init.demoUrl); - // Use base64url to avoid URL-encoding overhead for JSON payloads. - url.searchParams.set( - 'messages', - encodeBase64Url(JSON.stringify(init.messages)), - ); - if (init.actionMocks !== undefined) { + if (init.demoId) { + // Known demo: reference static JSON file by ID instead of inlining payload. + url.searchParams.set('demo', init.demoId); + } else { + // Custom JSON: inline the payload as base64url. url.searchParams.set( - 'actionMocks', - encodeBase64Url(JSON.stringify(init.actionMocks)), + 'messages', + encodeBase64Url(JSON.stringify(init.messages)), ); + + if (init.actionMocks !== undefined) { + url.searchParams.set( + 'actionMocks', + encodeBase64Url(JSON.stringify(init.actionMocks)), + ); + } + } + + if (init.speed !== undefined && init.speed !== 1) { + url.searchParams.set('speed', String(init.speed)); } return url.toString(); From 2d5c5e5a3dacbca21f7b678b357dcfd46296aa42 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Sun, 3 May 2026 23:22:50 +0200 Subject: [PATCH 2/2] fix(a2ui-playground): address code review feedback - Track JSON edits so custom changes use inline payload instead of demoId (fixes isKnownDemo override after user edits) - Forward speed param through render.tsx initData/globalProps so web preview honors the slider - Add speed param to native preview URL for LynxExplorer - Clear lynxDevUrl on Clear button to avoid stale QR codes --- .../a2ui-playground/src/pages/DemosPage.tsx | 20 +++++++++++++++---- packages/genui/a2ui-playground/src/render.tsx | 8 ++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/genui/a2ui-playground/src/pages/DemosPage.tsx b/packages/genui/a2ui-playground/src/pages/DemosPage.tsx index 66affcb7be..1915202ad9 100644 --- a/packages/genui/a2ui-playground/src/pages/DemosPage.tsx +++ b/packages/genui/a2ui-playground/src/pages/DemosPage.tsx @@ -93,6 +93,7 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { const [lynxDevCopied, setLynxDevCopied] = useState(false); const [speed, setSpeed] = useState(1); const [showSimTooltip, setShowSimTooltip] = useState(false); + const [jsonEdited, setJsonEdited] = useState(false); const baseUrl = window.location.href.replace(/#.*$/, ''); const rspeedyDevUrl = useRspeedyDevUrl(); @@ -132,8 +133,9 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { return; } const actionMocks = scenario?.actionMocks; - // Use short demo ID for known scenarios; inline payload for custom JSON. - const isKnownDemo = ALL_SCENARIOS.some((s) => s.id === scenario?.id); + // Use short demo ID only when the user hasn't edited the JSON. + const isKnownDemo = !jsonEdited + && ALL_SCENARIOS.some((s) => s.id === scenario?.id); setIsSimulated(isKnownDemo); const url = buildRenderUrl( { @@ -153,6 +155,9 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { const seq = ++lynxUrlSeqRef.current; if (rspeedyDevUrl) { const uInline = new URL(rspeedyDevUrl); + if (speed !== 1) { + uInline.searchParams.set('speed', String(speed)); + } if (isKnownDemo) { // Known demo: point to the static JSON served by the rsbuild dev server. // Native Lynx supports fetch, so App.tsx will load it via messagesUrl. @@ -231,7 +236,7 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { } })(); }, - [networkBaseUrl, protocol, rspeedyDevUrl, speed], + [jsonEdited, networkBaseUrl, protocol, rspeedyDevUrl, speed], ); useEffect(() => { @@ -245,6 +250,7 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { (id: string) => { setScenarioId(id); setError(''); + setJsonEdited(false); const scenario = ALL_SCENARIOS.find((s) => s.id === id); if (scenario) { const json = formatJson(scenario.messages); @@ -261,6 +267,7 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { const handleFillExample = useCallback(() => { setError(''); + setJsonEdited(false); if (currentScenario) { const json = formatJson(currentScenario.messages); setCustomJson(json); @@ -271,8 +278,10 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { const handleClear = useCallback(() => { setCustomJson('[]'); setRenderUrl(''); + setLynxDevUrl(''); setRenderQrError(''); setError(''); + setJsonEdited(false); }, []); return ( @@ -336,7 +345,10 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { className='codeEditor' value={customJson} extensions={jsonExtensions} - onChange={setCustomJson} + onChange={(v) => { + setCustomJson(v); + setJsonEdited(true); + }} theme='dark' basicSetup={{ lineNumbers: true, diff --git a/packages/genui/a2ui-playground/src/render.tsx b/packages/genui/a2ui-playground/src/render.tsx index 9558226b5b..25248aa39b 100644 --- a/packages/genui/a2ui-playground/src/render.tsx +++ b/packages/genui/a2ui-playground/src/render.tsx @@ -19,6 +19,7 @@ interface InitData { actionMocksUrl?: string; actionMocks?: unknown; demoUrl?: string; + speed?: number; } interface InitLynxViewMessage { @@ -62,12 +63,18 @@ function parseInitDataFromQuery(): InitData | null { const protocolValue = protocol === '0.9' ? '0.9' : undefined; + const speedRaw = params.get('speed'); + const speedVal = speedRaw === null ? undefined : Number(speedRaw); + const initData: InitData = { protocol: protocolValue, messagesUrl: messagesUrl ?? undefined, actionMocksUrl: actionMocksUrl ?? undefined, demoUrl: demoUrl ?? undefined, messages: [], // Default to an empty array + speed: speedVal && Number.isFinite(speedVal) && speedVal > 0 + ? speedVal + : undefined, }; if (messages) { @@ -111,6 +118,7 @@ function buildGlobalPropsFromInitData( if (initData.actionMocks !== undefined) { out.actionMocks = initData.actionMocks; } + if (initData.speed !== undefined) out.speed = initData.speed; return Object.keys(out).length > 0 ? out : null; }