Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/trace-explorer/web-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion apps/trace-explorer/web-react/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('App', () => {
render(<App host={host} />)
})
await waitFor(() => {
expect(screen.getByText(/fixture\.success/)).toBeInTheDocument()
expect(screen.getAllByText(/fixture\.success/).length).toBeGreaterThan(0)
})
})
})
50 changes: 48 additions & 2 deletions apps/trace-explorer/web-react/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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<SessionPresentation>(idlePresentation)

useEffect(() => {
observeSessionPresentation(api, setPresentation)
}, [api])

const refreshList = useCallback(() => {
setLoadState('loading')
Expand Down Expand Up @@ -92,6 +113,31 @@ function App({
Refresh
</button>
</div>
<p style={{ marginTop: '12px', color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
Session presentation: <strong>{presentation.presentationState}</strong>
{presentation.activeCapabilityId
? ` · active capability ${presentation.activeCapabilityId}`
: null}
{presentation.presentationError
? ` · ${presentation.presentationError}`
: null}
</p>
{presentation.capabilityProgress.length > 0 ? (
<ol
style={{
margin: '8px 0 0',
paddingLeft: '1.25rem',
color: 'var(--text-muted)',
fontSize: '0.85rem',
}}
>
{presentation.capabilityProgress.map((step) => (
<li key={`${step.capabilityId}-${step.phase}-${step.sequence}`}>
{step.capabilityId} · {step.phase}
</li>
))}
</ol>
) : null}
</section>

<section className="glass-panel" style={{ padding: '24px', minHeight: '200px' }}>
Expand Down
47 changes: 47 additions & 0 deletions apps/trace-explorer/web-react/src/host/sessionPresentation.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
58 changes: 58 additions & 0 deletions apps/trace-explorer/web-react/src/host/sessionPresentation.ts
Original file line number Diff line number Diff line change
@@ -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))
}
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading