import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { IconCheck, IconCopy } from '@tabler/icons-react' import { useSearchParams } from 'wouter' import { Button } from '@/client/components/ui/button' import { cn } from '@/client/lib/cn' import { wsUrl } from '@/client/lib/ws-url' import type { Model, WorkspaceAgent, WorkspaceEntry } from '@/lib/types' // Scratch route for driving any harness end to end: pick a workspace, fire // canned scenarios (or your own prompt), and watch three synchronized logs — // the backend's native wire (Codex: app-server JSON-RPC; Claude Code: raw SDK // messages), the frames the server pushes to chat clients, and the durable // events REST replay. See server/harness/README.md. type WireFrame = { seq: number; ts: number; dir: 'send' | 'recv'; frame: unknown } type BroadcastFrame = { seq: number; ts: number; frame: unknown } type ProcessInfo = { running: boolean; pid?: number; binary: string | null } type DebugPayload = { provider: string process: ProcessInfo | null wire: WireFrame[] broadcasts: BroadcastFrame[] } const SCENARIOS: { label: string; prompt: string }[] = [ { label: 'Trivial', prompt: 'Reply with exactly: pong' }, { label: 'Reasoning', prompt: 'Think carefully: a farmer has 17 sheep, all but 9 run away, then he buys twice as many as remain. How many sheep now? Reason it out before answering.' }, { label: 'Command', prompt: 'Run `ls -la` and summarize what you see in one sentence.' }, { label: 'File edit', prompt: 'Create or overwrite scratch.txt with three random words, one per line.' }, { label: 'Plan', prompt: 'Make a 3-step plan (use your plan tool) for adding a README to this folder, then execute it.' }, { label: 'Subagent', prompt: 'Spawn a subagent (collab/agent tool) to count the files in this directory and report back its answer.' }, { label: 'Web search', prompt: 'Search the web for the current Bun version and tell me what you find.' }, { label: 'Slow (interrupt me)', prompt: 'Run this exact command: sleep 30 && echo done. Nothing else.' } ] function shortJson(value: unknown, max = 110): string { const s = JSON.stringify(value) if (!s) return '' return s.length > max ? s.slice(0, max) + '…' : s } function frameLabel(frame: unknown): string { const f = frame as Record if (typeof f?.method === 'string') { return 'id' in f ? `${f.method} #${f.id}` : f.method } if ('id' in (f ?? {})) return `response #${f.id}` if (typeof f?.kind === 'string') return `${f.kind}` if (typeof f?.type === 'string') return `${f.type}` return '?' } function ts(t: number): string { return ( new Date(t).toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(t % 1000).padStart(3, '0') ) } type LogRowProps = { time: number badge: string badgeClass: string label: string body: unknown } function LogRow({ time, badge, badgeClass, label, body }: LogRowProps) { return (
{ts(time)} {badge} {label} {shortJson(body)}
        {JSON.stringify(body, null, 2)}
      
) } type PaneProps = { title: React.ReactNode hint?: string // Extra header widgets (e.g. a filter input), rendered before the follow toggle. controls?: React.ReactNode onCopy?: () => void onClear?: () => void children: React.ReactNode } function Pane({ title, hint, controls, onCopy, onClear, children }: PaneProps) { const scroller = useRef(null) const [follow, setFollow] = useState(true) const [copied, setCopied] = useState(false) const handleCopy = useCallback(() => { onCopy?.() setCopied(true) setTimeout(() => setCopied(false), 1500) }, [onCopy]) useEffect(() => { if (follow && scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight }) return (
{typeof title === 'string' ? {title} : title} {hint && {hint}} {controls} {onCopy && ( )} {onClear && ( )}
{children}
) } export function HarnessDebugPage() { const [searchParams, setSearchParams] = useSearchParams() const [workspaces, setWorkspaces] = useState([]) // Seeded from ?workspace= so the selection survives reloads / can be shared. const [workspaceId, setWorkspaceId] = useState(() => searchParams.get('workspace') ?? '') const [models, setModels] = useState([]) const [model, setModel] = useState('') const [effort, setEffort] = useState('') const [stream, setStream] = useState(true) const [sessionId, setSessionId] = useState(() => crypto.randomUUID()) const [isNew, setIsNew] = useState(true) const [prompt, setPrompt] = useState('') const [proc, setProc] = useState(null) const [wire, setWire] = useState([]) const [clientFrames, setClientFrames] = useState([]) const [events, setEvents] = useState(null) const [hidePreviews, setHidePreviews] = useState(false) const [rightTab, setRightTab] = useState<'frames' | 'events'>('frames') // Split position of the wire pane, as % of the row. Wire frames are the // denser log, so it gets more room by default; the handle between the panes // drags it between 20% and 80%. const [leftPct, setLeftPct] = useState(60) const rowRef = useRef(null) const wireCursor = useRef(0) const wsRef = useRef(null) const localSeq = useRef(0) const sessionRef = useRef(sessionId) sessionRef.current = sessionId // Every workspace is drivable — the panes adapt to its harness type. useEffect(() => { fetch('/api/workspaces') .then(r => r.json()) .then((list: WorkspaceEntry[]) => { setWorkspaces(list) // Keep the URL-seeded id only if it's a real workspace. setWorkspaceId(id => (id && list.some(w => w.id === id) ? id : (list[0]?.id ?? ''))) }) .catch(() => {}) }, []) const selectWorkspace = useCallback( (id: string) => { setWorkspaceId(id) setSearchParams( prev => { prev.set('workspace', id) return prev }, { replace: true } ) }, [setSearchParams] ) const provider = workspaces.find(w => w.id === workspaceId)?.type ?? 'claude-code' // Model list for the selected workspace. useEffect(() => { if (!workspaceId) return fetch(`/api/workspaces/${workspaceId}/agent`) .then(r => r.json()) .then((m: WorkspaceAgent) => setModels(m.models)) .catch(() => setModels([])) }, [workspaceId]) // Switching workspace targets a different harness — the old thread id is // meaningless there (a codex resume of a Claude session id just errors), // so start a fresh thread. useEffect(() => { setSessionId(crypto.randomUUID()) setIsNew(true) setEvents(null) }, [workspaceId]) // Poll the wire tap (the backend's native frames) once a second. useEffect(() => { if (!workspaceId) return wireCursor.current = 0 setWire([]) const timer = setInterval(async () => { try { const r = await fetch( `/api/workspaces/${workspaceId}/harness/debug?sinceWire=${wireCursor.current}&sinceBroadcast=-1` ) if (!r.ok) return const d = (await r.json()) as DebugPayload setProc(d.process) if (d.wire.length) { wireCursor.current = d.wire[d.wire.length - 1].seq setWire(prev => [...prev, ...d.wire].slice(-1000)) } } catch {} }, 1000) return () => clearInterval(timer) }, [workspaceId]) // Live client frames: our own chat socket, same protocol as the real UI. useEffect(() => { if (!workspaceId) return const sock = new WebSocket(wsUrl('/ws')) wsRef.current = sock sock.onmessage = e => { try { const frame = JSON.parse(String(e.data)) as Record if (frame.workspaceId && frame.workspaceId !== workspaceId) return if (frame.type === 'session_renamed' && frame.from === sessionRef.current) { setSessionId(frame.to as string) setIsNew(false) } setClientFrames(prev => [...prev, { seq: ++localSeq.current, ts: Date.now(), frame }].slice(-1000) ) } catch {} } return () => { wsRef.current = null sock.close() } }, [workspaceId]) const send = useCallback( (content: string) => { if (!content.trim() || !wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return wsRef.current.send( JSON.stringify({ type: 'chat', workspaceId, sessionId: sessionRef.current, isNew, content, optimisticId: crypto.randomUUID(), ...(model ? { model } : {}), ...(effort ? { effort } : {}), stream }) ) setIsNew(false) }, [workspaceId, isNew, model, effort, stream] ) const stop = useCallback(() => { wsRef.current?.send( JSON.stringify({ type: 'stop', workspaceId, sessionId: sessionRef.current }) ) }, [workspaceId]) const newThread = useCallback(() => { setSessionId(crypto.randomUUID()) setIsNew(true) setEvents(null) }, []) const fetchEvents = useCallback(async () => { const r = await fetch(`/api/workspaces/${workspaceId}/sessions/${sessionRef.current}/events`) setEvents(r.ok ? ((await r.json()) as unknown[]) : []) }, [workspaceId]) const effortLevels = useMemo( () => models.find(m => m.value === model)?.supportedEffortLevels ?? [], [models, model] ) const [wireFilter, setWireFilter] = useState('') const visibleWire = useMemo(() => { const q = wireFilter.trim().toLowerCase() return q ? wire.filter(f => frameLabel(f.frame).toLowerCase().includes(q)) : wire }, [wire, wireFilter]) const visibleClientFrames = useMemo( () => hidePreviews ? clientFrames.filter(f => (f.frame as { type?: string }).type !== 'preview') : clientFrames, [clientFrames, hidePreviews] ) // Copy the displayed frames (respecting any active filter) as timestamped // JSONL — the same shape the panes render, so a paste reads like the // on-screen log. const copyWire = useCallback(() => { navigator.clipboard.writeText( visibleWire .map(f => `${ts(f.ts)} ${f.dir === 'send' ? '→' : '←'} ${JSON.stringify(f.frame)}`) .join('\n') ) }, [visibleWire]) const copyRight = useCallback(() => { navigator.clipboard.writeText( rightTab === 'frames' ? visibleClientFrames.map(f => `${ts(f.ts)} ws ${JSON.stringify(f.frame)}`).join('\n') : JSON.stringify(events ?? [], null, 2) ) }, [rightTab, visibleClientFrames, events]) return (
Harness debug {provider === 'codex' && ( {proc?.running ? `app-server pid ${proc.pid}` : 'app-server not running'} )} thread {sessionId}
{SCENARIOS.map(s => ( ))} setPrompt(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { send(prompt) setPrompt('') } }} />
{/* Dynamic drag geometry can't be a static Tailwind class */}
setWireFilter(e.target.value)} /> } onCopy={copyWire} onClear={() => setWire([])} > {visibleWire.map(f => ( ))}
{ e.preventDefault() e.currentTarget.setPointerCapture(e.pointerId) }} onPointerMove={e => { if (!e.currentTarget.hasPointerCapture(e.pointerId) || !rowRef.current) return const rect = rowRef.current.getBoundingClientRect() const pct = ((e.clientX - rect.left) / rect.width) * 100 setLeftPct(Math.min(80, Math.max(20, pct))) }} /> } hint={ rightTab === 'frames' ? 'what the browser receives on /ws' : 'GET /sessions/:id/events replay' } onCopy={copyRight} onClear={rightTab === 'frames' ? () => setClientFrames([]) : undefined} > {rightTab === 'frames' ? ( <>
{visibleClientFrames.map(f => ( ))} ) : ( <>
{events?.map((ev, i) => ( ))} {events?.length === 0 && (
no events
)} )}
) }