diff --git a/apps/traverse-starter/web-react/README.md b/apps/traverse-starter/web-react/README.md index 253dc45..a266c1e 100644 --- a/apps/traverse-starter/web-react/README.md +++ b/apps/traverse-starter/web-react/README.md @@ -39,3 +39,5 @@ npm run test:coverage ``` Open `http://localhost:5173`. When the embedded host is ready, submit a note and confirm runtime-owned fields render in the output panel. + +**Session presentation:** Spec 001/002 fields update from each subscribed embedder event during submit (not a local timer). Because `submit` is synchronous, React may batch paints — a visible `loading` flash is not guaranteed; unit tests assert mid-stream `loading` via the presentation callback. diff --git a/apps/traverse-starter/web-react/src/App.test.tsx b/apps/traverse-starter/web-react/src/App.test.tsx index caaec66..1ed0912 100644 --- a/apps/traverse-starter/web-react/src/App.test.tsx +++ b/apps/traverse-starter/web-react/src/App.test.tsx @@ -49,5 +49,7 @@ describe('App', () => { expect(screen.getByText('T')).toBeInTheDocument() expect(screen.getByText('Summary')).toBeInTheDocument() expect(screen.getByText('Short')).toBeInTheDocument() + expect(screen.getByText(/Session presentation:/i)).toBeInTheDocument() + expect(screen.getByText('loaded')).toBeInTheDocument() }) }) diff --git a/apps/traverse-starter/web-react/src/App.tsx b/apps/traverse-starter/web-react/src/App.tsx index e221bee..78cf2e5 100644 --- a/apps/traverse-starter/web-react/src/App.tsx +++ b/apps/traverse-starter/web-react/src/App.tsx @@ -8,6 +8,7 @@ import { submitNote, type HostRunResult, type RuntimeStatus, + type SessionPresentation, type TraceEvent, type TraverseEmbedderApi, } from './host/embeddedHost' @@ -15,6 +16,13 @@ import { type TraverseStarterOutput } from './client/traverseOutput' const NOTE_MAX_LENGTH = 2000 +const IDLE_PRESENTATION: SessionPresentation = { + presentationState: 'idle', + presentationError: null, + capabilityProgress: [], + activeCapabilityId: null, +} + export interface AppProps { /** Injected embedder for tests; when omitted, production BundleEmbedder.init runs. */ embedder?: TraverseEmbedderApi | null @@ -27,6 +35,7 @@ function App({ embedder: injectedEmbedder }: AppProps = {}) { const [prodEmbedder, setProdEmbedder] = useState(null) const [submitting, setSubmitting] = useState(false) const [result, setResult] = useState(null) + const [livePresentation, setLivePresentation] = useState(null) const formRef = useRef(null) const embedder = injected ? injectedEmbedder : prodEmbedder @@ -58,8 +67,13 @@ function App({ embedder: injectedEmbedder }: AppProps = {}) { if (!canSubmit || !embedder) return setSubmitting(true) setResult(null) + setLivePresentation(IDLE_PRESENTATION) try { - setResult(submitNote(embedder, note)) + setResult( + submitNote(embedder, note, (presentation) => { + setLivePresentation(presentation) + }), + ) } finally { setSubmitting(false) } @@ -79,11 +93,14 @@ function App({ embedder: injectedEmbedder }: AppProps = {}) { } const parsed: TraverseStarterOutput | null = result?.output ?? null - const displayError = result?.error ?? null + const displayError = result?.error ?? livePresentation?.presentationError ?? null const trace: TraceEvent[] = result?.events ?? [] - const presentationState = result?.presentationState ?? null - const activeCapability = result?.activeCapabilityId ?? null - const capabilityProgress = result?.capabilityProgress ?? [] + const presentationState = + livePresentation?.presentationState ?? result?.presentationState ?? null + const activeCapability = + livePresentation?.activeCapabilityId ?? result?.activeCapabilityId ?? null + const capabilityProgress = + livePresentation?.capabilityProgress ?? result?.capabilityProgress ?? [] const statusLabel = runtimeStatus === 'ready' ? 'Ready' : runtimeStatus === 'unavailable' ? 'Unavailable' : 'Starting' diff --git a/apps/traverse-starter/web-react/src/host/embeddedHost.test.ts b/apps/traverse-starter/web-react/src/host/embeddedHost.test.ts index bcb7be7..6660c41 100644 --- a/apps/traverse-starter/web-react/src/host/embeddedHost.test.ts +++ b/apps/traverse-starter/web-react/src/host/embeddedHost.test.ts @@ -4,6 +4,7 @@ import { createTestEmbedder, submitNote, initProductionEmbedder, + observeSessionPresentation, DEFAULT_WORKFLOW_ID, RUNTIME_MODE_EMBEDDED, } from './embeddedHost' @@ -52,6 +53,51 @@ describe('embeddedHost', () => { expect(result.capabilityProgress.length).toBeGreaterThan(0) }) + it('submitNote notifies presentation after each subscribed event', () => { + const output = { + validate: { valid: true, issues: [] as string[] }, + process: { + title: 'Hello', + tags: ['a'], + noteType: 'note', + suggestedNextAction: 'next', + status: 'ok', + }, + summarize: { summary: 'Sum', wordCount: 1 }, + } + const embedder = createTestEmbedder(output) + const states: string[] = [] + const result = submitNote(embedder, 'note text', (presentation) => { + states.push(presentation.presentationState) + }) + expect(states).toContain('loading') + expect(states.at(-1)).toBe('loaded') + expect(result.presentationState).toBe('loaded') + }) + + it('observeSessionPresentation starts idle and updates after submit', () => { + const output = { + validate: { valid: true, issues: [] as string[] }, + process: { + title: 'Hello', + tags: ['a'], + noteType: 'note', + suggestedNextAction: 'next', + status: 'ok', + }, + summarize: { summary: 'Sum', wordCount: 1 }, + } + const embedder = createTestEmbedder(output) + const states: string[] = [] + observeSessionPresentation(embedder, (presentation) => { + states.push(presentation.presentationState) + }) + expect(states.at(-1)).toBe('idle') + submitNote(embedder, 'note text') + expect(states).toContain('loading') + expect(states.at(-1)).toBe('loaded') + }) + it('submitNote surfaces scripted execution errors', () => { const embedder = new EmbedderTestDouble({ appId: 'traverse-starter', diff --git a/apps/traverse-starter/web-react/src/host/embeddedHost.ts b/apps/traverse-starter/web-react/src/host/embeddedHost.ts index 59ef410..b83bf99 100644 --- a/apps/traverse-starter/web-react/src/host/embeddedHost.ts +++ b/apps/traverse-starter/web-react/src/host/embeddedHost.ts @@ -49,6 +49,21 @@ export interface HostRunResult { export type { TraverseEmbedderApi, EmbedderEvent, PresentationState, CapabilityProgressStep } +/** Spec 001/002 fields derived from an ordered public embedder event stream. */ +export type SessionPresentation = { + presentationState: PresentationState + presentationError: string | null + capabilityProgress: CapabilityProgressStep[] + activeCapabilityId: string | null +} + +const IDLE_PRESENTATION: SessionPresentation = { + presentationState: 'idle', + presentationError: null, + capabilityProgress: [], + activeCapabilityId: null, +} + /** Builds a deterministic test double for Vitest (spec 068 FR-006). */ export function createTestEmbedder(output: TraverseStarterOutput): TraverseEmbedderApi { return new EmbedderTestDouble({ @@ -95,6 +110,44 @@ function toEventLikes(events: readonly EmbedderEvent[]): EmbedderEventLike[] { })) } +/** Map an ordered public embedder event stream to Spec 001/002 UI fields. */ +export function mapSessionPresentation( + events: readonly EmbedderEvent[], + options?: { fallbackError?: string | null }, +): SessionPresentation { + if (events.length === 0 && !options?.fallbackError) { + return IDLE_PRESENTATION + } + const likes = toEventLikes(events) + const snap = mapPresentationState(likes) + const fallback = options?.fallbackError ?? null + const presentationState: PresentationState = + fallback && snap.state === 'idle' ? 'error' : snap.state + return { + presentationState, + presentationError: snap.errorMessage ?? (fallback && snap.state === 'idle' ? fallback : null), + capabilityProgress: mapCapabilityProgress(likes), + activeCapabilityId: activeCapabilityId(likes), + } +} + +/** + * Subscribe to the public embedder event stream and invoke `onChange` after each + * event (including an initial empty → `idle` snapshot). 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)) +} + function withPresentation( base: Omit< HostRunResult, @@ -102,30 +155,27 @@ function withPresentation( >, collected: readonly EmbedderEvent[], ): HostRunResult { - const likes = toEventLikes(collected) - const snap = mapPresentationState(likes) - const presentationState: PresentationState = - base.error && snap.state === 'idle' ? 'error' : snap.state return { ...base, - presentationState, - presentationError: - snap.errorMessage ?? (base.error && snap.state === 'idle' ? base.error : null), - capabilityProgress: mapCapabilityProgress(likes), - activeCapabilityId: activeCapabilityId(likes), + ...mapSessionPresentation(collected, { fallbackError: base.error }), } } /** Submit `{ note }` to `traverse-starter.pipeline` and collect terminal output. */ -export function submitNote(embedder: TraverseEmbedderApi, note: string): HostRunResult { +export function submitNote( + embedder: TraverseEmbedderApi, + note: string, + onPresentation?: (presentation: SessionPresentation) => void, +): HostRunResult { const collected: EmbedderEvent[] = [] embedder.subscribe((event) => { collected.push(event) + onPresentation?.(mapSessionPresentation(collected)) }) const outcome = embedder.submit(DEFAULT_WORKFLOW_ID, { note }) if (outcome.status === 'rejected') { - return withPresentation( + const rejected = withPresentation( { sessionId: outcome.sessionId ?? 'sess-unknown', output: null, @@ -137,6 +187,13 @@ export function submitNote(embedder: TraverseEmbedderApi, note: string): HostRun }, [], ) + onPresentation?.({ + presentationState: rejected.presentationState, + presentationError: rejected.presentationError, + capabilityProgress: rejected.capabilityProgress, + activeCapabilityId: rejected.activeCapabilityId, + }) + return rejected } const sessionId = outcome.sessionId ?? 'sess-unknown' diff --git a/docs/decision-log.md b/docs/decision-log.md index 56e952b..e164ab3 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -508,3 +508,77 @@ Append-only record of design decisions for App-References. Newest sessions at th - Assigning/staffing Traverse #1240 vs #1241 beyond “#1241 after hygiene” - Any App-Refs interim workaround that fakes kit MCP or BundleEmbedder success - Production registry release-train proof for lifecycle (fixture-only by design) + + +--- + +## 2026-09-06 — Live Spec 001 subscribe in starter web + +**Context:** traverse-starter web derives Spec 001 presentation from subscribed embedder events, but only paints the terminal state after sync `submit`. Question: change runtime, change the web ref app, or proof-only? + +### Where to change + +**Question:** Where should a change land for proper subscribe-driven UI status? + +**Options considered:** +- A — Web ref app only (live observe / per-event setState) — pros: UI-layer; proves Spec 001/002; no ABI change; cons: sync submit may still batch paints +- B — Traverse runtime/embedder (async submit / yields) — pros: all hosts get mid-flight paints; cons: larger; belongs upstream +- C — No product change; tests/docs only — pros: cheapest; cons: demo still jumps to loaded + +**Recommendation:** A (+ optional doc note from C). + +**Decision:** A — change the web ref app only. + +**Why:** Runtime event stream is already sufficient; gap is reference-app subscribe UX/proof. + +### Visible loading paint + +**Question:** How hard to push for a visible `loading` flash? + +**Options considered:** +- A1 — Live subscribe + setState per event — pros: matches Trace Explorer; Spec intent; cons: React may batch sync submit +- A2 — A1 + forced yield — pros: human-demoable flash; cons: artificial timing +- A3 — A1 wiring + tests only for mid-stream loading — pros: honest; cons: weaker live demo + +**Recommendation:** A1. + +**Decision:** A1. + +**Why:** Correct subscribe-driven updates without demo hacks; do not require a guaranteed visible flash under sync submit. + +### Scope + +**Question:** How wide should A1 ship? + +**Options considered:** +- S1 — traverse-starter web only — pros: smallest kit proof; cons: other shells still batch +- S2 — all primary web shells — pros: consistency; cons: large PR +- S3 — shared helper then starter — pros: less copy-paste later; cons: more upfront design + +**Recommendation:** S1. + +**Decision:** S1. + +**Why:** Prove the pattern on the kit flagship first. + +### Next action + +**Question:** Ticket + implement vs log-only vs implement without ticket? + +**Options considered:** +- N1 — Project 2 ticket + implement on cursor branch — pros: governance; cons: setup time +- N2 — Decision log only — pros: locks decision; cons: no code yet +- N3 — Implement without ticket — pros: fast; cons: breaks ticket rule + +**Recommendation:** N1. + +**Decision:** N1. + +**Why:** Meaningful UI behavior change requires Spec + DoD on Project 2. + +### What was explicitly deferred + +- Traverse async/yield embedder changes (Option B) +- Forced yield for visible loading flash (Option A2) +- Rolling A1 to doc-approval / meeting-notes / loop web (Option S2) +- Extracting a shared observe helper package (Option S3)