feat(web): wire Module 1/2 live feed to the real node (not Web Serial)

In live mode the sense loop runs on the board (ZeroClaw on the Uno Q),
so the browser observes it rather than reading frames over Web Serial.

- useNodeFeed(teamId, enabled): subscribes to the node's SSE activity
  feed (openTeamActivity) and seeds liveness with a one-shot getNodeStatus
  (the per-team stream only emits status on change). Inert in sim mode.
- LiveBoardFeed: shows the board's real perception->reason->act activity
  + a live tally (agent runs / flashes / errors) + online indicator.
- Module 1/2 are now mode-aware: sim keeps the synthetic IMU feed +
  browser classification (the teaching sandbox); live shows LiveBoardFeed
  from the actual board. Proceed gates on board online (M1) / real board
  activity (M2) in live, unchanged in sim.

Note: this consumes the board's real activity stream (the signal the
Uno Q emits today). Streaming raw IMU frames for browser-side
classification in live mode would need a board-side sensor emitter
(ZeroClaw firmware) + an API frame relay — a separate piece.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-07 17:37:38 -07:00
co-authored by Claude Opus 4.8
parent 71a255cf15
commit a26185d47b
6 changed files with 264 additions and 17 deletions
+78
View File
@@ -0,0 +1,78 @@
import type { NodeFeed } from '@/lib/useNodeFeed'
import type { NodeActivityKind } from '@/types'
import { cn } from '@/lib/utils'
const KIND_DOT: Record<NodeActivityKind, string> = {
thinking: 'bg-muted-foreground',
tool: 'bg-amber',
flash: 'bg-primary',
error: 'bg-destructive',
response: 'bg-teal',
}
const TALLY: { key: keyof NodeFeed['counts']; label: string }[] = [
{ key: 'calls', label: 'Agent runs' },
{ key: 'flashes', label: 'Flashes' },
{ key: 'errors', label: 'Errors' },
]
export interface LiveBoardFeedProps {
feed: NodeFeed
}
/**
* Live view of the team's real board: the perception -> reason -> act activity
* streamed from the node (Module 1/2 in live mode). The reasoning runs on the
* board here; the browser observes. Sim mode uses the synthetic IMU LiveFeed.
*/
export function LiveBoardFeed({ feed }: LiveBoardFeedProps) {
const { activity, counts, online } = feed
return (
<div data-testid="live-board-feed" className="border border-border rounded-md bg-card p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
data-state={online ? 'live' : 'idle'}
className={cn('w-2 h-2 rounded-full', online ? 'bg-teal animate-pulse' : 'bg-muted-foreground')}
/>
<span className="text-sm font-medium">{online ? 'Board online' : 'Board offline'}</span>
</div>
<span className="font-mono text-[9px] uppercase tracking-widest text-primary">live node</span>
</div>
{activity.length === 0 ? (
<p className="text-xs text-muted-foreground">
No activity yet — prompt your board (below) to watch it sense, reason, and act.
</p>
) : (
<ul className="space-y-1.5 max-h-40 overflow-y-auto" data-testid="live-board-activity">
{activity.map((e, i) => (
<li key={i} className="flex items-center gap-2 font-mono text-[11px]">
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', KIND_DOT[e.kind])} />
<span
className={cn(
'truncate',
e.kind === 'error' && 'text-destructive',
e.kind === 'flash' && 'text-foreground font-medium',
)}
>
{e.label}
</span>
</li>
))}
</ul>
)}
<div className="grid grid-cols-3 gap-px bg-border rounded-md overflow-hidden text-center">
{TALLY.map((c) => (
<div key={c.key} className="bg-background p-3 space-y-1">
<div className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground">{c.label}</div>
<div className="text-lg font-bold tabular-nums" data-stat={c.key}>
{counts[c.key]}
</div>
</div>
))}
</div>
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useReducer } from 'react'
import { openTeamActivity, getNodeStatus } from './api'
import type { WsEvent, NodeActivityKind } from '@/types'
import type { NodeActivityEntry, NodeCounts } from './useCollective'
interface FeedState {
activity: NodeActivityEntry[]
counts: NodeCounts
online: boolean
}
const MAX_ACTIVITY = 40
const ZERO: NodeCounts = { calls: 0, flashes: 0, errors: 0 }
const COUNT_KEY: Partial<Record<NodeActivityKind, keyof NodeCounts>> = {
thinking: 'calls',
flash: 'flashes',
error: 'errors',
}
const init: FeedState = { activity: [], counts: ZERO, online: false }
function reducer(state: FeedState, event: WsEvent): FeedState {
switch (event.type) {
case 'node:status':
return { ...state, online: event.online }
case 'node:activity': {
const key = COUNT_KEY[event.kind]
return {
...state,
activity: [
{ teamId: event.teamId, kind: event.kind, label: event.label, ts: event.ts },
...state.activity,
].slice(0, MAX_ACTIVITY),
counts: key ? { ...state.counts, [key]: state.counts[key] + 1 } : state.counts,
}
}
default:
return state
}
}
export type NodeFeed = FeedState
/**
* Live view of one team's own board (Module 1/2 in live mode). Subscribes to the
* node's SSE activity feed and seeds `online` with a one-shot status poll (the
* per-team stream only emits status on *change*, so an already-online board
* would otherwise read offline until its next transition). Inert when disabled
* (sim mode) — no fetch, no subscription.
*/
export function useNodeFeed(teamId: string, enabled: boolean): NodeFeed {
const [state, dispatch] = useReducer(reducer, init)
useEffect(() => {
if (!enabled) return
let cancelled = false
// seed current liveness (the SSE only reports transitions)
getNodeStatus(teamId)
.then((s) => {
if (!cancelled) dispatch({ type: 'node:status', teamId, online: s.online })
})
.catch(() => {
/* not registered yet / transient — the SSE will report it */
})
const close = openTeamActivity(teamId, (e) => {
if (!cancelled) dispatch(e)
})
return () => {
cancelled = true
close()
}
}, [teamId, enabled])
return state
}
+38 -1
View File
@@ -1,9 +1,20 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react' import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import { Module1 } from './Module1' import { Module1 } from './Module1'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import type { WsEvent } from '@/types'
let emit: (e: WsEvent) => void = () => {}
vi.mock('@/lib/api', async (orig) => ({
...(await orig<typeof import('@/lib/api')>()),
openTeamActivity: (_teamId: string, on: (e: WsEvent) => void) => {
emit = on
return () => {}
},
getNodeStatus: vi.fn().mockResolvedValue({ teamId: 't', online: false }),
}))
function renderPage() { function renderPage() {
return render( return render(
@@ -61,6 +72,32 @@ describe('Module1', () => {
}) })
}) })
describe('live mode (real node feed)', () => {
it('shows the live board feed instead of the sim IMU feed', async () => {
useSession.getState().setMode('live')
renderPage()
expect(await screen.findByTestId('live-board-feed')).toBeInTheDocument()
expect(screen.queryByTestId('live-feed')).toBeNull()
// real board activity streams in from the node
act(() =>
emit({ type: 'node:activity', teamId: 'x', kind: 'flash', label: 'Flashing sketch to the MCU', ts: '' }),
)
await waitFor(() =>
expect(screen.getByTestId('live-board-activity')).toHaveTextContent(/flashing sketch/i),
)
})
it('enables Proceed once the board is online and L1 is set', async () => {
useSession.getState().setMode('live')
useSession.getState().setAddLayer('L1', { goal: 'keep safe' })
renderPage()
const proceed = await screen.findByRole('button', { name: /proceed/i })
expect(proceed).toBeDisabled()
act(() => emit({ type: 'node:status', teamId: 'x', online: true }))
await waitFor(() => expect(proceed).toBeEnabled())
})
})
it('persists the Layer-1 goal to the store', async () => { it('persists the Layer-1 goal to the store', async () => {
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
+19 -4
View File
@@ -4,20 +4,28 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import { LiveFeed } from '@/components/LiveFeed' import { LiveFeed } from '@/components/LiveFeed'
import { LiveBoardFeed } from '@/components/LiveBoardFeed'
import { StatsTally } from '@/components/StatsTally' import { StatsTally } from '@/components/StatsTally'
import { ActorMap } from '@/components/ActorMap' import { ActorMap } from '@/components/ActorMap'
import { AddLayerForm } from '@/components/AddLayerForm' import { AddLayerForm } from '@/components/AddLayerForm'
import { useSerial } from '@/lib/useSerial' import { useSerial } from '@/lib/useSerial'
import { useNodeFeed } from '@/lib/useNodeFeed'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
export function Module1() { export function Module1() {
const navigate = useNavigate() const navigate = useNavigate()
const mode = useSession((s) => s.mode)
const teamId = useSession((s) => s.teamId)
const serial = useSerial() const serial = useSerial()
const feed = useNodeFeed(teamId, mode === 'live')
const stats = useSession((s) => s.stats) const stats = useSession((s) => s.stats)
const l1 = useSession((s) => s.add.L1) as { goal?: string } | null const l1 = useSession((s) => s.add.L1) as { goal?: string } | null
const completePhase = useSession((s) => s.completePhase) const completePhase = useSession((s) => s.completePhase)
const ready = stats.calls > 0 && !!l1?.goal?.trim() // Sim: a classified frame proves the loop. Live: the board's own loop runs
// on-device, so an online board (or any activity from it) is the proof.
const sensed = mode === 'live' ? feed.online || feed.activity.length > 0 : stats.calls > 0
const ready = sensed && !!l1?.goal?.trim()
const onProceed = () => { const onProceed = () => {
completePhase('m1') completePhase('m1')
@@ -53,16 +61,23 @@ export function Module1() {
<div className="grid lg:grid-cols-2 gap-6"> <div className="grid lg:grid-cols-2 gap-6">
<Card> <Card>
<CardHeader className="flex-row items-center justify-between space-y-0"> <CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Live IMU feed</CardTitle> <CardTitle className="text-base">{mode === 'live' ? 'Live board feed' : 'Live IMU feed'}</CardTitle>
{!serial.connected ? ( {mode === 'sim' &&
(!serial.connected ? (
<Button size="sm" onClick={() => void serial.connect()}>Start feed</Button> <Button size="sm" onClick={() => void serial.connect()}>Start feed</Button>
) : ( ) : (
<Button size="sm" variant="outline" onClick={() => void serial.disconnect()}>Stop</Button> <Button size="sm" variant="outline" onClick={() => void serial.disconnect()}>Stop</Button>
)} ))}
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{mode === 'live' ? (
<LiveBoardFeed feed={feed} />
) : (
<>
<LiveFeed last={serial.last} frames={serial.frames} connected={serial.connected} mocked={serial.mocked} /> <LiveFeed last={serial.last} frames={serial.frames} connected={serial.connected} mocked={serial.mocked} />
<StatsTally /> <StatsTally />
</>
)}
</CardContent> </CardContent>
</Card> </Card>
+31 -2
View File
@@ -1,9 +1,20 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react' import { render, screen, act, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import { Module2 } from './Module2' import { Module2 } from './Module2'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import type { WsEvent } from '@/types'
let emit: (e: WsEvent) => void = () => {}
vi.mock('@/lib/api', async (orig) => ({
...(await orig<typeof import('@/lib/api')>()),
openTeamActivity: (_teamId: string, on: (e: WsEvent) => void) => {
emit = on
return () => {}
},
getNodeStatus: vi.fn().mockResolvedValue({ teamId: 't', online: false }),
}))
function renderPage() { function renderPage() {
return render( return render(
@@ -62,6 +73,24 @@ describe('Module2', () => {
expect(proceed).toBeEnabled() expect(proceed).toBeEnabled()
}) })
describe('live mode (real node feed)', () => {
it('replaces triggers with the live board feed and gates on real activity', async () => {
useSession.getState().setMode('live')
useSession.getState().setAddLayer('L2', 'escalate on critical')
useSession.getState().setAddLayer('L3', 'drive damper on critical')
renderPage()
expect(await screen.findByTestId('live-board-feed')).toBeInTheDocument()
expect(screen.queryByTestId('trigger-buttons')).toBeNull() // no synthetic triggers in live mode
const proceed = screen.getByRole('button', { name: /proceed/i })
expect(proceed).toBeDisabled()
// the board acts (via Build & flash) → activity streams from the node
act(() => emit({ type: 'node:activity', teamId: 'x', kind: 'response', label: 'Agent finished', ts: '' }))
await waitFor(() => expect(proceed).toBeEnabled())
})
})
it('marks m2 complete on Proceed', async () => { it('marks m2 complete on Proceed', async () => {
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
+16 -2
View File
@@ -8,15 +8,20 @@ import { HarnessTuner } from '@/components/HarnessTuner'
import { HarnessTomlPreview } from '@/components/HarnessTomlPreview' import { HarnessTomlPreview } from '@/components/HarnessTomlPreview'
import { TriggerButtons } from '@/components/TriggerButtons' import { TriggerButtons } from '@/components/TriggerButtons'
import { LiveFeed } from '@/components/LiveFeed' import { LiveFeed } from '@/components/LiveFeed'
import { LiveBoardFeed } from '@/components/LiveBoardFeed'
import { StatsTally } from '@/components/StatsTally' import { StatsTally } from '@/components/StatsTally'
import { BuildFlash } from '@/components/BuildFlash' import { BuildFlash } from '@/components/BuildFlash'
import { AddLayerForm } from '@/components/AddLayerForm' import { AddLayerForm } from '@/components/AddLayerForm'
import { useSerial } from '@/lib/useSerial' import { useSerial } from '@/lib/useSerial'
import { useNodeFeed } from '@/lib/useNodeFeed'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
export function Module2() { export function Module2() {
const navigate = useNavigate() const navigate = useNavigate()
const mode = useSession((s) => s.mode)
const teamId = useSession((s) => s.teamId)
const serial = useSerial() const serial = useSerial()
const feed = useNodeFeed(teamId, mode === 'live')
const l2 = useSession((s) => s.add.L2) const l2 = useSession((s) => s.add.L2)
const l3 = useSession((s) => s.add.L3) const l3 = useSession((s) => s.add.L3)
const completePhase = useSession((s) => s.completePhase) const completePhase = useSession((s) => s.completePhase)
@@ -29,7 +34,10 @@ export function Module2() {
setFired(true) setFired(true)
} }
const ready = fired && l2.trim().length > 0 && l3.trim().length > 0 // Sim: a fired trigger crossing the threshold. Live: the board acting on a
// prompt (via Build & flash below) shows up in its activity feed.
const exercised = mode === 'live' ? feed.activity.length > 0 : fired
const ready = exercised && l2.trim().length > 0 && l3.trim().length > 0
const onProceed = () => { const onProceed = () => {
completePhase('m2') completePhase('m2')
@@ -75,12 +83,18 @@ export function Module2() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Fire test triggers</CardTitle> <CardTitle className="text-base">{mode === 'live' ? 'Live board feed' : 'Fire test triggers'}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{mode === 'live' ? (
<LiveBoardFeed feed={feed} />
) : (
<>
<TriggerButtons onFire={onFire} /> <TriggerButtons onFire={onFire} />
<LiveFeed last={serial.last} frames={serial.frames} connected={serial.connected || fired} mocked={serial.mocked} /> <LiveFeed last={serial.last} frames={serial.frames} connected={serial.connected || fired} mocked={serial.mocked} />
<StatsTally /> <StatsTally />
</>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>