diff --git a/README.md b/README.md index f3f1136..6e6b098 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,12 @@ All clients are **native UI shells** separated from business logic. Phase 1/2 us | Linux (GTK4 + Rust) | Shipped | [`apps/doc-approval/linux-gtk/`](apps/doc-approval/linux-gtk/) | | CLI (Rust) | Shipped | [`apps/doc-approval/cli-rust/`](apps/doc-approval/cli-rust/) | +### meeting-notes (list-type output) + +| Platform | Status | Path | +|---|---|---| +| Web (React + TypeScript) | Shipped | [`apps/meeting-notes/web-react/`](apps/meeting-notes/web-react/) | + ## Development ```bash @@ -170,5 +176,4 @@ Active blockers on [Project 2](https://github.com/orgs/traverse-framework/projec - **Phase 3 embedded runtime** ([#109](https://github.com/traverse-framework/reference-apps/issues/109)–[#118](https://github.com/traverse-framework/reference-apps/issues/118)) — all platform clients must bundle the WASM runtime host; blocked on [Traverse #553](https://github.com/traverse-framework/Traverse/issues/553). HTTP sidecar is dev-only. - **Multi-capability showcase workflow** ([#110](https://github.com/traverse-framework/reference-apps/issues/110), [#111](https://github.com/traverse-framework/reference-apps/issues/111)) — traverse-starter and doc-approval pipeline workflows with multiple WASM capabilities. - **SSE state subscription** ([#43](https://github.com/traverse-framework/reference-apps/issues/43)) — replace polling with runtime SSE; blocked on [Traverse #527](https://github.com/traverse-framework/Traverse/issues/527) only (#525/#526 done). -- **meeting-notes web-react** ([#57](https://github.com/traverse-framework/reference-apps/issues/57)) — second domain app (list-type output); blocked on [Traverse #532](https://github.com/traverse-framework/Traverse/issues/532). - **Embedded runtime client packages** ([#58](https://github.com/traverse-framework/reference-apps/issues/58), [#59](https://github.com/traverse-framework/reference-apps/issues/59), [#72](https://github.com/traverse-framework/reference-apps/issues/72), [#73](https://github.com/traverse-framework/reference-apps/issues/73)) — shared Swift/Rust host wiring for embedded mode; reprioritized from HTTP client extraction. diff --git a/apps/meeting-notes/web-react/.env b/apps/meeting-notes/web-react/.env new file mode 100644 index 0000000..096add4 --- /dev/null +++ b/apps/meeting-notes/web-react/.env @@ -0,0 +1,4 @@ +# Local Traverse Runtime configuration (see docs/traverse-runtime.md) +VITE_TRAVERSE_BASE_URL=http://127.0.0.1:8787 +VITE_TRAVERSE_WORKSPACE=local-default +VITE_TRAVERSE_CAPABILITY_ID=meeting-notes.process diff --git a/apps/meeting-notes/web-react/.gitignore b/apps/meeting-notes/web-react/.gitignore new file mode 100644 index 0000000..de9ee66 --- /dev/null +++ b/apps/meeting-notes/web-react/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +coverage + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/meeting-notes/web-react/README.md b/apps/meeting-notes/web-react/README.md new file mode 100644 index 0000000..1a3a342 --- /dev/null +++ b/apps/meeting-notes/web-react/README.md @@ -0,0 +1,62 @@ +# meeting-notes (Web React UI) + +React UI shell for the **meeting-notes** reference application — a second domain app that demonstrates **list-type structured output** from the Traverse runtime. + +Paste a meeting transcript → the runtime returns action items, decisions, follow-ups, and a summary. The UI renders those fields only; it does not extract or invent them. + +## What this demonstrates vs traverse-starter + +| | traverse-starter | meeting-notes | +|---|---|---| +| Input | Short note (`note`, 2000 chars) | Longer transcript (`transcript`, 5000 chars) | +| Output shape | Flat string fields + string tags | Object arrays (`action_items`, `decisions`) + string list + summary | +| Capability default | `traverse-starter.process` | `meeting-notes.process` | + +Same Phase 1 HTTP/JSON client pattern; richer rendering of runtime-owned list schemas. + +## Core Design Principles + +1. **UI is a rendering layer only** — `action_items`, `decisions`, `follow_ups`, and `summary` come from the runtime. The UI displays them; it does not compute them. +2. **Strict boundary isolation** — no private Traverse internals are imported. All communication uses public runtime surfaces. + +## Configuration + +| Variable | Default | Purpose | +|---|---|---| +| `VITE_TRAVERSE_BASE_URL` | `http://127.0.0.1:8787` | Runtime base URL | +| `VITE_TRAVERSE_WORKSPACE` | `local-default` | Workspace ID | +| `VITE_TRAVERSE_CAPABILITY_ID` | `meeting-notes.process` | Capability to execute | + +Legacy alias: `VITE_TRAVERSE_RUNTIME_URL` is accepted as a fallback for `VITE_TRAVERSE_BASE_URL`. + +Copy or edit `apps/meeting-notes/web-react/.env`: + +```bash +VITE_TRAVERSE_BASE_URL=http://127.0.0.1:8787 +VITE_TRAVERSE_WORKSPACE=local-default +VITE_TRAVERSE_CAPABILITY_ID=meeting-notes.process +``` + +## Start the Traverse Runtime + +```bash +git clone https://github.com/traverse-framework/Traverse.git /tmp/traverse +cd /tmp/traverse && git checkout v0.6.0 +cargo run -p traverse-cli -- serve +``` + +The `meeting-notes.process` capability must be registered in the runtime before execute calls succeed. See Traverse [#532](https://github.com/traverse-framework/Traverse/issues/532) / the Traverse repo for domain capability setup. + +## Development Commands + +From the **repository root**: + +```bash +npm install +npm run dev -w apps/meeting-notes/web-react +npm run build -w apps/meeting-notes/web-react +npm run typecheck -w apps/meeting-notes/web-react +npm run lint -w apps/meeting-notes/web-react +npm run test -w apps/meeting-notes/web-react +npm run test:coverage -w apps/meeting-notes/web-react +``` diff --git a/apps/meeting-notes/web-react/eslint.config.js b/apps/meeting-notes/web-react/eslint.config.js new file mode 100644 index 0000000..cd66985 --- /dev/null +++ b/apps/meeting-notes/web-react/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist', 'coverage']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/apps/meeting-notes/web-react/index.html b/apps/meeting-notes/web-react/index.html new file mode 100644 index 0000000..f13344b --- /dev/null +++ b/apps/meeting-notes/web-react/index.html @@ -0,0 +1,13 @@ + + + + + + + Meeting Notes + + +
+ + + diff --git a/apps/meeting-notes/web-react/package.json b/apps/meeting-notes/web-react/package.json new file mode 100644 index 0000000..509c7d8 --- /dev/null +++ b/apps/meeting-notes/web-react/package.json @@ -0,0 +1,38 @@ +{ + "name": "meeting-notes-web-react", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc -b", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest run", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^26.1.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "eslint": "^10.5.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.63.0", + "vite": "^8.1.3", + "vitest": "^4.1.10", + "jsdom": "^29.1.1", + "@testing-library/react": "^16.2.0", + "@testing-library/jest-dom": "^6.6.3", + "@vitest/coverage-v8": "^4.1.10" + } +} diff --git a/apps/meeting-notes/web-react/public/favicon.svg b/apps/meeting-notes/web-react/public/favicon.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/apps/meeting-notes/web-react/public/favicon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/meeting-notes/web-react/src/App.test.tsx b/apps/meeting-notes/web-react/src/App.test.tsx new file mode 100644 index 0000000..ab4414a --- /dev/null +++ b/apps/meeting-notes/web-react/src/App.test.tsx @@ -0,0 +1,162 @@ +import { render, screen, act, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import App from './App' + +const sampleOutput = { + action_items: [ + { task: 'Send follow-up email', owner: 'Alex', due: '2026-07-15' }, + ], + decisions: [ + { text: 'Ship Phase 1 this week', made_by: 'Jordan' }, + ], + follow_ups: ['Schedule design review'], + summary: 'Team aligned on Phase 1 scope.', +} + +const emptyListsOutput = { + action_items: [], + decisions: [], + follow_ups: [], + summary: 'Quiet meeting.', +} + +describe('App', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks() }) + + it('renders UI shell', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: () => Promise.resolve({}) } as Response) + await act(async () => { render() }) + expect(screen.getByText('Meeting Notes')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Meeting Transcript', level: 2 })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Process Transcript' })).toBeInTheDocument() + }) + + it('shows offline when health check fails', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error')) + render() + await act(async () => { await vi.runOnlyPendingTimersAsync() }) + expect(screen.getByText('Offline')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Process Transcript' })).toBeDisabled() + expect(screen.getByText(/The runtime is offline/)).toBeInTheDocument() + }) + + it('shows Online when health check succeeds', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: () => Promise.resolve({}) } as Response) + render() + await act(async () => { await vi.runOnlyPendingTimersAsync() }) + expect(screen.getByText('Online')).toBeInTheDocument() + }) + + it('submits transcript and displays all four output sections', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation((url, init) => { + const path = String(url) + if (path.includes('/healthz')) { + return Promise.resolve({ ok: true } as Response) + } + if (path.includes('/execute') && init?.method === 'POST') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ execution_id: 'exec-1' }), + } as Response) + } + if (path.includes('/executions/exec-1')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ execution_id: 'exec-1', status: 'succeeded', output: sampleOutput }), + } as Response) + } + if (path.includes('/traces/exec-1')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response) + } + return Promise.resolve({ ok: false, status: 404 } as Response) + }) + + render() + await act(async () => { await vi.runOnlyPendingTimersAsync() }) + + fireEvent.change(screen.getByPlaceholderText(/Paste the full meeting transcript/i), { + target: { value: 'Alex will send the follow-up email.' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Process Transcript' })) + + await act(async () => { await Promise.resolve() }) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + + expect(screen.getByText('Action Items')).toBeInTheDocument() + expect(screen.getByText('Send follow-up email')).toBeInTheDocument() + expect(screen.getByText('Alex')).toBeInTheDocument() + expect(screen.getByText('due 2026-07-15')).toBeInTheDocument() + expect(screen.getByText('Decisions')).toBeInTheDocument() + expect(screen.getByText('Ship Phase 1 this week')).toBeInTheDocument() + expect(screen.getByText(/decided by Jordan/)).toBeInTheDocument() + expect(screen.getByText('Follow-ups')).toBeInTheDocument() + expect(screen.getByText('Schedule design review')).toBeInTheDocument() + expect(screen.getByText('Summary')).toBeInTheDocument() + expect(screen.getByText('Team aligned on Phase 1 scope.')).toBeInTheDocument() + }) + + it('renders None recorded for empty arrays', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation((url) => { + const path = String(url) + if (path.includes('/healthz')) return Promise.resolve({ ok: true } as Response) + if (path.includes('/execute')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ execution_id: 'exec-1' }) } as Response) + } + if (path.includes('/executions/exec-1')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ execution_id: 'exec-1', status: 'succeeded', output: emptyListsOutput }), + } as Response) + } + if (path.includes('/traces/exec-1')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response) + } + return Promise.resolve({ ok: false, status: 404 } as Response) + }) + + render() + await act(async () => { await vi.runOnlyPendingTimersAsync() }) + fireEvent.change(screen.getByPlaceholderText(/Paste the full meeting transcript/i), { + target: { value: 'No action items today.' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Process Transcript' })) + await act(async () => { await Promise.resolve() }) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + + expect(screen.getAllByText('None recorded')).toHaveLength(3) + expect(screen.getByText('Quiet meeting.')).toBeInTheDocument() + }) + + it('reset returns to idle', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation((url) => { + const path = String(url) + if (path.includes('/healthz')) return Promise.resolve({ ok: true } as Response) + if (path.includes('/execute')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ execution_id: 'exec-1' }) } as Response) + } + if (path.includes('/executions/exec-1')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ execution_id: 'exec-1', status: 'succeeded', output: emptyListsOutput }), + } as Response) + } + if (path.includes('/traces/exec-1')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response) + } + return Promise.resolve({ ok: false, status: 404 } as Response) + }) + + render() + await act(async () => { await vi.runOnlyPendingTimersAsync() }) + fireEvent.change(screen.getByPlaceholderText(/Paste the full meeting transcript/i), { + target: { value: 'notes' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Process Transcript' })) + await act(async () => { await Promise.resolve() }) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + + fireEvent.click(screen.getByRole('button', { name: 'Reset' })) + expect(screen.getByText(/Submit a transcript above/)).toBeInTheDocument() + }) +}) diff --git a/apps/meeting-notes/web-react/src/App.tsx b/apps/meeting-notes/web-react/src/App.tsx new file mode 100644 index 0000000..7101bca --- /dev/null +++ b/apps/meeting-notes/web-react/src/App.tsx @@ -0,0 +1,99 @@ +import { useState, useEffect, useMemo, useCallback } from 'react' +import { createTraverseClient } from './client/traverseClient' +import { useExecution } from './hooks/useExecution' +import { HealthIndicator } from './components/HealthIndicator' +import { TranscriptInput } from './components/TranscriptInput' +import { MeetingResult } from './components/MeetingResult' + +const BASE_URL = + import.meta.env.VITE_TRAVERSE_BASE_URL ?? + import.meta.env.VITE_TRAVERSE_RUNTIME_URL ?? + 'http://127.0.0.1:8787' +const WORKSPACE = import.meta.env.VITE_TRAVERSE_WORKSPACE ?? 'local-default' +const CAPABILITY_ID = import.meta.env.VITE_TRAVERSE_CAPABILITY_ID ?? 'meeting-notes.process' +const TRANSCRIPT_MAX_LENGTH = 5000 + +function App() { + const [transcript, setTranscript] = useState('') + const [runtimeStatus, setRuntimeStatus] = useState<'checking' | 'online' | 'offline'>('checking') + + const client = useMemo(() => createTraverseClient(BASE_URL), []) + const { state, run, reset } = useExecution(client, WORKSPACE) + + useEffect(() => { + let active = true + const check = async () => { + try { + const res = await fetch(`${BASE_URL}/healthz`) + if (active) setRuntimeStatus(res.ok ? 'online' : 'offline') + } catch { + if (active) setRuntimeStatus('offline') + } + } + check() + const interval = setInterval(check, 5000) + return () => { active = false; clearInterval(interval) } + }, []) + + const isRunning = state.phase === 'loading' || state.phase === 'polling' + const canSubmit = transcript.trim().length > 0 && runtimeStatus === 'online' && !isRunning + + const handleSubmit = useCallback((e?: React.FormEvent) => { + e?.preventDefault() + if (!canSubmit) return + run(CAPABILITY_ID, { transcript }) + }, [canSubmit, run, transcript]) + + const handleReset = useCallback(() => { + reset() + setTranscript('') + }, [reset]) + + return ( +
+
+

+ Meeting Notes +

+

+ Extract action items and decisions from transcripts via the Traverse runtime +

+
+ +
+ + + + + +
+
+ ) +} + +export default App diff --git a/apps/meeting-notes/web-react/src/client/traverseClient.test.ts b/apps/meeting-notes/web-react/src/client/traverseClient.test.ts new file mode 100644 index 0000000..5fc415f --- /dev/null +++ b/apps/meeting-notes/web-react/src/client/traverseClient.test.ts @@ -0,0 +1,41 @@ +import { createTraverseClient } from './traverseClient' + +const BASE = 'http://127.0.0.1:8787' + +function mockFetch(responses: Record) { + return vi.fn((url: string) => { + const key = Object.keys(responses).find(k => url.includes(k)) + if (!key) return Promise.resolve({ ok: false, status: 404 }) + return Promise.resolve({ ok: true, json: () => Promise.resolve(responses[key]) }) + }) as unknown as typeof fetch +} + +describe('createTraverseClient', () => { + it('execute posts and returns execution_id', async () => { + const fetch = mockFetch({ '/execute': { execution_id: 'exec-1' } }) + globalThis.fetch = fetch + const id = await createTraverseClient(BASE).execute('local-default', 'doc-approval.analyze', { document: 'hi' }) + expect(id).toBe('exec-1') + expect(fetch).toHaveBeenCalledWith( + `${BASE}/v1/workspaces/local-default/execute`, + expect.objectContaining({ method: 'POST' }), + ) + }) + + it('pollExecution returns result', async () => { + globalThis.fetch = mockFetch({ '/executions/exec-1': { execution_id: 'exec-1', status: 'succeeded', output: { docType: 'invoice' } } }) + const result = await createTraverseClient(BASE).pollExecution('local-default', 'exec-1') + expect(result.status).toBe('succeeded') + }) + + it('fetchTrace returns events', async () => { + globalThis.fetch = mockFetch({ '/traces/exec-1': [{ event_type: 'start', timestamp: 't0' }] }) + const trace = await createTraverseClient(BASE).fetchTrace('local-default', 'exec-1') + expect(trace).toHaveLength(1) + }) + + it('execute throws on non-ok response', async () => { + globalThis.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 500 })) as unknown as typeof fetch + await expect(createTraverseClient(BASE).execute('ws', 'cap', {})).rejects.toThrow('execute failed: 500') + }) +}) diff --git a/apps/meeting-notes/web-react/src/client/traverseClient.ts b/apps/meeting-notes/web-react/src/client/traverseClient.ts new file mode 100644 index 0000000..dc538f3 --- /dev/null +++ b/apps/meeting-notes/web-react/src/client/traverseClient.ts @@ -0,0 +1,54 @@ +export type ExecutionStatus = 'pending' | 'running' | 'succeeded' | 'failed' + +export interface ExecutionResult { + execution_id: string + status: ExecutionStatus + output?: unknown + error?: string +} + +export interface TraceEvent { + event_type: string + timestamp: string + data?: unknown +} + +export interface TraverseClient { + execute(workspaceId: string, capability: string, input: unknown): Promise + pollExecution(workspaceId: string, executionId: string): Promise + fetchTrace(workspaceId: string, executionId: string): Promise +} + +export function createTraverseClient(baseUrl: string): TraverseClient { + return { + async execute(workspaceId, capability, input) { + const res = await fetch( + `${baseUrl}/v1/workspaces/${workspaceId}/execute`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ capability, input }), + }, + ) + if (!res.ok) throw new Error(`execute failed: ${res.status}`) + const data = (await res.json()) as { execution_id: string } + return data.execution_id + }, + + async pollExecution(workspaceId, executionId) { + const res = await fetch( + `${baseUrl}/v1/workspaces/${workspaceId}/executions/${executionId}`, + ) + if (!res.ok) throw new Error(`poll failed: ${res.status}`) + return (await res.json()) as ExecutionResult + }, + + async fetchTrace(workspaceId, executionId) { + const res = await fetch( + `${baseUrl}/v1/workspaces/${workspaceId}/traces/${executionId}`, + ) + if (!res.ok) throw new Error(`trace fetch failed: ${res.status}`) + return (await res.json()) as TraceEvent[] + }, + } +} diff --git a/apps/meeting-notes/web-react/src/client/traverseOutput.test.ts b/apps/meeting-notes/web-react/src/client/traverseOutput.test.ts new file mode 100644 index 0000000..4255d74 --- /dev/null +++ b/apps/meeting-notes/web-react/src/client/traverseOutput.test.ts @@ -0,0 +1,69 @@ +import { parseMeetingNotesOutput } from './traverseOutput' + +const valid = { + action_items: [ + { task: 'Send follow-up email', owner: 'Alex', due: '2026-07-15' }, + { task: 'Update roadmap', owner: null, due: null }, + ], + decisions: [ + { text: 'Ship Phase 1 this week', made_by: 'Jordan' }, + { text: 'Defer analytics', made_by: null }, + ], + follow_ups: ['Schedule design review', 'Share notes with team'], + summary: 'Team aligned on Phase 1 scope and owners.', +} + +describe('parseMeetingNotesOutput', () => { + it('returns structured output for valid shape', () => { + expect(parseMeetingNotesOutput(valid)).toEqual(valid) + }) + + it('returns null for null input', () => { + expect(parseMeetingNotesOutput(null)).toBeNull() + }) + + it('returns null when summary is missing', () => { + expect(parseMeetingNotesOutput({ ...valid, summary: undefined })).toBeNull() + }) + + it('returns null when action_items is not an array', () => { + expect(parseMeetingNotesOutput({ ...valid, action_items: 'none' })).toBeNull() + }) + + it('returns null when an action item has invalid shape', () => { + expect(parseMeetingNotesOutput({ + ...valid, + action_items: [{ task: 'x', owner: 1, due: null }], + })).toBeNull() + }) + + it('returns null when a decision has invalid shape', () => { + expect(parseMeetingNotesOutput({ + ...valid, + decisions: [{ text: 'x', made_by: 42 }], + })).toBeNull() + }) + + it('returns null when follow_ups contains non-strings', () => { + expect(parseMeetingNotesOutput({ ...valid, follow_ups: [1] })).toBeNull() + }) + + it('accepts empty arrays', () => { + expect(parseMeetingNotesOutput({ + action_items: [], + decisions: [], + follow_ups: [], + summary: 'Quiet meeting.', + })).toEqual({ + action_items: [], + decisions: [], + follow_ups: [], + summary: 'Quiet meeting.', + }) + }) + + it('returns null for non-object input', () => { + expect(parseMeetingNotesOutput('string')).toBeNull() + expect(parseMeetingNotesOutput(42)).toBeNull() + }) +}) diff --git a/apps/meeting-notes/web-react/src/client/traverseOutput.ts b/apps/meeting-notes/web-react/src/client/traverseOutput.ts new file mode 100644 index 0000000..34149fe --- /dev/null +++ b/apps/meeting-notes/web-react/src/client/traverseOutput.ts @@ -0,0 +1,53 @@ +export interface ActionItem { + task: string + owner: string | null + due: string | null +} + +export interface Decision { + text: string + made_by: string | null +} + +export interface MeetingNotesOutput { + action_items: ActionItem[] + decisions: Decision[] + follow_ups: string[] + summary: string +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === 'string' +} + +function isActionItem(value: unknown): value is ActionItem { + if (!value || typeof value !== 'object') return false + const o = value as Record + return typeof o.task === 'string' && isNullableString(o.owner) && isNullableString(o.due) +} + +function isDecision(value: unknown): value is Decision { + if (!value || typeof value !== 'object') return false + const o = value as Record + return typeof o.text === 'string' && isNullableString(o.made_by) +} + +export function parseMeetingNotesOutput(raw: unknown): MeetingNotesOutput | null { + if (!raw || typeof raw !== 'object') return null + const o = raw as Record + if ( + !Array.isArray(o.action_items) || + !Array.isArray(o.decisions) || + !Array.isArray(o.follow_ups) || + typeof o.summary !== 'string' + ) return null + if (!o.action_items.every(isActionItem)) return null + if (!o.decisions.every(isDecision)) return null + if (!o.follow_ups.every(item => typeof item === 'string')) return null + return { + action_items: o.action_items as ActionItem[], + decisions: o.decisions as Decision[], + follow_ups: o.follow_ups as string[], + summary: o.summary, + } +} diff --git a/apps/meeting-notes/web-react/src/components/HealthIndicator.tsx b/apps/meeting-notes/web-react/src/components/HealthIndicator.tsx new file mode 100644 index 0000000..f6e4ca5 --- /dev/null +++ b/apps/meeting-notes/web-react/src/components/HealthIndicator.tsx @@ -0,0 +1,53 @@ +type RuntimeStatus = 'checking' | 'online' | 'offline' + +interface HealthIndicatorProps { + baseUrl: string + workspace: string + capabilityId: string + status: RuntimeStatus +} + +export function HealthIndicator({ baseUrl, workspace, capabilityId, status }: HealthIndicatorProps) { + return ( +
+

+ Runtime Environment +

+
+
+
+ Runtime URL +
+
+ {baseUrl} +
+
+
+
+ Runtime Status +
+
+ + + {status === 'online' ? 'Online' : status === 'offline' ? 'Offline' : 'Checking...'} + +
+
+
+
+ Workspace: {workspace} · Capability: {capabilityId} +
+
+ ) +} + +export type { RuntimeStatus } diff --git a/apps/meeting-notes/web-react/src/components/MeetingResult.tsx b/apps/meeting-notes/web-react/src/components/MeetingResult.tsx new file mode 100644 index 0000000..19f5b92 --- /dev/null +++ b/apps/meeting-notes/web-react/src/components/MeetingResult.tsx @@ -0,0 +1,168 @@ +import type { ExecutionState } from '../hooks/useExecution' +import type { ActionItem, Decision } from '../client/traverseOutput' +import { parseMeetingNotesOutput } from '../client/traverseOutput' + +interface MeetingResultProps { + state: ExecutionState + runtimeOffline: boolean + onReset: () => void +} + +export function MeetingResult({ state, runtimeOffline, onReset }: MeetingResultProps) { + const showReset = state.phase === 'succeeded' || state.phase === 'failed' + + return ( +
+
+

+ Output +

+ {showReset && ( + + )} +
+ + {runtimeOffline && state.phase === 'idle' && ( +
+ Connect to the Traverse runtime to see meeting notes output here. +
+ )} + + {!runtimeOffline && state.phase === 'idle' && ( +
+ Submit a transcript above to extract action items and decisions. +
+ )} + + {state.phase === 'loading' && ( +
Starting processing…
+ )} + + {state.phase === 'polling' && ( +
+ Polling execution {state.executionId}… +
+ )} + + {state.phase === 'failed' && ( +
+ Error: {state.error} +
+ )} + + {state.phase === 'succeeded' && (() => { + const output = parseMeetingNotesOutput(state.result.output) + return output ? ( +
+ + + + +
+ ) : ( +
+            {JSON.stringify(state.result.output, null, 2)}
+          
+ ) + })()} +
+ ) +} + +function sectionLabel(label: string) { + return ( +
+ {label} +
+ ) +} + +function EmptyRecorded() { + return
None recorded
+} + +function OutputField({ label, value }: { label: string; value: string }) { + return ( +
+ {sectionLabel(label)} +
{value}
+
+ ) +} + +function Badge({ children }: { children: string }) { + return ( + + {children} + + ) +} + +function ActionItemsSection({ items }: { items: ActionItem[] }) { + return ( +
+ {sectionLabel('Action Items')} + {items.length === 0 ? ( + + ) : ( +
    + {items.map((item, index) => ( +
  • +
    + + {item.task} +
    + {(item.owner || item.due) && ( +
    + {item.owner && {item.owner}} + {item.due && {`due ${item.due}`}} +
    + )} +
  • + ))} +
+ )} +
+ ) +} + +function DecisionsSection({ items }: { items: Decision[] }) { + return ( +
+ {sectionLabel('Decisions')} + {items.length === 0 ? ( + + ) : ( +
    + {items.map((item, index) => ( +
  • + {item.text} + {item.made_by && ( + — decided by {item.made_by} + )} +
  • + ))} +
+ )} +
+ ) +} + +function StringListSection({ label, items }: { label: string; items: string[] }) { + return ( +
+ {sectionLabel(label)} + {items.length === 0 ? ( + + ) : ( +
    + {items.map((item, index) => ( +
  • {item}
  • + ))} +
+ )} +
+ ) +} diff --git a/apps/meeting-notes/web-react/src/components/TranscriptInput.tsx b/apps/meeting-notes/web-react/src/components/TranscriptInput.tsx new file mode 100644 index 0000000..e678238 --- /dev/null +++ b/apps/meeting-notes/web-react/src/components/TranscriptInput.tsx @@ -0,0 +1,79 @@ +interface TranscriptInputProps { + transcript: string + maxLength: number + canSubmit: boolean + isRunning: boolean + runtimeOffline: boolean + onChange: (value: string) => void + onSubmit: (e?: React.FormEvent) => void +} + +export function TranscriptInput({ + transcript, + maxLength, + canSubmit, + isRunning, + runtimeOffline, + onChange, + onSubmit, +}: TranscriptInputProps) { + const handleKeyDown = (e: React.KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault() + onSubmit() + } + } + + return ( +
+

+ Meeting Transcript +

+
+
+ +