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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
4 changes: 4 additions & 0 deletions apps/meeting-notes/web-react/.env
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions apps/meeting-notes/web-react/.gitignore
Original file line number Diff line number Diff line change
@@ -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?
62 changes: 62 additions & 0 deletions apps/meeting-notes/web-react/README.md
Original file line number Diff line number Diff line change
@@ -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
```
22 changes: 22 additions & 0 deletions apps/meeting-notes/web-react/eslint.config.js
Original file line number Diff line number Diff line change
@@ -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,
},
},
])
13 changes: 13 additions & 0 deletions apps/meeting-notes/web-react/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Meeting Notes</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
38 changes: 38 additions & 0 deletions apps/meeting-notes/web-react/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
24 changes: 24 additions & 0 deletions apps/meeting-notes/web-react/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
162 changes: 162 additions & 0 deletions apps/meeting-notes/web-react/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<App />) })
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(<App />)
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(<App />)
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(<App />)
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(<App />)
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(<App />)
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()
})
})
Loading
Loading