feat(phase2): real say-hi chat + Connected node state on Meet-your-node
- Open your node: shows a 'Connected' badge (+ board name) once the board is claimed. - Say hi: replaces the liveness-poll self-test with an actual round-trip to the agent on the team's own board — sends a greeting, shows the agent's reply, and only unlocks once it answers. New blocking /nodes/:teamId/say-hi route (greeting never flashes, so no hang risk) + bridge.sayHi + client sayHi(). The /prompt route stays fire-and-forget for the flash path. - Pick your domain already captures to the store and syncs to the server DB via useCollectiveSync -> pushTeam, available to later steps/judging. - Proceed gates on connected + agent-replied + domain. Tests updated + say-hi coverage (route + page). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
fc5f66021f
commit
cefddb245c
@@ -248,6 +248,22 @@ export function createApp(opts: AppOptions): Express {
|
|||||||
res.status(202).json({ accepted: true })
|
res.status(202).json({ accepted: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Onboarding "say hi": prompt the node and WAIT for its reply (blocking) so the
|
||||||
|
// participant sees their agent answer. Greeting only — never a flash turn.
|
||||||
|
app.post('/nodes/:teamId/say-hi', async (req, res) => {
|
||||||
|
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
|
||||||
|
const b = req.body ?? {}
|
||||||
|
const message = typeof b.message === 'string' && b.message.trim() ? b.message : 'Hi! Introduce yourself in one sentence.'
|
||||||
|
const agent = typeof b.agent === 'string' ? b.agent : undefined
|
||||||
|
try {
|
||||||
|
const reply = await nodes.sayHi(String(req.params.teamId), message, agent)
|
||||||
|
if (reply === null) return res.status(404).json({ error: 'no node registered for team' })
|
||||||
|
res.json({ reply })
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ error: 'your node did not answer — is it 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) => {
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ describe('node bridge + /nodes routes', () => {
|
|||||||
send: async (node, message, agent) => {
|
send: async (node, message, agent) => {
|
||||||
sent.push({ node, message, agent })
|
sent.push({ node, message, agent })
|
||||||
},
|
},
|
||||||
|
sendAndWait: async (node, message, agent) => {
|
||||||
|
sent.push({ node, message, agent })
|
||||||
|
return `echo: ${message}`
|
||||||
|
},
|
||||||
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 })
|
||||||
@@ -78,6 +82,20 @@ describe('node bridge + /nodes routes', () => {
|
|||||||
await request(app).post('/nodes/t1/prompt').send({ message: ' ' }).expect(400)
|
await request(app).post('/nodes/t1/prompt').send({ message: ' ' }).expect(400)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('say-hi waits for the node reply; 404s an unregistered team', async () => {
|
||||||
|
await request(app).post('/nodes/ghost/say-hi').send({}).expect(404)
|
||||||
|
await request(app)
|
||||||
|
.post('/nodes')
|
||||||
|
.set('x-access-code', ADMIN)
|
||||||
|
.send({ teamId: 't1', url: 'http://n', token: 'zc_secret' })
|
||||||
|
.expect(201)
|
||||||
|
const res = await request(app).post('/nodes/t1/say-hi').send({ agent: 'cloud' }).expect(200)
|
||||||
|
expect(res.body.reply).toMatch(/^echo: /)
|
||||||
|
// a default greeting is sent when no message is supplied
|
||||||
|
expect(sent.at(-1)?.message).toMatch(/introduce yourself/i)
|
||||||
|
expect(sent.at(-1)?.agent).toBe('cloud')
|
||||||
|
})
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -123,6 +123,22 @@ export async function sendPrompt(node: NodeRef, message: string, agent = 'defaul
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt a node and WAIT for its reply text (the blocking `/webhook` response).
|
||||||
|
* Only for non-flashing turns (e.g. the onboarding greeting) — a flash turn must
|
||||||
|
* stay fire-and-forget via {@link sendPrompt} or it hangs the agent's task.
|
||||||
|
*/
|
||||||
|
export async function promptAndWait(node: NodeRef, message: string, agent = 'default'): Promise<string> {
|
||||||
|
const res = await fetch(`${node.url}/webhook?agent=${encodeURIComponent(agent)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`webhook ${res.status}`)
|
||||||
|
const body = (await res.json().catch(() => ({}))) as { response?: string }
|
||||||
|
return (body.response ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubscribeOptions {
|
export interface SubscribeOptions {
|
||||||
/** Aborts the whole reconnect loop when fired. */
|
/** Aborts the whole reconnect loop when fired. */
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
@@ -219,6 +235,9 @@ export interface NodeBridge {
|
|||||||
remove(teamId: string): void
|
remove(teamId: string): void
|
||||||
list(): NodeView[]
|
list(): NodeView[]
|
||||||
prompt(teamId: string, message: string, agent?: string): Promise<boolean>
|
prompt(teamId: string, message: string, agent?: string): Promise<boolean>
|
||||||
|
/** 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). */
|
||||||
|
sayHi(teamId: string, message: string, agent?: string): Promise<string | null>
|
||||||
/** 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
|
||||||
@@ -230,6 +249,7 @@ export interface NodeBridgeDeps {
|
|||||||
/** Injectable for tests. */
|
/** Injectable for tests. */
|
||||||
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>
|
||||||
subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void, onStatus: (online: boolean) => void) => () => void
|
subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void, onStatus: (online: boolean) => void) => () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +263,7 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
|
|||||||
const registry = deps.registry ?? createNodeRegistry()
|
const registry = deps.registry ?? createNodeRegistry()
|
||||||
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 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>()
|
||||||
@@ -287,6 +308,11 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
|
|||||||
await send(node, message, agent)
|
await send(node, message, agent)
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
async sayHi(teamId, message, agent) {
|
||||||
|
const node = registry.get(teamId)
|
||||||
|
if (!node) return null
|
||||||
|
return sendAndWait(node, message, agent)
|
||||||
|
},
|
||||||
onTeamActivity(teamId, listener) {
|
onTeamActivity(teamId, listener) {
|
||||||
let set = teamListeners.get(teamId)
|
let set = teamListeners.get(teamId)
|
||||||
if (!set) {
|
if (!set) {
|
||||||
|
|||||||
@@ -58,9 +58,14 @@ export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps)
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xl font-bold tracking-tight">Open your node →</div>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl font-bold tracking-tight">Open your node →</span>
|
||||||
|
<span className="font-mono text-[9px] uppercase tracking-widest text-teal border border-teal/40 bg-teal/10 rounded px-1.5 py-0.5">
|
||||||
|
Connected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="font-mono text-[10px] text-muted-foreground mt-1 truncate max-w-xs">
|
<div className="font-mono text-[10px] text-muted-foreground mt-1 truncate max-w-xs">
|
||||||
{device.nodeUrl}
|
{device.port ? `${device.port} · ` : ''}{device.nodeUrl}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-teal animate-pulse shrink-0" aria-hidden />
|
<span className="w-2.5 h-2.5 rounded-full bg-teal animate-pulse shrink-0" aria-hidden />
|
||||||
|
|||||||
@@ -161,6 +161,20 @@ export async function releaseBoard(kit: string, code: string): Promise<{ release
|
|||||||
|
|
||||||
// --- ZeroClaw node (participant) ------------------------------------------
|
// --- ZeroClaw node (participant) ------------------------------------------
|
||||||
/** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */
|
/** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */
|
||||||
|
/** Onboarding "say hi": prompt the team's node and wait for its reply text. */
|
||||||
|
export async function sayHi(teamId: string, agent?: string, message?: string): Promise<string> {
|
||||||
|
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/say-hi`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ agent, message }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
||||||
|
throw new Error(body.error ?? `sayHi ${res.status}`)
|
||||||
|
}
|
||||||
|
return ((await res.json()) as { reply: string }).reply
|
||||||
|
}
|
||||||
|
|
||||||
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',
|
||||||
|
|||||||
+33
-37
@@ -1,15 +1,15 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
import { render, screen, fireEvent } 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 { getNodeStatus } from '@/lib/api'
|
import { sayHi } from '@/lib/api'
|
||||||
|
|
||||||
vi.mock('@/lib/api', async (orig) => ({
|
vi.mock('@/lib/api', async (orig) => ({
|
||||||
...(await orig<typeof import('@/lib/api')>()),
|
...(await orig<typeof import('@/lib/api')>()),
|
||||||
getNodeStatus: vi.fn(),
|
sayHi: vi.fn(),
|
||||||
}))
|
}))
|
||||||
const mockNodeStatus = vi.mocked(getNodeStatus)
|
const mockSayHi = vi.mocked(sayHi)
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
return render(
|
return render(
|
||||||
@@ -21,13 +21,14 @@ function renderPage() {
|
|||||||
|
|
||||||
/** Connect a board with a live node URL — the common precondition. */
|
/** Connect a board with a live node URL — the common precondition. */
|
||||||
function connect(nodeUrl: string | null = 'http://192.168.1.7:8080') {
|
function connect(nodeUrl: string | null = 'http://192.168.1.7:8080') {
|
||||||
useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0, nodeUrl })
|
useSession.getState().setDevice({ connected: true, port: 'board · crimson-otter', uptimeS: 0, nodeUrl })
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('EnvSetup — Meet your node', () => {
|
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', () => {
|
||||||
@@ -42,19 +43,17 @@ describe('EnvSetup — Meet your node', () => {
|
|||||||
it('prompts to claim a board first when not connected', () => {
|
it('prompts to claim a board first when not connected', () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
|
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('link', { name: /open your node/i })).not.toBeInTheDocument()
|
expect(screen.getByRole('button', { name: /say hi to your agent/i })).toBeDisabled()
|
||||||
// say-hi is disabled with no device
|
|
||||||
expect(screen.getByRole('button', { name: /say hi \/ confirm online/i })).toBeDisabled()
|
|
||||||
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
|
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the Open-your-node CTA to the board url when connected with a nodeUrl', () => {
|
it('shows the node as Connected with the board url when claimed', () => {
|
||||||
connect('http://192.168.1.7:8080')
|
connect('http://192.168.1.7:8080')
|
||||||
renderPage()
|
renderPage()
|
||||||
const link = screen.getByRole('link', { name: /open your node/i })
|
const link = screen.getByRole('link', { name: /open your node/i })
|
||||||
expect(link).toHaveAttribute('href', 'http://192.168.1.7:8080')
|
expect(link).toHaveAttribute('href', 'http://192.168.1.7:8080')
|
||||||
expect(link).toHaveAttribute('target', '_blank')
|
expect(link).toHaveAttribute('target', '_blank')
|
||||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
expect(screen.getByText(/^connected$/i)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to the claim prompt when connected but there is no nodeUrl', () => {
|
it('falls back to the claim prompt when connected but there is no nodeUrl', () => {
|
||||||
@@ -64,39 +63,34 @@ describe('EnvSetup — Meet your node', () => {
|
|||||||
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
|
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires a domain even after the board is online', async () => {
|
it('says hi to the agent and shows its reply', async () => {
|
||||||
vi.useFakeTimers()
|
mockSayHi.mockResolvedValue("Hi! I'm your node — I can read your sensor and drive the matrix.")
|
||||||
try {
|
|
||||||
mockNodeStatus.mockResolvedValue({ teamId: 't', online: true })
|
|
||||||
connect()
|
connect()
|
||||||
renderPage()
|
renderPage()
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /say hi \/ confirm online/i }))
|
fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
|
||||||
await act(async () => {
|
expect(await screen.findByTestId('node-reply')).toHaveTextContent(/read your sensor/i)
|
||||||
await vi.advanceTimersByTimeAsync(2000)
|
expect(screen.getByRole('button', { name: /your node replied/i })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
expect(screen.getByText(/online ✓/i)).toBeInTheDocument()
|
|
||||||
|
|
||||||
// online but no domain → still gated
|
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()
|
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
|
||||||
} finally {
|
|
||||||
vi.useRealTimers()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps Proceed gated when a domain is named but the board is not confirmed online', () => {
|
it('keeps Proceed gated when a domain is named but the agent has not replied', () => {
|
||||||
connect()
|
connect()
|
||||||
useSession.getState().setDomain('air quality')
|
useSession.getState().setDomain('air quality')
|
||||||
renderPage()
|
renderPage()
|
||||||
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
|
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('say hi / online confirmation', () => {
|
it('enables Proceed once the agent has replied AND a domain is named', async () => {
|
||||||
beforeEach(() => vi.useFakeTimers())
|
mockSayHi.mockResolvedValue('hello there')
|
||||||
afterEach(() => vi.useRealTimers())
|
|
||||||
|
|
||||||
it('enables Proceed once online AND a domain is named, without polluting stats', async () => {
|
|
||||||
mockNodeStatus.mockResolvedValue({ teamId: 't', online: true })
|
|
||||||
connect()
|
connect()
|
||||||
useSession.getState().setDomain('structural stress')
|
useSession.getState().setDomain('structural stress')
|
||||||
renderPage()
|
renderPage()
|
||||||
@@ -104,15 +98,17 @@ describe('EnvSetup — Meet your node', () => {
|
|||||||
const proceed = screen.getByRole('button', { name: /proceed/i })
|
const proceed = screen.getByRole('button', { name: /proceed/i })
|
||||||
expect(proceed).toBeDisabled()
|
expect(proceed).toBeDisabled()
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /say hi \/ confirm online/i }))
|
fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
|
||||||
await act(async () => {
|
await screen.findByTestId('node-reply')
|
||||||
await vi.advanceTimersByTimeAsync(2000)
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(screen.getByText(/online ✓/i)).toBeInTheDocument()
|
|
||||||
expect(proceed).toBeEnabled()
|
expect(proceed).toBeEnabled()
|
||||||
// confirmation polls must NOT be counted as workshop events
|
|
||||||
expect(useSession.getState().stats.calls).toBe(0)
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+54
-30
@@ -7,14 +7,10 @@ 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 { getNodeStatus } from '@/lib/api'
|
import { sayHi } from '@/lib/api'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type SelfTest = 'idle' | 'running' | 'ok'
|
type HiState = 'idle' | 'running' | 'ok'
|
||||||
|
|
||||||
/** Say-hi confirmation: poll the claimed board's liveness this many times. */
|
|
||||||
const SELFTEST_POLLS = 12
|
|
||||||
const SELFTEST_POLL_MS = 500
|
|
||||||
|
|
||||||
export function EnvSetup() {
|
export function EnvSetup() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -22,27 +18,27 @@ export function EnvSetup() {
|
|||||||
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 [selfTest, setSelfTest] = useState<SelfTest>('idle')
|
const [hi, setHi] = useState<HiState>('idle')
|
||||||
|
const [reply, setReply] = useState('')
|
||||||
|
const [hiError, setHiError] = useState<string | null>(null)
|
||||||
|
|
||||||
const runSelfTest = async () => {
|
const saidHi = async () => {
|
||||||
setSelfTest('running')
|
if (hi === 'running') return
|
||||||
// Confirm the claimed board is reachable and online.
|
setHi('running')
|
||||||
for (let i = 0; i < SELFTEST_POLLS; i++) {
|
setHiError(null)
|
||||||
|
setReply('')
|
||||||
try {
|
try {
|
||||||
const s = await getNodeStatus(teamId)
|
// Chat with the agent on the team's own board; a reply means it's live.
|
||||||
if (s.online) {
|
const r = await sayHi(teamId, 'cloud')
|
||||||
setSelfTest('ok')
|
setReply(r || '(your node replied)')
|
||||||
return
|
setHi('ok')
|
||||||
|
} catch (e) {
|
||||||
|
setHiError(e instanceof Error ? e.message : 'Could not reach your node.')
|
||||||
|
setHi('idle')
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
/* board not registered yet / transient — keep polling */
|
|
||||||
}
|
|
||||||
await new Promise((r) => setTimeout(r, SELFTEST_POLL_MS))
|
|
||||||
}
|
|
||||||
setSelfTest('idle') // couldn't confirm — let them retry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ready = device.connected && selfTest === 'ok' && domain.trim().length > 0
|
const ready = device.connected && hi === 'ok' && domain.trim().length > 0
|
||||||
|
|
||||||
const onProceed = () => {
|
const onProceed = () => {
|
||||||
completePhase('setup')
|
completePhase('setup')
|
||||||
@@ -92,26 +88,54 @@ export function EnvSetup() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
Open your node’s dashboard and say hi — it introduces itself and lists the skills
|
Say hi to the agent running on your board. When it replies, your node is live and
|
||||||
it already has. Then confirm it’s online here.
|
listening — and you’re clear to name its domain.
|
||||||
</p>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant={hi === 'ok' ? 'outline' : 'default'}
|
||||||
className="w-full justify-start"
|
className="w-full justify-start"
|
||||||
disabled={!device.connected || selfTest === 'running'}
|
disabled={!device.connected || hi === 'running'}
|
||||||
onClick={runSelfTest}
|
onClick={saidHi}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-2 h-2 rounded-full mr-3',
|
'w-2 h-2 rounded-full mr-3',
|
||||||
selfTest === 'ok' ? 'bg-teal' : selfTest === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground',
|
hi === 'ok' ? 'bg-teal' : hi === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{selfTest === 'ok' ? 'Online ✓' : selfTest === 'running' ? 'Checking…' : 'Say hi / confirm online'}
|
{hi === 'ok' ? 'Your node replied ✓' : hi === 'running' ? 'Waiting for your node…' : 'Say hi to your agent'}
|
||||||
</Button>
|
</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">
|
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
|
||||||
Confirms the board is reachable and live. Does not count toward your session stats.
|
Bind your board in team registration first.
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user