diff --git a/packages/genui/a2ui-playground/src/demos.ts b/packages/genui/a2ui-playground/src/demos.ts index 9bde9966c9..4859a14796 100644 --- a/packages/genui/a2ui-playground/src/demos.ts +++ b/packages/genui/a2ui-playground/src/demos.ts @@ -53,6 +53,28 @@ function tagsFromMessages(messages: unknown): string[] { return Array.from(out).sort((a, b) => a.localeCompare(b)); } +/** + * Extract new component names introduced by each message (in order). + * Returns an array parallel to the messages array: each entry is + * the list of component names that appear for the first time in that message. + */ +export function componentsByMessage(messages: unknown): string[][] { + if (!Array.isArray(messages)) return []; + const seen = new Set(); + return messages.map((msg) => { + const msgComponents = new Set(); + collectComponentNamesFromMessages(msg, msgComponents); + const newOnes: string[] = []; + for (const name of msgComponents) { + if (!seen.has(name)) { + seen.add(name); + newOnes.push(name); + } + } + return newOnes; + }); +} + export interface StaticDemo { id: string; title: string; diff --git a/packages/genui/a2ui-playground/src/pages/DemosPage.tsx b/packages/genui/a2ui-playground/src/pages/DemosPage.tsx index 9cb265e1e6..e0451f6629 100644 --- a/packages/genui/a2ui-playground/src/pages/DemosPage.tsx +++ b/packages/genui/a2ui-playground/src/pages/DemosPage.tsx @@ -5,10 +5,13 @@ import { json } from '@codemirror/lang-json'; import CodeMirror from '@uiw/react-codemirror'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Chip } from '../components/Chip.js'; import { MobilePreview } from '../components/MobilePreview.js'; import { QrCode } from '../components/QrCode.js'; -import { DYNAMIC_PRESETS, STATIC_DEMOS } from '../demos.js'; +import { + DYNAMIC_PRESETS, + STATIC_DEMOS, + componentsByMessage, +} from '../demos.js'; import { DEFAULT_DEMO_URL } from '../utils/demoUrl.js'; import type { ProtocolVersion } from '../utils/protocol.js'; import { buildRenderUrl } from '../utils/renderUrl.js'; @@ -94,7 +97,12 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { const [speed, setSpeed] = useState(1); const [showSimTooltip, setShowSimTooltip] = useState(false); const [jsonEdited, setJsonEdited] = useState(false); - const [previewMode, setPreviewMode] = useState<'phone' | 'full'>('phone'); + const [previewMode, setPreviewMode] = useState<'phone' | 'full'>( + () => window.innerWidth <= 980 ? 'full' : 'phone', + ); + const [fullscreen, setFullscreen] = useState(false); + const [liveComponents, setLiveComponents] = useState([]); + const liveTimersRef = useRef[]>([]); const baseUrl = window.location.href.replace(/#.*$/, ''); const rspeedyDevUrl = useRspeedyDevUrl(); @@ -151,6 +159,28 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { ); setRenderUrl(url); + // Live component stack: reveal component names as they would appear + // during streaming, synced with the replay speed. + for (const t of liveTimersRef.current) clearTimeout(t); + liveTimersRef.current = []; + setLiveComponents([]); + const perMsg = componentsByMessage(parsed); + const delayMs = 800 / (speed || 1); + let accumulated: string[] = []; + perMsg.forEach((newNames, i) => { + if (newNames.length === 0) return; + const timer = setTimeout(() => { + accumulated = [...accumulated, ...newNames]; + setLiveComponents([...accumulated]); + }, delayMs * (i + 1)); + liveTimersRef.current.push(timer); + }); + + // On mobile, auto-expand preview to fullscreen when rendering. + if (window.innerWidth <= 980) { + setFullscreen(true); + } + // Native in-app preview: pass A2UI payload via global props, directly through URL query. // In Lynx, query params are exposed in `lynx.__globalProps` / `useGlobalProps()`. const seq = ++lynxUrlSeqRef.current; @@ -363,18 +393,14 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { {/* Preview Panel */} -
+
Lynx Preview - {currentScenario - ? ( -
-
- {currentScenario.tags.map((t) => {t})} -
-
- ) - : null} +
+
{isSimulated ? ( @@ -467,6 +501,20 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { )}
+ {/* Live Component Stack */} + {liveComponents.length > 0 + ? ( +
+ Components +
+ {liveComponents.map((name) => ( + {name} + ))} +
+
+ ) + : null} + {/* QR Code Section — only shown when there's a render URL */} {renderUrl || lynxDevUrl ? ( diff --git a/packages/genui/a2ui-playground/src/styles.css b/packages/genui/a2ui-playground/src/styles.css index 3eadc57ac2..c352e74b47 100644 --- a/packages/genui/a2ui-playground/src/styles.css +++ b/packages/genui/a2ui-playground/src/styles.css @@ -299,6 +299,37 @@ a { border-left: 1px solid var(--geist-border); } +.previewPanelFullscreen { + position: fixed; + inset: 0; + z-index: 200; + width: 100%; + border-left: none; + background: var(--geist-background); +} + +.previewExpandBtn { + width: 28px; + height: 28px; + border: none; + border-radius: var(--geist-radius-sm); + background: transparent; + color: var(--geist-secondary); + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--geist-transition); + flex-shrink: 0; + padding: 0; +} + +.previewExpandBtn:hover { + color: var(--geist-foreground); + background: var(--geist-surface); +} + .previewPanelHeader { display: flex; align-items: center; @@ -359,6 +390,57 @@ a { padding: 0; } +/* ── Live Component Stack ── */ +.liveComponentStack { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 16px; + border-top: 1px solid var(--geist-border); + background: var(--geist-background); + font-size: 12px; + overflow-x: auto; +} + +.liveComponentLabel { + font-weight: 600; + color: var(--geist-secondary); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + flex-shrink: 0; +} + +.liveComponentTags { + display: flex; + gap: 4px; + flex-wrap: nowrap; +} + +.liveComponentTag { + padding: 2px 8px; + border-radius: 4px; + background: var(--geist-surface); + border: 1px solid var(--geist-border); + font-size: 11px; + font-weight: 500; + color: var(--geist-foreground); + white-space: nowrap; + animation: tagAppear 300ms ease-out; +} + +@keyframes tagAppear { + from { + opacity: 0; + transform: translateY(4px) scale(0.9); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + .previewFullIframe { width: 100%; height: 100%; @@ -665,6 +747,23 @@ a { color: var(--geist-foreground); } +.scenarioTags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.scenarioTag { + font-size: 10px; + font-weight: 500; + color: var(--geist-secondary); + padding: 1px 6px; + border-radius: 4px; + background: var(--geist-surface); + border: 1px solid var(--geist-border); +} + .scenarioDesc { font-size: 11px; color: var(--geist-secondary);