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:
Omar Sobh
2026-07-22 01:17:17 -07:00
co-authored by Claude Opus 4.8
parent e0bcf51b08
commit f6979a685a
4 changed files with 46 additions and 214 deletions
+8 -42
View File
@@ -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)
}) })
}) })
+6 -140
View File
@@ -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&rsquo;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>
) )
} }
+9
View File
@@ -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
View File
@@ -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 &amp; events</h1> <h1 className="text-3xl font-bold tracking-tight">Module 1 · Domain &amp; 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">