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:
co-authored by
Claude Opus 4.8
parent
9d44931752
commit
8001e100c3
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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’ll design next
|
What you’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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user