Merge feat/local-demo-phase1: local-demo landing + Phase 1 rework + live Telegram setup

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-22 00:24:11 -07:00
co-authored by Claude Opus 4.8
19 changed files with 687 additions and 362 deletions
+18
View File
@@ -309,6 +309,24 @@ export function createApp(opts: AppOptions): Express {
} }
}) })
// Set the team's Telegram bot token on their node and reload it so the channel
// starts. Public + participant-scoped (like /prompt): the value is the team's
// own @BotFather token; the node's bearer stays server-side in the bridge.
app.post('/nodes/:teamId/telegram', async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const b = req.body ?? {}
if (typeof b.token !== 'string' || !b.token.trim()) {
return res.status(400).json({ error: 'token is required' })
}
try {
const ok = await nodes.configureTelegram(String(req.params.teamId), b.token.trim())
if (!ok) return res.status(404).json({ error: 'no node registered for team' })
res.json({ ok: true })
} catch {
res.status(502).json({ error: 'could not apply the Telegram config — is your node online?' })
}
})
// Public liveness for a team's board — the wizard/self-test polls this after // Public liveness for a team's board — the wizard/self-test polls this after
// a claim. Online reflects the bridge's live /health + SSE view. // a claim. Online reflects the bridge's live /health + SSE view.
app.get('/nodes/:teamId/status', (req, res) => { app.get('/nodes/:teamId/status', (req, res) => {
+29
View File
@@ -16,11 +16,15 @@ const store = {} as unknown as Store
describe('node bridge + /nodes routes', () => { describe('node bridge + /nodes routes', () => {
let events: WsEvent[] let events: WsEvent[]
let sent: { node: NodeRef; message: string; agent?: string }[] let sent: { node: NodeRef; message: string; agent?: string }[]
let telegramCalls: { node: NodeRef; token: string }[]
let telegramThrows: boolean
let app: ReturnType<typeof createApp> let app: ReturnType<typeof createApp>
beforeEach(() => { beforeEach(() => {
events = [] events = []
sent = [] sent = []
telegramCalls = []
telegramThrows = false
const nodes = createNodeBridge({ const nodes = createNodeBridge({
broadcast: (e) => events.push(e), broadcast: (e) => events.push(e),
ping: async () => true, // pretend the node is online ping: async () => true, // pretend the node is online
@@ -31,11 +35,18 @@ describe('node bridge + /nodes routes', () => {
sent.push({ node, message, agent }) sent.push({ node, message, agent })
return `echo: ${message}` return `echo: ${message}`
}, },
setTelegram: async (node, token) => {
if (telegramThrows) throw new Error('node reload failed (403)')
telegramCalls.push({ node, token })
},
subscribe: () => () => {}, // no live SSE in the unit test subscribe: () => () => {}, // no live SSE in the unit test
}) })
app = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE, nodes }) app = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE, nodes })
}) })
const registerNode = () =>
request(app).post('/nodes').set('x-access-code', ADMIN).send({ teamId: 't1', url: 'http://n', token: 'zc_secret' })
it('registers a node (admin only) and never leaks the token', async () => { it('registers a node (admin only) and never leaks the token', async () => {
await request(app).post('/nodes').send({ teamId: 't1', url: 'http://n', token: 'zc_secret' }).expect(401) await request(app).post('/nodes').send({ teamId: 't1', url: 'http://n', token: 'zc_secret' }).expect(401)
@@ -96,6 +107,24 @@ describe('node bridge + /nodes routes', () => {
expect(sent.at(-1)?.agent).toBe('cloud') expect(sent.at(-1)?.agent).toBe('cloud')
}) })
it('applies a Telegram token to a registered node (public, token-scoped)', async () => {
await registerNode().expect(201)
const res = await request(app).post('/nodes/t1/telegram').send({ token: 'bot-123' }).expect(200)
expect(res.body).toEqual({ ok: true })
expect(telegramCalls).toEqual([{ node: { teamId: 't1', url: 'http://n', token: 'zc_secret' }, token: 'bot-123' }])
})
it('400s a missing token, 404s an unregistered team', async () => {
await request(app).post('/nodes/t1/telegram').send({ token: '' }).expect(400)
await request(app).post('/nodes/nope/telegram').send({ token: 'bot-123' }).expect(404)
})
it('502s when the node rejects the config/reload', async () => {
await registerNode().expect(201)
telegramThrows = true
await request(app).post('/nodes/t1/telegram').send({ token: 'bot-123' }).expect(502)
})
it('503s when no bridge is configured', async () => { it('503s when no bridge is configured', async () => {
const bare = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE }) const bare = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE })
await request(bare).get('/nodes').set('x-access-code', ADMIN).expect(503) await request(bare).get('/nodes').set('x-access-code', ADMIN).expect(503)
+44
View File
@@ -26,6 +26,50 @@ function setup(pingUp = true) {
const statusEvents = (feed: WsEvent[]) => feed.filter((e) => e.type === 'node:status') const statusEvents = (feed: WsEvent[]) => feed.filter((e) => e.type === 'node:status')
describe('createNodeBridge — configureTelegram', () => {
it('returns false when the team has no registered node (no apply attempted)', async () => {
let called = false
const bridge = createNodeBridge({
broadcast: () => {},
ping: async () => true,
subscribe: () => () => {},
setTelegram: async () => {
called = true
},
})
expect(await bridge.configureTelegram('nobody', 'tok')).toBe(false)
expect(called).toBe(false)
})
it('applies the token to a registered node', async () => {
const applied: Array<{ url: string; token: string }> = []
const bridge = createNodeBridge({
broadcast: () => {},
ping: async () => true,
subscribe: () => () => {},
setTelegram: async (n, token) => {
applied.push({ url: n.url, token })
},
})
await bridge.register({ teamId: 't1', url: 'http://b', token: 'zc_secret' })
expect(await bridge.configureTelegram('t1', 'bot-token')).toBe(true)
expect(applied).toEqual([{ url: 'http://b', token: 'bot-token' }])
})
it('propagates a node rejection as a throw', async () => {
const bridge = createNodeBridge({
broadcast: () => {},
ping: async () => true,
subscribe: () => () => {},
setTelegram: async () => {
throw new Error('node reload failed (403)')
},
})
await bridge.register({ teamId: 't1', url: 'http://b', token: 'zc_secret' })
await expect(bridge.configureTelegram('t1', 'bot-token')).rejects.toThrow(/reload failed/)
})
})
const flash: WsEvent = { type: 'node:activity', teamId: 't1', kind: 'flash', label: 'Flashed to 0x80F0000', ts: 'T' } const flash: WsEvent = { type: 'node:activity', teamId: 't1', kind: 'flash', label: 'Flashed to 0x80F0000', ts: 'T' }
describe('createNodeBridge — per-team activity', () => { describe('createNodeBridge — per-team activity', () => {
+41
View File
@@ -139,6 +139,35 @@ export async function promptAndWait(node: NodeRef, message: string, agent = 'def
return (body.response ?? '').trim() return (body.response ?? '').trim()
} }
/**
* Set the team's Telegram bot token on their node and restart it so the channel
* comes up. Two calls against the board's ZeroClaw gateway (bearer = the node's
* server-side token):
* 1. PUT /api/config/prop — writes `channels.telegram.main.bot_token`. The
* gateway auto-creates the `main` alias (`ensure_map_key_for_path`) and
* enc2-encrypts the secret on disk.
* 2. POST /admin/reload — in-place daemon reload; re-instantiates every
* subsystem (channels included) from fresh config, so the newly-added
* Telegram channel starts. Same PID, sub-second downtime.
* Remote /admin/reload requires the board to have `gateway.allow_remote_admin`
* enabled (+ pairing); on an open board it 403s — surfaced as a thrown error.
*/
export async function configureTelegram(node: NodeRef, token: string): Promise<void> {
const auth = { authorization: `Bearer ${node.token}` }
const put = await fetch(`${node.url}/api/config/prop`, {
method: 'PUT',
headers: { ...auth, 'content-type': 'application/json' },
body: JSON.stringify({
path: 'channels.telegram.main.bot_token',
value: token,
comment: 'set via APESS onboarding',
}),
})
if (!put.ok) throw new Error(`config write failed (${put.status})`)
const reload = await fetch(`${node.url}/admin/reload`, { method: 'POST', headers: auth })
if (!reload.ok) throw new Error(`node reload failed (${reload.status})`)
}
export interface SubscribeOptions { export interface SubscribeOptions {
/** Aborts the whole reconnect loop when fired. */ /** Aborts the whole reconnect loop when fired. */
signal?: AbortSignal signal?: AbortSignal
@@ -238,6 +267,10 @@ export interface NodeBridge {
/** Say-hi: prompt the node and return its reply text (blocking). `null` if the /** Say-hi: prompt the node and return its reply text (blocking). `null` if the
* team has no registered node. Non-flash use only (the greeting). */ * team has no registered node. Non-flash use only (the greeting). */
sayHi(teamId: string, message: string, agent?: string): Promise<string | null> sayHi(teamId: string, message: string, agent?: string): Promise<string | null>
/** Write the team's Telegram bot token to their node + reload it so the channel
* starts. Resolves `true` on success, `false` if no node is registered;
* throws if the node rejects the config write or reload. */
configureTelegram(teamId: string, token: string): Promise<boolean>
/** Stream one team's node activity to a participant. Returns an unsubscribe fn. */ /** Stream one team's node activity to a participant. Returns an unsubscribe fn. */
onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void
stopAll(): void stopAll(): void
@@ -250,6 +283,7 @@ export interface NodeBridgeDeps {
ping?: (n: NodeRef) => Promise<boolean> ping?: (n: NodeRef) => Promise<boolean>
send?: (n: NodeRef, m: string, agent?: string) => Promise<void> send?: (n: NodeRef, m: string, agent?: string) => Promise<void>
sendAndWait?: (n: NodeRef, m: string, agent?: string) => Promise<string> sendAndWait?: (n: NodeRef, m: string, agent?: string) => Promise<string>
setTelegram?: (n: NodeRef, token: string) => Promise<void>
subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void, onStatus: (online: boolean) => void) => () => void subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void, onStatus: (online: boolean) => void) => () => void
} }
@@ -264,6 +298,7 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
const ping = deps.ping ?? pingNode const ping = deps.ping ?? pingNode
const send = deps.send ?? sendPrompt const send = deps.send ?? sendPrompt
const sendAndWait = deps.sendAndWait ?? promptAndWait const sendAndWait = deps.sendAndWait ?? promptAndWait
const setTelegram = deps.setTelegram ?? configureTelegram
const subscribe = deps.subscribe ?? ((n, on, onStatus) => subscribeNodeEvents(n, on, { onStatus })) const subscribe = deps.subscribe ?? ((n, on, onStatus) => subscribeNodeEvents(n, on, { onStatus }))
const online = new Map<string, boolean>() const online = new Map<string, boolean>()
const stops = new Map<string, () => void>() const stops = new Map<string, () => void>()
@@ -313,6 +348,12 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
if (!node) return null if (!node) return null
return sendAndWait(node, message, agent) return sendAndWait(node, message, agent)
}, },
async configureTelegram(teamId, token) {
const node = registry.get(teamId)
if (!node) return false
await setTelegram(node, token)
return true
},
onTeamActivity(teamId, listener) { onTeamActivity(teamId, listener) {
let set = teamListeners.get(teamId) let set = teamListeners.get(teamId)
if (!set) { if (!set) {
+10
View File
@@ -47,6 +47,16 @@ describe('PhaseStrip', () => {
expect(screen.getByText('Module 2').closest('a')).toHaveAttribute('data-state', 'pending') expect(screen.getByText('Module 2').closest('a')).toHaveAttribute('data-state', 'pending')
}) })
it('never lights up a phase AFTER the active one, even with a stale stored flag', () => {
// a revisit / persisted session may have setup flagged done; on phase 1 it
// must still read pending, not green.
useSession.getState().completePhase('reg')
useSession.getState().completePhase('setup')
renderAt('/workshop', 'reg')
expect(screen.getByText('Team reg').closest('a')).toHaveAttribute('data-state', 'active')
expect(screen.getByText('Meet your node').closest('a')).toHaveAttribute('data-state', 'pending')
})
it('links each phase to its workshop sub-route', () => { it('links each phase to its workshop sub-route', () => {
renderAt('/workshop') renderAt('/workshop')
expect(screen.getByText('Team reg').closest('a')).toHaveAttribute('href', '/workshop') expect(screen.getByText('Team reg').closest('a')).toHaveAttribute('href', '/workshop')
+7 -1
View File
@@ -22,10 +22,16 @@ export interface PhaseStripProps {
export function PhaseStrip({ active }: PhaseStripProps) { export function PhaseStrip({ active }: PhaseStripProps) {
const phases = useSession((s) => s.phases) const phases = useSession((s) => s.phases)
// Progress is driven by WHERE you are: phases before the active one are done,
// the active one is active, later ones are pending — regardless of stray
// stored flags (a completed-then-revisited phase must not light up a *future*
// phase green). Falls back to the stored flags only when no active phase is
// given (e.g. an embedded/preview use).
const activeIndex = PHASES.findIndex((p) => p.key === active)
return ( return (
<nav aria-label="Workshop phases" data-testid="phase-strip" className="grid grid-cols-5 gap-2 px-8 py-4 border-b border-border"> <nav aria-label="Workshop phases" data-testid="phase-strip" className="grid grid-cols-5 gap-2 px-8 py-4 border-b border-border">
{PHASES.map((p, i) => { {PHASES.map((p, i) => {
const done = phases[p.key] const done = activeIndex >= 0 ? i < activeIndex : phases[p.key]
const isActive = active === p.key const isActive = active === p.key
return ( return (
<NavLink <NavLink
+93
View File
@@ -0,0 +1,93 @@
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useSession } from '@/store/session'
import { sayHi } from '@/lib/api'
import { cn } from '@/lib/utils'
type HiState = 'idle' | 'running' | 'ok'
/**
* "Say hi to your agent" — chats with the cloud agent on the team's own board.
* A reply proves the node is live and listening. Shown once the board is bound.
*/
export function SayHiCard() {
const teamId = useSession((s) => s.teamId)
const connected = useSession((s) => s.device.connected)
const [hi, setHi] = useState<HiState>('idle')
const [reply, setReply] = useState('')
const [error, setError] = useState<string | null>(null)
const saidHi = async () => {
if (hi === 'running') return
setHi('running')
setError(null)
setReply('')
try {
const r = await sayHi(teamId, 'cloud')
setReply(r || '(your node replied)')
setHi('ok')
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not reach your node.')
setHi('idle')
}
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Say hi to your agent</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground leading-relaxed">
Your board is a node running its own agent. Say hi — when it replies, your node is
live and listening.
</p>
{(hi !== 'idle' || reply) && (
<div className="space-y-2" data-testid="say-hi-chat">
<div className="flex justify-end">
<span className="rounded-lg bg-primary/10 text-foreground px-3 py-1.5 text-sm max-w-[80%]">Hi 👋</span>
</div>
{hi === 'running' && (
<div className="flex items-center gap-2 font-mono text-[11px] text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-amber animate-pulse" />
your node is thinking…
</div>
)}
{reply && (
<div className="flex justify-start">
<span
className="rounded-lg border border-teal/40 bg-teal/5 px-3 py-1.5 text-sm max-w-[80%]"
data-testid="node-reply"
>
{reply}
</span>
</div>
)}
</div>
)}
<Button
variant={hi === 'ok' ? 'outline' : 'default'}
className="w-full justify-start"
disabled={!connected || hi === 'running'}
onClick={saidHi}
>
<span
className={cn(
'w-2 h-2 rounded-full mr-3',
hi === 'ok' ? 'bg-teal' : hi === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground',
)}
/>
{hi === 'ok' ? 'Your node replied ✓' : hi === 'running' ? 'Waiting for your node…' : 'Say hi to your agent'}
</Button>
{error && (
<p role="alert" className="text-xs text-red-500 leading-relaxed">
{error}
</p>
)}
</CardContent>
</Card>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TelegramSetup } from './TelegramSetup'
import { useSession } from '@/store/session'
import { configureTelegram } from '@/lib/api'
vi.mock('@/lib/api', () => ({ configureTelegram: vi.fn() }))
const mockApply = vi.mocked(configureTelegram)
const TOKEN = '8842279117:AAFBBcbUNRsvhgzFvXE1W_Yh6VDCnGkyijw'
describe('TelegramSetup', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
mockApply.mockReset()
mockApply.mockResolvedValue(undefined)
})
it('walks through the wizard, applies the token to the node, and saves it', async () => {
const user = userEvent.setup()
render(<TelegramSetup />)
await user.click(screen.getByRole('button', { name: /set up telegram/i }))
// step 1 → step 2
await user.click(screen.getByRole('button', { name: /next/i }))
const finish = screen.getByRole('button', { name: /finish/i })
expect(finish).toBeDisabled() // no token yet
await user.type(screen.getByLabelText(/bot token/i), TOKEN)
expect(finish).toBeEnabled()
await user.click(finish)
// pushed to the running node before it's saved locally
expect(mockApply).toHaveBeenCalledWith(expect.any(String), TOKEN)
await waitFor(() => expect(useSession.getState().channels.telegram).toBe(TOKEN))
// card now shows the connected state with the bot id
expect(screen.getByTestId('telegram-configured')).toHaveTextContent(/bot 8842279117/)
})
it('surfaces an error and does NOT save when the node rejects the token', async () => {
mockApply.mockRejectedValue(new Error('could not apply the Telegram config — is your node online?'))
const user = userEvent.setup()
render(<TelegramSetup />)
await user.click(screen.getByRole('button', { name: /set up telegram/i }))
await user.click(screen.getByRole('button', { name: /next/i }))
await user.type(screen.getByLabelText(/bot token/i), TOKEN)
await user.click(screen.getByRole('button', { name: /finish/i }))
expect(await screen.findByRole('alert')).toHaveTextContent(/could not apply/i)
expect(useSession.getState().channels.telegram).toBeNull() // not saved on failure
})
it('rejects an obviously bad token and can be cancelled', async () => {
const user = userEvent.setup()
render(<TelegramSetup />)
await user.click(screen.getByRole('button', { name: /set up telegram/i }))
await user.click(screen.getByRole('button', { name: /next/i }))
const token = screen.getByLabelText(/bot token/i)
await user.type(token, 'not-a-token')
await user.tab() // blur → touched
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /finish/i })).toBeDisabled()
await user.click(screen.getByRole('button', { name: /back/i }))
await user.click(screen.getByRole('button', { name: /cancel/i }))
expect(useSession.getState().channels.telegram).toBeNull()
})
it('lets a configured token be removed', async () => {
useSession.getState().setChannels({ telegram: TOKEN })
const user = userEvent.setup()
render(<TelegramSetup />)
expect(screen.getByTestId('telegram-configured')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /remove/i }))
expect(useSession.getState().channels.telegram).toBeNull()
expect(screen.getByRole('button', { name: /set up telegram/i })).toBeInTheDocument()
})
})
+178
View File
@@ -0,0 +1,178 @@
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Modal } from '@/components/ui/modal'
import { useSession } from '@/store/session'
import { configureTelegram } from '@/lib/api'
// A @BotFather token looks like `8842279117:AAF...` — a numeric id, a colon,
// then a ~35-char secret. Validate the shape so we don't save an obvious typo.
const TOKEN_RE = /^\d{6,}:[A-Za-z0-9_-]{30,}$/
const botId = (token: string) => token.split(':')[0]
/**
* Extra channel — Telegram. A guided modal wizard: make a bot with @BotFather,
* paste its token, done. The token is stored with the team so the node can pick
* it up (same value the node dashboard's Config → channels expects). Optional —
* the whole thing can be cancelled out of.
*/
export function TelegramSetup() {
const teamId = useSession((s) => s.teamId)
const telegram = useSession((s) => s.channels.telegram)
const setChannels = useSession((s) => s.setChannels)
const [open, setOpen] = useState(false)
const [step, setStep] = useState(0)
const [token, setToken] = useState('')
const [touched, setTouched] = useState(false)
const [applying, setApplying] = useState(false)
const [error, setError] = useState<string | null>(null)
const valid = TOKEN_RE.test(token.trim())
const start = () => {
setToken(telegram ?? '')
setStep(0)
setTouched(false)
setError(null)
setApplying(false)
setOpen(true)
}
const finish = async () => {
if (applying) return
setApplying(true)
setError(null)
try {
// push the token to the running node + restart it so the channel starts
await configureTelegram(teamId, token.trim())
setChannels({ telegram: token.trim() })
setOpen(false)
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not apply the token to your node.')
} finally {
setApplying(false)
}
}
const remove = () => setChannels({ telegram: null })
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
Extra channels
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">optional</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground leading-relaxed">
Your dashboard is the main way in, but you can also talk to your node from{' '}
<span className="font-medium text-foreground">Telegram</span>. Set up a bot and it routes
straight to your current agent.
</p>
{telegram ? (
<div
className="flex items-center justify-between gap-3 rounded-md border border-teal/40 bg-teal/5 px-3 py-2"
data-testid="telegram-configured"
>
<div className="flex items-center gap-2 min-w-0">
<span className="w-2 h-2 rounded-full bg-teal shrink-0" />
<span className="text-sm">
Telegram connected{' '}
<span className="font-mono text-xs text-muted-foreground">· bot {botId(telegram)}</span>
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<button type="button" onClick={start} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground hover:text-foreground">
Edit
</button>
<button type="button" onClick={remove} className="font-mono text-[10px] uppercase tracking-widest text-red-500/80 hover:text-red-500">
Remove
</button>
</div>
</div>
) : (
<Button variant="outline" onClick={start}>
Set up Telegram →
</Button>
)}
</CardContent>
<Modal open={open} onClose={() => setOpen(false)} title="Connect Telegram">
{step === 0 ? (
<div className="space-y-4">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Step 1 of 2 · Make a bot</div>
<ol className="space-y-2 text-sm text-muted-foreground leading-relaxed list-decimal pl-5">
<li>
Open{' '}
<a href="https://t.me/BotFather" target="_blank" rel="noreferrer" className="text-primary hover:underline">
@BotFather
</a>{' '}
in Telegram.
</li>
<li>
Send <span className="font-mono text-xs">/newbot</span> and follow the prompts (pick a name + a
username ending in <span className="font-mono text-xs">bot</span>).
</li>
<li>BotFather replies with a token — copy it for the next step.</li>
</ol>
<div className="flex justify-between pt-2">
<Button variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={() => setStep(1)}>Next →</Button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Step 2 of 2 · Paste the token</div>
<div className="space-y-2">
<label htmlFor="tg-token" className="text-sm text-muted-foreground">
Bot token from @BotFather
</label>
<Input
id="tg-token"
value={token}
onChange={(e) => setToken(e.target.value)}
onBlur={() => setTouched(true)}
placeholder="8842279117:AAF…"
className="font-mono text-xs"
autoComplete="off"
spellCheck={false}
/>
{touched && token.trim() && !valid && (
<p role="alert" className="text-xs text-red-500">
That doesn&rsquo;t look like a bot token — it should be digits, a colon, then a long secret.
</p>
)}
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
On finish we write it to your node and restart it so Telegram comes up. You can
remove it any time.
</p>
{applying && (
<div className="flex items-center gap-2 font-mono text-[11px] text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-amber animate-pulse" />
writing config + restarting your node…
</div>
)}
{error && (
<p role="alert" className="text-xs text-red-500 leading-relaxed">
{error}
</p>
)}
</div>
<div className="flex justify-between pt-2">
<Button variant="ghost" onClick={() => setStep(0)} disabled={applying}>
← Back
</Button>
<Button onClick={finish} disabled={!valid || applying}>
{applying ? 'Applying…' : 'Finish'}
</Button>
</div>
</div>
)}
</Modal>
</Card>
)
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { VoiceSetup } from './VoiceSetup'
import { useSession } from '@/store/session'
describe('VoiceSetup', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
})
it('toggles the voice preference on and off', async () => {
const user = userEvent.setup()
render(<VoiceSetup />)
const toggle = screen.getByRole('switch', { name: /enable voice/i })
expect(toggle).toHaveAttribute('aria-checked', 'false')
await user.click(toggle)
expect(useSession.getState().channels.voice).toBe(true)
expect(toggle).toHaveAttribute('aria-checked', 'true')
await user.click(toggle)
expect(useSession.getState().channels.voice).toBe(false)
})
})
+48
View File
@@ -0,0 +1,48 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useSession } from '@/store/session'
import { cn } from '@/lib/utils'
/**
* Voice channel — a simple enable/disable toggle. When on, the team uses the mic
* button in the node dashboard to talk to the agent directly. Client-side pref
* (the node dashboard owns the actual mic), so this just records the choice.
*/
export function VoiceSetup() {
const voice = useSession((s) => s.channels.voice)
const setChannels = useSession((s) => s.setChannels)
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
Voice
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">optional</span>
</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground leading-relaxed">
Talk to your node out loud — use the mic button in its dashboard. Enable it here so your
team knows it&rsquo;s on.
</p>
<button
type="button"
role="switch"
aria-checked={voice}
aria-label="Enable voice"
onClick={() => setChannels({ voice: !voice })}
className={cn(
'relative w-11 h-6 rounded-full shrink-0 transition-colors',
voice ? 'bg-teal' : 'bg-muted-foreground/30',
)}
>
<span
className={cn(
'absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-background shadow transition-transform',
voice && 'translate-x-5',
)}
/>
</button>
</CardContent>
</Card>
)
}
+17
View File
@@ -188,6 +188,23 @@ export async function askNode(teamId: string, prompt: string, agent = 'cloud'):
return sayHi(teamId, agent, prompt) return sayHi(teamId, agent, prompt)
} }
/**
* Apply a Telegram bot token to the team's node and restart it so the channel
* comes up. The server writes it into the node's config and calls the in-place
* reload; resolves when the node has accepted both.
*/
export async function configureTelegram(teamId: string, token: string): Promise<void> {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/telegram`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token }),
})
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
throw new Error(body.error ?? `configureTelegram ${res.status}`)
}
}
export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> { export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/prompt`, { const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/prompt`, {
method: 'POST', method: 'POST',
+6 -51
View File
@@ -1,15 +1,8 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import { EnvSetup } from './EnvSetup' import { EnvSetup } from './EnvSetup'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { sayHi } from '@/lib/api'
vi.mock('@/lib/api', async (orig) => ({
...(await orig<typeof import('@/lib/api')>()),
sayHi: vi.fn(),
}))
const mockSayHi = vi.mocked(sayHi)
function renderPage() { function renderPage() {
return render( return render(
@@ -28,7 +21,6 @@ describe('EnvSetup — Meet your node', () => {
beforeEach(() => { beforeEach(() => {
useSession.getState().reset() useSession.getState().reset()
sessionStorage.clear() sessionStorage.clear()
mockSayHi.mockReset()
}) })
it('renders the phase strip set to setup and the heading', () => { it('renders the phase strip set to setup and the heading', () => {
@@ -40,10 +32,9 @@ describe('EnvSetup — Meet your node', () => {
).toHaveAttribute('data-state', 'active') ).toHaveAttribute('data-state', 'active')
}) })
it('prompts to claim a board first when not connected', () => { it('prompts to claim a board first when not connected, and gates Proceed', () => {
renderPage() renderPage()
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument() expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /say hi to your agent/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled() expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
}) })
@@ -63,52 +54,16 @@ describe('EnvSetup — Meet your node', () => {
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument() expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
}) })
it('says hi to the agent and shows its reply', async () => { it('keeps Proceed gated when connected but no domain is named', () => {
mockSayHi.mockResolvedValue("Hi! I'm your node — I can read your sensor and drive the matrix.")
connect() connect()
renderPage() renderPage()
fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
expect(await screen.findByTestId('node-reply')).toHaveTextContent(/read your sensor/i)
expect(screen.getByRole('button', { name: /your node replied/i })).toBeInTheDocument()
})
it('requires a domain even after the agent replies', async () => {
mockSayHi.mockResolvedValue('hello there')
connect()
renderPage()
fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
await screen.findByTestId('node-reply')
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
})
it('keeps Proceed gated when a domain is named but the agent has not replied', () => {
connect()
useSession.getState().setDomain('air quality')
renderPage()
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled() expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
}) })
it('enables Proceed once the agent has replied AND a domain is named', async () => { it('enables Proceed once connected AND a domain is named', () => {
mockSayHi.mockResolvedValue('hello there')
connect() connect()
useSession.getState().setDomain('structural stress') useSession.getState().setDomain('structural stress')
renderPage() renderPage()
expect(screen.getByRole('button', { name: /proceed/i })).toBeEnabled()
const proceed = screen.getByRole('button', { name: /proceed/i })
expect(proceed).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
await screen.findByTestId('node-reply')
expect(proceed).toBeEnabled()
expect(useSession.getState().stats.calls).toBe(0)
})
it('surfaces an error when the node does not answer', async () => {
mockSayHi.mockRejectedValue(new Error('your node did not answer — is it online?'))
connect()
renderPage()
fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
expect(await screen.findByRole('alert')).toHaveTextContent(/did not answer/i)
}) })
}) })
+4 -132
View File
@@ -1,4 +1,3 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -7,38 +6,14 @@ import { PhaseStrip } from '@/components/PhaseStrip'
import { OpenYourNode } from '@/components/OpenYourNode' import { OpenYourNode } from '@/components/OpenYourNode'
import { DomainPicker } from '@/components/DomainPicker' import { DomainPicker } from '@/components/DomainPicker'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { sayHi } from '@/lib/api'
import { cn } from '@/lib/utils'
type HiState = 'idle' | 'running' | 'ok'
export function EnvSetup() { export function EnvSetup() {
const navigate = useNavigate() const navigate = useNavigate()
const teamId = useSession((s) => s.teamId)
const device = useSession((s) => s.device) const device = useSession((s) => s.device)
const domain = useSession((s) => s.domain) const domain = useSession((s) => s.domain)
const completePhase = useSession((s) => s.completePhase) const completePhase = useSession((s) => s.completePhase)
const [hi, setHi] = useState<HiState>('idle')
const [reply, setReply] = useState('')
const [hiError, setHiError] = useState<string | null>(null)
const saidHi = async () => { const ready = device.connected && domain.trim().length > 0
if (hi === 'running') return
setHi('running')
setHiError(null)
setReply('')
try {
// Chat with the agent on the team's own board; a reply means it's live.
const r = await sayHi(teamId, 'cloud')
setReply(r || '(your node replied)')
setHi('ok')
} catch (e) {
setHiError(e instanceof Error ? e.message : 'Could not reach your node.')
setHi('idle')
}
}
const ready = device.connected && hi === 'ok' && domain.trim().length > 0
const onProceed = () => { const onProceed = () => {
completePhase('setup') completePhase('setup')
@@ -66,8 +41,8 @@ export function EnvSetup() {
</Badge> </Badge>
<h1 className="text-3xl font-bold tracking-tight">Meet your node</h1> <h1 className="text-3xl font-bold tracking-tight">Meet your node</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl"> <p className="text-sm text-muted-foreground mt-2 max-w-xl">
Your board is a node running its own agent. Open it, say hi, name the domain it&rsquo;s Open your node&rsquo;s own dashboard and name the domain it&rsquo;s for — then
for — then you&rsquo;re clear for Module 1. you&rsquo;re clear for Module 1.
</p> </p>
</div> </div>
@@ -81,65 +56,7 @@ export function EnvSetup() {
</CardContent> </CardContent>
</Card> </Card>
{/* 2 — Say hi to your agent */} {/* 2 — Pick your domain */}
<Card>
<CardHeader>
<CardTitle className="text-base">Say hi to your agent</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground leading-relaxed">
Say hi to the agent running on your board. When it replies, your node is live and
listening — and you&rsquo;re clear to name its domain.
</p>
{/* the exchange */}
{(hi !== 'idle' || reply) && (
<div className="space-y-2" data-testid="say-hi-chat">
<div className="flex justify-end">
<span className="rounded-lg bg-primary/10 text-foreground px-3 py-1.5 text-sm max-w-[80%]">Hi 👋</span>
</div>
{hi === 'running' && (
<div className="flex items-center gap-2 font-mono text-[11px] text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-amber animate-pulse" />
your node is thinking…
</div>
)}
{reply && (
<div className="flex justify-start">
<span className="rounded-lg border border-teal/40 bg-teal/5 px-3 py-1.5 text-sm max-w-[80%]" data-testid="node-reply">
{reply}
</span>
</div>
)}
</div>
)}
<Button
variant={hi === 'ok' ? 'outline' : 'default'}
className="w-full justify-start"
disabled={!device.connected || hi === 'running'}
onClick={saidHi}
>
<span
className={cn(
'w-2 h-2 rounded-full mr-3',
hi === 'ok' ? 'bg-teal' : hi === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground',
)}
/>
{hi === 'ok' ? 'Your node replied ✓' : hi === 'running' ? 'Waiting for your node…' : 'Say hi to your agent'}
</Button>
{hiError && (
<p role="alert" className="text-xs text-red-500 leading-relaxed">{hiError}</p>
)}
{!device.connected && (
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
Bind your board in team registration first.
</p>
)}
</CardContent>
</Card>
{/* 3 — Pick your domain */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Pick your domain</CardTitle> <CardTitle className="text-base">Pick your domain</CardTitle>
@@ -149,51 +66,6 @@ export function EnvSetup() {
</CardContent> </CardContent>
</Card> </Card>
{/* 4 — Extra channels (optional) */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Extra channels <span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">optional</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground leading-relaxed">
<p>
Your dashboard is the main way in, but you can reach your node other ways too:
</p>
<ul className="space-y-2">
<li>
<span className="font-medium text-foreground">Telegram</span> — make a bot with{' '}
<span className="font-mono text-xs">@BotFather</span>, then paste its token in the node
dashboard → Config → channels.
</li>
<li>
<span className="font-medium text-foreground">Voice</span> — use the mic button in the
node dashboard to talk to it directly.
</li>
</ul>
</CardContent>
</Card>
{/* 5 — Lock it down (policy moment) */}
<Card>
<CardHeader>
<CardTitle className="text-base">Lock it down</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground leading-relaxed">
<p>
Your board boots <span className="font-medium text-foreground">open</span> so setup is
frictionless — anyone on the LAN can reach it right now. That&rsquo;s your first{' '}
<span className="font-medium text-foreground">policy</span> decision: when setup is done,
harden it so only your group can talk to it.
</p>
<p>
Run <span className="font-mono text-xs">zeroclaw-lockdown.sh</span> on the board — it mints
a pair code your group uses to reconnect. Leave it open for now; you&rsquo;ll revisit this
once you&rsquo;ve designed the agent&rsquo;s policies.
</p>
</CardContent>
</Card>
<div className="flex justify-end pt-4"> <div className="flex justify-end pt-4">
<Button size="lg" disabled={!ready} onClick={onProceed}> <Button size="lg" disabled={!ready} onClick={onProceed}>
Proceed to Module 1 → Proceed to Module 1 →
+13 -15
View File
@@ -19,31 +19,29 @@ describe('Landing', () => {
expect(screen.getByText(/July 27, 2026/i)).toBeInTheDocument() expect(screen.getByText(/July 27, 2026/i)).toBeInTheDocument()
}) })
it('exposes the four PRD success-metric stats', () => { it('shows the presenter contact details', () => {
renderLanding() renderLanding()
expect(screen.getByText(/14:00 – 19:00/i)).toBeInTheDocument() expect(screen.getByRole('link', { name: /^redclaw\.dev$/i })).toHaveAttribute('href', 'https://redclaw.dev')
expect(screen.getByText(/15 teams/i)).toBeInTheDocument() expect(screen.getByRole('link', { name: /osobh@redclaw\.dev/i })).toHaveAttribute(
expect(screen.getAllByText(/Arduino Uno Q/i).length).toBeGreaterThan(0) 'href',
'mailto:[email protected]',
)
}) })
it('has a primary CTA linking to /workshop', () => { it('has a primary CTA linking to /workshop', () => {
renderLanding() renderLanding()
const ctas = screen.getAllByRole('link', { name: /enter workshop/i }) const ctas = screen.getAllByRole('link', { name: /start the workshop/i })
expect(ctas.length).toBeGreaterThan(0) expect(ctas.length).toBeGreaterThan(0)
ctas.forEach((cta) => expect(cta).toHaveAttribute('href', '/workshop')) ctas.forEach((cta) => expect(cta).toHaveAttribute('href', '/workshop'))
}) })
it('exposes staff sign-in entrances for judge and instructor', () => { it('drops the class-event chrome for the local single-team demo', () => {
renderLanding() renderLanding()
expect(screen.getByRole('link', { name: /judge/i })).toHaveAttribute('href', '/judge') // no staff sign-in, no lecture links, no programme timeline
expect(screen.getByRole('link', { name: /instructor/i })).toHaveAttribute('href', '/admin') expect(screen.queryByRole('link', { name: /judge/i })).toBeNull()
}) expect(screen.queryByRole('link', { name: /instructor/i })).toBeNull()
expect(screen.queryByRole('link', { name: /lecture/i })).toBeNull()
it('lists the programme timeline with at least 5 phases', () => { expect(screen.queryByTestId('programme')).toBeNull()
renderLanding()
const programme = screen.getByTestId('programme')
const rows = programme.querySelectorAll('[data-programme-row]')
expect(rows.length).toBeGreaterThanOrEqual(5)
}) })
it('renders the speaker card with Omar Sobh', () => { it('renders the speaker card with Omar Sobh', () => {
+20 -158
View File
@@ -1,37 +1,7 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
interface ProgrammeRow {
time: string
title: string
desc: string
tags?: string[]
}
const PROGRAMME: ProgrammeRow[] = [
{ time: '10:45', title: 'Lecture · Agentic design thinking', desc: 'Separate morning session — the five layers, and designing for failure.', tags: ['lecture'] },
{ time: '14:00', title: 'Arrival & registration', desc: 'Boards backed up and reflashed for the workshop while you register.', tags: ['setup'] },
{ time: '14:25', title: 'Meet your node', desc: 'The board you already know — now carrying an agent that can drive your devices.', tags: ['setup'] },
{ time: '14:45', title: 'Module 1 · Domain & events', desc: 'Your sensors, your data, the events that matter — Layer 1.', tags: ['build'] },
{ time: '16:10', title: 'Module 2 · Skills & policies', desc: 'Drive a real sensor, enumerate the failure states, set the actuation gate — Layers 2 + 3.', tags: ['build'] },
{ time: '17:40', title: 'Module 3 · Harness, loops & submit', desc: 'How it degrades, how often it runs, then submit — Layers 4 + 5.', tags: ['add'] },
{ time: '19:00', title: 'Judging & award', desc: 'Panel reviews the Agent Design Documents; RedClaw Systems award announced.', tags: ['judge'] },
]
const STACK = [
{ icon: '⚙', name: 'Rust on the Uno Q', desc: 'ZeroClaw runs on the Uno Q’s quad-core Linux side and self-flashes its on-board STM32 MCU.' },
{ icon: '⌬', name: 'Claude on the edge', desc: 'Each node reasons with Claude via a Max token, with an on-board Qwen model as an offline fallback.' },
{ icon: '⚡', name: 'Talk to your node', desc: 'Load expert skills, then converse with the board through its own dashboard, Telegram, or voice.' },
]
const PREREQS = [
'Laptop with Chrome or Edge (to reach your board)',
'USB-C data cable (provided in kit)',
'Basic Rust familiarity helpful but not required',
'Hands-on attitude — you will flash hardware today',
]
export function Landing() { export function Landing() {
return ( return (
@@ -42,30 +12,8 @@ export function Landing() {
<span className="text-muted-foreground"> · Workshop</span> <span className="text-muted-foreground"> · Workshop</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link to="/lecture" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
Lecture
</Link>
<details className="relative group [&_summary::-webkit-details-marker]:hidden">
<summary className="list-none cursor-pointer select-none font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
Staff sign-in ▾
</summary>
<div className="absolute right-0 mt-2 w-40 rounded-md border border-border bg-background shadow-md py-1 z-50">
<Link
to="/judge"
className="block px-3 py-2 font-mono text-[11px] text-muted-foreground hover:text-foreground hover:bg-secondary/60 tracking-widest uppercase"
>
Judge
</Link>
<Link
to="/admin"
className="block px-3 py-2 font-mono text-[11px] text-muted-foreground hover:text-foreground hover:bg-secondary/60 tracking-widest uppercase"
>
Instructor
</Link>
</div>
</details>
<Button asChild size="sm"> <Button asChild size="sm">
<Link to="/workshop">Enter workshop →</Link> <Link to="/workshop">Start the workshop →</Link>
</Button> </Button>
</div> </div>
</header> </header>
@@ -78,7 +26,7 @@ export function Landing() {
<h1 className="text-5xl md:text-6xl font-bold tracking-tight leading-[1.05]"> <h1 className="text-5xl md:text-6xl font-bold tracking-tight leading-[1.05]">
Design a domain node Design a domain node
<br /> <br />
<span className="text-primary">a Claude agent on the edge</span> <span className="text-primary">an APESS agent on the edge</span>
</h1> </h1>
<p className="text-base md:text-lg text-muted-foreground leading-relaxed max-w-2xl mx-auto"> <p className="text-base md:text-lg text-muted-foreground leading-relaxed max-w-2xl mx-auto">
Your Uno Q already senses. Today it gets an agent — one you talk to, one that drives your devices, and Your Uno Q already senses. Today it gets an agent — one you talk to, one that drives your devices, and
@@ -87,51 +35,8 @@ export function Landing() {
</p> </p>
<div className="flex flex-wrap gap-3 justify-center pt-4"> <div className="flex flex-wrap gap-3 justify-center pt-4">
<Button asChild size="lg"> <Button asChild size="lg">
<Link to="/workshop">Enter workshop →</Link> <Link to="/workshop">Start the workshop →</Link>
</Button> </Button>
<Button asChild variant="outline" size="lg">
<Link to="/lecture">Read the lecture</Link>
</Button>
</div>
</div>
<div className="max-w-4xl mx-auto mt-16 grid grid-cols-2 md:grid-cols-4 gap-px bg-border rounded-md overflow-hidden text-center">
{[
['Hackathon', '14:00 – 19:00'],
['Teams', '15 teams'],
['Per team', '3–5 students'],
['Hardware', 'Arduino Uno Q · 4 GB'],
].map(([k, v]) => (
<div key={k} className="bg-background p-4 space-y-1">
<div className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground">{k}</div>
<div className="text-sm font-semibold">{v}</div>
</div>
))}
</div>
</section>
<section className="px-8 py-16 border-b border-border">
<div className="max-w-4xl mx-auto space-y-6">
<div>
<p className="font-mono text-[10px] tracking-widest uppercase text-primary mb-2">Programme</p>
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Lecture at 10:45 · build from 14:00</h2>
</div>
<div data-testid="programme" className="border border-border rounded-lg overflow-hidden bg-card divide-y divide-border">
{PROGRAMME.map((row) => (
<div key={row.time} data-programme-row className="grid grid-cols-[80px_1fr_auto] gap-6 px-6 py-4 items-start hover:bg-secondary/40 transition">
<div className="font-mono text-xs text-muted-foreground pt-0.5">{row.time}</div>
<div>
<div className="text-sm font-semibold">{row.title}</div>
<div className="text-xs text-muted-foreground mt-1 leading-relaxed">{row.desc}</div>
</div>
<div className="flex gap-1.5 flex-wrap justify-end">
{row.tags?.map((t) => (
<Badge key={t} variant="outline" className="font-mono text-[9px] uppercase tracking-wider">
{t}
</Badge>
))}
</div>
</div>
))}
</div> </div>
</div> </div>
</section> </section>
@@ -155,6 +60,22 @@ export function Landing() {
Builds AI agents that live at the edge — from on-device LLM runtimes to multi-agent fleets. Builds AI agents that live at the edge — from on-device LLM runtimes to multi-agent fleets.
Maintains ZeroClaw, EdgeHDF5, and RustyHDF5. Previously: hardware + ML at scale. Maintains ZeroClaw, EdgeHDF5, and RustyHDF5. Previously: hardware + ML at scale.
</p> </p>
<div className="space-y-1 font-mono text-xs">
<a
href="https://redclaw.dev"
target="_blank"
rel="noreferrer"
className="text-primary hover:underline"
>
redclaw.dev
</a>
<div className="text-muted-foreground">
Email:{' '}
<a href="mailto:[email protected]" className="text-primary hover:underline">
osobh@redclaw.dev
</a>
</div>
</div>
<div className="flex gap-1.5 flex-wrap"> <div className="flex gap-1.5 flex-wrap">
{['Rust', 'Edge AI', 'Agentic systems', 'HDF5'].map((c) => ( {['Rust', 'Edge AI', 'Agentic systems', 'HDF5'].map((c) => (
<Badge key={c} variant="outline" className="font-mono text-[9px] uppercase tracking-wider"> <Badge key={c} variant="outline" className="font-mono text-[9px] uppercase tracking-wider">
@@ -168,65 +89,6 @@ export function Landing() {
</div> </div>
</section> </section>
<section className="px-8 py-16 border-b border-border">
<div className="max-w-4xl mx-auto space-y-6">
<div>
<p className="font-mono text-[10px] tracking-widest uppercase text-primary mb-2">Stack</p>
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">What runs where</h2>
</div>
<div className="grid md:grid-cols-3 gap-4">
{STACK.map((s) => (
<Card key={s.name}>
<CardHeader className="pb-3">
<div className="w-9 h-9 rounded-md bg-primary/10 text-primary flex items-center justify-center text-lg">
{s.icon}
</div>
<CardTitle className="text-base">{s.name}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground leading-relaxed">{s.desc}</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
<section className="px-8 py-16 border-b border-border bg-secondary/30">
<div className="max-w-4xl mx-auto space-y-6">
<div>
<p className="font-mono text-[10px] tracking-widest uppercase text-primary mb-2">Prerequisites</p>
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Bring this · we provide the rest</h2>
</div>
<div className="grid md:grid-cols-2 gap-3">
{PREREQS.map((p, i) => (
<Card key={p} className="p-4 flex gap-3">
<div className="font-mono text-xs font-bold text-primary w-5 shrink-0">{String(i + 1).padStart(2, '0')}</div>
<div className="text-sm text-muted-foreground leading-relaxed">{p}</div>
</Card>
))}
</div>
</div>
</section>
<section className="px-8 py-16">
<div className="max-w-4xl mx-auto">
<Card className="bg-primary text-primary-foreground border-primary">
<CardContent className="p-10 flex flex-col md:flex-row items-center justify-between gap-6">
<div>
<div className="text-xl md:text-2xl font-bold tracking-tight">Ready when you are.</div>
<div className="font-mono text-[10px] uppercase tracking-widest opacity-70 mt-2">
Five screens · one Agent Design Document · 19:00 deadline
</div>
</div>
<Button asChild size="lg" variant="secondary">
<Link to="/workshop">Start Module 1 →</Link>
</Button>
</CardContent>
</Card>
</div>
</section>
<footer className="px-8 py-6 border-t border-border font-mono text-[10px] text-muted-foreground flex justify-between items-center"> <footer className="px-8 py-6 border-t border-border font-mono text-[10px] text-muted-foreground flex justify-between items-center">
<span>RedClaw Systems LLC · Los Gatos, CA</span> <span>RedClaw Systems LLC · Los Gatos, CA</span>
<span>apess.redclaw.dev</span> <span>apess.redclaw.dev</span>
+16
View File
@@ -98,6 +98,22 @@ describe('TeamRegistration', () => {
expect(useSession.getState().phases.reg).toBe(true) expect(useSession.getState().phases.reg).toBe(true)
}) })
it('reveals the agent setup cards only once the board is connected', () => {
const { rerender } = renderPage()
expect(screen.queryByTestId('post-connect')).toBeNull()
act(() => useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0 }))
rerender(
<MemoryRouter>
<TeamRegistration />
</MemoryRouter>,
)
const post = screen.getByTestId('post-connect')
expect(post).toBeInTheDocument()
expect(screen.getByRole('button', { name: /say hi to your agent/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /set up telegram/i })).toBeInTheDocument()
expect(screen.getByRole('switch', { name: /enable voice/i })).toBeInTheDocument()
})
it('pre-fills the claim code from the ?code= URL param', () => { it('pre-fills the claim code from the ?code= URL param', () => {
render( render(
<MemoryRouter initialEntries={['/workshop?code=4821']}> <MemoryRouter initialEntries={['/workshop?code=4821']}>
+18
View File
@@ -6,6 +6,9 @@ import { Badge } from '@/components/ui/badge'
import { MemberFields } from '@/components/MemberFields' import { MemberFields } from '@/components/MemberFields'
import { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import { BoardClaim } from '@/components/BoardClaim' import { BoardClaim } from '@/components/BoardClaim'
import { SayHiCard } from '@/components/SayHiCard'
import { TelegramSetup } from '@/components/TelegramSetup'
import { VoiceSetup } from '@/components/VoiceSetup'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
export function TeamRegistration() { export function TeamRegistration() {
@@ -121,6 +124,21 @@ export function TeamRegistration() {
</Card> </Card>
</div> </div>
{/* Once the board is bound, the agent-facing setup unfolds below. */}
{device.connected && (
<div className="space-y-6" data-testid="post-connect">
<div className="pt-2">
<h2 className="text-lg font-semibold tracking-tight">Your agent</h2>
<p className="text-sm text-muted-foreground mt-1">
Your board is bound — now meet the agent on it and set up how you reach it.
</p>
</div>
<SayHiCard />
<TelegramSetup />
<VoiceSetup />
</div>
)}
<div className="flex justify-end pt-4"> <div className="flex justify-end pt-4">
<Button size="lg" disabled={!ready} onClick={onProceed}> <Button size="lg" disabled={!ready} onClick={onProceed}>
Proceed to environment setup → Proceed to environment setup →
+18 -5
View File
@@ -48,6 +48,14 @@ export interface Submission {
submittedAt: string | null submittedAt: string | null
} }
/** Optional extra ways to reach the node, configured during onboarding. */
export interface Channels {
/** Telegram bot token (from @BotFather); null until the wizard completes. */
telegram: string | null
/** Whether the team enabled browser voice on the node. */
voice: boolean
}
/** Stable per-browser identity, generated once and persisted. */ /** Stable per-browser identity, generated once and persisted. */
function genTeamId(): string { function genTeamId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID() if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
@@ -64,9 +72,12 @@ export interface SessionState {
stats: SessionStats stats: SessionStats
add: AddLayers add: AddLayers
submission: Submission submission: Submission
/** Extra channels + voice, set up during onboarding (client-side prefs). */
channels: Channels
setTeam: (patch: Partial<Team>) => void setTeam: (patch: Partial<Team>) => void
setDevice: (patch: Partial<Device>) => void setDevice: (patch: Partial<Device>) => void
setDomain: (d: string) => void setDomain: (d: string) => void
setChannels: (patch: Partial<Channels>) => void
completePhase: (phase: PhaseKey) => void completePhase: (phase: PhaseKey) => void
recordEvent: (kind: 'nominal' | 'anomalous' | 'critical') => void recordEvent: (kind: 'nominal' | 'anomalous' | 'critical') => void
setAddLayer: <K extends keyof AddLayers>(key: K, value: AddLayers[K]) => void setAddLayer: <K extends keyof AddLayers>(key: K, value: AddLayers[K]) => void
@@ -93,6 +104,7 @@ const initial = {
stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 }, stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 },
add: { L1: '', L2: '', L3: '', L4: '', L5: '' } as AddLayers, add: { L1: '', L2: '', L3: '', L4: '', L5: '' } as AddLayers,
submission: { code: null, submittedAt: null } as Submission, submission: { code: null, submittedAt: null } as Submission,
channels: { telegram: null, voice: false } as Channels,
} }
export const useSession = create<SessionState>()( export const useSession = create<SessionState>()(
@@ -102,6 +114,7 @@ export const useSession = create<SessionState>()(
setTeam: (patch) => set((s) => ({ team: { ...s.team, ...patch } })), setTeam: (patch) => set((s) => ({ team: { ...s.team, ...patch } })),
setDevice: (patch) => set((s) => ({ device: { ...s.device, ...patch } })), setDevice: (patch) => set((s) => ({ device: { ...s.device, ...patch } })),
setDomain: (domain) => set({ domain }), setDomain: (domain) => set({ domain }),
setChannels: (patch) => set((s) => ({ channels: { ...s.channels, ...patch } })),
completePhase: (phase) => completePhase: (phase) =>
set((s) => ({ phases: { ...s.phases, [phase]: true } })), set((s) => ({ phases: { ...s.phases, [phase]: true } })),
recordEvent: (kind) => recordEvent: (kind) =>
@@ -123,13 +136,13 @@ export const useSession = create<SessionState>()(
{ {
name: 'apess_state', name: 'apess_state',
storage: createJSONStorage(() => sessionStorage), storage: createJSONStorage(() => sessionStorage),
version: 2, version: 3,
// v1 held a different shape (add.L1 was an object, no `domain`). Rather than // v1 held a different shape (add.L1 was an object, no `domain`) — too stale
// patch a stale blob field-by-field, drop any older/absent version back to a // to salvage, so reset. From v2 on we merge over `initial` so newly-added
// fresh initial state so the app can never crash on a legacy snapshot. // fields (e.g. `channels`) are always present without wiping progress.
migrate: (_persisted, version) => { migrate: (_persisted, version) => {
if (version < 2) return { ...initial } if (version < 2) return { ...initial }
return _persisted as SessionState return { ...initial, ...(_persisted as object) } as SessionState
}, },
}, },
), ),