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:
@@ -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) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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’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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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’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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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’s
|
Open your node’s own dashboard and name the domain it’s for — then
|
||||||
for — then you’re clear for Module 1.
|
you’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’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’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’ll revisit this
|
|
||||||
once you’ve designed the agent’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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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']}>
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user