feat(apess): wire real ZeroClaw nodes + live/sim + cloud-local fallback #1
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BoardActivity } from './BoardActivity'
|
||||
import type { NodeActivityEntry } from '@/lib/useCollective'
|
||||
|
||||
const entry = (over: Partial<NodeActivityEntry>): NodeActivityEntry => ({
|
||||
teamId: 't1',
|
||||
kind: 'flash',
|
||||
label: 'Flashed to 0x80F0000',
|
||||
ts: 'T',
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('BoardActivity', () => {
|
||||
it('shows an empty hint when there is no activity', () => {
|
||||
render(<BoardActivity activity={[]} />)
|
||||
expect(screen.getByTestId('board-activity-empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders entries newest-first with resolved team names', () => {
|
||||
render(
|
||||
<BoardActivity
|
||||
activity={[entry({ label: 'Flashed to 0x80F0000' }), entry({ kind: 'thinking', label: 'Agent started' })]}
|
||||
nameFor={(id) => (id === 't1' ? 'Team Rocket' : id)}
|
||||
/>,
|
||||
)
|
||||
const items = screen.getByTestId('board-activity').querySelectorAll('li')
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[0]).toHaveTextContent('Flashed to 0x80F0000')
|
||||
expect(screen.getAllByText('Team Rocket')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { NodeActivityEntry } from '@/lib/useCollective'
|
||||
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',
|
||||
}
|
||||
|
||||
export interface BoardActivityProps {
|
||||
activity: NodeActivityEntry[]
|
||||
/** Resolve a teamId to a display name (falls back to the id). */
|
||||
nameFor?: (teamId: string) => string
|
||||
}
|
||||
|
||||
/** Instructor-facing rolling feed of real on-device agent activity across boards. */
|
||||
export function BoardActivity({ activity, nameFor }: BoardActivityProps) {
|
||||
if (activity.length === 0) {
|
||||
return (
|
||||
<p data-testid="board-activity-empty" className="font-mono text-[11px] text-muted-foreground">
|
||||
No board activity yet — boards report generate / compile / flash here as teams work.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ul data-testid="board-activity" className="space-y-1.5">
|
||||
{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="text-muted-foreground shrink-0">{nameFor ? nameFor(e.teamId) : e.teamId}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate',
|
||||
e.kind === 'error' && 'text-destructive',
|
||||
e.kind === 'flash' && 'text-foreground font-medium',
|
||||
)}
|
||||
>
|
||||
{e.label}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const team = (id: string): TeamSnapshot => ({
|
||||
updatedAt: '2026-07-27T13:00:00.000Z',
|
||||
})
|
||||
|
||||
const empty: CollectiveState = { teams: {}, submissions: {} }
|
||||
const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [] }
|
||||
|
||||
describe('collectiveReducer', () => {
|
||||
it('seeds from a snapshot', () => {
|
||||
@@ -46,4 +46,35 @@ describe('collectiveReducer', () => {
|
||||
const next = collectiveReducer(empty, { type: 'score:new', teamId: 'ghost', total: 1 })
|
||||
expect(next).toBe(empty)
|
||||
})
|
||||
|
||||
it('tracks per-team board online status on node:status', () => {
|
||||
const next = collectiveReducer(empty, { type: 'node:status', teamId: 'a', online: true })
|
||||
expect(next.nodes.a).toBe(true)
|
||||
const off = collectiveReducer(next, { type: 'node:status', teamId: 'a', online: false })
|
||||
expect(off.nodes.a).toBe(false)
|
||||
})
|
||||
|
||||
it('prepends node:activity to a bounded, most-recent-first feed', () => {
|
||||
const first = collectiveReducer(empty, {
|
||||
type: 'node:activity',
|
||||
teamId: 'a',
|
||||
kind: 'thinking',
|
||||
label: 'Agent started',
|
||||
ts: 'T1',
|
||||
})
|
||||
const second = collectiveReducer(first, {
|
||||
type: 'node:activity',
|
||||
teamId: 'a',
|
||||
kind: 'flash',
|
||||
label: 'Flashed to 0x80F0000',
|
||||
ts: 'T2',
|
||||
})
|
||||
expect(second.activity.map((e) => e.label)).toEqual(['Flashed to 0x80F0000', 'Agent started'])
|
||||
})
|
||||
|
||||
it('preserves node status/activity across a fresh snapshot', () => {
|
||||
const withNode = collectiveReducer(empty, { type: 'node:status', teamId: 'a', online: true })
|
||||
const next = collectiveReducer(withNode, { type: 'snapshot', teams: [team('a')], submissions: [] })
|
||||
expect(next.nodes.a).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { useEffect, useReducer, useRef, useState } from 'react'
|
||||
import { openCollective, getTeams, getSubmissions } from './api'
|
||||
import type { TeamSnapshot, SubmissionSummary, WsEvent } from '@/types'
|
||||
import type { TeamSnapshot, SubmissionSummary, WsEvent, NodeActivityKind } from '@/types'
|
||||
|
||||
export interface NodeActivityEntry {
|
||||
teamId: string
|
||||
kind: NodeActivityKind
|
||||
label: string
|
||||
ts: string
|
||||
}
|
||||
|
||||
export interface CollectiveState {
|
||||
teams: Record<string, TeamSnapshot>
|
||||
submissions: Record<string, SubmissionSummary>
|
||||
/** teamId → board online. */
|
||||
nodes: Record<string, boolean>
|
||||
/** rolling board-activity feed, most-recent-first, bounded. */
|
||||
activity: NodeActivityEntry[]
|
||||
}
|
||||
|
||||
const empty: CollectiveState = { teams: {}, submissions: {} }
|
||||
const MAX_ACTIVITY = 40
|
||||
const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [] }
|
||||
|
||||
export function collectiveReducer(state: CollectiveState, event: WsEvent): CollectiveState {
|
||||
switch (event.type) {
|
||||
@@ -16,7 +28,7 @@ export function collectiveReducer(state: CollectiveState, event: WsEvent): Colle
|
||||
event.teams.forEach((t) => (teams[t.id] = t))
|
||||
const submissions: Record<string, SubmissionSummary> = {}
|
||||
event.submissions.forEach((s) => (submissions[s.teamId] = s))
|
||||
return { teams, submissions }
|
||||
return { ...state, teams, submissions }
|
||||
}
|
||||
case 'team:update':
|
||||
return { ...state, teams: { ...state.teams, [event.team.id]: event.team } }
|
||||
@@ -33,6 +45,16 @@ export function collectiveReducer(state: CollectiveState, event: WsEvent): Colle
|
||||
submissions: { ...state.submissions, [event.teamId]: { ...existing, scored: true } },
|
||||
}
|
||||
}
|
||||
case 'node:status':
|
||||
return { ...state, nodes: { ...state.nodes, [event.teamId]: event.online } }
|
||||
case 'node:activity':
|
||||
return {
|
||||
...state,
|
||||
activity: [
|
||||
{ teamId: event.teamId, kind: event.kind, label: event.label, ts: event.ts },
|
||||
...state.activity,
|
||||
].slice(0, MAX_ACTIVITY),
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
@@ -43,6 +65,8 @@ export type CollectiveStatus = 'connecting' | 'live' | 'polling'
|
||||
export interface Collective {
|
||||
teams: TeamSnapshot[]
|
||||
submissions: Record<string, SubmissionSummary>
|
||||
nodes: Record<string, boolean>
|
||||
activity: NodeActivityEntry[]
|
||||
status: CollectiveStatus
|
||||
}
|
||||
|
||||
@@ -93,5 +117,5 @@ export function useCollective(code: string): Collective {
|
||||
}, [code])
|
||||
|
||||
const teams = Object.values(state.teams).sort((a, b) => a.name.localeCompare(b.name))
|
||||
return { teams, submissions: state.submissions, status }
|
||||
return { teams, submissions: state.submissions, nodes: state.nodes, activity: state.activity, status }
|
||||
}
|
||||
|
||||
+11
-2
@@ -1,6 +1,7 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { AccessGate } from '@/components/AccessGate'
|
||||
import { TeamCard } from '@/components/TeamCard'
|
||||
import { BoardActivity } from '@/components/BoardActivity'
|
||||
import { useCollective } from '@/lib/useCollective'
|
||||
import { useNow } from '@/lib/useNow'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -15,11 +16,13 @@ function Stat({ label, value }: { label: string; value: number | string }) {
|
||||
}
|
||||
|
||||
function AdminBoard({ code }: { code: string }) {
|
||||
const { teams, submissions, status } = useCollective(code)
|
||||
const { teams, submissions, nodes, activity, status } = useCollective(code)
|
||||
const now = useNow()
|
||||
const connected = teams.filter((t) => t.deviceConnected).length
|
||||
const boardsLive = Object.values(nodes).filter(Boolean).length
|
||||
const submittedCount = Object.keys(submissions).length
|
||||
const judgedCount = Object.values(submissions).filter((s) => s.scored).length
|
||||
const nameFor = (id: string) => teams.find((t) => t.id === id)?.name || id
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background">
|
||||
@@ -40,13 +43,19 @@ function AdminBoard({ code }: { code: string }) {
|
||||
</header>
|
||||
|
||||
<section className="px-8 py-8 max-w-6xl mx-auto space-y-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-px bg-border rounded-md overflow-hidden">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-px bg-border rounded-md overflow-hidden">
|
||||
<Stat label="Teams" value={teams.length} />
|
||||
<Stat label="Connected" value={connected} />
|
||||
<Stat label="Boards live" value={boardsLive} />
|
||||
<Stat label="Submitted" value={submittedCount} />
|
||||
<Stat label="Judged" value={judgedCount} />
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-md p-4 space-y-2">
|
||||
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Board activity</div>
|
||||
<BoardActivity activity={activity} nameFor={nameFor} />
|
||||
</div>
|
||||
|
||||
{teams.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No teams have checked in yet.</p>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user