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
+31 -2
View File
@@ -1,9 +1,20 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, act, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { Module2 } from './Module2'
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() {
return render(
@@ -62,6 +73,24 @@ describe('Module2', () => {
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 () => {
const user = userEvent.setup()
renderPage()