feat: add plugin framework with UI contributions and suite selection

- Implemented PluginBlocks component to render various plugin UI elements.
- Created SuitePicker for selecting test suites and questions.
- Introduced usePluginUI hook for managing plugin UI state and manifest.
- Developed DocsPage for comprehensive plugin framework documentation.
- Added PluginPage to render pages declared by plugins based on the current path.
This commit is contained in:
Netherwarlord
2026-07-28 03:00:55 -04:00
parent adf61ae1a0
commit 8f246fabbc
73 changed files with 4972 additions and 640 deletions
+15 -1
View File
@@ -1,14 +1,17 @@
import { Shell } from './components/Shell'
import { useRouter } from './lib/router'
import { useRunFeed } from './hooks/useRunFeed'
import { PluginUIProvider } from './hooks/usePluginUI'
import { RunPage } from './pages/RunPage'
import { SuitePage } from './pages/SuitePage'
import { ReportsPage } from './pages/ReportsPage'
import { PlaygroundPage } from './pages/PlaygroundPage'
import { SettingsPage } from './pages/SettingsPage'
import { DocsPage } from './pages/DocsPage'
import { PluginPage } from './pages/PluginPage'
import { NotFoundPage } from './pages/NotFoundPage'
export function App() {
function Routes() {
const { path } = useRouter()
const { run, isLive } = useRunFeed()
@@ -18,7 +21,18 @@ export function App() {
else if (path.startsWith('/reports')) page = <ReportsPage />
else if (path === '/playground') page = <PlaygroundPage />
else if (path === '/settings') page = <SettingsPage />
else if (path === '/docs') page = <DocsPage />
// Everything under /x/ belongs to a plugin; PluginPage resolves which one.
else if (path.startsWith('/x/')) page = <PluginPage />
else page = <NotFoundPage />
return <Shell activeRun={isLive ? run : null}>{page}</Shell>
}
export function App() {
return (
<PluginUIProvider>
<Routes />
</PluginUIProvider>
)
}
+343
View File
@@ -0,0 +1,343 @@
/**
* Renders the interface plugins declare.
*
* A plugin returns data — never code — from its `ui_contributions()` hook, and
* this module draws it with the same primitives the built-in pages use, so a
* plugin panel is indistinguishable from a native one. That is the whole reason
* installing a plugin never requires rebuilding this bundle.
*
* Blocks may carry inline data or a `source` URL. With a source the block
* fetches its own data and re-fetches whenever `refreshKey` changes, which is
* how an Action button refreshes its siblings.
*/
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import {
Activity, AlertTriangle, BarChart3, Bug, CheckCircle2, Database, FileText,
Gauge, Hash, Layers, ListChecks, Play, Puzzle, RefreshCw, Search, Settings2,
Shield, Sparkles, Table2, Terminal, Trash2, Wrench, Zap,
} from 'lucide-react'
import { Markdown } from './Markdown'
import { Card, CardHeader, ConfirmDialog, EmptyState, ErrorNote, Spinner, StatTile, useToast } from './ui'
import { api, type ActionBlock, type MarkdownBlock, type PanelBlock, type PluginBlock, type StatRowBlock, type TableBlock } from '../lib/api'
import { usePluginSlot } from '../hooks/usePluginUI'
import { cx } from '../lib/format'
/* Plugins name an icon; only these resolve. An unknown name is not an error —
* it falls back to a puzzle piece so a typo never blanks the interface. */
const ICONS: Record<string, typeof Puzzle> = {
activity: Activity, alert: AlertTriangle, bar: BarChart3, bug: Bug,
check: CheckCircle2, database: Database, file: FileText, gauge: Gauge,
hash: Hash, layers: Layers, list: ListChecks, play: Play, puzzle: Puzzle,
refresh: RefreshCw, search: Search, settings: Settings2, shield: Shield,
sparkles: Sparkles, table: Table2, terminal: Terminal, trash: Trash2,
wrench: Wrench, zap: Zap,
}
export function pluginIcon(name: string | null | undefined) {
return ICONS[(name || '').toLowerCase()] ?? Puzzle
}
const TONE_TO_TILE = {
default: 'neutral',
good: 'mint',
warn: 'gold',
bad: 'rose',
} as const
/* ------------------------------------------------------------ data plumbing */
/** Inline data, or fetched from `source` and refreshed on demand. */
function useBlockData<T extends object>(block: T & { source?: string | null }, refreshKey: number) {
const [data, setData] = useState<T>(block)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(Boolean(block.source))
useEffect(() => {
if (!block.source) {
setData(block)
setLoading(false)
return
}
let cancelled = false
setLoading(true)
api
.pluginData<Partial<T>>(block.source)
.then((fresh) => {
if (!cancelled) {
setData({ ...block, ...fresh })
setError(null)
}
})
.catch((cause: unknown) => {
if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [block.source, refreshKey])
return { data, error, loading }
}
/* ------------------------------------------------------------------ blocks */
function StatRow({ block, refreshKey }: { block: StatRowBlock; refreshKey: number }) {
const { data, error, loading } = useBlockData(block, refreshKey)
if (error) return <ErrorNote message={error} />
const stats = data.stats ?? []
if (loading && !stats.length) {
return <div className="flex items-center gap-2 text-[0.8125rem] text-ink-500"><Spinner /> Loading</div>
}
if (!stats.length) return null
return (
<div className="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((stat, index) => (
<StatTile
key={`${stat.label}-${index}`}
label={stat.label}
value={stat.value}
hint={stat.hint}
tone={TONE_TO_TILE[stat.tone] ?? 'neutral'}
/>
))}
</div>
)
}
function PluginTable({ block, refreshKey }: { block: TableBlock; refreshKey: number }) {
const { data, error, loading } = useBlockData(block, refreshKey)
if (error) return <ErrorNote message={error} />
const rows = data.rows ?? []
const columns = data.columns ?? []
if (loading && !rows.length) {
return <div className="flex items-center gap-2 px-4 py-6 text-[0.8125rem] text-ink-500"><Spinner /> Loading</div>
}
if (!rows.length) {
return <EmptyState icon={<Table2 size={18} />} title="Nothing yet" description={data.empty} />
}
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-[0.8125rem]">
<thead>
<tr className="border-b border-navy-700">
{columns.map((column) => (
<th key={column} className="px-4 py-2.5 text-[0.6875rem] font-semibold tracking-wide text-ink-500 uppercase">
{column}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={rowIndex} className="border-b border-navy-800/70 last:border-0 hover:bg-navy-800/40">
{row.map((cell, cellIndex) => (
<td key={cellIndex} className="px-4 py-2.5 align-top text-ink-300">
{String(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
function PluginMarkdown({ block, refreshKey }: { block: MarkdownBlock; refreshKey: number }) {
const { data, error } = useBlockData(block, refreshKey)
if (error) return <ErrorNote message={error} />
if (!data.text) return null
return (
<div className="px-4 py-3">
<Markdown>{data.text}</Markdown>
</div>
)
}
function PluginAction({ block, onRefresh }: { block: ActionBlock; onRefresh: () => void }) {
const toast = useToast()
const [busy, setBusy] = useState(false)
const [confirming, setConfirming] = useState(false)
const Icon = pluginIcon(block.icon)
const fire = useCallback(async () => {
setConfirming(false)
setBusy(true)
try {
const result = await api.pluginAction(block.post)
if (result?.message) toast(result.message, 'success')
if (result?.refresh) onRefresh()
} catch (cause: unknown) {
toast(cause instanceof Error ? cause.message : String(cause), 'error')
} finally {
setBusy(false)
}
}, [block.post, onRefresh, toast])
const style =
block.style === 'danger' ? 'btn-danger' : block.style === 'ghost' ? 'btn-ghost' : 'btn-primary'
return (
<>
<button
className={cx('btn btn-sm', style)}
disabled={busy}
onClick={() => (block.confirm ? setConfirming(true) : fire())}
>
{busy ? <Spinner className="h-3.5 w-3.5" /> : <Icon size={14} />}
{block.label}
</button>
<ConfirmDialog
open={confirming}
title={block.label}
body={block.confirm ?? ''}
confirmLabel={block.label}
destructive={block.style === 'danger'}
onConfirm={fire}
onCancel={() => setConfirming(false)}
/>
</>
)
}
function PluginPanel({
block,
refreshKey,
onRefresh,
}: {
block: PanelBlock
refreshKey: number
onRefresh: () => void
}) {
const children = block.blocks ?? []
// Actions belong in the panel header, everything else in the body.
const actions = children.filter((child): child is ActionBlock => child.kind === 'action')
const body = children.filter((child) => child.kind !== 'action')
// Padding is the block's own job for tables and markdown, which bleed to the edge.
const bleeds = (kind: string) => kind === 'table' || kind === 'markdown'
return (
<Card>
<CardHeader
title={block.title}
hint={block.subtitle || undefined}
actions={
actions.length ? (
<div className="flex items-center gap-2">
{actions.map((action, index) => (
<PluginAction key={index} block={action} onRefresh={onRefresh} />
))}
</div>
) : undefined
}
/>
{body.map((child, index) => (
<div key={index} className={bleeds(child.kind) ? '' : 'px-4 py-3'}>
<BlockView block={child} refreshKey={refreshKey} onRefresh={onRefresh} />
</div>
))}
</Card>
)
}
function BlockView({
block,
refreshKey,
onRefresh,
}: {
block: PluginBlock
refreshKey: number
onRefresh: () => void
}): ReactNode {
switch (block.kind) {
case 'stat_row':
return <StatRow block={block} refreshKey={refreshKey} />
case 'table':
return <PluginTable block={block} refreshKey={refreshKey} />
case 'markdown':
return <PluginMarkdown block={block} refreshKey={refreshKey} />
case 'action':
return <PluginAction block={block} onRefresh={onRefresh} />
case 'panel':
return <PluginPanel block={block} refreshKey={refreshKey} onRefresh={onRefresh} />
default:
// A plugin built against a newer host than this bundle. Say so rather
// than rendering nothing, so the cause is obvious.
return (
<ErrorNote
message={`This build does not know how to render a "${(block as { kind: string }).kind}" block.`}
/>
)
}
}
/* ------------------------------------------------------------------- public */
export function PluginBlocks({
blocks,
className,
autoRefreshMs,
}: {
blocks: PluginBlock[]
className?: string
autoRefreshMs?: number
}) {
const [refreshKey, setRefreshKey] = useState(0)
const refresh = useCallback(() => setRefreshKey((key) => key + 1), [])
useEffect(() => {
if (!autoRefreshMs) return
const timer = setInterval(refresh, autoRefreshMs)
return () => clearInterval(timer)
}, [autoRefreshMs, refresh])
const rendered = useMemo(
() =>
blocks.map((block, index) => (
<BlockView key={index} block={block} refreshKey={refreshKey} onRefresh={refresh} />
)),
[blocks, refreshKey, refresh],
)
return <div className={cx('space-y-4', className)}>{rendered}</div>
}
/**
* Every panel plugins contributed to one injection point.
*
* Renders nothing at all when no plugin uses the slot, so built-in pages carry
* these with no visual cost until something opts in.
*/
export function PluginSlot({
slot,
className,
autoRefreshMs,
}: {
slot: string
className?: string
autoRefreshMs?: number
}) {
const panels = usePluginSlot(slot)
if (!panels.length) return null
return (
<div className={cx('space-y-4', className)}>
{panels.map((entry, index) => (
<PluginBlocks
key={`${entry.slug}-${index}`}
blocks={[entry.panel]}
autoRefreshMs={autoRefreshMs}
/>
))}
</div>
)
}
+25 -6
View File
@@ -2,6 +2,7 @@
import { useEffect, useState, type ReactNode } from 'react'
import {
BookOpen,
Cpu,
FileText,
FlaskConical,
@@ -12,16 +13,20 @@ import {
X,
} from 'lucide-react'
import { Logo } from './Logo'
import { pluginIcon } from './PluginBlocks'
import { Link, useRouter } from '../lib/router'
import { api, type Run, type SystemInfo } from '../lib/api'
import { usePluginUI } from '../hooks/usePluginUI'
import { cx } from '../lib/format'
/** Built-ins carry an order so plugin entries can slot between them. */
const NAV = [
{ to: '/', label: 'Run', icon: Gauge, hint: 'Execute the diagnostic suite' },
{ to: '/suite', label: 'Suite', icon: ListChecks, hint: 'Author the questions' },
{ to: '/reports', label: 'Reports', icon: FileText, hint: 'Read past results' },
{ to: '/playground', label: 'Playground', icon: FlaskConical, hint: 'Try a single prompt' },
{ to: '/settings', label: 'Settings', icon: Settings2, hint: 'Paths and defaults' },
{ to: '/', label: 'Run', icon: Gauge, hint: 'Execute the diagnostic suite', order: 10 },
{ to: '/suite', label: 'Testing Suites', icon: ListChecks, hint: 'Browse and author question suites', order: 20 },
{ to: '/reports', label: 'Reports', icon: FileText, hint: 'Read past results', order: 30 },
{ to: '/playground', label: 'Playground', icon: FlaskConical, hint: 'Try a single prompt', order: 40 },
{ to: '/docs', label: 'Docs', icon: BookOpen, hint: 'Plugin framework reference', order: 80 },
{ to: '/settings', label: 'Settings', icon: Settings2, hint: 'Paths and defaults', order: 90 },
]
function isActive(path: string, to: string) {
@@ -32,6 +37,20 @@ export function Shell({ children, activeRun }: { children: ReactNode; activeRun:
const { path } = useRouter()
const [system, setSystem] = useState<SystemInfo | null>(null)
const [drawerOpen, setDrawerOpen] = useState(false)
const { nav: pluginNav } = usePluginUI()
// Plugin entries are merged into the rail rather than fenced off in a
// section of their own, so a plugin view feels like part of the app.
const navItems = [
...NAV,
...pluginNav.map((item) => ({
to: item.path,
label: item.label,
icon: pluginIcon(item.icon),
hint: item.hint || `From the ${item.slug} plugin`,
order: item.order,
})),
].sort((a, b) => a.order - b.order || a.label.localeCompare(b.label))
useEffect(() => {
api.system().then(setSystem).catch(() => setSystem(null))
@@ -43,7 +62,7 @@ export function Shell({ children, activeRun }: { children: ReactNode; activeRun:
const nav = (
<nav className="flex flex-col gap-1">
{NAV.map(({ to, label, icon: Icon, hint }) => {
{navItems.map(({ to, label, icon: Icon, hint }) => {
const active = isActive(path, to)
return (
<Link
+224
View File
@@ -0,0 +1,224 @@
/**
* Pick which suites — and which questions inside them — a run should cover.
*
* The selection model mirrors the API's `selections` shape rather than a flat
* list of question IDs, which buys two things:
*
* - A whole suite is expressed as `{suite}` with no filenames, so "run all of
* math-code" stays true even if that suite gains a question later.
* - Selecting a suite needs no knowledge of its contents, so questions are
* only fetched for the suites a user actually expands.
*/
import { useCallback, useEffect, useState } from 'react'
import { ChevronDown, ChevronRight, Loader2, Lock } from 'lucide-react'
import { api, type RunSelection, type SuiteSummary, type TestPrompt } from '../lib/api'
import { cx } from '../lib/format'
/** `'all'` means the whole suite; an array means those filenames only. */
export type Picks = Record<string, 'all' | string[]>
export function selectionsFrom(picks: Picks): RunSelection[] {
return Object.entries(picks)
.filter(([, value]) => value === 'all' || value.length > 0)
.map(([suite, value]) =>
value === 'all' ? { suite } : { suite, filenames: value },
)
}
export function countSelected(picks: Picks, suites: SuiteSummary[]): number {
return suites.reduce((total, suite) => {
const pick = picks[suite.slug]
if (pick === 'all') return total + suite.count
return total + (pick?.length ?? 0)
}, 0)
}
type PickState = 'none' | 'some' | 'all'
function stateOf(pick: Picks[string] | undefined, count: number): PickState {
if (pick === 'all') return 'all'
if (!pick || pick.length === 0) return 'none'
return pick.length >= count ? 'all' : 'some'
}
export function SuitePicker({
suites,
picks,
onChange,
disabled,
}: {
suites: SuiteSummary[]
picks: Picks
onChange: (next: Picks) => void
disabled?: boolean
}) {
const [expanded, setExpanded] = useState<string | null>(null)
const [questions, setQuestions] = useState<Record<string, TestPrompt[]>>({})
const [loading, setLoading] = useState<string | null>(null)
// Only fetch a suite's questions when it is actually opened.
const expand = useCallback(
(slug: string) => {
if (expanded === slug) {
setExpanded(null)
return
}
setExpanded(slug)
if (questions[slug]) return
setLoading(slug)
api
.suite(slug)
.then((detail) => setQuestions((map) => ({ ...map, [slug]: detail.tests })))
.catch(() => setQuestions((map) => ({ ...map, [slug]: [] })))
.finally(() => setLoading(null))
},
[expanded, questions],
)
const toggleSuite = (suite: SuiteSummary) => {
const next = { ...picks }
if (stateOf(picks[suite.slug], suite.count) === 'none') next[suite.slug] = 'all'
else delete next[suite.slug]
onChange(next)
}
const toggleQuestion = (suite: SuiteSummary, filename: string) => {
const loaded = questions[suite.slug] ?? []
const currentPick = picks[suite.slug]
// Narrowing an "all" suite needs its concrete filenames first.
const current =
currentPick === 'all' ? loaded.map((q) => q.filename) : [...(currentPick ?? [])]
const at = current.indexOf(filename)
if (at >= 0) current.splice(at, 1)
else current.push(filename)
const next = { ...picks }
if (current.length === 0) delete next[suite.slug]
else if (loaded.length > 0 && current.length >= loaded.length) next[suite.slug] = 'all'
else next[suite.slug] = current
onChange(next)
}
return (
<ul className="space-y-1.5">
{suites.map((suite) => {
const state = stateOf(picks[suite.slug], suite.count)
const open = expanded === suite.slug
const rows = questions[suite.slug] ?? []
const pick = picks[suite.slug]
return (
<li
key={suite.slug}
className={cx(
'overflow-hidden rounded-lg border transition-colors',
state === 'none'
? 'border-navy-700 bg-navy-900/30'
: 'border-gold-500/35 bg-gold-500/8',
)}
>
<div className="flex items-center gap-2 px-2.5 py-2">
<input
type="checkbox"
className="h-3.5 w-3.5 shrink-0 accent-[#e3ad46]"
checked={state !== 'none'}
ref={(node) => {
// Partial selection is neither checked nor unchecked.
if (node) node.indeterminate = state === 'some'
}}
disabled={disabled}
onChange={() => toggleSuite(suite)}
aria-label={`Select ${suite.name}`}
/>
<button
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
onClick={() => expand(suite.slug)}
disabled={disabled}
>
{open ? (
<ChevronDown size={12} className="shrink-0 text-ink-500" />
) : (
<ChevronRight size={12} className="shrink-0 text-ink-500" />
)}
{suite.builtin && <Lock size={10} className="shrink-0 text-ink-600" />}
<span className="truncate text-[0.75rem] text-ink-200">{suite.name}</span>
</button>
<span className="num shrink-0 text-[0.6875rem] text-ink-500">
{state === 'all'
? suite.count
: state === 'some'
? `${(pick as string[]).length}/${suite.count}`
: suite.count}
</span>
</div>
{open && (
<div className="border-t border-navy-700/70 bg-navy-950/30 px-2.5 py-2">
{loading === suite.slug ? (
<p className="flex items-center gap-2 py-1 text-[0.75rem] text-ink-500">
<Loader2 size={12} className="animate-spin" /> Loading questions
</p>
) : rows.length === 0 ? (
<p className="py-1 text-[0.75rem] text-ink-500">This suite has no questions.</p>
) : (
<ul className="space-y-0.5">
{rows.map((question, index) => {
const checked =
pick === 'all' || (pick ?? []).includes(question.filename)
return (
<li key={question.id}>
<label
className={cx(
'flex cursor-pointer items-start gap-2 rounded px-1.5 py-1 transition-colors hover:bg-navy-800/60',
disabled && 'cursor-not-allowed opacity-60',
)}
>
<input
type="checkbox"
className="mt-0.5 h-3 w-3 shrink-0 accent-[#e3ad46]"
checked={checked}
disabled={disabled}
onChange={() => toggleQuestion(suite, question.filename)}
/>
<span className="min-w-0">
<span className="num mr-1.5 text-[0.625rem] text-ink-600">
{String(index + 1).padStart(2, '0')}
</span>
<span className="text-[0.6875rem] leading-snug text-ink-300">
{question.title}
</span>
</span>
</label>
</li>
)
})}
</ul>
)}
</div>
)}
</li>
)
})}
</ul>
)
}
/** Default selection: every built-in suite, matching the CLI's bare run. */
export function useDefaultPicks(suites: SuiteSummary[]) {
const [picks, setPicks] = useState<Picks>({})
const [seeded, setSeeded] = useState(false)
useEffect(() => {
if (seeded || !suites.length) return
const next: Picks = {}
for (const suite of suites) if (suite.builtin) next[suite.slug] = 'all'
setPicks(next)
setSeeded(true)
}, [suites, seeded])
return [picks, setPicks] as const
}
+57
View File
@@ -0,0 +1,57 @@
/**
* The plugin UI manifest, fetched once and shared.
*
* Nav entries, pages and slot panels all come from here, so a single fetch at
* startup is enough for plugins to appear everywhere they contribute. The
* manifest is intentionally forgiving: if it cannot be loaded the app renders
* exactly as it did before plugins existed rather than showing an error.
*/
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
import { api, type PluginSlotPanel, type PluginUIManifest } from '../lib/api'
const EMPTY: PluginUIManifest = { nav: [], pages: [], slots: {} }
interface PluginUIValue extends PluginUIManifest {
reload: () => void
}
const PluginUIContext = createContext<PluginUIValue>({ ...EMPTY, reload: () => {} })
export function PluginUIProvider({ children }: { children: ReactNode }) {
const [manifest, setManifest] = useState<PluginUIManifest>(EMPTY)
const [nonce, setNonce] = useState(0)
useEffect(() => {
let cancelled = false
api
.pluginUI()
.then((next) => {
if (!cancelled) setManifest(next ?? EMPTY)
})
.catch(() => {
// A plugin manifest is additive. Failing to load one must never take
// the rest of the app down with it.
if (!cancelled) setManifest(EMPTY)
})
return () => {
cancelled = true
}
}, [nonce])
const value = useMemo(
() => ({ ...manifest, reload: () => setNonce((n) => n + 1) }),
[manifest],
)
return <PluginUIContext.Provider value={value}>{children}</PluginUIContext.Provider>
}
export function usePluginUI() {
return useContext(PluginUIContext)
}
/** Panels contributed to one injection point, already ordered. */
export function usePluginSlot(slot: string): PluginSlotPanel[] {
const { slots } = usePluginUI()
return slots?.[slot] ?? []
}
+3
View File
@@ -21,6 +21,7 @@ import {
subscribeToRun,
type Run,
type RunEvent,
type RunSelection,
type TestCompletedEvent,
} from '../lib/api'
@@ -48,6 +49,7 @@ interface RunFeedValue {
provider: string
model_id: string
temperature: number
selections?: RunSelection[]
filenames?: string[]
}) => Promise<void>
cancel: () => Promise<void>
@@ -159,6 +161,7 @@ export function RunFeedProvider({ children }: { children: ReactNode }) {
provider: string
model_id: string
temperature: number
selections?: RunSelection[]
filenames?: string[]
}) => {
setStarting(true)
+139 -5
View File
@@ -14,11 +14,29 @@ export interface TestPrompt {
filename: string
title: string
prompt: string
/** Owning suite slug. */
suite: string
/** Globally unique "<suite>/<file>". `filename` repeats across suites. */
id: string
}
export interface TestSuite {
export interface SuiteSummary {
slug: string
name: string
description: string
order: number
builtin: boolean
count: number
}
export interface SuiteDetail extends SuiteSummary {
tests: TestPrompt[]
directory: string
}
/** One suite's contribution to a run; omit `filenames` to take all of it. */
export interface RunSelection {
suite: string
filenames?: string[]
}
export interface RunMetrics {
@@ -57,6 +75,94 @@ export interface PluginSummary {
error: string | null
}
/* -------------------------------------------------------- plugin-declared UI */
/* Plugins describe interface as data; this app renders it. Nothing here is
* plugin-supplied code — see /docs for the contribution vocabulary. */
export interface PluginStat {
label: string
value: string | number
hint: string
tone: 'default' | 'good' | 'warn' | 'bad'
}
export interface StatRowBlock {
kind: 'stat_row'
stats: PluginStat[]
source: string | null
}
export interface TableBlock {
kind: 'table'
columns: string[]
rows: (string | number)[][]
source: string | null
empty: string
}
export interface MarkdownBlock {
kind: 'markdown'
text: string
source: string | null
}
export interface ActionBlock {
kind: 'action'
label: string
post: string
confirm: string | null
style: 'primary' | 'ghost' | 'danger'
icon: string | null
}
export interface PanelBlock {
kind: 'panel'
title: string
subtitle: string
blocks: PluginBlock[]
}
export type PluginBlock = StatRowBlock | TableBlock | MarkdownBlock | ActionBlock | PanelBlock
export interface PluginNavItem {
slug: string
label: string
path: string
icon: string
hint: string
order: number
}
export interface PluginPageSpec {
slug: string
plugin: string
path: string
title: string
subtitle: string
blocks: PluginBlock[]
}
export interface PluginSlotPanel {
slug: string
plugin: string
order: number
panel: PanelBlock
}
export interface PluginUIManifest {
nav: PluginNavItem[]
pages: PluginPageSpec[]
slots: Record<string, PluginSlotPanel[]>
}
/** Plugin data URLs are confined to this app's own plugin namespace. */
function assertPluginUrl(url: string): string {
if (!url.startsWith('/api/plugins/')) {
throw new ApiError(`Refusing to call ${url} — plugin URLs must start with /api/plugins/.`, 0)
}
return url
}
export type RunStatus = 'running' | 'completed' | 'failed' | 'cancelled'
export interface Run {
@@ -205,17 +311,35 @@ export const api = {
`/providers/${encodeURIComponent(provider)}/models`,
),
tests: () => request<TestSuite>('/tests'),
saveTests: (prompts: string[]) =>
request<TestSuite>('/tests', {
suites: () => request<SuiteSummary[]>('/suites'),
suite: (slug: string) => request<SuiteDetail>(`/suites/${encodeURIComponent(slug)}`),
createSuite: (body: { name: string; description?: string; slug?: string }) =>
request<SuiteDetail>('/suites', { method: 'POST', body: JSON.stringify(body) }),
updateSuite: (slug: string, body: { name?: string; description?: string }) =>
request<SuiteDetail>(`/suites/${encodeURIComponent(slug)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteSuite: (slug: string) =>
request<void>(`/suites/${encodeURIComponent(slug)}`, { method: 'DELETE' }),
saveSuiteTests: (slug: string, prompts: string[]) =>
request<SuiteDetail>(`/suites/${encodeURIComponent(slug)}/tests`, {
method: 'PUT',
body: JSON.stringify({ tests: prompts.map((prompt) => ({ prompt })) }),
}),
duplicateSuite: (slug: string, name?: string) =>
request<SuiteDetail>(`/suites/${encodeURIComponent(slug)}/duplicate`, {
method: 'POST',
body: JSON.stringify({ name }),
}),
startRun: (body: {
provider: string
model_id: string
temperature: number
/** Suites to run, each optionally narrowed to some of its questions. */
selections?: RunSelection[]
/** Deprecated: flat qualified "<suite>/<file>" IDs. Prefer `selections`. */
filenames?: string[]
}) => request<Run>('/runs', { method: 'POST', body: JSON.stringify(body) }),
runs: () => request<Run[]>('/runs'),
@@ -236,6 +360,16 @@ export const api = {
plugins: () => request<PluginSummary[]>('/plugins'),
reloadPlugins: () => request<PluginSummary[]>('/plugins/reload', { method: 'POST' }),
pluginUI: () => request<PluginUIManifest>('/plugins/ui'),
/** Read a block's `source`. The URL comes from a plugin, so it is checked. */
pluginData: <T>(url: string) => request<T>(assertPluginUrl(url).replace(/^\/api/, '')),
/** Fire an Action button. May return a message to toast and a refresh flag. */
pluginAction: (url: string) =>
request<{ message?: string; refresh?: boolean }>(assertPluginUrl(url).replace(/^\/api/, ''), {
method: 'POST',
body: '{}',
}),
settings: () => request<Settings>('/settings'),
saveSettings: (body: {
+335
View File
@@ -0,0 +1,335 @@
/**
* Plugin framework documentation.
*
* The reference prose is static, but the "Installed" section reads the live
* registry so the examples and the reality can never drift apart.
*/
import { useState } from 'react'
import { BookOpen, CircleAlert, Puzzle } from 'lucide-react'
import { Markdown } from '../components/Markdown'
import { pluginIcon } from '../components/PluginBlocks'
import { Card, CardHeader, PageHeader, useAsync } from '../components/ui'
import { usePluginUI } from '../hooks/usePluginUI'
import { api } from '../lib/api'
import { cx } from '../lib/format'
const SECTIONS = [
{ id: 'start', label: 'Getting started' },
{ id: 'hooks', label: 'Hooks' },
{ id: 'grading', label: 'Grading' },
{ id: 'ui', label: 'Contributing UI' },
{ id: 'http', label: 'HTTP routes' },
{ id: 'installed', label: 'Installed' },
] as const
const GETTING_STARTED = `
Drop a \`.py\` file into \`plugins/\` and it loads at startup. A directory with an
\`__init__.py\` works too, when one file is not enough.
\`\`\`bash
cp plugins/_skeleton.py plugins/my_plugin.py
\`\`\`
Files beginning with \`_\` are ignored, so the skeleton itself never runs.
Every hook is optional — a plugin defining only \`grade\` is perfectly valid.
Plugins import from \`plugin_api\` and nothing else in the project, so they keep
working across refactors of the engine or the server.
\`\`\`python
NAME = "My plugin" # shown in Settings and in report tables
VERSION = "1.0.0"
DESCRIPTION = "One line."
ENABLED = True # False keeps the file but stops loading it
\`\`\`
**Reloading.** Settings → Plugins has a refresh button that re-scans the
directory and picks up edits live. The one exception is \`register_routes\`:
HTTP routes are bound when the server starts, so adding or changing them needs
a restart.
**Failure isolation.** A plugin that raises is logged and skipped for that call
only. It stays loaded, its other hooks keep firing, and a broken plugin can
never fail a run or stop the server.
`
const HOOKS = `
| Hook | Called | Use it for |
| --- | --- | --- |
| \`grade(test)\` | after each answer | Score it, or \`None\` to abstain |
| \`on_run_start(run)\` | once, before the first question | Reset state, open a file |
| \`on_test_complete(test)\` | after each question | Log, stream, react to failures |
| \`on_run_complete(run)\` | when the run ends | Notify, export — fires on cancel and failure too |
| \`report_sections(run)\` | after the report is written | Return extra markdown to append |
| \`register_routes(router)\` | at server start | Endpoints under \`/api/plugins/<slug>\` |
| \`ui_contributions()\` | when the UI loads | Nav entries, pages and panels |
| \`register()\` | once, at load | Setup; raising here disables the plugin |
\`TestRecord\` gives you \`index\`, \`total\`, \`title\`, \`filename\`, \`suite\`,
\`prompt\`, \`ok\`, \`response\`, \`error\`, \`metrics\`, \`elapsed\`, plus
\`tokens_per_second\`, \`total_tokens\` and \`time_to_first_token\`.
> **\`filename\` is not unique.** Questions live in named suites and every suite
> has a \`test1.txt\`. Identify a question by \`question_id\` — the qualified
> \`"<suite>/<file>"\` — never by \`filename\` alone. A run routinely spans
> several suites, and graders derive their whole rubric from \`prompt\`, so
> mixing two questions up produces a confident, wrong score in silence.
\`RunRecord\` gives you \`tests\`, \`grades\`, \`summary\`, \`status\`, \`report_path\`,
\`score_for(index)\` and \`overall_score\`.
`
const GRADING = `
\`grade\` may return \`None\` to abstain, a float from 0.0 to 1.0, \`True\`/\`False\`,
or a \`Grade\` with a label and notes that reach the report.
\`\`\`python
from plugin_api import Grade
def grade(test):
if "json" not in test.prompt.lower():
return None # not my kind of question
ok = test.response.strip().startswith("{")
return Grade(
score=1.0 if ok else 0.0,
label="valid shape" if ok else "not an object",
notes="Checked the answer is a bare JSON object.",
)
\`\`\`
- **Abstaining is free.** \`None\` excludes the question from that grader
entirely; it never counts as a zero. Prefer it over guessing.
- **A question's score is the mean** of every grader that scored it, and the
run's score is the mean of those.
- **Failed questions are never graded** — there is no answer to judge. Use
\`on_test_complete\` to see failures.
- **Two graders should not check the same thing.** The bundled pair are split
deliberately: \`response_checks\` asks whether an answer contains code,
\`code_lint\` asks whether that code is any good, and abstains when there is none.
`
const UI_DOCS = `
Plugins describe interface as **data**, and the app renders it. No plugin ships
JavaScript, and installing one never requires rebuilding the frontend.
\`\`\`python
from plugin_api import Action, NavItem, Page, Panel, StatRow, Table
def ui_contributions():
base = "/api/plugins/my_plugin"
return [
NavItem(label="My tool", path="/tool", icon="wrench", order=50),
Page(
path="/tool",
title="My tool",
subtitle="What it does.",
blocks=[
StatRow(source=f"{base}/stats"),
Panel(title="Results", blocks=[
Table(source=f"{base}/rows"),
Action(label="Clear", post=f"{base}/clear", style="ghost"),
]),
],
),
]
\`\`\`
### Blocks
| Block | Renders | Fields |
| --- | --- | --- |
| \`StatRow\` | a row of headline numbers | \`stats\` of \`Stat(label, value, hint, tone)\` |
| \`Table\` | a column-headed table | \`columns\`, \`rows\`, \`empty\` |
| \`Markdown\` | rendered markdown | \`text\` |
| \`Action\` | a button that POSTs | \`label\`, \`post\`, \`confirm\`, \`style\`, \`icon\` |
| \`Panel\` | a titled card wrapping blocks | \`title\`, \`subtitle\`, \`blocks\` |
\`tone\` is one of \`default\`, \`good\`, \`warn\`, \`bad\`. \`style\` is \`primary\`,
\`ghost\` or \`danger\`.
### Static or live
Every data block takes either inline values or a \`source\` URL. With a source,
the block fetches it and expects the same field names back as JSON — so the
same \`Table\` can be a fixed list or a live view without changing shape.
An \`Action\` may return \`{"message": "..."}\` to raise a toast and
\`{"refresh": true}\` to make sibling blocks re-fetch.
### Surfaces
| Surface | Effect |
| --- | --- |
| \`NavItem(label, path, icon, hint, order)\` | an entry in the left rail |
| \`Page(path, title, subtitle, blocks)\` | a full page |
| \`SlotPanel(slot, panel, order)\` | a panel injected into a built-in page |
Slots are \`run.aside\`, \`reports.aside\`, \`suite.aside\` and \`settings.section\`.
**Paths are namespaced.** Whatever you declare is served under \`/x/<slug>/\`, so
\`"/tool"\` in the plugin \`my_plugin\` becomes \`/x/my_plugin/tool\`. Two plugins
can use the same short path without colliding, and no plugin can ever shadow a
built-in route.
**Icons** are named: \`activity\`, \`alert\`, \`bar\`, \`bug\`, \`check\`, \`database\`,
\`file\`, \`gauge\`, \`hash\`, \`layers\`, \`list\`, \`play\`, \`puzzle\`, \`refresh\`,
\`search\`, \`settings\`, \`shield\`, \`sparkles\`, \`table\`, \`terminal\`, \`trash\`,
\`wrench\`, \`zap\`. An unknown name falls back to a puzzle piece.
`
const HTTP_DOCS = `
\`register_routes\` receives a FastAPI router already prefixed with
\`/api/plugins/<slug>\`. This is where \`source\` and \`post\` URLs point.
\`\`\`python
def register_routes(router):
@router.get("/stats")
async def stats() -> dict:
return {"stats": [
{"label": "Checked", "value": 12, "hint": "this run", "tone": "good"},
]}
@router.post("/clear")
async def clear() -> dict:
return {"message": "Cleared.", "refresh": True}
\`\`\`
The frontend refuses any block URL that does not begin with \`/api/plugins/\`,
so a plugin cannot point the browser at an external host.
> **Routes bind at server start, and reloading does not rebind them.** The
> handlers you register close over the module object that existed at startup.
> After a plugin reload, the *new* module handles \`grade\` and the other hooks
> while your routes keep reading the *old* module's globals — so an endpoint
> can serve stale state with no error to hint at it. Restart the server after
> editing a plugin that defines \`register_routes\`.
`
function SectionCard({ id, title, body }: { id: string; title: string; body: string }) {
return (
<Card className="scroll-mt-6" >
<div id={id}>
<CardHeader title={title} icon={<BookOpen size={14} />} />
<div className="px-4 py-3">
<Markdown>{body}</Markdown>
</div>
</div>
</Card>
)
}
function Installed() {
const plugins = useAsync(() => api.plugins(), [])
const { nav, pages, slots } = usePluginUI()
const contributionsFor = (slug: string) => {
const bits: string[] = []
const navCount = nav.filter((n) => n.slug === slug).length
const pageCount = pages.filter((p) => p.slug === slug).length
const slotCount = Object.values(slots ?? {}).flat().filter((s) => s.slug === slug).length
if (navCount) bits.push(`${navCount} nav entry`)
if (pageCount) bits.push(`${pageCount} page`)
if (slotCount) bits.push(`${slotCount} panel`)
return bits
}
return (
<Card>
<div id="installed">
<CardHeader
title="Installed plugins"
icon={<Puzzle size={14} />}
hint="Read live from the registry."
/>
<div className="divide-y divide-navy-800/70">
{(plugins.data ?? []).map((plugin) => {
const Icon = pluginIcon(nav.find((n) => n.slug === plugin.slug)?.icon)
const bits = contributionsFor(plugin.slug)
return (
<div key={plugin.slug} className="flex items-start gap-3 px-4 py-3">
<Icon size={15} className="mt-0.5 shrink-0 text-gold-500" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-2">
<span className="text-[0.8125rem] font-semibold text-ink-200">{plugin.name}</span>
<span className="num text-[0.6875rem] text-ink-500">v{plugin.version}</span>
<code className="text-[0.6875rem] text-ink-500">{plugin.slug}</code>
</div>
<p className="mt-0.5 text-[0.8125rem] text-ink-400">{plugin.description}</p>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{plugin.hooks.map((hook) => (
<span
key={hook}
className="rounded border border-navy-700 bg-navy-800/60 px-1.5 py-0.5 text-[0.625rem] text-ink-400"
>
{hook}
</span>
))}
{bits.map((bit) => (
<span
key={bit}
className="rounded border border-gold-500/30 bg-gold-500/10 px-1.5 py-0.5 text-[0.625rem] text-gold-400"
>
{bit}
</span>
))}
</div>
{plugin.error && (
<p className="mt-1.5 flex items-center gap-1.5 text-[0.75rem] text-rose-400">
<CircleAlert size={12} /> {plugin.error}
</p>
)}
</div>
</div>
)
})}
{!plugins.data?.length && (
<p className="px-4 py-6 text-center text-[0.8125rem] text-ink-500">
Nothing in <code>plugins/</code> yet.
</p>
)}
</div>
</div>
</Card>
)
}
export function DocsPage() {
const [active, setActive] = useState<string>('start')
return (
<>
<PageHeader
title="Plugin framework"
subtitle="Everything a plugin can do: grade answers, extend reports, add HTTP routes, and contribute interface — without shipping a line of JavaScript."
/>
<nav className="mb-5 flex flex-wrap gap-1.5">
{SECTIONS.map((section) => (
<a
key={section.id}
href={`#${section.id}`}
onClick={() => setActive(section.id)}
className={cx(
'rounded-lg border px-2.5 py-1 text-[0.75rem] transition-colors',
active === section.id
? 'border-gold-500/40 bg-gold-500/10 text-gold-400'
: 'border-navy-700 text-ink-400 hover:text-ink-200',
)}
>
{section.label}
</a>
))}
</nav>
<div className="space-y-4">
<SectionCard id="start" title="Getting started" body={GETTING_STARTED} />
<SectionCard id="hooks" title="Hook reference" body={HOOKS} />
<SectionCard id="grading" title="Grading" body={GRADING} />
<SectionCard id="ui" title="Contributing UI" body={UI_DOCS} />
<SectionCard id="http" title="HTTP routes" body={HTTP_DOCS} />
<Installed />
</div>
</>
)
}
+71 -13
View File
@@ -29,6 +29,12 @@ export function PlaygroundPage() {
const [prompt, setPrompt] = useState('')
const [result, setResult] = useState<PlaygroundResult | null>(null)
const [sending, setSending] = useState(false)
const [saveTarget, setSaveTarget] = useState<null | true>(null)
// Built-ins are read-only, so only custom suites are valid targets.
const customSuites = useAsync(
() => api.suites().then((all) => all.filter((s) => !s.builtin)),
[],
)
useEffect(() => {
if (!providers.data?.length || provider) return
@@ -58,12 +64,24 @@ export function PlaygroundPage() {
}
}
const saveAsQuestion = async () => {
/**
* Append the prompt to a custom suite.
*
* There is no longer a single "the suite" to append to, and the built-ins
* are read-only, so this needs an explicit target. Only custom suites can
* be chosen; if none exist yet the button points at Testing Suites instead.
*/
const saveAsQuestion = async (slug: string) => {
if (!prompt.trim()) return
setSaveTarget(null)
try {
const suite = await api.tests()
await api.saveTests([...suite.tests.map((t) => t.prompt), prompt.trim()])
toast('Added to the suite as the last question.', 'success')
const detail = await api.suite(slug)
const saved = await api.saveSuiteTests(slug, [
...detail.tests.map((t) => t.prompt),
prompt.trim(),
])
toast(`Added to "${saved.name}" as question ${saved.tests.length}.`, 'success')
customSuites.reload()
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
}
@@ -146,15 +164,55 @@ export function PlaygroundPage() {
title="Prompt"
icon={<FlaskConical size={14} />}
actions={
<button
className="btn btn-ghost btn-sm"
onClick={saveAsQuestion}
disabled={!prompt.trim() || disabled}
title="Append this prompt to the diagnostic suite"
>
<Plus size={12} />
Add to suite
</button>
<div className="relative">
<button
className="btn btn-ghost btn-sm"
onClick={() => setSaveTarget((open) => (open ? null : true))}
disabled={!prompt.trim() || disabled}
title="Append this prompt to a custom suite"
>
<Plus size={12} />
Add to suite
</button>
{saveTarget && (
<>
<div className="fixed inset-0 z-10" onClick={() => setSaveTarget(null)} />
<div className="surface-raised animate-fade-up absolute right-0 z-20 mt-1.5 w-60 p-1.5">
<p className="px-2 py-1 text-[0.625rem] font-semibold tracking-[0.09em] text-ink-500 uppercase">
Add to
</p>
{customSuites.data?.length ? (
customSuites.data.map((suite) => (
<button
key={suite.slug}
className="flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left text-[0.75rem] text-ink-300 transition-colors hover:bg-navy-750 hover:text-ink-100"
onClick={() => saveAsQuestion(suite.slug)}
>
<span className="truncate">{suite.name}</span>
<span className="num shrink-0 text-[0.6875rem] text-ink-500">
{suite.count}
</span>
</button>
))
) : (
<div className="px-2 py-2">
<p className="text-[0.75rem] leading-relaxed text-ink-500">
No custom suites yet. Built-in suites are read-only.
</p>
<Link
to="/suite"
className="btn btn-ghost btn-sm mt-2 w-full"
onClick={() => setSaveTarget(null)}
>
Open Testing Suites
</Link>
</div>
)}
</div>
</>
)}
</div>
}
/>
<div className="p-4">
+45
View File
@@ -0,0 +1,45 @@
/** Renders a page declared by a plugin, matched on the current path. */
import { Puzzle } from 'lucide-react'
import { PluginBlocks } from '../components/PluginBlocks'
import { EmptyState, PageHeader } from '../components/ui'
import { usePluginUI } from '../hooks/usePluginUI'
import { useRouter } from '../lib/router'
import { Link } from '../lib/router'
export function PluginPage() {
const { path } = useRouter()
const { pages } = usePluginUI()
const page = pages.find((candidate) => candidate.path === path)
if (!page) {
return (
<EmptyState
icon={<Puzzle size={18} />}
title="No plugin owns this page"
description={`Nothing is registered at ${path}. The plugin that provided it may have been disabled or removed — check Settings, or reload plugins.`}
action={
<Link to="/settings" className="btn btn-ghost btn-sm">
Open Settings
</Link>
}
/>
)
}
return (
<>
<PageHeader
title={page.title}
subtitle={page.subtitle || undefined}
actions={
<span className="rounded-lg border border-navy-700 bg-navy-800/60 px-2.5 py-1 text-[0.6875rem] text-ink-500">
from {page.plugin}
</span>
}
/>
<PluginBlocks blocks={page.blocks} />
</>
)
}
+2
View File
@@ -20,6 +20,7 @@ import {
useAsync,
useToast,
} from '../components/ui'
import { PluginSlot } from '../components/PluginBlocks'
import { Markdown } from '../components/Markdown'
import { ScoreBadge, scoreTone } from '../components/ScoreBadge'
import { ThroughputChart } from '../components/ThroughputChart'
@@ -185,6 +186,7 @@ export function ReportsPage() {
</ul>
)}
</Card>
<PluginSlot slot="reports.aside" className="mt-4" />
</div>
{/* ----------------------------------------------------- detail */}
+51 -66
View File
@@ -28,6 +28,13 @@ import {
} from '../components/ui'
import { Markdown } from '../components/Markdown'
import { ScoreBadge, scoreTone } from '../components/ScoreBadge'
import { PluginSlot } from '../components/PluginBlocks'
import {
SuitePicker,
countSelected,
selectionsFrom,
useDefaultPicks,
} from '../components/SuitePicker'
import { Link } from '../lib/router'
import { api, ApiError, type TestCompletedEvent } from '../lib/api'
import { clockTime, cx, formatDuration } from '../lib/format'
@@ -38,12 +45,11 @@ export function RunPage() {
const { run, outcomes, logs, starting, isLive, start, cancel, clear } = useRunFeed()
const providers = useAsync(() => api.providers(), [])
const suite = useAsync(() => api.tests(), [])
const suites = useAsync(() => api.suites(), [])
const [provider, setProvider] = useState('')
const [modelId, setModelId] = useState('')
const [temperature, setTemperature] = useState(0.1)
const [selected, setSelected] = useState<string[] | null>(null)
const [pickerOpen, setPickerOpen] = useState(false)
// Seed provider + temperature from saved settings, falling back to the
@@ -72,25 +78,23 @@ export function RunPage() {
setModelId((current) => (list.some((m) => m.id === current) ? current : (list[0]?.id ?? '')))
}, [models.data])
const tests = suite.data?.tests ?? []
const selectedFilenames = selected ?? tests.map((t) => t.filename)
const allSelected = selectedFilenames.length === tests.length
const suiteList = suites.data ?? []
const [picks, setPicks] = useDefaultPicks(suiteList)
const toggleTest = (filename: string) => {
const next = new Set(selectedFilenames)
if (next.has(filename)) next.delete(filename)
else next.add(filename)
setSelected(tests.filter((t) => next.has(t.filename)).map((t) => t.filename))
}
const selectedCount = countSelected(picks, suiteList)
const totalCount = suiteList.reduce((sum, s) => sum + s.count, 0)
const pickedSuites = Object.keys(picks).length
const launch = async () => {
if (!provider || !modelId) return
try {
// Sent as `selections`, so "all of a suite" stays all of it even if that
// suite gains a question between now and the next run.
await start({
provider,
model_id: modelId,
temperature,
filenames: allSelected ? undefined : selectedFilenames,
selections: selectionsFrom(picks),
})
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
@@ -105,8 +109,7 @@ export function RunPage() {
}
}
const canLaunch =
!!provider && !!modelId && selectedFilenames.length > 0 && !isLive && !starting
const canLaunch = !!provider && !!modelId && selectedCount > 0 && !isLive && !starting
return (
<>
@@ -189,92 +192,72 @@ export function RunPage() {
</div>
</Card>
{/* question selection */}
{/* suite + question selection */}
<Card raised>
<CardHeader
title="Questions"
title="Suites"
icon={<ListChecks size={14} />}
actions={
<span className="chip chip-gold num">
{selectedFilenames.length}/{tests.length}
{selectedCount}/{totalCount}
</span>
}
/>
<div className="p-4">
{suite.error && <ErrorNote message={suite.error} onRetry={suite.reload} />}
{suites.error && <ErrorNote message={suites.error} onRetry={suites.reload} />}
{!suite.error && tests.length === 0 && !suite.loading && (
{!suites.error && suiteList.length === 0 && !suites.loading && (
<EmptyState
icon={<ListChecks size={20} />}
title="No questions yet"
description="Add questions in the Suite Builder before running diagnostics."
title="No suites found"
description="Create a suite before running diagnostics."
action={
<Link to="/suite" className="btn btn-primary">
Open Suite Builder
Open Testing Suites
</Link>
}
/>
)}
{tests.length > 0 && (
{suiteList.length > 0 && (
<>
<div className="mb-3 flex items-center gap-2">
<button
className="btn btn-ghost btn-sm"
disabled={isLive}
onClick={() => setSelected(allSelected ? [] : tests.map((t) => t.filename))}
onClick={() =>
setPicks(
pickedSuites === suiteList.length
? {}
: Object.fromEntries(suiteList.map((s) => [s.slug, 'all' as const])),
)
}
>
{allSelected ? 'Clear all' : 'Select all'}
{pickedSuites === suiteList.length ? 'Clear all' : 'Select all'}
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setPickerOpen((open) => !open)}
>
{pickerOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{pickerOpen ? 'Hide list' : 'Choose questions'}
{pickerOpen ? 'Hide suites' : 'Choose suites'}
</button>
</div>
{pickerOpen && (
<ul className="animate-fade-in max-h-72 space-y-1 overflow-y-auto pr-1">
{tests.map((test, index) => {
const checked = selectedFilenames.includes(test.filename)
return (
<li key={test.filename}>
<label
className={cx(
'flex cursor-pointer items-start gap-2.5 rounded-lg border px-2.5 py-2 transition-colors',
checked
? 'border-gold-500/35 bg-gold-500/8'
: 'border-navy-700 bg-navy-900/30 hover:border-navy-600',
isLive && 'cursor-not-allowed opacity-60',
)}
>
<input
type="checkbox"
className="mt-0.5 h-3.5 w-3.5 shrink-0 accent-[#e3ad46]"
checked={checked}
disabled={isLive}
onChange={() => toggleTest(test.filename)}
/>
<span className="min-w-0">
<span className="num mr-1.5 text-[0.6875rem] text-ink-500">
{String(index + 1).padStart(2, '0')}
</span>
<span className="text-[0.75rem] text-ink-200">{test.title}</span>
</span>
</label>
</li>
)
})}
</ul>
)}
{!pickerOpen && (
<p className="text-[0.75rem] text-ink-500">
{allSelected
? `The full suite of ${tests.length} questions will run in order.`
: `${selectedFilenames.length} of ${tests.length} questions selected.`}
{pickerOpen ? (
<div className="animate-fade-in max-h-96 overflow-y-auto pr-1">
<SuitePicker
suites={suiteList}
picks={picks}
onChange={setPicks}
disabled={isLive}
/>
</div>
) : (
<p className="text-[0.75rem] leading-relaxed text-ink-500">
{selectedCount === 0
? 'Nothing selected — pick at least one suite.'
: `${selectedCount} question${selectedCount === 1 ? '' : 's'} from ${pickedSuites} suite${pickedSuites === 1 ? '' : 's'}, run in order.`}
</p>
)}
</>
@@ -295,6 +278,8 @@ export function RunPage() {
</button>
)}
</div>
<PluginSlot slot="run.aside" autoRefreshMs={isLive ? 4000 : undefined} />
</div>
{/* ------------------------------------------------------ live feed */}
+2
View File
@@ -26,6 +26,7 @@ import {
useAsync,
useToast,
} from '../components/ui'
import { PluginSlot } from '../components/PluginBlocks'
import { api, ApiError, type ModelPathEntry, type PluginSummary } from '../lib/api'
import { cx } from '../lib/format'
@@ -203,6 +204,7 @@ export function SettingsPage() {
</div>
<div className="space-y-5 xl:col-span-5">
<PluginSlot slot="settings.section" />
<Card>
<CardHeader title="Engine" icon={<Cpu size={14} />} />
<div className="p-4">
+580 -187
View File
@@ -1,10 +1,23 @@
/**
* Testing Suites — browse every suite, edit the custom ones.
*
* Two tiers. Built-ins ship with the app and are read-only at both levels: the
* suite cannot be renamed or deleted, and its questions cannot be edited. That
* is enforced by the API (409), not by the disabled controls here — this page
* only reflects it. "Duplicate" is what keeps read-only from being a dead end.
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import {
ArrowDown,
ArrowUp,
Copy,
FolderPlus,
Info,
Layers,
ListChecks,
Lock,
Pencil,
Plus,
RotateCcw,
Save,
@@ -12,15 +25,18 @@ import {
} from 'lucide-react'
import {
Card,
CardHeader,
ConfirmDialog,
EmptyState,
ErrorNote,
PageHeader,
SkeletonRows,
Spinner,
useAsync,
useToast,
} from '../components/ui'
import { api, ApiError } from '../lib/api'
import { PluginSlot } from '../components/PluginBlocks'
import { api, ApiError, type SuiteDetail, type SuiteSummary } from '../lib/api'
import { cx, deriveTitle } from '../lib/format'
import { useRunFeed } from '../hooks/useRunFeed'
@@ -33,48 +49,69 @@ interface Draft {
let keySeed = 1
const newKey = () => `q${keySeed++}`
const PLACEHOLDER =
'Write the question exactly as the model should receive it.\n\n' +
'Example: Analyze the sentiment of the following customer review.'
export function SuitePage() {
const toast = useToast()
const { isLive } = useRunFeed()
const suites = useAsync(() => api.suites(), [])
const [activeSlug, setActiveSlug] = useState<string | null>(null)
const [detail, setDetail] = useState<SuiteDetail | null>(null)
const [drafts, setDrafts] = useState<Draft[]>([])
const [baseline, setBaseline] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [loadingDetail, setLoadingDetail] = useState(false)
const [detailError, setDetailError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [confirmDiscard, setConfirmDiscard] = useState(false)
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
const [focusKey, setFocusKey] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const load = useCallback(() => {
setLoading(true)
setLoadError(null)
const [focusKey, setFocusKey] = useState<string | null>(null)
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
const [confirmDiscard, setConfirmDiscard] = useState(false)
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null)
const [deleteSuiteOpen, setDeleteSuiteOpen] = useState(false)
const [dialog, setDialog] = useState<null | { mode: 'create' | 'rename' | 'duplicate' }>(null)
const list = suites.data ?? []
const builtins = list.filter((s) => s.builtin)
const customs = list.filter((s) => !s.builtin)
const readOnly = detail?.builtin ?? true
// Select something as soon as the list arrives.
useEffect(() => {
if (!activeSlug && list.length) setActiveSlug(list[0].slug)
}, [list, activeSlug])
const loadDetail = useCallback((slug: string) => {
setLoadingDetail(true)
setDetailError(null)
api
.tests()
.then((suite) => {
.suite(slug)
.then((data) => {
setDetail(data)
setDrafts(
suite.tests.map((test) => ({
key: newKey(),
prompt: test.prompt,
filename: test.filename,
})),
data.tests.map((t) => ({ key: newKey(), prompt: t.prompt, filename: t.filename })),
)
setBaseline(suite.tests.map((test) => test.prompt))
setBaseline(data.tests.map((t) => t.prompt))
})
.catch((cause: unknown) =>
setLoadError(cause instanceof Error ? cause.message : String(cause)),
setDetailError(cause instanceof Error ? cause.message : String(cause)),
)
.finally(() => setLoading(false))
.finally(() => setLoadingDetail(false))
}, [])
useEffect(load, [load])
useEffect(() => {
if (activeSlug) loadDetail(activeSlug)
}, [activeSlug, loadDetail])
const current = drafts.map((draft) => draft.prompt)
const current = drafts.map((d) => d.prompt)
const dirty =
current.length !== baseline.length ||
current.some((prompt, index) => prompt.trim() !== baseline[index]?.trim())
!readOnly &&
(current.length !== baseline.length ||
current.some((p, i) => p.trim() !== baseline[i]?.trim()))
// Guard against losing edits on reload / close.
useEffect(() => {
if (!dirty) return
const warn = (event: BeforeUnloadEvent) => event.preventDefault()
@@ -82,36 +119,45 @@ export function SuitePage() {
return () => window.removeEventListener('beforeunload', warn)
}, [dirty])
/** Switching away from unsaved edits asks first. */
const selectSuite = (slug: string) => {
if (slug === activeSlug) return
if (dirty) setPendingSwitch(slug)
else setActiveSlug(slug)
}
/* ------------------------------------------------------------ questions */
const update = (key: string, prompt: string) =>
setDrafts((list) => list.map((d) => (d.key === key ? { ...d, prompt } : d)))
setDrafts((l) => l.map((d) => (d.key === key ? { ...d, prompt } : d)))
const addQuestion = () => {
const key = newKey()
setDrafts((list) => [...list, { key, prompt: '', filename: null }])
setDrafts((l) => [...l, { key, prompt: '', filename: null }])
setFocusKey(key)
}
const duplicate = (key: string) =>
setDrafts((list) => {
const index = list.findIndex((d) => d.key === key)
if (index < 0) return list
const copy = { key: newKey(), prompt: list[index].prompt, filename: null }
return [...list.slice(0, index + 1), copy, ...list.slice(index + 1)]
const duplicateQuestion = (key: string) =>
setDrafts((l) => {
const i = l.findIndex((d) => d.key === key)
if (i < 0) return l
return [...l.slice(0, i + 1), { key: newKey(), prompt: l[i].prompt, filename: null }, ...l.slice(i + 1)]
})
const remove = (key: string) => setDrafts((list) => list.filter((d) => d.key !== key))
const removeQuestion = (key: string) => setDrafts((l) => l.filter((d) => d.key !== key))
const move = (key: string, direction: -1 | 1) =>
setDrafts((list) => {
const index = list.findIndex((d) => d.key === key)
const target = index + direction
if (index < 0 || target < 0 || target >= list.length) return list
const next = [...list]
;[next[index], next[target]] = [next[target], next[index]]
setDrafts((l) => {
const i = l.findIndex((d) => d.key === key)
const target = i + direction
if (i < 0 || target < 0 || target >= l.length) return l
const next = [...l]
;[next[i], next[target]] = [next[target], next[i]]
return next
})
const save = async () => {
if (!detail) return
const blank = drafts.findIndex((d) => !d.prompt.trim())
if (blank >= 0) {
toast(`Question ${blank + 1} is empty. Add a prompt or remove it.`, 'error')
@@ -119,19 +165,12 @@ export function SuitePage() {
}
setSaving(true)
try {
const suite = await api.saveTests(drafts.map((d) => d.prompt))
setDrafts(
suite.tests.map((test) => ({
key: newKey(),
prompt: test.prompt,
filename: test.filename,
})),
)
setBaseline(suite.tests.map((test) => test.prompt))
toast(
`Saved ${suite.tests.length} question${suite.tests.length === 1 ? '' : 's'} to the suite.`,
'success',
)
const saved = await api.saveSuiteTests(detail.slug, drafts.map((d) => d.prompt))
setDetail(saved)
setDrafts(saved.tests.map((t) => ({ key: newKey(), prompt: t.prompt, filename: t.filename })))
setBaseline(saved.tests.map((t) => t.prompt))
suites.reload()
toast(`Saved ${saved.tests.length} question${saved.tests.length === 1 ? '' : 's'} to ${saved.name}.`, 'success')
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
} finally {
@@ -139,29 +178,72 @@ export function SuitePage() {
}
}
const deleting = drafts.find((d) => d.key === pendingDelete)
/* --------------------------------------------------------------- suites */
const submitDialog = async (name: string, description: string) => {
if (!name.trim()) return
setBusy(true)
try {
let result: SuiteDetail
if (dialog?.mode === 'create') {
result = await api.createSuite({ name, description })
toast(`Created "${result.name}".`, 'success')
} else if (dialog?.mode === 'duplicate') {
result = await api.duplicateSuite(detail!.slug, name)
toast(`Copied to "${result.name}" — this one is editable.`, 'success')
} else {
result = await api.updateSuite(detail!.slug, { name, description })
toast('Suite renamed.', 'success')
}
setDialog(null)
await suites.reload()
setActiveSlug(result.slug)
if (result.slug === activeSlug) loadDetail(result.slug)
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
} finally {
setBusy(false)
}
}
const deleteSuite = async () => {
if (!detail) return
setDeleteSuiteOpen(false)
setBusy(true)
try {
await api.deleteSuite(detail.slug)
toast(`Deleted "${detail.name}".`, 'success')
setActiveSlug(null)
setDetail(null)
await suites.reload()
} catch (cause) {
toast(cause instanceof ApiError ? cause.message : String(cause), 'error')
} finally {
setBusy(false)
}
}
const deletingQuestion = drafts.find((d) => d.key === pendingDelete)
return (
<>
<PageHeader
title="Suite Builder"
subtitle="Each box is one question. They are sent to the model one at a time, in this order."
title="Testing Suites"
subtitle="Questions are grouped into named suites. Built-in suites ship with the app and are read-only; duplicate one to make it yours."
actions={
<>
{dirty && (
<button
className="btn btn-ghost"
onClick={() => setConfirmDiscard(true)}
disabled={saving}
>
<button className="btn btn-ghost" onClick={() => setConfirmDiscard(true)} disabled={saving}>
<RotateCcw size={14} />
Discard
</button>
)}
<button className="btn btn-primary" onClick={save} disabled={!dirty || saving || isLive}>
{saving ? <Spinner /> : <Save size={14} />}
{saving ? 'Saving…' : dirty ? 'Save suite' : 'Saved'}
</button>
{!readOnly && detail && (
<button className="btn btn-primary" onClick={save} disabled={!dirty || saving || isLive}>
{saving ? <Spinner /> : <Save size={14} />}
{saving ? 'Saving…' : dirty ? 'Save questions' : 'Saved'}
</button>
)}
</>
}
/>
@@ -170,108 +252,238 @@ export function SuitePage() {
<div className="mb-4 flex items-start gap-2.5 rounded-xl border border-ember-500/35 bg-ember-500/8 px-4 py-3">
<Info size={15} className="mt-0.5 shrink-0 text-ember-400" />
<p className="text-[0.8125rem] text-ember-300">
A run is in progress. You can edit questions, but saving is blocked until it finishes.
A run is in progress. Editing is blocked until it finishes.
</p>
</div>
)}
{loadError && <ErrorNote message={loadError} onRetry={load} />}
{suites.error && <ErrorNote message={suites.error} onRetry={suites.reload} />}
{loading ? (
<Card>
<SkeletonRows rows={4} />
</Card>
) : (
<div className="grid gap-5 xl:grid-cols-12">
<div className="space-y-3 xl:col-span-9">
{drafts.length === 0 ? (
<Card>
<EmptyState
icon={<ListChecks size={20} />}
title="The suite is empty"
description="Add your first question to start benchmarking."
action={
<button className="btn btn-primary" onClick={addQuestion}>
<Plus size={14} />
Add question
</button>
}
/>
</Card>
<div className="grid gap-5 xl:grid-cols-12">
{/* ------------------------------------------------- suite list */}
<div className="xl:col-span-4">
<Card className="xl:sticky xl:top-6">
<CardHeader
title="Suites"
icon={<Layers size={14} />}
actions={
<button
className="btn btn-ghost btn-sm"
onClick={() => setDialog({ mode: 'create' })}
disabled={busy || isLive}
>
<FolderPlus size={13} />
New
</button>
}
/>
{suites.loading && !list.length ? (
<SkeletonRows rows={4} />
) : (
drafts.map((draft, index) => (
<QuestionCard
key={draft.key}
draft={draft}
index={index}
total={drafts.length}
autoFocus={focusKey === draft.key}
onFocused={() => setFocusKey(null)}
onChange={(prompt) => update(draft.key, prompt)}
onMoveUp={() => move(draft.key, -1)}
onMoveDown={() => move(draft.key, 1)}
onDuplicate={() => duplicate(draft.key)}
onDelete={() =>
draft.prompt.trim() ? setPendingDelete(draft.key) : remove(draft.key)
}
/>
))
<div className="p-2">
<SuiteGroup label="Built-in" hint="read-only">
{builtins.map((s) => (
<SuiteRow
key={s.slug}
suite={s}
active={s.slug === activeSlug}
onClick={() => selectSuite(s.slug)}
/>
))}
</SuiteGroup>
<SuiteGroup label="Custom" hint={customs.length ? 'editable' : undefined}>
{customs.length ? (
customs.map((s) => (
<SuiteRow
key={s.slug}
suite={s}
active={s.slug === activeSlug}
onClick={() => selectSuite(s.slug)}
/>
))
) : (
<p className="px-2.5 py-3 text-[0.75rem] leading-relaxed text-ink-500">
None yet. Duplicate a built-in suite, or create an empty one.
</p>
)}
</SuiteGroup>
</div>
)}
</Card>
{drafts.length > 0 && (
<button
className="flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-navy-600 py-3.5 text-[0.8125rem] font-medium text-ink-400 transition-colors hover:border-ember-500/60 hover:bg-ember-500/5 hover:text-ember-400"
onClick={addQuestion}
>
<Plus size={15} />
Add question
</button>
)}
</div>
<aside className="xl:col-span-3">
<div className="sticky top-6 space-y-3">
<Card>
<div className="p-4">
<div className="text-[0.625rem] font-semibold tracking-[0.09em] text-ink-500 uppercase">
Suite
</div>
<div className="num mt-1 text-[1.6rem] font-semibold text-gold-400">
{drafts.length}
</div>
<div className="text-[0.75rem] text-ink-500">
question{drafts.length === 1 ? '' : 's'}
{dirty && <span className="ml-1.5 text-ember-400">· unsaved</span>}
</div>
</div>
</Card>
<Card>
<div className="space-y-2.5 p-4 text-[0.75rem] leading-relaxed text-ink-400">
<p className="flex items-center gap-1.5 font-semibold text-ink-300">
<Info size={13} className="text-gold-500" />
How saving works
</p>
<p>
The whole prompt is sent to the model. Its{' '}
<span className="text-ink-200">first line</span> doubles as the title in reports,
so lead with the task.
</p>
<p>
Saving rewrites the suite as{' '}
<code className="rounded bg-navy-750 px-1 py-0.5 font-mono text-[0.6875rem] text-gold-300">
test1.txt testN.txt
</code>{' '}
in this order, so the CLI (
<code className="font-mono text-[0.6875rem]">auto-test.py</code>) sees exactly
what you see here.
</p>
</div>
</Card>
</div>
</aside>
<PluginSlot slot="suite.aside" className="mt-4" />
</div>
)}
{/* ---------------------------------------------- selected suite */}
<div className="space-y-3 xl:col-span-8">
{detailError && <ErrorNote message={detailError} onRetry={() => activeSlug && loadDetail(activeSlug)} />}
{loadingDetail && !detail ? (
<Card>
<SkeletonRows rows={4} />
</Card>
) : !detail ? (
<Card>
<EmptyState
icon={<Layers size={20} />}
title="No suite selected"
description="Pick one on the left, or create a new custom suite."
/>
</Card>
) : (
<>
<Card raised>
<div className="flex flex-wrap items-start justify-between gap-3 p-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-[0.9375rem] font-semibold text-ink-100">{detail.name}</h2>
{detail.builtin ? (
<span className="flex items-center gap-1 rounded border border-gold-500/30 bg-gold-500/10 px-1.5 py-0.5 text-[0.625rem] text-gold-400">
<Lock size={9} /> built-in
</span>
) : (
<span className="rounded border border-mint-500/30 bg-mint-500/10 px-1.5 py-0.5 text-[0.625rem] text-mint-400">
custom
</span>
)}
</div>
<p className="mt-1 max-w-xl text-[0.8125rem] leading-relaxed text-ink-400">
{detail.description || 'No description.'}
</p>
<p className="mt-1.5 text-[0.6875rem] text-ink-500">
<code className="font-mono">{detail.slug}</code> · {drafts.length} question
{drafts.length === 1 ? '' : 's'}
{dirty && <span className="ml-1.5 text-ember-400">· unsaved</span>}
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<button
className="btn btn-ghost btn-sm"
onClick={() => setDialog({ mode: 'duplicate' })}
disabled={busy || isLive}
title="Clone this suite into an editable custom one"
>
<Copy size={13} />
Duplicate
</button>
{!readOnly && (
<>
<button
className="btn btn-ghost btn-sm"
onClick={() => setDialog({ mode: 'rename' })}
disabled={busy || isLive}
>
<Pencil size={13} />
Rename
</button>
<button
className="btn btn-ghost btn-sm text-rose-400 hover:bg-rose-500/10"
onClick={() => setDeleteSuiteOpen(true)}
disabled={busy || isLive}
>
<Trash2 size={13} />
Delete
</button>
</>
)}
</div>
</div>
{readOnly && (
<div className="flex items-start gap-2.5 border-t border-navy-700 bg-gold-500/5 px-4 py-3">
<Lock size={14} className="mt-0.5 shrink-0 text-gold-400" />
<p className="text-[0.75rem] leading-relaxed text-gold-300">
This suite ships with the app, so its questions cannot be changed. Use{' '}
<strong>Duplicate</strong> to get an editable copy the original stays intact
as a stable baseline for comparing models.
</p>
</div>
)}
</Card>
{drafts.length === 0 ? (
<Card>
<EmptyState
icon={<ListChecks size={20} />}
title="No questions yet"
description={
readOnly
? 'This suite is empty.'
: 'Add your first question to start benchmarking.'
}
action={
readOnly ? undefined : (
<button className="btn btn-primary" onClick={addQuestion}>
<Plus size={14} />
Add question
</button>
)
}
/>
</Card>
) : (
drafts.map((draft, index) => (
<QuestionCard
key={draft.key}
draft={draft}
index={index}
total={drafts.length}
readOnly={readOnly}
autoFocus={focusKey === draft.key}
onFocused={() => setFocusKey(null)}
onChange={(prompt) => update(draft.key, prompt)}
onMoveUp={() => move(draft.key, -1)}
onMoveDown={() => move(draft.key, 1)}
onDuplicate={() => duplicateQuestion(draft.key)}
onDelete={() =>
draft.prompt.trim() ? setPendingDelete(draft.key) : removeQuestion(draft.key)
}
/>
))
)}
{!readOnly && drafts.length > 0 && (
<button
className="flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-navy-600 py-3.5 text-[0.8125rem] font-medium text-ink-400 transition-colors hover:border-ember-500/60 hover:bg-ember-500/5 hover:text-ember-400"
onClick={addQuestion}
>
<Plus size={15} />
Add question
</button>
)}
</>
)}
</div>
</div>
<SuiteDialog
open={!!dialog}
mode={dialog?.mode ?? 'create'}
busy={busy}
initialName={
dialog?.mode === 'rename'
? (detail?.name ?? '')
: dialog?.mode === 'duplicate'
? `${detail?.name ?? 'Suite'} (copy)`
: ''
}
initialDescription={dialog?.mode === 'rename' ? (detail?.description ?? '') : ''}
onSubmit={submitDialog}
onCancel={() => setDialog(null)}
/>
<ConfirmDialog
open={deleteSuiteOpen}
title={`Delete "${detail?.name}"?`}
body={`This removes the suite and all ${drafts.length} of its questions from disk. This cannot be undone.`}
confirmLabel="Delete suite"
destructive
onConfirm={deleteSuite}
onCancel={() => setDeleteSuiteOpen(false)}
/>
<ConfirmDialog
open={confirmDiscard}
@@ -281,19 +493,32 @@ export function SuitePage() {
destructive
onConfirm={() => {
setConfirmDiscard(false)
load()
if (activeSlug) loadDetail(activeSlug)
}}
onCancel={() => setConfirmDiscard(false)}
/>
<ConfirmDialog
open={!!deleting}
open={!!pendingSwitch}
title="Leave without saving?"
body="You have unsaved edits in this suite. Switching will discard them."
confirmLabel="Discard and switch"
destructive
onConfirm={() => {
setActiveSlug(pendingSwitch)
setPendingSwitch(null)
}}
onCancel={() => setPendingSwitch(null)}
/>
<ConfirmDialog
open={!!deletingQuestion}
title="Delete this question?"
body={deriveTitle(deleting?.prompt ?? '', 'This question')}
body={deriveTitle(deletingQuestion?.prompt ?? '', 'This question')}
confirmLabel="Delete"
destructive
onConfirm={() => {
if (pendingDelete) remove(pendingDelete)
if (pendingDelete) removeQuestion(pendingDelete)
setPendingDelete(null)
}}
onCancel={() => setPendingDelete(null)}
@@ -302,12 +527,178 @@ export function SuitePage() {
)
}
/* ------------------------------------------------------------------- card */
/* -------------------------------------------------------------- suite list */
function SuiteGroup({
label,
hint,
children,
}: {
label: string
hint?: string
children: React.ReactNode
}) {
return (
<div className="mb-1 last:mb-0">
<div className="flex items-baseline gap-2 px-2.5 pt-2 pb-1">
<span className="text-[0.625rem] font-semibold tracking-[0.09em] text-ink-500 uppercase">
{label}
</span>
{hint && <span className="text-[0.625rem] text-ink-600">{hint}</span>}
</div>
<div className="space-y-0.5">{children}</div>
</div>
)
}
function SuiteRow({
suite,
active,
onClick,
}: {
suite: SuiteSummary
active: boolean
onClick: () => void
}) {
return (
<button
onClick={onClick}
className={cx(
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors',
active ? 'bg-navy-750 text-ink-100' : 'text-ink-300 hover:bg-navy-800/60',
)}
>
{suite.builtin ? (
<Lock size={12} className={cx('shrink-0', active ? 'text-gold-400' : 'text-ink-500')} />
) : (
<ListChecks size={12} className={cx('shrink-0', active ? 'text-mint-400' : 'text-ink-500')} />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.8125rem] font-medium">{suite.name}</span>
<span className="block truncate text-[0.6875rem] text-ink-500">{suite.slug}</span>
</span>
<span className="num shrink-0 text-[0.6875rem] text-ink-500">{suite.count}</span>
</button>
)
}
/* ----------------------------------------------------------- name dialog */
function SuiteDialog({
open,
mode,
busy,
initialName,
initialDescription,
onSubmit,
onCancel,
}: {
open: boolean
mode: 'create' | 'rename' | 'duplicate'
busy: boolean
initialName: string
initialDescription: string
onSubmit: (name: string, description: string) => void
onCancel: () => void
}) {
const [name, setName] = useState(initialName)
const [description, setDescription] = useState(initialDescription)
useEffect(() => {
if (open) {
setName(initialName)
setDescription(initialDescription)
}
}, [open, initialName, initialDescription])
useEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onCancel()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onCancel])
if (!open) return null
const copy = {
create: { title: 'New suite', action: 'Create suite' },
rename: { title: 'Rename suite', action: 'Save' },
duplicate: { title: 'Duplicate to a custom suite', action: 'Duplicate' },
}[mode]
return (
<div
className="animate-fade-in fixed inset-0 z-50 flex items-center justify-center bg-navy-950/75 p-5 backdrop-blur-sm"
onClick={onCancel}
>
<form
className="surface-raised animate-fade-up w-full max-w-md p-5"
onClick={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault()
onSubmit(name, description)
}}
>
<h3 className="text-[0.9375rem] font-semibold text-ink-100">{copy.title}</h3>
<label className="label mt-4 block" htmlFor="suite-name">
Name
</label>
<input
id="suite-name"
className="field"
value={name}
autoFocus
maxLength={80}
placeholder="e.g. Domain knowledge"
onChange={(event) => setName(event.target.value)}
/>
{mode !== 'duplicate' && (
<>
<label className="label mt-3 block" htmlFor="suite-description">
Description <span className="text-ink-600">(optional)</span>
</label>
<input
id="suite-description"
className="field"
value={description}
placeholder="What this suite covers"
onChange={(event) => setDescription(event.target.value)}
/>
</>
)}
{mode === 'duplicate' && (
<p className="mt-3 text-[0.75rem] leading-relaxed text-ink-500">
Copies every question into a new custom suite you can edit. The original is
untouched.
</p>
)}
<div className="mt-5 flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={onCancel}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={!name.trim() || busy}>
{busy && <Spinner className="h-3.5 w-3.5" />}
{copy.action}
</button>
</div>
</form>
</div>
)
}
/* ------------------------------------------------------------- question */
function QuestionCard({
draft,
index,
total,
readOnly,
autoFocus,
onFocused,
onChange,
@@ -319,6 +710,7 @@ function QuestionCard({
draft: Draft
index: number
total: number
readOnly: boolean
autoFocus: boolean
onFocused: () => void
onChange: (prompt: string) => void
@@ -332,7 +724,6 @@ function QuestionCard({
const empty = !draft.prompt.trim()
const words = draft.prompt.trim() ? draft.prompt.trim().split(/\s+/).length : 0
// Grow the box to fit its content so long prompts stay readable.
useLayoutEffect(() => {
const node = textareaRef.current
if (!node) return
@@ -348,16 +739,12 @@ function QuestionCard({
}, [autoFocus, onFocused])
return (
<Card
className={cx('animate-fade-up overflow-hidden', empty && 'border-ember-500/40')}
>
<Card className={cx('animate-fade-up overflow-hidden', empty && !readOnly && 'border-ember-500/40')}>
<div className="flex items-center gap-3 border-b border-navy-700 px-3.5 py-2.5">
<span
className={cx(
'num flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-[0.6875rem] font-semibold',
empty
? 'bg-ember-500/15 text-ember-400'
: 'bg-gold-500/12 text-gold-400',
empty && !readOnly ? 'bg-ember-500/15 text-ember-400' : 'bg-gold-500/12 text-gold-400',
)}
>
{index + 1}
@@ -378,31 +765,37 @@ function QuestionCard({
<span className="num">{words}w</span>
</span>
<div className="flex shrink-0 items-center gap-0.5">
<IconButton label="Move up" disabled={index === 0} onClick={onMoveUp}>
<ArrowUp size={13} />
</IconButton>
<IconButton label="Move down" disabled={index === total - 1} onClick={onMoveDown}>
<ArrowDown size={13} />
</IconButton>
<IconButton label="Duplicate" onClick={onDuplicate}>
<Copy size={13} />
</IconButton>
<IconButton label="Delete" danger onClick={onDelete}>
<Trash2 size={13} />
</IconButton>
</div>
{readOnly ? (
<Lock size={12} className="shrink-0 text-ink-600" aria-label="Read-only" />
) : (
<div className="flex shrink-0 items-center gap-0.5">
<IconButton label="Move up" disabled={index === 0} onClick={onMoveUp}>
<ArrowUp size={13} />
</IconButton>
<IconButton label="Move down" disabled={index === total - 1} onClick={onMoveDown}>
<ArrowDown size={13} />
</IconButton>
<IconButton label="Duplicate" onClick={onDuplicate}>
<Copy size={13} />
</IconButton>
<IconButton label="Delete" danger onClick={onDelete}>
<Trash2 size={13} />
</IconButton>
</div>
)}
</div>
<textarea
ref={textareaRef}
value={draft.prompt}
readOnly={readOnly}
onChange={(event) => onChange(event.target.value)}
placeholder={
'Write the question exactly as the model should receive it.\n\nExample: Write a Swift function that solves the FizzBuzz problem.'
}
placeholder={PLACEHOLDER}
spellCheck={false}
className="w-full resize-none border-0 bg-transparent px-4 py-3.5 font-mono text-[0.8125rem] leading-relaxed text-ink-200 placeholder:text-ink-500/70 focus:outline-none"
className={cx(
'w-full resize-none border-0 bg-transparent px-4 py-3.5 font-mono text-[0.8125rem] leading-relaxed placeholder:text-ink-500/70 focus:outline-none',
readOnly ? 'cursor-default text-ink-300' : 'text-ink-200',
)}
/>
</Card>
)