Repository Public
1// V7 research runtime — the owner's side of autonomous research jobs: describe a mission in plain language, set budgets,2// watch the job's log, read the submission. The screen keeps two things apart everywhere: what the AI agent WROTE (its3// hypothesis, its notes, the numbers it claims) and what THIS SERVER MEASURED on data the job never had. Only the second4// one can be approved. Every string is in the `rj` block of i18n.ts.5import { useQuery, useQueryClient } from '@tanstack/react-query';6import { useContext, useEffect, useRef, useState } from 'react';7import { get, post } from '../api';8import type { Nav } from '../App';9import { errText, I18n, useT, type Key } from '../i18n';10import { Btn, Card, Field, H, Icon, inputCls, Notice, Skeleton, when } from '../ui';1112interface Budgets { wallSeconds: number; maxTrials: number; maxDiskMb: number; maxModelTokens: number; maxModelUsd: number }13interface Usage { wallMs: number; trials: number; diskMb: number; modelTokens: number; modelRequests: number; modelUsd: number | null; ledgerTampered: boolean; sandbox: 'sandbox-exec' | 'none'; agent: string; labCommit: string | null }14interface JobV { id: string; agentId: string | null; kind: 'spot' | 'perp'; mission: string; universe: string[]; interval: string; capitalUsd: number; budgets: Budgets; state: string; reason: string | null; strategy: string;15 runnerId: string | null; cancelRequested: boolean; usage: Usage | null; versionId: string | null; createdAt: string; finishedAt: string | null; serverStatus?: string | null; trialsTotal?: number | null; deflatedSharpe?: number | null; claimsDiffer?: boolean | null }16type Nums = Partial<Record<'total_return' | 'sharpe' | 'max_drawdown' | 'trades', number | null>>;17interface Server { status: 'ok' | 'failed'; reason?: string; sha256?: string; sandbox?: string; holdout?: { testStart: string; testEnd: string; bars: number }; trials?: { job: number; server: number; total: number; ledgerTampered: boolean };18 deflatedSharpe?: number | null; sharpeCi?: [number | null, number | null] | null; metrics?: Record<'train' | 'validation' | 'test', Nums>; benchmark?: Nums; checks?: Record<string, unknown>; replication?: Record<string, Nums>;19 comparison?: { split: string; metric: string; claimed: number | null; measured: number | null; differs: boolean }[]; claimsDiffer?: boolean; claimsProblem?: string | null }20interface Detail extends JobV { holdout: { start: string; devEnd: string; end: string; devBars: number; testBars: number } | null; dataReady: boolean | null; dataError: string | null; agent: { id: string; name: string; state: string } | null;21 version: { id: string; status: string; notes: string } | null; submission: { sha256: string; path: string; files: Record<string, string>; claims: { summary?: string; trials?: number } | null; claimsProblem: string | null } | null; server: Server | null }22interface LogLine { id: number; ts: number; s: string; t: string }2324const FINAL = ['submitted', 'failed', 'cancelled', 'over_budget'];25const TONE: Record<string, string> = { submitted: 'text-emerald bg-emerald/10', failed: 'text-danger bg-danger/10', over_budget: 'text-warn bg-warn/12', cancelled: 'text-mute bg-elev', running: 'text-blue bg-blue/10', evaluating: 'text-blue bg-blue/10', preparing: 'text-blue bg-blue/10', queued: 'text-mute bg-elev' };26export function JobState({ state }: { state: string }) {27 const t = useT();28 return <span data-testid="rj-state" data-state={state} className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-semibold ${TONE[state] ?? 'text-mute bg-elev'}`}>29 <Icon n={state === 'submitted' ? 'check' : state === 'failed' ? 'x' : 'clock'} className="h-3 w-3" />{t(`rjS_${state}` as Key)}</span>;30}31const pct = (x: number | null | undefined) => (typeof x === 'number' ? `${(x * 100).toFixed(1)} %` : '—');32const num = (x: number | null | undefined, d = 2) => (typeof x === 'number' ? x.toFixed(d) : '—');33const fmt = (metric: string, x: number | null) => (x === null ? '—' : metric === 'sharpe' ? x.toFixed(2) : metric === 'trades' ? String(Math.round(x)) : `${(x * 100).toFixed(1)} %`);34const dur = (ms: number) => (ms < 90_000 ? `${Math.round(ms / 1000)} s` : `${Math.round(ms / 60_000)} min`);3536/** The form: a mission in the owner's words and the budgets the runner enforces from outside the agent. */37export function ResearchForm({ agent, onCreated }: { agent?: { id: string; kind?: string; universe: string[]; interval: string }; onCreated: (id: string) => void }) {38 const t = useT();39 const [f, setF] = useState({ mission: '', kind: 'spot' as 'spot' | 'perp', symbols: 'BTC, ETH', interval: '1h', capital: '1000', minutes: 60, maxTrials: 200, tokensM: 5, usd: 10, diskMb: 512 });40 const [busy, setBusy] = useState(false), [err, setErr] = useState('');41 const universe = f.symbols.split(/[\s,;]+/).map((s) => s.trim().toUpperCase()).filter(Boolean);42 const n = (k: 'minutes' | 'maxTrials' | 'tokensM' | 'usd' | 'diskMb', label: string, suffix: string, min: number, max: number) => <Field label={label}><div className="flex items-center gap-2">43 <input data-testid={`rj-budget-${k}`} className={inputCls} inputMode="decimal" value={String(f[k])} onChange={(e) => setF({ ...f, [k]: Math.max(0, Math.min(max, Number(e.target.value.replace(/[^0-9.]/g, '')) || 0)) })} onBlur={() => setF((x) => ({ ...x, [k]: Math.max(min, x[k]) }))} />44 <span className="w-14 shrink-0 text-[13px] text-mute">{suffix}</span></div></Field>;45 const create = async () => {46 setBusy(true); setErr('');47 try {48 const j = await post<JobV>('/research/jobs', { mission: f.mission.trim(), ...(agent ? { agentId: agent.id, universe: agent.universe } : { kind: f.kind, universe, interval: f.interval }), capitalUsd: Number(f.capital) || 1000,49 budgets: { wallSeconds: Math.round(f.minutes * 60), maxTrials: Math.round(f.maxTrials), maxDiskMb: Math.round(f.diskMb), maxModelTokens: Math.round(f.tokensM * 1_000_000), maxModelUsd: f.usd } });50 setF((x) => ({ ...x, mission: '' })); onCreated(j.id);51 } catch (e) { setErr(errText(t, e)); } finally { setBusy(false); }52 };53 return (54 <div className="space-y-4" data-testid="rj-form">55 <Field label={t('rjMission')} hint={t('rjMissionHint')}><textarea data-testid="rj-mission" className={`${inputCls} min-h-[104px] resize-none`} maxLength={2000} value={f.mission} placeholder={t('rjMissionExample')} onChange={(e) => setF({ ...f, mission: e.target.value })} /></Field>56 {agent ? <p className="text-[13px] text-mute">{t('rjForAgent', { assets: agent.universe.join(', '), interval: agent.interval, kind: agent.kind === 'perp' ? t('rjKindPerp') : t('rjKindSpot') })}</p> : <div className="grid grid-cols-2 gap-3">57 <Field label={t('rjKind')}><select data-testid="rj-kind" className={inputCls} value={f.kind} onChange={(e) => setF({ ...f, kind: e.target.value as 'spot' | 'perp' })}><option value="spot">{t('rjKindSpot')}</option><option value="perp">{t('rjKindPerp')}</option></select></Field>58 <Field label={t('rjInterval')}><select className={inputCls} value={f.interval} onChange={(e) => setF({ ...f, interval: e.target.value })}><option value="1h">1 h</option>{f.kind === 'perp' ? <option value="4h">4 h</option> : <option value="1d">1 j / 1 d</option>}</select></Field>59 <div className="col-span-2"><Field label={t('rjSymbols')} hint={t('rjSymbolsHint')}><input data-testid="rj-symbols" className={inputCls} value={f.symbols} onChange={(e) => setF({ ...f, symbols: e.target.value })} /></Field></div></div>}60 <Field label={t('rjCapital')} hint={t('rjCapitalHint')}><div className="flex items-center gap-2"><input className={inputCls} inputMode="decimal" value={f.capital} onChange={(e) => setF({ ...f, capital: e.target.value.replace(/[^0-9.]/g, '') })} /><span className="w-14 shrink-0 text-[13px] text-mute">USDC</span></div></Field>61 <div><div className="mb-2 text-[13px] font-semibold text-mute">{t('rjBudgets')}</div>62 <div className="grid grid-cols-2 gap-3">{n('minutes', t('rjBudgetTime'), 'min', 1, 1440)}{n('maxTrials', t('rjBudgetTrials'), t('rjTrialsUnit'), 1, 5000)}{n('tokensM', t('rjBudgetTokens'), 'M', 0.01, 500)}{n('usd', t('rjBudgetUsd'), 'USD', 0.05, 1000)}</div>63 <p className="mt-2 text-[12.5px] text-mute">{t('rjBudgetsNote')}</p></div>64 <Notice tone="info">{t('rjFormNote')}</Notice>65 {err && <Notice tone="danger">{err}</Notice>}66 <Btn full busy={busy} data-testid="rj-create" disabled={f.mission.trim().length < 10 || (!agent && universe.length === 0)} onClick={() => void create()}><Icon n="key" className="h-4 w-4" />{t('rjCreate')}</Btn>67 </div>68 );69}7071/** The job's log, live while it runs (Server-Sent Events through the owner session), read once when it is finished. */72function JobLog({ id, live, onState }: { id: string; live: boolean; onState: () => void }) {73 const t = useT();74 const [lines, setLines] = useState<LogLine[]>([]), box = useRef<HTMLPreElement>(null), seen = useRef(0);75 useEffect(() => {76 let closed = false, es: EventSource | null = null;77 const add = (l: LogLine[]) => { const fresh = l.filter((x) => x.id > seen.current); if (!fresh.length) return; seen.current = fresh.at(-1)!.id; setLines((p) => [...p, ...fresh].slice(-1500)); };78 const load = () => get<{ lines: LogLine[] }>(`/research/jobs/${id}/log?after=${seen.current}&limit=1000`).then((r) => { if (!closed) add(r.lines); }).catch(() => {});79 void load().then(() => {80 if (closed || !live || typeof EventSource === 'undefined') return;81 es = new EventSource(`/api/v1/research/jobs/${id}/log/stream?after=${seen.current}`);82 es.addEventListener('log', (e) => { try { add([JSON.parse((e as MessageEvent).data) as LogLine]); } catch { /* not a line */ } });83 es.addEventListener('state', () => onState());84 es.addEventListener('end', () => { es?.close(); onState(); });85 });86 return () => { closed = true; es?.close(); };87 }, [id, live]); // eslint-disable-line react-hooks/exhaustive-deps88 useEffect(() => { const b = box.current; if (b) b.scrollTop = b.scrollHeight; }, [lines.length]);89 return <pre ref={box} data-testid="rj-log" aria-label={t('rjLog')} className="max-h-72 overflow-auto whitespace-pre-wrap break-words rounded-2xl bg-elev/70 p-3 font-mono text-[11.5px] leading-[1.5]">90 {lines.length === 0 ? <span className="text-mute">{t('rjLogEmpty')}</span> : lines.map((l) => <span key={l.id} className={l.s === 'err' ? 'text-danger' : l.s === 'sys' || l.s === 'api' ? 'text-mute' : ''}>{l.s === 'api' ? '[api] ' : l.s === 'sys' ? '[runner] ' : ''}{l.t}{'\n'}</span>)}</pre>;91}9293/** One job: state, budgets used, log, and — for a submission — the agent's statements next to the server's measurements. */94export function ResearchJob({ id, nav }: { id: string; nav: Nav }) {95 const t = useT(); const { locale } = useContext(I18n); const qc = useQueryClient();96 const q = useQuery({ queryKey: ['research-job', id], queryFn: () => get<Detail>(`/research/jobs/${id}`), refetchInterval: (x) => (x.state.data && FINAL.includes(x.state.data.state) ? false : 4000) });97 const agents = useQuery({ queryKey: ['agents'], queryFn: () => get<{ agents: { id: string; name: string; universe: string[]; interval: string; kind?: string }[] }>('/agents'), enabled: !!q.data && q.data.state === 'submitted' && !q.data.agentId });98 const [busy, setBusy] = useState(''), [err, setErr] = useState(''), [attached, setAttached] = useState('');99 if (q.isLoading || !q.data) return <Skeleton h="h-40" />;100 const j = q.data, s = j.server, sub = j.submission, u = j.usage, live = !FINAL.includes(j.state);101 const refresh = () => { void qc.invalidateQueries({ queryKey: ['research-job', id] }); void qc.invalidateQueries({ queryKey: ['research-jobs'] }); if (j.agentId) void qc.invalidateQueries({ queryKey: ['agent', j.agentId] }); };102 const act = async (name: string, fn: () => Promise<unknown>) => { setBusy(name); setErr(''); try { await fn(); refresh(); } catch (e) { setErr(errText(t, e)); } finally { setBusy(''); } };103 const fits = (agents.data?.agents ?? []).filter((a) => (a.kind ?? 'spot') === j.kind && a.interval === j.interval && [...a.universe].sort().join() === [...j.universe].sort().join());104 const row = (label: string, value: string, sub2?: string) => <div className="rounded-xl bg-surface p-2 text-center"><div className="text-[12px] text-mute">{label}</div><div className="text-[14px] font-semibold">{value}</div>{sub2 && <div className="text-[11px] text-mute">{sub2}</div>}</div>;105 return (106 <div className="space-y-3" data-testid="rj-job" data-job={j.id}>107 <div className="flex items-start justify-between gap-3"><p className="min-w-0 flex-1 whitespace-pre-wrap text-[14px]">{j.mission}</p><JobState state={j.state} /></div>108 <div className="text-[12.5px] text-mute">{j.kind === 'perp' ? t('rjKindPerp') : t('rjKindSpot')} · {j.universe.join(' · ')} · {j.interval} · {when(j.createdAt, locale)}{j.agent ? ` · ${j.agent.name}` : ` · ${t('rjLibraryDraft')}`}</div>109 {j.reason && <Notice tone={j.state === 'cancelled' ? 'info' : 'warn'}>{j.reason}</Notice>}110 {j.dataError && <Notice tone="danger">{j.dataError}</Notice>}111 {j.holdout && <p className="text-[12.5px] text-mute" data-testid="rj-holdout">{t('rjHoldout', { dev: j.holdout.devBars, test: j.holdout.testBars, from: when(j.holdout.devEnd, locale) })}</p>}112 <div className="grid grid-cols-2 gap-2 text-[12.5px] sm:grid-cols-4" data-testid="rj-usage">113 {row(t('rjBudgetTime'), u ? dur(u.wallMs) : '—', `/ ${dur(j.budgets.wallSeconds * 1000)}`)}{row(t('rjBudgetTrials'), u ? String(u.trials) : '—', `/ ${j.budgets.maxTrials}`)}114 {row(t('rjBudgetTokens'), u ? `${(u.modelTokens / 1e6).toFixed(2)} M` : '—', `/ ${(j.budgets.maxModelTokens / 1e6).toFixed(2)} M`)}{row(t('rjBudgetUsd'), u?.modelUsd != null ? u.modelUsd.toFixed(2) : '—', `/ ${j.budgets.maxModelUsd}`)}</div>115 {u && <p className="text-[12px] text-mute" data-testid="rj-isolation">{u.sandbox === 'sandbox-exec' ? t('rjSandboxOn', { agent: u.agent }) : t('rjSandboxOff', { agent: u.agent })}{u.ledgerTampered ? ` ${t('rjTampered')}` : ''}</p>}116 {u && u.sandbox !== 'sandbox-exec' && <Notice tone="warn">{t('rjSandboxOffNote')}</Notice>}117 <JobLog id={j.id} live={live} onState={refresh} />118 {live && j.state !== 'evaluating' && <Btn kind="ghost" full busy={busy === 'cancel'} disabled={j.cancelRequested} data-testid="rj-cancel" onClick={() => void act('cancel', () => post(`/research/jobs/${j.id}/cancel`))}><Icon n="stop" className="h-4 w-4" />{j.cancelRequested ? t('rjCancelling') : t('rjCancel')}</Btn>}119 {err && <Notice tone="danger">{err}</Notice>}120121 {sub && <div className="space-y-3 rounded-2xl border border-line p-3.5" data-testid="rj-claims">122 <div className="flex items-center gap-2"><span className="rounded-full bg-warn/12 px-2 py-0.5 text-[11.5px] font-semibold text-warn">{t('rjClaimedTag')}</span><span className="text-[15px] font-semibold">{t('rjClaimedTitle')}</span></div>123 <p className="text-[12.5px] text-mute">{t('rjClaimedNote')}</p>124 <pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-xl bg-elev/70 p-3 text-[12.5px] leading-[1.5]" data-testid="rj-readme">{sub.files['README.md']}</pre>125 {sub.claims?.summary && <blockquote className="border-l-2 border-warn/60 pl-3 text-[13px] italic" data-testid="rj-summary">{sub.claims.summary}</blockquote>}126 {sub.claimsProblem && <p className="text-[12.5px] text-warn">{sub.claimsProblem}</p>}127 {sub.files['NOTES.md'] && <details><summary className="cursor-pointer text-[13px] font-medium">{t('rjNotes')}</summary><pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-xl bg-elev/70 p-3 text-[12px]">{sub.files['NOTES.md']}</pre></details>}128 <details data-testid="rj-code"><summary className="cursor-pointer text-[13px] font-medium">{t('rjCode')}</summary>129 <div className="mt-2 break-all font-mono text-[11px] text-mute">sha256 {sub.sha256}</div>130 <pre className="mt-2 max-h-80 overflow-auto rounded-xl bg-elev/70 p-3 font-mono text-[11.5px] leading-[1.45]">{sub.files['strategy.py']}</pre></details>131 </div>}132133 {s && s.status === 'failed' && <Notice tone="danger"><span data-testid="rj-refused">{t('rjRefused')} {s.reason}</span></Notice>}134 {s && s.status === 'ok' && <div className="space-y-3 rounded-2xl border border-emerald/40 p-3.5" data-testid="rj-measured">135 <div className="flex items-center gap-2"><span className="rounded-full bg-emerald/10 px-2 py-0.5 text-[11.5px] font-semibold text-emerald">{t('rjMeasuredTag')}</span><span className="text-[15px] font-semibold">{t('rjMeasuredTitle')}</span></div>136 <p className="text-[12.5px] text-mute">{t('rjMeasuredNote', { from: s.holdout ? when(s.holdout.testStart, locale) : '?', to: s.holdout ? when(s.holdout.testEnd, locale) : '?', bars: s.holdout?.bars ?? '?' })}</p>137 <div className="grid grid-cols-3 gap-2">{row(t('agRet'), pct(s.metrics?.test.total_return), `${t('agVs')} ${pct(s.benchmark?.total_return)}`)}138 {row('Sharpe', num(s.metrics?.test.sharpe), s.sharpeCi ? `[${num(s.sharpeCi[0])} ; ${num(s.sharpeCi[1])}]` : undefined)}{row(t('agDD'), pct(s.metrics?.test.max_drawdown), `${t('agVs')} ${pct(s.benchmark?.max_drawdown)}`)}</div>139 <div className="grid grid-cols-2 gap-2">{row(t('rjTrials'), String(s.trials?.total ?? '—'), t('rjTrialsSplit', { job: s.trials?.job ?? '?', server: s.trials?.server ?? '?' }))}140 {row(t('rjDsr'), num(s.deflatedSharpe, 3), typeof s.deflatedSharpe === 'number' && s.deflatedSharpe < 0.95 ? t('rjDsrLow') : t('rjDsrOk'))}</div>141 <p className="text-[12.5px] text-mute">{t('rjSplits', { train: pct(s.metrics?.train.total_return), val: pct(s.metrics?.validation.total_return), test: pct(s.metrics?.test.total_return) })} {s.sharpeCi && (s.sharpeCi[0] ?? 0) <= 0 && (s.sharpeCi[1] ?? 0) >= 0 ? t('rjCiZero') : ''}</p>142 <p className="text-[12px] text-mute">{t('rjChecks')}: {Object.entries(s.checks ?? {}).filter(([, v]) => v === 'pass' || v === 'fail').map(([k, v]) => `${k} ${v === 'pass' ? '✓' : '✗'}`).join(' · ')} · {s.sandbox === 'sandbox-exec' ? t('rjServerSandbox') : t('rjServerNoSandbox')}</p>143 <div data-testid="rj-compare"><div className="mb-1.5 text-[13px] font-semibold">{t('rjCompareTitle')}</div>144 {s.claimsDiffer ? <Notice tone="warn"><span data-testid="rj-differs">{t('rjDiffers')}</span></Notice> : <p className="text-[12.5px] text-mute" data-testid="rj-agrees">{t('rjAgrees')}</p>}145 <table className="mt-2 w-full text-[12.5px]"><thead><tr className="text-left text-mute"><th className="py-1 font-medium">{t('rjColSplit')}</th><th className="font-medium">{t('rjColAgent')}</th><th className="font-medium">{t('rjColServer')}</th></tr></thead>146 <tbody>{(s.comparison ?? []).filter((c) => c.metric !== 'trades').map((c) => <tr key={c.split + c.metric} className={`border-t border-line ${c.differs ? 'text-danger' : ''}`} data-differs={c.differs}>147 <td className="py-1">{t(`rjSplit_${c.split}` as Key)} · {c.metric === 'total_return' ? t('agRet') : c.metric === 'sharpe' ? 'Sharpe' : t('agDD')}</td><td>{fmt(c.metric, c.claimed)}</td><td className="font-semibold">{fmt(c.metric, c.measured)}{c.differs ? ' ≠' : ''}</td></tr>)}</tbody></table>148 {sub?.claims?.trials !== undefined && <p className="mt-1.5 text-[12px] text-mute">{t('rjTrialsClaim', { claimed: sub.claims.trials, counted: s.trials?.job ?? '?' })}</p>}</div>149 </div>}150151 {j.state === 'submitted' && s?.status === 'ok' && j.agent && j.version && <div className="space-y-2.5" data-testid="rj-actions">152 {j.version.status === 'draft' && <><Notice tone="info">{t('rjApproveNote')}</Notice>153 <Btn full busy={busy === 'approve'} data-testid="rj-approve" onClick={() => void act('approve', () => post(`/agents/${j.agent!.id}/strategies/${j.version!.id}/approve`))}><Icon n="check" className="h-4 w-4" />{t('rjApprove')}</Btn></>}154 {j.version.status === 'approved' && <><Notice tone="ok"><span data-testid="rj-approved">{t('rjApproved')}</span></Notice>155 {j.agent.state !== 'PAPER_TRADING' && j.agent.state !== 'LIVE' && <Btn full kind="soft" busy={busy === 'paper'} data-testid="rj-paper" onClick={() => void act('paper', () => post(`/agents/${j.agent!.id}/mode`, { mode: 'PAPER' }))}>{t('rjStartPaper')}</Btn>}156 {j.agent.state === 'PAPER_TRADING' && <p className="text-[13px] text-mute" data-testid="rj-paper-on">{t('rjPaperOn')}</p>}</>}157 <Btn full kind="ghost" onClick={() => nav({ tab: 'agent', asset: j.agent!.id })}>{t('rjOpenAgent', { name: j.agent.name })}</Btn></div>}158 {j.state === 'submitted' && s?.status === 'ok' && !j.agentId && <div className="space-y-2" data-testid="rj-attach">159 <div className="text-[13px] font-semibold">{t('rjAttachTitle')}</div>160 {attached ? <Notice tone="ok">{t('rjAttached')}</Notice> : fits.length === 0 ? <p className="text-[12.5px] text-mute">{t('rjAttachNone', { assets: j.universe.join(', '), interval: j.interval })}</p> :161 fits.map((a) => <Btn key={a.id} kind="soft" full busy={busy === `at${a.id}`} onClick={() => void act(`at${a.id}`, async () => { await post(`/agents/${a.id}/strategies`, { path: j.strategy }); setAttached(a.id); })}>{t('rjAttachTo', { name: a.name })}</Btn>)}</div>}162 </div>163 );164}165166function JobList({ jobs, open, setOpen, nav }: { jobs: JobV[]; open: string; setOpen: (id: string) => void; nav: Nav }) {167 const t = useT(); const { locale } = useContext(I18n);168 if (jobs.length === 0) return <p className="py-2 text-[14px] text-mute" data-testid="rj-none">{t('rjNone')}</p>;169 return <ul className="space-y-2.5" data-testid="rj-list">{jobs.map((j) => <li key={j.id} className="rounded-2xl bg-elev/60 p-3.5" data-job={j.id}>170 <button className="flex w-full items-center gap-3 text-left" aria-expanded={open === j.id} onClick={() => setOpen(open === j.id ? '' : j.id)}>171 <span className="min-w-0 flex-1"><span className="block truncate text-[14.5px] font-medium">{j.mission}</span>172 <span className="block truncate text-[12px] text-mute">{j.universe.join(' · ')} · {j.interval} · {when(j.createdAt, locale)}{j.serverStatus === 'ok' ? ` · ${t('rjListMeasured', { trials: j.trialsTotal ?? '?', dsr: typeof j.deflatedSharpe === 'number' ? j.deflatedSharpe.toFixed(2) : '—' })}` : ''}{j.claimsDiffer ? ` · ${t('rjListDiffers')}` : ''}</span></span>173 <JobState state={j.state} /><Icon n="chev" className={`h-4 w-4 shrink-0 text-mute transition ${open === j.id ? 'rotate-90' : ''}`} /></button>174 {open === j.id && <div className="mt-3 border-t border-line pt-3"><ResearchJob id={j.id} nav={nav} /></div>}</li>)}</ul>;175}176177/** On an agent's page: start a research job for exactly what this agent trades, and follow its jobs. */178export function ResearchPanel({ agent, nav }: { agent: { id: string; kind?: string; universe: string[]; interval: string; state: string }; nav: Nav }) {179 const t = useT(); const qc = useQueryClient();180 const q = useQuery({ queryKey: ['research-jobs', agent.id], queryFn: () => get<{ jobs: JobV[] }>(`/research/jobs?agentId=${agent.id}`), refetchInterval: 10_000 });181 const st = useQuery({ queryKey: ['research-status'], queryFn: () => get<{ runnerConfigured: boolean }>('/research/status'), staleTime: 60_000 });182 const [form, setForm] = useState(false), [open, setOpen] = useState('');183 return (184 <Card><div data-testid="rj-panel"><H right={<button className="text-[13px] font-semibold text-blue" data-testid="rj-new" onClick={() => setForm(!form)}>{form ? t('cancel') : t('rjNew')}</button>}>{t('rjTitle')}</H>185 <p className="mb-3 text-[13px] text-mute">{t('rjPanelIntro')}</p>186 {st.data && !st.data.runnerConfigured && <div className="mb-3"><Notice tone="warn">{t('rjNoRunner')}</Notice></div>}187 {form && <div className="mb-4"><ResearchForm agent={agent} onCreated={(id) => { setForm(false); setOpen(id); void qc.invalidateQueries({ queryKey: ['research-jobs', agent.id] }); }} /></div>}188 {q.isLoading ? <Skeleton h="h-16" /> : <JobList jobs={q.data?.jobs ?? []} open={open} setOpen={setOpen} nav={nav} />}189 <button className="mt-3 text-[13px] font-semibold text-blue" onClick={() => nav({ tab: 'research' })}>{t('rjAll')}</button></div>190 </Card>191 );192}193194/** The "Recherche" view: every job, and jobs that are not tied to an agent (library drafts). */195export function Research({ nav, job }: { nav: Nav; job?: string }) {196 const t = useT(); const { locale } = useContext(I18n); const qc = useQueryClient();197 const q = useQuery({ queryKey: ['research-jobs'], queryFn: () => get<{ jobs: JobV[] }>('/research/jobs'), refetchInterval: 10_000 });198 const st = useQuery({ queryKey: ['research-status'], queryFn: () => get<{ runnerConfigured: boolean; lastRunnerContact: string | null; openJobs: number; maxOpenJobs: number; labCommit: string | null }>('/research/status'), refetchInterval: 30_000 });199 const [form, setForm] = useState(false), [open, setOpen] = useState(job ?? '');200 return (201 <div className="space-y-4" data-testid="rj-screen">202 <div className="flex items-center gap-2 pt-1"><button onClick={() => nav({ tab: 'agents' })} aria-label={t('back')} className="-ml-2 grid h-11 w-11 place-items-center rounded-full"><Icon n="chev" className="h-6 w-6 rotate-180" /></button>203 <h1 className="min-w-0 flex-1 text-[28px] font-semibold tracking-[-0.02em]">{t('rjScreen')}</h1>204 <Btn kind="soft" data-testid="rj-new" onClick={() => setForm(!form)}><Icon n={form ? 'x' : 'key'} className="h-4 w-4" />{form ? t('cancel') : t('rjNew')}</Btn></div>205 <Notice tone="info">{t('rjIntro')}</Notice>206 {st.data && <p className="text-[12.5px] text-mute" data-testid="rj-status">{st.data.runnerConfigured ? (st.data.lastRunnerContact ? t('rjRunnerSeen', { when: when(st.data.lastRunnerContact, locale) }) : t('rjRunnerNever')) : t('rjNoRunner')} · {t('rjOpenJobs', { n: st.data.openJobs, max: st.data.maxOpenJobs })}</p>}207 {form && <Card><H>{t('rjNewTitle')}</H><ResearchForm onCreated={(id) => { setForm(false); setOpen(id); void qc.invalidateQueries({ queryKey: ['research-jobs'] }); }} /></Card>}208 <Card><H>{t('rjJobs')}</H>{q.isLoading ? <Skeleton h="h-24" /> : <JobList jobs={q.data?.jobs ?? []} open={open} setOpen={setOpen} nav={nav} />}</Card>209 <Card><H>{t('rjHowTitle')}</H><ul className="list-disc space-y-1.5 pl-5 text-[13px] text-mute">{(['rjHow1', 'rjHow2', 'rjHow3', 'rjHow4', 'rjHow5'] as const).map((k) => <li key={k}>{t(k)}</li>)}</ul></Card>210 </div>211 );212}213