diff --git a/apps/trace-explorer/web-react/package.json b/apps/trace-explorer/web-react/package.json index acd8d04..60fbe74 100644 --- a/apps/trace-explorer/web-react/package.json +++ b/apps/trace-explorer/web-react/package.json @@ -13,6 +13,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "event-ui-conformance": "*", "react": "^19.2.8", "react-dom": "^19.2.8", "traverse-embedder-web": "file:../../../vendor/traverse-embedder-web" diff --git a/apps/trace-explorer/web-react/src/App.test.tsx b/apps/trace-explorer/web-react/src/App.test.tsx index 711a953..64d05bd 100644 --- a/apps/trace-explorer/web-react/src/App.test.tsx +++ b/apps/trace-explorer/web-react/src/App.test.tsx @@ -22,7 +22,7 @@ describe('App', () => { render() }) await waitFor(() => { - expect(screen.getByText(/fixture\.success/)).toBeInTheDocument() + expect(screen.getAllByText(/fixture\.success/).length).toBeGreaterThan(0) }) }) }) diff --git a/apps/trace-explorer/web-react/src/App.tsx b/apps/trace-explorer/web-react/src/App.tsx index 09188d2..dc91f19 100644 --- a/apps/trace-explorer/web-react/src/App.tsx +++ b/apps/trace-explorer/web-react/src/App.tsx @@ -4,23 +4,38 @@ import { type EmbeddedTraceApi, type EmbeddedTraceDetail, type EmbeddedTraceSummary, + type TraverseEmbedderApi, } from 'traverse-embedder-web' import { createEmbeddedTraceClient, summaryPreview, } from './client/traceClient' +import { + observeSessionPresentation, + type SessionPresentation, +} from './host/sessionPresentation' + +/** Host must expose Trace API + subscribe (product shells share EmbedderTestDouble / BundleEmbedder). */ +export type TraceHost = TraverseEmbedderApi & EmbeddedTraceApi /** Injected host for tests; production uses EmbedderTestDouble until a session host is wired. */ -export type TraceHostFactory = () => EmbeddedTraceApi +export type TraceHostFactory = () => TraceHost const defaultHostFactory: TraceHostFactory = () => new EmbedderTestDouble() +const idlePresentation: SessionPresentation = { + presentationState: 'idle', + presentationError: null, + capabilityProgress: [], + activeCapabilityId: null, +} + function App({ hostFactory = defaultHostFactory, host, }: { hostFactory?: TraceHostFactory - host?: EmbeddedTraceApi + host?: TraceHost }) { const api = useMemo(() => host ?? hostFactory(), [host, hostFactory]) const client = useMemo(() => createEmbeddedTraceClient(api), [api]) @@ -30,6 +45,12 @@ function App({ const [loadState, setLoadState] = useState<'idle' | 'loading' | 'error'>('idle') const [error, setError] = useState('') const [apiVersion, setApiVersion] = useState('') + const [presentation, setPresentation] = + useState(idlePresentation) + + useEffect(() => { + observeSessionPresentation(api, setPresentation) + }, [api]) const refreshList = useCallback(() => { setLoadState('loading') @@ -92,6 +113,31 @@ function App({ Refresh +

+ Session presentation: {presentation.presentationState} + {presentation.activeCapabilityId + ? ` · active capability ${presentation.activeCapabilityId}` + : null} + {presentation.presentationError + ? ` · ${presentation.presentationError}` + : null} +

+ {presentation.capabilityProgress.length > 0 ? ( +
    + {presentation.capabilityProgress.map((step) => ( +
  1. + {step.capabilityId} · {step.phase} +
  2. + ))} +
+ ) : null}
diff --git a/apps/trace-explorer/web-react/src/host/sessionPresentation.test.ts b/apps/trace-explorer/web-react/src/host/sessionPresentation.test.ts new file mode 100644 index 0000000..6084184 --- /dev/null +++ b/apps/trace-explorer/web-react/src/host/sessionPresentation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { EmbedderTestDouble } from 'traverse-embedder-web' +import { + mapSessionPresentation, + observeSessionPresentation, +} from './sessionPresentation' + +describe('mapSessionPresentation', () => { + it('maps an empty stream to idle', () => { + const snap = mapSessionPresentation([]) + expect(snap.presentationState).toBe('idle') + expect(snap.presentationError).toBeNull() + expect(snap.capabilityProgress).toEqual([]) + expect(snap.activeCapabilityId).toBeNull() + }) + + it('maps capability invoke/result to loaded with progress', () => { + const host = new EmbedderTestDouble().withTargetOutput('fixture.process', { + ok: true, + }) + const collected: import("traverse-embedder-web").EmbedderEvent[] = [] + host.subscribe((event) => { + collected.push(event) + }) + host.submit('fixture.process', { note: 'n' }) + const snap = mapSessionPresentation(collected) + expect(snap.presentationState).toBe('loaded') + expect(snap.capabilityProgress.length).toBeGreaterThan(0) + expect(snap.capabilityProgress.some((s) => s.phase === 'invoked')).toBe(true) + expect(snap.capabilityProgress.some((s) => s.phase === 'result')).toBe(true) + }) +}) + +describe('observeSessionPresentation', () => { + it('replays and updates after submit', () => { + const host = new EmbedderTestDouble().withTargetOutput('fixture.process', { + ok: true, + }) + const states: string[] = [] + observeSessionPresentation(host, (p) => { + states.push(p.presentationState) + }) + expect(states.at(-1)).toBe('idle') + host.submit('fixture.process', { note: 'n' }) + expect(states.at(-1)).toBe('loaded') + }) +}) diff --git a/apps/trace-explorer/web-react/src/host/sessionPresentation.ts b/apps/trace-explorer/web-react/src/host/sessionPresentation.ts new file mode 100644 index 0000000..b6ed606 --- /dev/null +++ b/apps/trace-explorer/web-react/src/host/sessionPresentation.ts @@ -0,0 +1,58 @@ +import type { + CapabilityProgressStep, + EmbedderEventLike, + PresentationState, +} from 'event-ui-conformance' +import { + activeCapabilityId, + mapCapabilityProgress, + mapPresentationState, +} from 'event-ui-conformance' +import type { EmbedderEvent, TraverseEmbedderApi } from 'traverse-embedder-web' + +export type SessionPresentation = { + presentationState: PresentationState + presentationError: string | null + capabilityProgress: CapabilityProgressStep[] + activeCapabilityId: string | null +} + +function toEventLikes(events: readonly EmbedderEvent[]): EmbedderEventLike[] { + return events.map((event) => ({ + event_type: event.event_type, + sequence: event.sequence, + session_id: event.session_id, + data: event.data, + })) +} + +/** Map an ordered public embedder event stream to Spec 001/002 UI fields. */ +export function mapSessionPresentation( + events: readonly EmbedderEvent[], +): SessionPresentation { + const likes = toEventLikes(events) + const snap = mapPresentationState(likes) + return { + presentationState: snap.state, + presentationError: snap.errorMessage, + capabilityProgress: mapCapabilityProgress(likes), + activeCapabilityId: activeCapabilityId(likes), + } +} + +/** + * Subscribe to the public embedder event stream and invoke `onChange` after each + * event (including replay). The embedder API has no unsubscribe; drop the host + * when tearing down. + */ +export function observeSessionPresentation( + host: TraverseEmbedderApi, + onChange: (presentation: SessionPresentation) => void, +): void { + const collected: EmbedderEvent[] = [] + host.subscribe((event) => { + collected.push(event) + onChange(mapSessionPresentation(collected)) + }) + onChange(mapSessionPresentation(collected)) +} diff --git a/package-lock.json b/package-lock.json index 706863a..e3f3d4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -665,6 +665,7 @@ "name": "trace-explorer-web-react", "version": "0.0.0", "dependencies": { + "event-ui-conformance": "*", "react": "^19.2.8", "react-dom": "^19.2.8", "traverse-embedder-web": "file:../../../vendor/traverse-embedder-web"