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:
co-authored by
Claude Opus 4.8
parent
71a255cf15
commit
a26185d47b
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
+24
-9
@@ -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' &&
|
||||||
<Button size="sm" onClick={() => void serial.connect()}>Start feed</Button>
|
(!serial.connected ? (
|
||||||
) : (
|
<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">
|
||||||
<LiveFeed last={serial.last} frames={serial.frames} connected={serial.connected} mocked={serial.mocked} />
|
{mode === 'live' ? (
|
||||||
<StatsTally />
|
<LiveBoardFeed feed={feed} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LiveFeed last={serial.last} frames={serial.frames} connected={serial.connected} mocked={serial.mocked} />
|
||||||
|
<StatsTally />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+19
-5
@@ -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">
|
||||||
<TriggerButtons onFire={onFire} />
|
{mode === 'live' ? (
|
||||||
<LiveFeed last={serial.last} frames={serial.frames} connected={serial.connected || fired} mocked={serial.mocked} />
|
<LiveBoardFeed feed={feed} />
|
||||||
<StatsTally />
|
) : (
|
||||||
|
<>
|
||||||
|
<TriggerButtons onFire={onFire} />
|
||||||
|
<LiveFeed last={serial.last} frames={serial.frames} connected={serial.connected || fired} mocked={serial.mocked} />
|
||||||
|
<StatsTally />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user