feat: shared workshop components (ADD form, stats, feed, harness) — TDD
- AddLayerForm: keystone ADD capture — string layers (L2-L5) as textarea, object layers (L1) as per-field inputs; writes straight to the store - StatsTally: live classified-frame counts - LiveFeed: presentational IMU readout (props-driven) - HarnessTomlPreview: live harness.toml render - HarnessProviderSelect: provider toggle group + model field - ui/textarea primitive 14 new tests; suite 63/63 green, typecheck + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
66191217ec
commit
965af7e5c0
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { AddLayerForm } from './AddLayerForm'
|
||||||
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
|
describe('AddLayerForm', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useSession.getState().reset()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes a string layer (L2) to the store as the user types', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<AddLayerForm layer="L2" title="Layer 2" placeholder="reasoning policy" />)
|
||||||
|
await user.type(screen.getByLabelText(/layer 2/i), 'escalate on critical')
|
||||||
|
expect(useSession.getState().add.L2).toBe('escalate on critical')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hydrates a string layer from existing store state', () => {
|
||||||
|
useSession.getState().setAddLayer('L3', 'prior text')
|
||||||
|
render(<AddLayerForm layer="L3" title="Layer 3" />)
|
||||||
|
expect(screen.getByLabelText(/layer 3/i)).toHaveValue('prior text')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes an object layer (L1) field-by-field', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(
|
||||||
|
<AddLayerForm
|
||||||
|
layer="L1"
|
||||||
|
title="Layer 1"
|
||||||
|
fields={[
|
||||||
|
{ key: 'goal', label: 'Goal' },
|
||||||
|
{ key: 'perception', label: 'Perception' },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
await user.type(screen.getByLabelText(/goal/i), 'keep structure safe')
|
||||||
|
await user.type(screen.getByLabelText(/perception/i), 'imu + acoustic')
|
||||||
|
expect(useSession.getState().add.L1).toEqual({
|
||||||
|
goal: 'keep structure safe',
|
||||||
|
perception: 'imu + acoustic',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useId } from 'react'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { useSession, type AddLayers } from '@/store/session'
|
||||||
|
|
||||||
|
export interface AddLayerFieldSpec {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
placeholder?: string
|
||||||
|
multiline?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddLayerFormProps {
|
||||||
|
layer: keyof AddLayers
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
/** when present, the layer is a structured object keyed by these fields (L1) */
|
||||||
|
fields?: AddLayerFieldSpec[]
|
||||||
|
/** placeholder for the single-textarea string layers (L2–L5) */
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keystone ADD capture component. Drives one layer of the 5-layer Agent Design
|
||||||
|
* Document: string layers (L2–L5) render a single textarea; object layers (L1)
|
||||||
|
* render one input per field. All edits flow straight into the session store.
|
||||||
|
*/
|
||||||
|
export function AddLayerForm({ layer, title, description, fields, placeholder }: AddLayerFormProps) {
|
||||||
|
const value = useSession((s) => s.add[layer])
|
||||||
|
const setAddLayer = useSession((s) => s.setAddLayer)
|
||||||
|
const baseId = useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
|
{description && <p className="text-xs text-muted-foreground leading-relaxed">{description}</p>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{fields ? (
|
||||||
|
fields.map((f) => {
|
||||||
|
const obj = (value as Record<string, string> | null) ?? {}
|
||||||
|
const id = `${baseId}-${f.key}`
|
||||||
|
return (
|
||||||
|
<div key={f.key} className="space-y-2">
|
||||||
|
<label htmlFor={id} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||||
|
{f.label}
|
||||||
|
</label>
|
||||||
|
{f.multiline ? (
|
||||||
|
<Textarea
|
||||||
|
id={id}
|
||||||
|
placeholder={f.placeholder}
|
||||||
|
value={obj[f.key] ?? ''}
|
||||||
|
onChange={(e) => setAddLayer(layer, { ...obj, [f.key]: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
placeholder={f.placeholder}
|
||||||
|
value={obj[f.key] ?? ''}
|
||||||
|
onChange={(e) => setAddLayer(layer, { ...obj, [f.key]: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor={baseId} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||||
|
{title}
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id={baseId}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={(value as string) ?? ''}
|
||||||
|
onChange={(e) => setAddLayer(layer, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { HarnessProviderSelect } from './HarnessProviderSelect'
|
||||||
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
|
describe('HarnessProviderSelect', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useSession.getState().reset()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the current provider as pressed', () => {
|
||||||
|
render(<HarnessProviderSelect />)
|
||||||
|
expect(screen.getByRole('button', { name: /anthropic/i })).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes the chosen provider to the store', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<HarnessProviderSelect />)
|
||||||
|
await user.click(screen.getByRole('button', { name: /groq/i }))
|
||||||
|
expect(useSession.getState().harness.provider).toBe('groq')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes the model field to the store', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<HarnessProviderSelect />)
|
||||||
|
const input = screen.getByLabelText(/model/i)
|
||||||
|
await user.clear(input)
|
||||||
|
await user.type(input, 'claude-sonnet-4-6')
|
||||||
|
expect(useSession.getState().harness.model).toBe('claude-sonnet-4-6')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables editing when read-only', () => {
|
||||||
|
render(<HarnessProviderSelect readOnly />)
|
||||||
|
expect(screen.getByRole('button', { name: /groq/i })).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText(/model/i)).toHaveAttribute('readonly')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useSession, type Provider } from '@/store/session'
|
||||||
|
|
||||||
|
const PROVIDERS: { id: Provider; label: string }[] = [
|
||||||
|
{ id: 'anthropic', label: 'Anthropic' },
|
||||||
|
{ id: 'groq', label: 'Groq' },
|
||||||
|
{ id: 'openai', label: 'OpenAI' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface HarnessProviderSelectProps {
|
||||||
|
/** render the model field read-only (e.g. the Module 2 config summary) */
|
||||||
|
readOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider toggle group + model field, bound to the session harness. */
|
||||||
|
export function HarnessProviderSelect({ readOnly }: HarnessProviderSelectProps) {
|
||||||
|
const provider = useSession((s) => s.harness.provider)
|
||||||
|
const model = useSession((s) => s.harness.model)
|
||||||
|
const setHarness = useSession((s) => s.setHarness)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-2" role="group" aria-label="Reasoning provider">
|
||||||
|
{PROVIDERS.map((p) => {
|
||||||
|
const selected = p.id === provider
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
disabled={readOnly}
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={() => setHarness({ provider: p.id })}
|
||||||
|
className={cn(
|
||||||
|
'font-mono text-xs px-2 py-2 rounded-md border transition disabled:opacity-60',
|
||||||
|
selected
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-card text-muted-foreground border-border hover:border-primary/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="harness-model" className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||||
|
Model
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="harness-model"
|
||||||
|
value={model}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onChange={(e) => setHarness({ model: e.target.value })}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { HarnessTomlPreview } from './HarnessTomlPreview'
|
||||||
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
|
describe('HarnessTomlPreview', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useSession.getState().reset()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the live harness as TOML', () => {
|
||||||
|
render(<HarnessTomlPreview />)
|
||||||
|
const pre = screen.getByTestId('harness-toml')
|
||||||
|
expect(pre).toHaveTextContent('[harness]')
|
||||||
|
expect(pre).toHaveTextContent('threshold_g = 0.8')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reflects a tuned threshold', () => {
|
||||||
|
useSession.getState().setHarness({ thresholdG: 1.1 })
|
||||||
|
render(<HarnessTomlPreview />)
|
||||||
|
expect(screen.getByTestId('harness-toml')).toHaveTextContent('threshold_g = 1.1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useSession } from '@/store/session'
|
||||||
|
import { harnessToToml } from '@/lib/harness'
|
||||||
|
|
||||||
|
/** Read-only `harness.toml` preview that reflects the live tuned config. */
|
||||||
|
export function HarnessTomlPreview() {
|
||||||
|
const harness = useSession((s) => s.harness)
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
data-testid="harness-toml"
|
||||||
|
className="font-mono text-xs bg-card border border-border rounded-md p-4 overflow-x-auto whitespace-pre"
|
||||||
|
>
|
||||||
|
{harnessToToml(harness)}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { LiveFeed } from './LiveFeed'
|
||||||
|
|
||||||
|
describe('LiveFeed', () => {
|
||||||
|
it('prompts to start when there is no frame', () => {
|
||||||
|
render(<LiveFeed last={null} frames={[]} connected={false} />)
|
||||||
|
expect(screen.getByText(/no frames yet/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('live-feed').querySelector('[data-state="idle"]')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the latest frame axes and magnitude when live', () => {
|
||||||
|
render(
|
||||||
|
<LiveFeed
|
||||||
|
last={{ ax: 0.3, ay: 0.4, az: 0, db: 50, t: 1 }}
|
||||||
|
frames={[{ ax: 0.3, ay: 0.4, az: 0, db: 50, t: 1 }]}
|
||||||
|
connected
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const feed = screen.getByTestId('live-feed')
|
||||||
|
expect(feed.querySelector('[data-state="live"]')).toBeTruthy()
|
||||||
|
expect(feed.querySelector('[data-axis="ax"]')).toHaveTextContent('0.30')
|
||||||
|
expect(feed.querySelector('[data-axis="mag"]')).toHaveTextContent('0.50')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a simulated connection', () => {
|
||||||
|
render(<LiveFeed last={null} frames={[]} connected mocked />)
|
||||||
|
expect(screen.getByText(/simulated/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { ImuFrame } from '@/lib/serial'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface LiveFeedProps {
|
||||||
|
last: ImuFrame | null
|
||||||
|
frames: ImuFrame[]
|
||||||
|
connected: boolean
|
||||||
|
mocked?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function mag(f: ImuFrame): number {
|
||||||
|
return Math.sqrt(f.ax * f.ax + f.ay * f.ay + f.az * f.az)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presentational live IMU readout. Pages feed it `useSerial()` output; tests
|
||||||
|
* seed it with props directly.
|
||||||
|
*/
|
||||||
|
export function LiveFeed({ last, frames, connected, mocked }: LiveFeedProps) {
|
||||||
|
return (
|
||||||
|
<div data-testid="live-feed" className="border border-border rounded-md bg-card p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
data-state={connected ? 'live' : 'idle'}
|
||||||
|
className={cn('w-2 h-2 rounded-full', connected ? 'bg-teal animate-pulse' : 'bg-muted-foreground')}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{connected ? 'Live feed' : 'Feed idle'}</span>
|
||||||
|
</div>
|
||||||
|
{mocked && (
|
||||||
|
<span className="font-mono text-[9px] uppercase tracking-widest text-amber">simulated</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{last ? (
|
||||||
|
<div className="grid grid-cols-4 gap-2 font-mono text-xs">
|
||||||
|
{(['ax', 'ay', 'az'] as const).map((k) => (
|
||||||
|
<div key={k} className="space-y-0.5">
|
||||||
|
<div className="text-[9px] uppercase tracking-widest text-muted-foreground">{k}</div>
|
||||||
|
<div className="tabular-nums" data-axis={k}>{last[k].toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-[9px] uppercase tracking-widest text-muted-foreground">|g|</div>
|
||||||
|
<div className="tabular-nums" data-axis="mag">{mag(last).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">No frames yet — start the feed.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-0.5 h-8 items-end" aria-hidden="true">
|
||||||
|
{frames.slice(-32).map((f, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="flex-1 bg-primary/40 rounded-sm"
|
||||||
|
style={{ height: `${Math.min(100, mag(f) * 50)}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { StatsTally } from './StatsTally'
|
||||||
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
|
describe('StatsTally', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useSession.getState().reset()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders zeroed counts initially', () => {
|
||||||
|
render(<StatsTally />)
|
||||||
|
expect(screen.getByTestId('stats-tally')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Frames').nextElementSibling).toHaveTextContent('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reflects recorded events from the store', () => {
|
||||||
|
useSession.getState().recordEvent('nominal')
|
||||||
|
useSession.getState().recordEvent('critical')
|
||||||
|
render(<StatsTally />)
|
||||||
|
const tally = screen.getByTestId('stats-tally')
|
||||||
|
expect(tally.querySelector('[data-stat="calls"]')).toHaveTextContent('2')
|
||||||
|
expect(tally.querySelector('[data-stat="nominal"]')).toHaveTextContent('1')
|
||||||
|
expect(tally.querySelector('[data-stat="critical"]')).toHaveTextContent('1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useSession } from '@/store/session'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const CELLS: { key: 'calls' | 'nominal' | 'anomalous' | 'critical'; label: string; tone: string }[] = [
|
||||||
|
{ key: 'calls', label: 'Frames', tone: 'text-foreground' },
|
||||||
|
{ key: 'nominal', label: 'Nominal', tone: 'text-teal' },
|
||||||
|
{ key: 'anomalous', label: 'Anomalous', tone: 'text-amber' },
|
||||||
|
{ key: 'critical', label: 'Critical', tone: 'text-rose' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Live tally of classified-frame counts from the session store. */
|
||||||
|
export function StatsTally() {
|
||||||
|
const stats = useSession((s) => s.stats)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="stats-tally"
|
||||||
|
className="grid grid-cols-4 gap-px bg-border rounded-md overflow-hidden text-center"
|
||||||
|
>
|
||||||
|
{CELLS.map((c) => (
|
||||||
|
<div key={c.key} className="bg-background p-3 space-y-1">
|
||||||
|
<div className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground">{c.label}</div>
|
||||||
|
<div className={cn('text-lg font-bold tabular-nums', c.tone)} data-stat={c.key}>
|
||||||
|
{stats[c.key]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-20 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
Reference in New Issue
Block a user