refactor(workshop): domain is name-only; Module 1 carries it read-only
Phase 2 "Pick your domain": drop the Refine button + the four-dimension
generation machinery — just capture the domain name and save it for the
modules ahead (DomainPicker is now a single input).
Module 1: show the domain the team named on the previous screen as
read-only ("Your domain" carried over), and remove the "Open your node"
inline link + the editable domain field.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e0bcf51b08
commit
f6979a685a
@@ -1,20 +1,12 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
import { DomainPicker } from './DomainPicker'
|
import { DomainPicker } from './DomainPicker'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
import { askNode } from '@/lib/api'
|
|
||||||
|
|
||||||
vi.mock('@/lib/api', async (orig) => ({
|
|
||||||
...(await orig<typeof import('@/lib/api')>()),
|
|
||||||
askNode: vi.fn(),
|
|
||||||
}))
|
|
||||||
const mockAsk = vi.mocked(askNode)
|
|
||||||
|
|
||||||
describe('DomainPicker', () => {
|
describe('DomainPicker', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSession.getState().reset()
|
useSession.getState().reset()
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
mockAsk.mockReset()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('binds the input to the session domain', () => {
|
it('binds the input to the session domain', () => {
|
||||||
@@ -24,41 +16,15 @@ describe('DomainPicker', () => {
|
|||||||
expect(useSession.getState().domain).toBe('air quality')
|
expect(useSession.getState().domain).toBe('air quality')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows generic scaffolding hints for the four next dimensions', () => {
|
it('reflects an already-named domain', () => {
|
||||||
|
useSession.getState().setDomain('structural stress')
|
||||||
render(<DomainPicker />)
|
render(<DomainPicker />)
|
||||||
for (const label of ['Skills', 'Policies', 'Harness', 'Loops']) {
|
expect(screen.getByLabelText(/your domain/i)).toHaveValue('structural stress')
|
||||||
expect(screen.getByText(label)).toBeInTheDocument()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('disables Refine until a domain is named', () => {
|
it('is just the name — no refine / generation controls', () => {
|
||||||
render(<DomainPicker />)
|
render(<DomainPicker />)
|
||||||
expect(screen.getByRole('button', { name: /refine/i })).toBeDisabled()
|
expect(screen.queryByRole('button', { name: /refine/i })).toBeNull()
|
||||||
fireEvent.change(screen.getByLabelText(/your domain/i), { target: { value: 'air quality' } })
|
expect(screen.queryByText('Skills')).toBeNull()
|
||||||
expect(screen.getByRole('button', { name: /refine/i })).toBeEnabled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('refine drafts all four dimensions into the store and opens a modal', async () => {
|
|
||||||
mockAsk.mockImplementation(async (_t, prompt) => {
|
|
||||||
const which = /SKILLS/.test(prompt) ? 'skills'
|
|
||||||
: /POLICIES/.test(prompt) ? 'policies'
|
|
||||||
: /HARNESS/.test(prompt) ? 'harness' : 'loops'
|
|
||||||
return `draft for ${which}`
|
|
||||||
})
|
|
||||||
useSession.getState().setDomain('air quality')
|
|
||||||
render(<DomainPicker />)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /refine/i }))
|
|
||||||
|
|
||||||
await waitFor(() => expect(useSession.getState().add.L2).toBe('draft for skills'))
|
|
||||||
expect(useSession.getState().add.L3).toBe('draft for policies')
|
|
||||||
expect(useSession.getState().add.L4).toBe('draft for harness')
|
|
||||||
expect(useSession.getState().add.L5).toBe('draft for loops')
|
|
||||||
|
|
||||||
// the Skills card is now done + clickable → clicking pops the modal
|
|
||||||
const skills = await screen.findByTestId('dim-L2')
|
|
||||||
expect(skills).toHaveAttribute('data-state', 'done')
|
|
||||||
fireEvent.click(skills)
|
|
||||||
expect(screen.getByRole('dialog')).toHaveTextContent(/draft for skills/i)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,89 +1,17 @@
|
|||||||
import { useId, useState } from 'react'
|
import { useId } from 'react'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Button } from '@/components/ui/button'
|
import { useSession } from '@/store/session'
|
||||||
import { Modal } from '@/components/ui/modal'
|
|
||||||
import { useSession, type AddLayers } from '@/store/session'
|
|
||||||
import { askNode } from '@/lib/api'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
type LayerKey = 'L2' | 'L3' | 'L4' | 'L5'
|
|
||||||
type GenState = 'idle' | 'running' | 'done'
|
|
||||||
|
|
||||||
/** The four design dimensions the domain refine jump-starts, mapped to ADD layers.
|
|
||||||
* Each prompt is failure-first framed so the drafts seed the real deliverable. */
|
|
||||||
const DIMENSIONS: { key: LayerKey; label: string; hint: string; prompt: (d: string) => string }[] = [
|
|
||||||
{
|
|
||||||
key: 'L2',
|
|
||||||
label: 'Skills',
|
|
||||||
hint: 'What domain knowledge must it know?',
|
|
||||||
prompt: (d) =>
|
|
||||||
`Designing an autonomous monitoring agent for this domain: "${d}". In 3–4 concise sentences, describe the SKILLS it needs — the domain knowledge and sensing/interpretation capabilities required to understand this domain. Plain prose, no preamble, no heading.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'L3',
|
|
||||||
label: 'Policies',
|
|
||||||
hint: 'What may it do autonomously vs. need approval?',
|
|
||||||
prompt: (d) =>
|
|
||||||
`Designing an autonomous monitoring agent for this domain: "${d}". In 3–4 concise sentences, describe its POLICIES — what it may do autonomously vs. what needs human approval, and how each thing fails safe (a failure must never read as "nominal"; degrade to unknown/escalate). Plain prose, no preamble, no heading.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'L4',
|
|
||||||
label: 'Harness',
|
|
||||||
hint: 'When does it decide locally vs. escalate?',
|
|
||||||
prompt: (d) =>
|
|
||||||
`Designing an autonomous monitoring agent for this domain: "${d}". In 3–4 concise sentences, describe its HARNESS — the degradation path (cloud → on-board → fully offline) and what still works with no network at all. Plain prose, no preamble, no heading.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'L5',
|
|
||||||
label: 'Loops',
|
|
||||||
hint: 'How often does it check its world + report by exception?',
|
|
||||||
prompt: (d) =>
|
|
||||||
`Designing an autonomous monitoring agent for this domain: "${d}". In 3–4 concise sentences, describe its LOOPS — how often it checks its world, how it reports by exception, and what it does when a cycle fails (stale readings, missed ticks, partial data). Plain prose, no preamble, no heading.`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Names the problem domain and, via "Refine", asks the team's node to jump-start
|
* Names the problem domain the team is tackling. Saved to the session store so
|
||||||
* drafts for the four design dimensions (ADD layers L2–L5). Each card generates
|
* the modules ahead frame everything around it. Just the name — no generation.
|
||||||
* behind the scenes, turns green when done, and opens a modal with its draft.
|
|
||||||
* Drafts land in the session store so later modules pick them up.
|
|
||||||
*/
|
*/
|
||||||
export function DomainPicker() {
|
export function DomainPicker() {
|
||||||
const domain = useSession((s) => s.domain)
|
const domain = useSession((s) => s.domain)
|
||||||
const setDomain = useSession((s) => s.setDomain)
|
const setDomain = useSession((s) => s.setDomain)
|
||||||
const add = useSession((s) => s.add)
|
|
||||||
const setAddLayer = useSession((s) => s.setAddLayer)
|
|
||||||
const teamId = useSession((s) => s.teamId)
|
|
||||||
const inputId = useId()
|
const inputId = useId()
|
||||||
|
|
||||||
const [state, setState] = useState<Record<LayerKey, GenState>>({ L2: 'idle', L3: 'idle', L4: 'idle', L5: 'idle' })
|
|
||||||
const [open, setOpen] = useState<LayerKey | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const anyRunning = Object.values(state).some((s) => s === 'running')
|
|
||||||
const doneCount = DIMENSIONS.filter((d) => state[d.key] === 'done').length
|
|
||||||
|
|
||||||
const refine = () => {
|
|
||||||
if (!domain.trim() || anyRunning) return
|
|
||||||
setError(null)
|
|
||||||
for (const dim of DIMENSIONS) {
|
|
||||||
setState((s) => ({ ...s, [dim.key]: 'running' }))
|
|
||||||
askNode(teamId, dim.prompt(domain))
|
|
||||||
.then((text) => {
|
|
||||||
setAddLayer(dim.key as keyof AddLayers, text.trim())
|
|
||||||
setState((s) => ({ ...s, [dim.key]: 'done' }))
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setState((s) => ({ ...s, [dim.key]: 'idle' }))
|
|
||||||
setError('Could not reach your node — say hi first, then try again.')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const active = DIMENSIONS.find((d) => d.key === open)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor={inputId} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
<label htmlFor={inputId} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||||
Your domain
|
Your domain
|
||||||
@@ -95,71 +23,9 @@ export function DomainPicker() {
|
|||||||
onChange={(e) => setDomain(e.target.value)}
|
onChange={(e) => setDomain(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
|
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
|
||||||
Name the domain your node is for and the events it senses — ideally the one you have been measuring
|
Name the domain your agent is for and the events it senses — ideally the one you have been
|
||||||
already. This frames everything you design next.
|
measuring already. This frames everything you design next.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
|
||||||
What you’ll design next
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
className="h-7 px-2.5 font-mono text-[11px] tracking-wider"
|
|
||||||
disabled={!domain.trim() || anyRunning}
|
|
||||||
onClick={refine}
|
|
||||||
title="Draft all four dimensions from your domain"
|
|
||||||
>
|
|
||||||
{anyRunning ? `Refining… ${doneCount}/4` : doneCount > 0 ? '↻ Refine again' : '✦ Refine'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-2 gap-3">
|
|
||||||
{DIMENSIONS.map((d) => {
|
|
||||||
const st = state[d.key]
|
|
||||||
const content = add[d.key]
|
|
||||||
const clickable = st === 'done' && !!content
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={d.key}
|
|
||||||
type="button"
|
|
||||||
data-testid={`dim-${d.key}`}
|
|
||||||
data-state={st}
|
|
||||||
disabled={!clickable}
|
|
||||||
onClick={() => clickable && setOpen(d.key)}
|
|
||||||
className={cn(
|
|
||||||
'text-left rounded-md border px-3 py-2.5 transition-colors',
|
|
||||||
st === 'done'
|
|
||||||
? 'border-teal/40 bg-teal/10 hover:bg-teal/15 cursor-pointer'
|
|
||||||
: st === 'running'
|
|
||||||
? 'border-amber/40 bg-amber/5'
|
|
||||||
: 'border-border',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="font-mono text-[10px] uppercase tracking-widest text-primary">{d.label}</span>
|
|
||||||
{st === 'running' && <span className="w-1.5 h-1.5 rounded-full bg-amber animate-pulse" />}
|
|
||||||
{st === 'done' && <span className="font-mono text-[9px] uppercase tracking-widest text-teal">ready ✓</span>}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground leading-snug mt-1 line-clamp-2">
|
|
||||||
{st === 'done' && content ? content : d.hint}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{error && <p role="alert" className="text-xs text-red-500 leading-relaxed">{error}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal open={open !== null} onClose={() => setOpen(null)} title={active ? `Draft · ${active.label}` : ''}>
|
|
||||||
<p className="text-sm leading-relaxed whitespace-pre-wrap text-foreground/90">{open ? add[open] : ''}</p>
|
|
||||||
<p className="font-mono text-[10px] text-muted-foreground mt-4 leading-relaxed">
|
|
||||||
A starting draft from your node — refine it further in the modules ahead.
|
|
||||||
</p>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ describe('Module1', () => {
|
|||||||
).toHaveAttribute('data-state', 'active')
|
).toHaveAttribute('data-state', 'active')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows the domain carried over from the earlier screen (read-only)', () => {
|
||||||
|
useSession.getState().setDomain('air quality')
|
||||||
|
renderPage()
|
||||||
|
const carried = screen.getByTestId('domain-carried')
|
||||||
|
expect(carried).toHaveTextContent('air quality')
|
||||||
|
// no editable domain input here anymore
|
||||||
|
expect(screen.queryByLabelText(/your domain/i)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders the actor map and the live board feed', () => {
|
it('renders the actor map and the live board feed', () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
expect(screen.getByTestId('actor-map')).toBeInTheDocument()
|
expect(screen.getByTestId('actor-map')).toBeInTheDocument()
|
||||||
|
|||||||
+10
-19
@@ -2,12 +2,10 @@ 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'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { PhaseStrip } from '@/components/PhaseStrip'
|
import { PhaseStrip } from '@/components/PhaseStrip'
|
||||||
import { LiveBoardFeed } from '@/components/LiveBoardFeed'
|
import { LiveBoardFeed } from '@/components/LiveBoardFeed'
|
||||||
import { ActorMap } from '@/components/ActorMap'
|
import { ActorMap } from '@/components/ActorMap'
|
||||||
import { AddLayerForm } from '@/components/AddLayerForm'
|
import { AddLayerForm } from '@/components/AddLayerForm'
|
||||||
import { OpenYourNode } from '@/components/OpenYourNode'
|
|
||||||
import { useNodeFeed } from '@/lib/useNodeFeed'
|
import { useNodeFeed } from '@/lib/useNodeFeed'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
@@ -17,7 +15,6 @@ export function Module1() {
|
|||||||
const feed = useNodeFeed(teamId, true)
|
const feed = useNodeFeed(teamId, true)
|
||||||
const l1 = useSession((s) => s.add.L1)
|
const l1 = useSession((s) => s.add.L1)
|
||||||
const domain = useSession((s) => s.domain)
|
const domain = useSession((s) => s.domain)
|
||||||
const setDomain = useSession((s) => s.setDomain)
|
|
||||||
const completePhase = useSession((s) => s.completePhase)
|
const completePhase = useSession((s) => s.completePhase)
|
||||||
|
|
||||||
// The board's own loop runs on-device, so an online board (or any activity
|
// The board's own loop runs on-device, so an online board (or any activity
|
||||||
@@ -51,26 +48,20 @@ export function Module1() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Module 1 · Domain & events</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Module 1 · Domain & events</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">
|
||||||
Define the domain your node is for and the events it must sense and act on. You seeded the
|
Define the domain your agent is for and the events it must sense and act on. Carried over
|
||||||
domain during setup — refine it here, then draft Layer 1 of your Agent Design Document.
|
from the domain you named earlier — now draft Layer 1 of your Agent Design Document.
|
||||||
Open your node to explore what it already senses.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border border-border bg-card px-5 py-4 space-y-3">
|
<div className="rounded-md border border-border bg-card px-5 py-4 space-y-1.5" data-testid="domain-carried">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Your domain</div>
|
||||||
<label htmlFor="domain-refine" className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
{domain.trim() ? (
|
||||||
Your domain
|
<div className="text-lg font-semibold tracking-tight">{domain}</div>
|
||||||
</label>
|
) : (
|
||||||
<OpenYourNode variant="inline" />
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Not set yet — name it on <span className="font-medium">Meet your agent</span>.
|
||||||
</div>
|
</div>
|
||||||
<Input
|
)}
|
||||||
id="domain-refine"
|
|
||||||
placeholder="e.g. structural resonance monitoring"
|
|
||||||
value={domain}
|
|
||||||
onChange={(e) => setDomain(e.target.value)}
|
|
||||||
className="font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid lg:grid-cols-2 gap-6">
|
<div className="grid lg:grid-cols-2 gap-6">
|
||||||
|
|||||||
Reference in New Issue
Block a user