(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) => (
+ -
+ {step.capabilityId} · {step.phase}
+
+ ))}
+
+ ) : 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"