refactor(web): reorganize phases + fix Open-your-node URL
Move sections to where they read better: - Phase 1 (Team registration): drop the "Your Agent" block. - Phase 2 (Meet your agent): host the "Your Agent" section (say-hi / Telegram / voice) AND the agent chat under it; remove "Pick your domain". - Module 1 (Domain & events): "Pick your domain" moves here (no more read-only carry) alongside the ADD Layer 1 card. - Module 2 (Skills & policies): the chat is gone; the L2/L3 ADD cards show directly (still auto-prefilled if the three prompts ran in Meet your agent). Also: OpenYourNode opens the browser-reachable board URL. In self-host/USB mode the stored url is container-facing (host.docker.internal) which the browser can't resolve — derive the current host on :8080 instead. Tests updated. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
48b88b0f0d
commit
4dd3681eef
@@ -18,6 +18,8 @@ vi.mock('@/lib/api', () => ({
|
|||||||
liveOnEvent = onEvent
|
liveOnEvent = onEvent
|
||||||
return closeSpy
|
return closeSpy
|
||||||
},
|
},
|
||||||
|
// OpenYourNode (rendered here) reads the runtime mode.
|
||||||
|
getMode: () => Promise.resolve({ localMode: false }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('BuildFlash', () => {
|
describe('BuildFlash', () => {
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
import { useLocalMode } from '@/lib/useLocalMode'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The browser-reachable board dashboard URL. In self-host/USB (local) mode the
|
||||||
|
* API stores the board's *container-facing* url (host.docker.internal:8080) so
|
||||||
|
* the API container can reach it — but the browser can't resolve that. From the
|
||||||
|
* browser the board's gateway is adb-forwarded to the local host on :8080, so
|
||||||
|
* open it at the current host on :8080.
|
||||||
|
*/
|
||||||
|
function boardHref(nodeUrl: string | null, localMode: boolean | null): string | null {
|
||||||
|
if (localMode) return `${window.location.protocol}//${window.location.hostname}:8080`
|
||||||
|
return nodeUrl
|
||||||
|
}
|
||||||
|
|
||||||
export interface OpenYourNodeProps {
|
export interface OpenYourNodeProps {
|
||||||
/** 'hero' is the big primary CTA used on the setup page; 'inline' is a compact
|
/** 'hero' is the big primary CTA used on the setup page; 'inline' is a compact
|
||||||
* link for reuse inside later module pages. */
|
* link for reuse inside later module pages. */
|
||||||
@@ -16,7 +29,9 @@ export interface OpenYourNodeProps {
|
|||||||
*/
|
*/
|
||||||
export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps) {
|
export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps) {
|
||||||
const device = useSession((s) => s.device)
|
const device = useSession((s) => s.device)
|
||||||
const canOpen = device.connected && !!device.nodeUrl
|
const localMode = useLocalMode()
|
||||||
|
const nodeHref = boardHref(device.nodeUrl, localMode)
|
||||||
|
const canOpen = device.connected && !!nodeHref
|
||||||
|
|
||||||
if (!canOpen) {
|
if (!canOpen) {
|
||||||
return (
|
return (
|
||||||
@@ -34,7 +49,7 @@ export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps)
|
|||||||
if (variant === 'inline') {
|
if (variant === 'inline') {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={device.nodeUrl!}
|
href={nodeHref!}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -49,7 +64,7 @@ export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={device.nodeUrl!}
|
href={nodeHref!}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -65,7 +80,7 @@ export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps)
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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.port ? `${device.port} · ` : ''}{device.nodeUrl}
|
{device.port ? `${device.port} · ` : ''}{nodeHref}
|
||||||
</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 />
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { render, screen } 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'
|
||||||
|
|
||||||
|
// EnvSetup now renders the agent chat + say-hi; stub the streaming/mode calls.
|
||||||
|
vi.mock('@/lib/api', async (orig) => ({
|
||||||
|
...(await orig<typeof import('@/lib/api')>()),
|
||||||
|
openTeamActivity: () => () => {},
|
||||||
|
getMode: () => Promise.resolve({ localMode: false }),
|
||||||
|
}))
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
@@ -12,7 +19,6 @@ function renderPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 · crimson-otter', uptimeS: 0, nodeUrl })
|
useSession.getState().setDevice({ connected: true, port: 'board · crimson-otter', uptimeS: 0, nodeUrl })
|
||||||
}
|
}
|
||||||
@@ -50,15 +56,16 @@ describe('EnvSetup — Meet your agent', () => {
|
|||||||
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
|
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps Proceed gated when connected but no domain is named', () => {
|
it('reveals the Your Agent section (say-hi + chat) once connected', () => {
|
||||||
connect()
|
connect()
|
||||||
renderPage()
|
renderPage()
|
||||||
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
|
expect(screen.getByTestId('your-agent')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: /say hi to your agent/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('agent-chat')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('enables Proceed once connected AND a domain is named', () => {
|
it('enables Proceed once the board is connected (domain moved to Module 1)', () => {
|
||||||
connect()
|
connect()
|
||||||
useSession.getState().setDomain('structural stress')
|
|
||||||
renderPage()
|
renderPage()
|
||||||
expect(screen.getByRole('button', { name: /proceed/i })).toBeEnabled()
|
expect(screen.getByRole('button', { name: /proceed/i })).toBeEnabled()
|
||||||
})
|
})
|
||||||
|
|||||||
+29
-20
@@ -1,24 +1,21 @@
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { OpenYourNode } from '@/components/OpenYourNode'
|
import { OpenYourNode } from '@/components/OpenYourNode'
|
||||||
import { DomainPicker } from '@/components/DomainPicker'
|
import { SayHiCard } from '@/components/SayHiCard'
|
||||||
|
import { TelegramSetup } from '@/components/TelegramSetup'
|
||||||
|
import { VoiceSetup } from '@/components/VoiceSetup'
|
||||||
|
import { AgentChat } from '@/components/AgentChat'
|
||||||
import { PanelHeading, PanelCard, ProceedButton } from '@/components/cockpit/PanelChrome'
|
import { PanelHeading, PanelCard, ProceedButton } from '@/components/cockpit/PanelChrome'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
export function EnvSetup() {
|
export function EnvSetup() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const device = useSession((s) => s.device)
|
const device = useSession((s) => s.device)
|
||||||
const domain = useSession((s) => s.domain)
|
const setTried = useSession((s) => s.setTried)
|
||||||
const add = useSession((s) => s.add)
|
|
||||||
const setAddLayer = useSession((s) => s.setAddLayer)
|
|
||||||
const completePhase = useSession((s) => s.completePhase)
|
const completePhase = useSession((s) => s.completePhase)
|
||||||
|
|
||||||
const ready = device.connected && domain.trim().length > 0
|
const ready = device.connected
|
||||||
|
|
||||||
const onProceed = () => {
|
const onProceed = () => {
|
||||||
// Carry the domain into Layer 1 as a starting draft (only if untouched).
|
|
||||||
if (!add.L1.trim() && domain.trim()) {
|
|
||||||
setAddLayer('L1', `Domain: ${domain.trim()}. Events: an impact spike, a sustained sway, a stale sensor.`)
|
|
||||||
}
|
|
||||||
completePhase('setup')
|
completePhase('setup')
|
||||||
navigate('/workshop/module1')
|
navigate('/workshop/module1')
|
||||||
}
|
}
|
||||||
@@ -28,7 +25,7 @@ export function EnvSetup() {
|
|||||||
<PanelHeading
|
<PanelHeading
|
||||||
eyebrow="PHASE 2 OF 5 · ~15 MIN"
|
eyebrow="PHASE 2 OF 5 · ~15 MIN"
|
||||||
title="Meet your agent"
|
title="Meet your agent"
|
||||||
intro="Your board runs the APESS agent — a Claude-powered agent on the edge. It reasons about your domain, drives the board's own devices, and keeps working when the cloud drops. Open it to explore, then name the domain it's for."
|
intro="Your board runs the APESS agent — a Claude-powered agent on the edge. Open it to explore, say hi, set up how you reach it, then put it to work on your real board."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Open your agent */}
|
{/* Open your agent */}
|
||||||
@@ -39,17 +36,29 @@ export function EnvSetup() {
|
|||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
|
|
||||||
{/* Pick your domain — emphasized: this seeds all five layers */}
|
{device.connected ? (
|
||||||
<PanelCard emphasized className="mt-[22px]">
|
<div className="mt-8 space-y-5" data-testid="your-agent">
|
||||||
<div className="text-[17px] font-semibold">Pick your domain</div>
|
<div>
|
||||||
<p className="mt-1.5 text-[14px] leading-[1.5] text-ink-3">
|
<h2 className="text-[22px] font-semibold tracking-[-0.01em]">Your agent</h2>
|
||||||
This one line seeds all five layers of your Agent Design Document — the domain your agent
|
<p className="mt-1 text-[15px] text-ink-2">
|
||||||
serves and the events it must notice.
|
Say hi to the agent on your board, set up how you reach it — then chat with it and watch
|
||||||
</p>
|
it use its skills on the real hardware.
|
||||||
<div className="mt-4">
|
</p>
|
||||||
<DomainPicker />
|
</div>
|
||||||
|
<SayHiCard />
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<TelegramSetup />
|
||||||
|
<VoiceSetup />
|
||||||
|
</div>
|
||||||
|
<AgentChat onProgress={(done) => setTried(done)} />
|
||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
) : (
|
||||||
|
<PanelCard className="mt-6">
|
||||||
|
<p className="text-[14px] leading-[1.5] text-ink-3">
|
||||||
|
Connect your board on the previous step to meet your agent.
|
||||||
|
</p>
|
||||||
|
</PanelCard>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-9 flex justify-end">
|
<div className="mt-9 flex justify-end">
|
||||||
<ProceedButton disabled={!ready} onClick={onProceed}>
|
<ProceedButton disabled={!ready} onClick={onProceed}>
|
||||||
|
|||||||
@@ -35,13 +35,10 @@ describe('Module1', () => {
|
|||||||
expect(screen.getByRole('heading', { name: /domain.*events/i })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { name: /domain.*events/i })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the domain carried over from the earlier screen (read-only)', () => {
|
it('lets you pick the domain here (no read-only carry)', () => {
|
||||||
useSession.getState().setDomain('air quality')
|
|
||||||
renderPage()
|
renderPage()
|
||||||
const carried = screen.getByTestId('domain-carried')
|
expect(screen.queryByTestId('domain-carried')).toBeNull()
|
||||||
expect(carried).toHaveTextContent('air quality')
|
expect(screen.getByPlaceholderText(/image measurement|structural stress|air quality/i)).toBeInTheDocument()
|
||||||
// no editable domain input here anymore
|
|
||||||
expect(screen.queryByLabelText(/your domain/i)).toBeNull()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('focuses on Layer 1 — no live board feed or actor map cards', () => {
|
it('focuses on Layer 1 — no live board feed or actor map cards', () => {
|
||||||
@@ -50,12 +47,13 @@ describe('Module1', () => {
|
|||||||
expect(screen.queryByTestId('actor-map')).toBeNull()
|
expect(screen.queryByTestId('actor-map')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('gates Proceed until the board is online and L1 is filled', async () => {
|
it('gates Proceed until the board is online, a domain is named, and L1 is filled', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderPage()
|
renderPage()
|
||||||
const proceed = screen.getByRole('button', { name: /proceed/i })
|
const proceed = screen.getByRole('button', { name: /proceed/i })
|
||||||
expect(proceed).toBeDisabled()
|
expect(proceed).toBeDisabled()
|
||||||
|
|
||||||
|
act(() => useSession.getState().setDomain('resonance'))
|
||||||
await user.type(screen.getByLabelText(/layer 1/i), 'structural resonance')
|
await user.type(screen.getByLabelText(/layer 1/i), 'structural resonance')
|
||||||
expect(proceed).toBeDisabled() // board not online yet
|
expect(proceed).toBeDisabled() // board not online yet
|
||||||
|
|
||||||
|
|||||||
+14
-16
@@ -1,6 +1,7 @@
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { DomainPicker } from '@/components/DomainPicker'
|
||||||
import { AddLayerForm } from '@/components/AddLayerForm'
|
import { AddLayerForm } from '@/components/AddLayerForm'
|
||||||
import { PanelHeading, ProceedButton } from '@/components/cockpit/PanelChrome'
|
import { PanelHeading, PanelCard, ProceedButton } from '@/components/cockpit/PanelChrome'
|
||||||
import { useNodeFeed } from '@/lib/useNodeFeed'
|
import { useNodeFeed } from '@/lib/useNodeFeed'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ export function Module1() {
|
|||||||
const completePhase = useSession((s) => s.completePhase)
|
const completePhase = useSession((s) => s.completePhase)
|
||||||
|
|
||||||
const sensed = feed.online || feed.activity.length > 0
|
const sensed = feed.online || feed.activity.length > 0
|
||||||
const ready = sensed && l1.trim().length > 0
|
const ready = sensed && domain.trim().length > 0 && l1.trim().length > 0
|
||||||
|
|
||||||
const onProceed = () => {
|
const onProceed = () => {
|
||||||
completePhase('m1')
|
completePhase('m1')
|
||||||
@@ -25,23 +26,20 @@ export function Module1() {
|
|||||||
<PanelHeading
|
<PanelHeading
|
||||||
eyebrow="PHASE 3 OF 5 · ~75 MIN"
|
eyebrow="PHASE 3 OF 5 · ~75 MIN"
|
||||||
title="Module 1 · Domain & events"
|
title="Module 1 · Domain & events"
|
||||||
intro="Define the domain your agent is for and the events it must sense and act on — carried from what you named earlier. This is Layer 1 of your Agent Design Document."
|
intro="Name the domain your agent is for and the events it must sense and act on — this is Layer 1 of your Agent Design Document."
|
||||||
size={46}
|
size={46}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
{/* Pick your domain — the seed of all five layers */}
|
||||||
className="mt-9 rounded-xl border border-line bg-surface-soft px-5 py-4"
|
<PanelCard emphasized className="mt-9">
|
||||||
data-testid="domain-carried"
|
<div className="text-[17px] font-semibold">Pick your domain</div>
|
||||||
>
|
<p className="mt-1.5 text-[14px] leading-[1.5] text-ink-3">
|
||||||
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--muted)]">Your domain</div>
|
This one line seeds all five layers — the domain your agent serves and the events it must notice.
|
||||||
{domain.trim() ? (
|
</p>
|
||||||
<div className="mt-1.5 text-[19px] font-semibold tracking-[-0.01em]">{domain}</div>
|
<div className="mt-4">
|
||||||
) : (
|
<DomainPicker />
|
||||||
<div className="mt-1.5 text-[14px] text-ink-3">
|
</div>
|
||||||
Not set yet — name it on <span className="font-medium">Meet your agent</span>.
|
</PanelCard>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<AddLayerForm
|
<AddLayerForm
|
||||||
|
|||||||
+20
-54
@@ -1,20 +1,9 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { render, screen, act, waitFor } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
import { Module2 } from './Module2'
|
import { Module2 } from './Module2'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
import type { WsEvent } from '@/types'
|
|
||||||
|
|
||||||
let emit: (e: WsEvent) => void = () => {}
|
|
||||||
vi.mock('@/lib/api', async (orig) => ({
|
|
||||||
...(await orig<typeof import('@/lib/api')>()),
|
|
||||||
openTeamActivity: (_teamId: string, on: (e: WsEvent) => void) => {
|
|
||||||
emit = on
|
|
||||||
return () => {}
|
|
||||||
},
|
|
||||||
sendPrompt: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}))
|
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
return render(
|
return render(
|
||||||
@@ -24,13 +13,6 @@ function renderPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click a canned prompt and let the agent reach a terminal (success) step. */
|
|
||||||
async function completePrompt(user: ReturnType<typeof userEvent.setup>, id: string) {
|
|
||||||
await user.click(screen.getByTestId(`prompt-${id}`))
|
|
||||||
act(() => emit({ type: 'node:activity', teamId: 'x', kind: 'response', label: 'Agent finished', ts: '' }))
|
|
||||||
await waitFor(() => expect(screen.getByTestId(`prompt-${id}`)).toHaveAttribute('data-state', 'done'))
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Module2', () => {
|
describe('Module2', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSession.getState().reset()
|
useSession.getState().reset()
|
||||||
@@ -42,53 +24,37 @@ describe('Module2', () => {
|
|||||||
expect(screen.getByRole('heading', { name: /skills.*policies/i })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { name: /skills.*policies/i })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('is a chat with the three canned prompts — no live feed / build & flash', () => {
|
it('shows the Layer 2 (Skills) and Layer 3 (Policies) cards — the chat moved to Meet your agent', () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
expect(screen.getByTestId('agent-chat')).toBeInTheDocument()
|
|
||||||
expect(screen.getByTestId('prompt-i2c')).toBeInTheDocument()
|
|
||||||
expect(screen.getByTestId('prompt-count')).toBeInTheDocument()
|
|
||||||
expect(screen.getByTestId('prompt-scroll')).toBeInTheDocument()
|
|
||||||
expect(screen.queryByTestId('live-board-feed')).toBeNull()
|
|
||||||
expect(screen.queryByTestId('activity-log')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reveals "what\'s next" (the ADD layers) only after all three prompts succeed', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
renderPage()
|
|
||||||
expect(screen.queryByTestId('whats-next')).toBeNull()
|
|
||||||
expect(screen.queryByLabelText(/layer 2/i)).toBeNull()
|
|
||||||
|
|
||||||
await completePrompt(user, 'i2c')
|
|
||||||
await completePrompt(user, 'count')
|
|
||||||
expect(screen.queryByTestId('whats-next')).toBeNull() // still one to go
|
|
||||||
await completePrompt(user, 'scroll')
|
|
||||||
|
|
||||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
|
||||||
expect(screen.getByLabelText(/layer 2/i)).toBeInTheDocument()
|
expect(screen.getByLabelText(/layer 2/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/layer 3/i)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('agent-chat')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('gates Proceed until all prompts ran AND L2 + L3 are filled', async () => {
|
it('prefills Layers 2 & 3 when the three prompts were run in Meet your agent', () => {
|
||||||
const user = userEvent.setup()
|
useSession.getState().setTried(3)
|
||||||
useSession.getState().setAddLayer('L2', 'escalate on critical')
|
|
||||||
useSession.getState().setAddLayer('L3', 'drive damper on critical')
|
|
||||||
renderPage()
|
renderPage()
|
||||||
|
expect((screen.getByLabelText(/layer 2/i) as HTMLTextAreaElement).value).toMatch(/i2c_scan/i)
|
||||||
|
expect((screen.getByLabelText(/layer 3/i) as HTMLTextAreaElement).value).toMatch(/exception/i)
|
||||||
|
})
|
||||||
|
|
||||||
await completePrompt(user, 'i2c')
|
it('gates Proceed until L2 and L3 are filled', async () => {
|
||||||
await completePrompt(user, 'count')
|
const user = userEvent.setup()
|
||||||
await completePrompt(user, 'scroll')
|
renderPage()
|
||||||
|
|
||||||
const proceed = screen.getByRole('button', { name: /proceed/i })
|
const proceed = screen.getByRole('button', { name: /proceed/i })
|
||||||
|
expect(proceed).toBeDisabled()
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/layer 2/i), 'escalate on critical')
|
||||||
|
expect(proceed).toBeDisabled()
|
||||||
|
await user.type(screen.getByLabelText(/layer 3/i), 'drive damper on critical')
|
||||||
expect(proceed).toBeEnabled()
|
expect(proceed).toBeEnabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks m2 complete on Proceed', async () => {
|
it('marks m2 complete on Proceed', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
|
useSession.getState().setAddLayer('L2', 'L2 text')
|
||||||
|
useSession.getState().setAddLayer('L3', 'L3 text')
|
||||||
renderPage()
|
renderPage()
|
||||||
await completePrompt(user, 'i2c')
|
|
||||||
await completePrompt(user, 'count')
|
|
||||||
await completePrompt(user, 'scroll')
|
|
||||||
await user.type(screen.getByLabelText(/layer 2/i), 'L2 text')
|
|
||||||
await user.type(screen.getByLabelText(/layer 3/i), 'L3 text')
|
|
||||||
await user.click(screen.getByRole('button', { name: /proceed/i }))
|
await user.click(screen.getByRole('button', { name: /proceed/i }))
|
||||||
expect(useSession.getState().phases.m2).toBe(true)
|
expect(useSession.getState().phases.m2).toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
+25
-50
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { AgentChat } from '@/components/AgentChat'
|
|
||||||
import { AddLayerForm } from '@/components/AddLayerForm'
|
import { AddLayerForm } from '@/components/AddLayerForm'
|
||||||
import { PanelHeading, PanelCard, ProceedButton } from '@/components/cockpit/PanelChrome'
|
import { PanelHeading, ProceedButton } from '@/components/cockpit/PanelChrome'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
const L2_PREFILL =
|
const L2_PREFILL =
|
||||||
@@ -15,19 +14,18 @@ export function Module2() {
|
|||||||
const l2 = useSession((s) => s.add.L2)
|
const l2 = useSession((s) => s.add.L2)
|
||||||
const l3 = useSession((s) => s.add.L3)
|
const l3 = useSession((s) => s.add.L3)
|
||||||
const tried = useSession((s) => s.tried)
|
const tried = useSession((s) => s.tried)
|
||||||
const setTried = useSession((s) => s.setTried)
|
|
||||||
const setAddLayer = useSession((s) => s.setAddLayer)
|
const setAddLayer = useSession((s) => s.setAddLayer)
|
||||||
const completePhase = useSession((s) => s.completePhase)
|
const completePhase = useSession((s) => s.completePhase)
|
||||||
|
|
||||||
const allTried = tried >= 3
|
const ready = l2.trim().length > 0 && l3.trim().length > 0
|
||||||
const ready = allTried && l2.trim().length > 0 && l3.trim().length > 0
|
|
||||||
|
|
||||||
// On reaching 3/3, seed Layers 2 & 3 with a starting draft (only if untouched).
|
// If the team ran the three agent prompts in "Meet your agent", seed Layers 2 & 3
|
||||||
|
// with a starting draft (only if untouched).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!allTried) return
|
if (tried < 3) return
|
||||||
if (!useSession.getState().add.L2.trim()) setAddLayer('L2', L2_PREFILL)
|
if (!useSession.getState().add.L2.trim()) setAddLayer('L2', L2_PREFILL)
|
||||||
if (!useSession.getState().add.L3.trim()) setAddLayer('L3', L3_PREFILL)
|
if (!useSession.getState().add.L3.trim()) setAddLayer('L3', L3_PREFILL)
|
||||||
}, [allTried, setAddLayer])
|
}, [tried, setAddLayer])
|
||||||
|
|
||||||
const onProceed = () => {
|
const onProceed = () => {
|
||||||
completePhase('m2')
|
completePhase('m2')
|
||||||
@@ -39,53 +37,30 @@ export function Module2() {
|
|||||||
<PanelHeading
|
<PanelHeading
|
||||||
eyebrow="PHASE 4 OF 5 · ~90 MIN"
|
eyebrow="PHASE 4 OF 5 · ~90 MIN"
|
||||||
title="Module 2 · Skills & policies"
|
title="Module 2 · Skills & policies"
|
||||||
intro="Talk to your agent and watch it run real tools on your board. Each prompt makes it use a built-in skill — then capture your domain's skills and the policy that governs them."
|
intro="Capture what your agent can do and the policy that governs it — Layers 2 and 3 of your Agent Design Document."
|
||||||
size={46}
|
size={46}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-9">
|
<div className="mt-9 space-y-5">
|
||||||
<AgentChat onProgress={(done) => setTried(done)} />
|
<AddLayerForm
|
||||||
|
layer="L2"
|
||||||
|
title="ADD · Layer 2 — Skills"
|
||||||
|
description="What skills can the agent invoke to act on its domain?"
|
||||||
|
placeholder="Flash a sketch to the MCU; scroll a message; drive the damper; sample the IMU."
|
||||||
|
/>
|
||||||
|
<AddLayerForm
|
||||||
|
layer="L3"
|
||||||
|
title="ADD · Layer 3 — Policies & failure"
|
||||||
|
description="The actuation gate — and what happens when things break. A fail-safe must never quietly report 'normal'."
|
||||||
|
placeholder="Report only on exception; escalate ambiguous calls to the cloud; never suppress a stale-sensor alarm."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{allTried ? (
|
<div className="mt-6 flex justify-end">
|
||||||
<div className="mt-8 space-y-5" data-testid="whats-next">
|
<ProceedButton disabled={!ready} onClick={onProceed}>
|
||||||
<div>
|
Proceed to Module 3 →
|
||||||
<h2 className="text-[22px] font-semibold tracking-[-0.01em]">What’s next</h2>
|
</ProceedButton>
|
||||||
<p className="mt-1 max-w-[560px] text-[15px] text-ink-2">
|
</div>
|
||||||
You just watched the agent enumerate the bus and drive the matrix with its built-in
|
|
||||||
skills. Now capture <span className="font-medium text-ink">your domain’s</span> skills and
|
|
||||||
the policy that governs them — Layers 2 and 3, drafted below.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AddLayerForm
|
|
||||||
layer="L2"
|
|
||||||
title="ADD · Layer 2 — Skills"
|
|
||||||
description="What skills can the agent invoke to act on its domain?"
|
|
||||||
placeholder="Flash a sketch to the MCU; scroll a message; drive the damper; sample the IMU."
|
|
||||||
/>
|
|
||||||
<AddLayerForm
|
|
||||||
layer="L3"
|
|
||||||
title="ADD · Layer 3 — Policies & failure"
|
|
||||||
description="The actuation gate — and what happens when things break. A fail-safe must never quietly report 'normal'."
|
|
||||||
placeholder="Report only on exception; escalate ambiguous calls to the cloud; never suppress a stale-sensor alarm."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<ProceedButton disabled={!ready} onClick={onProceed}>
|
|
||||||
Proceed to Module 3 →
|
|
||||||
</ProceedButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<PanelCard className="mt-6">
|
|
||||||
<div className="text-[15px] font-semibold text-ink-2">What’s next</div>
|
|
||||||
<p className="mt-2 text-[14px] leading-[1.5] text-ink-3">
|
|
||||||
Try all three prompts above. Once your agent has run each one successfully, we’ll
|
|
||||||
capture your domain’s skills and policies here.
|
|
||||||
</p>
|
|
||||||
</PanelCard>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,20 +104,11 @@ 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', () => {
|
it('no longer shows the agent-setup cards here (moved to Meet your agent)', () => {
|
||||||
const { rerender } = renderPage()
|
renderPage()
|
||||||
expect(screen.queryByTestId('post-connect')).toBeNull()
|
|
||||||
act(() => useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0 }))
|
act(() => useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0 }))
|
||||||
rerender(
|
expect(screen.queryByTestId('post-connect')).toBeNull()
|
||||||
<MemoryRouter>
|
expect(screen.queryByRole('button', { name: /say hi to your agent/i })).toBeNull()
|
||||||
<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', () => {
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { MemberFields } from '@/components/MemberFields'
|
import { MemberFields } from '@/components/MemberFields'
|
||||||
import { BoardClaim } from '@/components/BoardClaim'
|
import { BoardClaim } from '@/components/BoardClaim'
|
||||||
import { LocalBoardConnect } from '@/components/LocalBoardConnect'
|
import { LocalBoardConnect } from '@/components/LocalBoardConnect'
|
||||||
import { SayHiCard } from '@/components/SayHiCard'
|
|
||||||
import { TelegramSetup } from '@/components/TelegramSetup'
|
|
||||||
import { VoiceSetup } from '@/components/VoiceSetup'
|
|
||||||
import { PanelHeading, PanelCard, ProceedButton, FieldLabel } from '@/components/cockpit/PanelChrome'
|
import { PanelHeading, PanelCard, ProceedButton, FieldLabel } from '@/components/cockpit/PanelChrome'
|
||||||
import { useLocalMode } from '@/lib/useLocalMode'
|
import { useLocalMode } from '@/lib/useLocalMode'
|
||||||
import type { ClaimResult } from '@/lib/api'
|
import type { ClaimResult } from '@/lib/api'
|
||||||
@@ -103,24 +100,6 @@ export function TeamRegistration() {
|
|||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The connection-sequencing fix: channels appear only after the board is
|
|
||||||
bound, so the first-connection moment isn't a pile-up. */}
|
|
||||||
{device.connected && (
|
|
||||||
<div className="mt-8 space-y-5" data-testid="post-connect">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-[22px] font-semibold tracking-[-0.01em]">Your agent</h2>
|
|
||||||
<p className="mt-1 text-[15px] text-ink-2">
|
|
||||||
Your board is bound — say hi to the agent on it, then set up how you reach it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SayHiCard />
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
|
||||||
<TelegramSetup />
|
|
||||||
<VoiceSetup />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-9 flex justify-end">
|
<div className="mt-9 flex justify-end">
|
||||||
<ProceedButton disabled={!ready} onClick={onProceed}>
|
<ProceedButton disabled={!ready} onClick={onProceed}>
|
||||||
Meet your agent →
|
Meet your agent →
|
||||||
|
|||||||
Reference in New Issue
Block a user