feat(refine): AI Refine on domain (Phase 2) + wand on ADD layers

Phase 2 'Pick your domain' gets a Refine button that asks the team's node (cloud
sonnet) to draft all four design dimensions from the typed domain — Skills/Policies/
Harness/Loops → ADD layers L2-L5. Each card generates behind the scenes, turns green
when ready, and opens a modal with its draft (failure-first framed prompts). Drafts
land in the session store for later modules.

AddLayerForm (incl. Module 1's L1 Domain & events) gets a 🪄 Refine wand that reformats
+ structures the author's notes in place (meaning preserved) for submission.

Both reuse a new askNode() over the blocking say-hi/promptAndWait path. New reusable
Modal. Proven against the board: Skills draft for 'stress fractures' in 5.6s. Tests +196.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-21 10:16:18 -07:00
co-authored by Claude Opus 4.8
parent 9d44931752
commit 8001e100c3
6 changed files with 303 additions and 30 deletions
+27 -2
View File
@@ -1,13 +1,21 @@
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, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { AddLayerForm } from './AddLayerForm' import { AddLayerForm } from './AddLayerForm'
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('AddLayerForm', () => { describe('AddLayerForm', () => {
beforeEach(() => { beforeEach(() => {
useSession.getState().reset() useSession.getState().reset()
sessionStorage.clear() sessionStorage.clear()
mockAsk.mockReset()
}) })
it('writes a string layer (L2) to the store as the user types', async () => { it('writes a string layer (L2) to the store as the user types', async () => {
@@ -29,4 +37,21 @@ describe('AddLayerForm', () => {
await user.type(screen.getByLabelText(/layer 1/i), 'structural resonance') await user.type(screen.getByLabelText(/layer 1/i), 'structural resonance')
expect(useSession.getState().add.L1).toBe('structural resonance') expect(useSession.getState().add.L1).toBe('structural resonance')
}) })
it('Refine is disabled while empty, then reformats the text via the node', async () => {
const user = userEvent.setup()
useSession.getState().setAddLayer('L1', 'rough notes about impact spikes')
mockAsk.mockResolvedValue('Domain: impact monitoring.\n\n- Impact spike\n- Sustained sway')
render(<AddLayerForm layer="L1" title="Layer 1" />)
await user.click(screen.getByRole('button', { name: /refine/i }))
await waitFor(() => expect(useSession.getState().add.L1).toMatch(/Impact spike/))
// the reformatted text replaced the original notes
expect(useSession.getState().add.L1).not.toMatch(/rough notes/)
})
it('Refine is disabled when the layer is empty', () => {
render(<AddLayerForm layer="L1" title="Layer 1" />)
expect(screen.getByRole('button', { name: /refine/i })).toBeDisabled()
})
}) })
+43 -3
View File
@@ -1,7 +1,9 @@
import { useId } from 'react' import { useId, useState } from 'react'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useSession, type AddLayers } from '@/store/session' import { useSession, type AddLayers } from '@/store/session'
import { askNode } from '@/lib/api'
export interface AddLayerFormProps { export interface AddLayerFormProps {
layer: keyof AddLayers layer: keyof AddLayers
@@ -13,13 +15,37 @@ export interface AddLayerFormProps {
/** /**
* Keystone ADD capture component. Drives one layer of the 5-layer Agent Design * Keystone ADD capture component. Drives one layer of the 5-layer Agent Design
* Document — each layer is free text rendered as a single textarea. All edits * Document — each layer is free text rendered as a single textarea, with a
* flow straight into the session store. * "Refine" wand that asks the team's node to reformat + structure what they wrote
* (meaning preserved) so it's submission-ready.
*/ */
export function AddLayerForm({ layer, title, description, placeholder }: AddLayerFormProps) { export function AddLayerForm({ layer, title, description, placeholder }: AddLayerFormProps) {
const value = useSession((s) => s.add[layer]) const value = useSession((s) => s.add[layer])
const setAddLayer = useSession((s) => s.setAddLayer) const setAddLayer = useSession((s) => s.setAddLayer)
const teamId = useSession((s) => s.teamId)
const baseId = useId() const baseId = useId()
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const refine = async () => {
const text = value.trim()
if (!text || busy) return
setBusy(true)
setError(null)
try {
const refined = await askNode(
teamId,
`Reformat and lightly structure the following notes for the "${title}" section of an Agent Design Document. ` +
`Preserve the author's meaning and facts — do NOT invent new content. Improve clarity and grammar, and add ` +
`light structure (short paragraphs or bullets) where it helps readability. Return ONLY the improved text, no preamble.\n\nNotes:\n${text}`,
)
if (refined.trim()) setAddLayer(layer, refined.trim())
} catch {
setError('Could not reach your node — say hi first, then try again.')
} finally {
setBusy(false)
}
}
return ( return (
<Card> <Card>
@@ -29,15 +55,29 @@ export function AddLayerForm({ layer, title, description, placeholder }: AddLaye
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<label htmlFor={baseId} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground"> <label htmlFor={baseId} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
{title} {title}
</label> </label>
<Button
type="button"
size="sm"
variant="secondary"
className="h-7 px-2.5 font-mono text-[11px] tracking-wider"
disabled={!value.trim() || busy}
onClick={refine}
title="Reformat and structure your notes"
>
{busy ? 'Refining…' : '🪄 Refine'}
</Button>
</div>
<Textarea <Textarea
id={baseId} id={baseId}
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
onChange={(e) => setAddLayer(layer, e.target.value)} onChange={(e) => setAddLayer(layer, e.target.value)}
/> />
{error && <p role="alert" className="text-xs text-red-500 leading-relaxed">{error}</p>}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+41 -2
View File
@@ -1,12 +1,20 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react' import { render, screen, fireEvent, waitFor } 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', () => {
@@ -22,4 +30,35 @@ describe('DomainPicker', () => {
expect(screen.getByText(label)).toBeInTheDocument() expect(screen.getByText(label)).toBeInTheDocument()
} }
}) })
it('disables Refine until a domain is named', () => {
render(<DomainPicker />)
expect(screen.getByRole('button', { name: /refine/i })).toBeDisabled()
fireEvent.change(screen.getByLabelText(/your domain/i), { target: { value: 'air quality' } })
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)
})
}) })
+125 -17
View File
@@ -1,26 +1,87 @@
import { useId } from 'react' import { useId, useState } from 'react'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useSession } from '@/store/session' import { Button } from '@/components/ui/button'
import { Modal } from '@/components/ui/modal'
import { useSession, type AddLayers } from '@/store/session'
import { askNode } from '@/lib/api'
import { cn } from '@/lib/utils'
/** Generic, domain-agnostic scaffolding prompts for the four design dimensions type LayerKey = 'L2' | 'L3' | 'L4' | 'L5'
* the team builds next. Deliberately NOT a fixed catalog — just questions. */ type GenState = 'idle' | 'running' | 'done'
const DIMENSION_HINTS: { label: string; prompt: string }[] = [
{ label: 'Skills', prompt: 'What domain knowledge must it know?' }, /** The four design dimensions the domain refine jump-starts, mapped to ADD layers.
{ label: 'Policies', prompt: 'What may it do autonomously vs. need approval?' }, * Each prompt is failure-first framed so the drafts seed the real deliverable. */
{ label: 'Harness', prompt: 'When does it decide locally vs. escalate?' }, const DIMENSIONS: { key: LayerKey; label: string; hint: string; prompt: (d: string) => string }[] = [
{ label: 'Loops', prompt: 'How often does it check its world + report by exception?' }, {
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 the team's node is for, plus the events it senses. * Names the problem domain and, via "Refine", asks the team's node to jump-start
* Bound straight to the session store's free-text `domain`. Below the input we * drafts for the four design dimensions (ADD layers L2–L5). Each card generates
* surface generic scaffolding prompts for the next four design dimensions. * 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-5">
<div className="space-y-2"> <div className="space-y-2">
@@ -40,18 +101,65 @@ export function DomainPicker() {
</div> </div>
<div className="space-y-2"> <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"> <div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
What you&rsquo;ll design next What you&rsquo;ll design next
</div> </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"> <div className="grid sm:grid-cols-2 gap-3">
{DIMENSION_HINTS.map((d) => ( {DIMENSIONS.map((d) => {
<div key={d.label} className="rounded-md border border-border px-3 py-2.5"> const st = state[d.key]
<div className="font-mono text-[10px] uppercase tracking-widest text-primary">{d.label}</div> const content = add[d.key]
<div className="text-sm text-muted-foreground leading-snug mt-1">{d.prompt}</div> 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>
))} <div className="text-sm text-muted-foreground leading-snug mt-1 line-clamp-2">
{st === 'done' && content ? content : d.hint}
</div> </div>
</button>
)
})}
</div> </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> </div>
) )
} }
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
export interface ModalProps {
open: boolean
onClose: () => void
title?: ReactNode
children: ReactNode
className?: string
}
/** Lightweight accessible modal: dimmed overlay, click-outside + Esc to close. */
export function Modal({ open, onClose, title, children, className }: ModalProps) {
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
onClick={onClose}
role="presentation"
>
<div
role="dialog"
aria-modal="true"
className={cn(
'w-full max-w-lg max-h-[85vh] overflow-y-auto rounded-xl border border-border bg-background shadow-2xl',
className,
)}
onClick={(e) => e.stopPropagation()}
>
{title && (
<div className="flex items-center justify-between gap-4 border-b border-border px-5 py-3.5 sticky top-0 bg-background">
<div className="font-mono text-[11px] uppercase tracking-widest text-primary">{title}</div>
<button
type="button"
aria-label="Close"
onClick={onClose}
className="text-muted-foreground hover:text-foreground text-lg leading-none"
>
×
</button>
</div>
)}
<div className="px-5 py-4">{children}</div>
</div>
</div>
)
}
+6
View File
@@ -175,6 +175,12 @@ export async function sayHi(teamId: string, agent?: string, message?: string): P
return ((await res.json()) as { reply: string }).reply return ((await res.json()) as { reply: string }).reply
} }
/** Ask the team's node a one-off prompt and wait for its reply (reuses the
* blocking say-hi path). Backs the "Refine" features. Defaults to the cloud agent. */
export async function askNode(teamId: string, prompt: string, agent = 'cloud'): Promise<string> {
return sayHi(teamId, agent, prompt)
}
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',