Compare commits

...

1 Commits

Author SHA1 Message Date
7ad425ffc3 feat(ui): completed-only view + inline verdict + no polling
Two product simplifications per user feedback:

1. TopicList shows ONLY completed debates. Status filter dropdown
   removed — debates in earlier lifecycle states (signup / debating)
   are agent internals; human readers care about the finished record.
   API call hardcodes status=completed. Page title is now 'completed
   debates' + 'archive' eyebrow.

2. TopicDetail merges verdict inline. Loads topic + arguments +
   verdict concurrently on mount, renders transcript followed by
   structured verdict + judge rationale on the same page. Drops the
   8-second polling loop (completed debates don't change).

The /topics/:id/verdict route is kept as a shareable permalink for
the verdict alone, but the in-page link to it from the detail view
is gone since the same content is already there.

Build delta: 179 → 178 KB / gzip 57.70 → 57.40 (-300 bytes; one
less route imported on the critical path).
2026-05-24 03:36:38 +01:00
2 changed files with 81 additions and 115 deletions

View File

@@ -1,56 +1,46 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { getTopic, listArguments } from '../api'; import { getTopic, getVerdict, listArguments } from '../api';
import type { Argument, TopicDetail } from '../types'; import type { Argument, TopicDetail, Verdict } from '../types';
import { fmtTime, fmtRelative } from '../util'; import { fmtTime } from '../util';
// Polling interval for the live transcript. 8s strikes a balance — fast // v0.3.1: completed-only view. Loads topic + arguments + verdict
// enough that a new argument appears within an attentive viewer's // concurrently on mount. No polling — completed debates don't change.
// attention span, slow enough that idle tabs don't hammer the backend.
const POLL_MS = 8000;
export function TopicDetailPage() { export function TopicDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [topic, setTopic] = useState<TopicDetail | null>(null); const [topic, setTopic] = useState<TopicDetail | null>(null);
const [args, setArgs] = useState<Argument[]>([]); const [args, setArgs] = useState<Argument[]>([]);
const [verdict, setVerdict] = useState<Verdict | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [lastTick, setLastTick] = useState<number>(0); const [loading, setLoading] = useState(true);
const stopped = useRef(false);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
stopped.current = false;
const ac = new AbortController(); const ac = new AbortController();
setLoading(true);
async function tick() { Promise.all([
try { getTopic(id, ac.signal),
const [t, a] = await Promise.all([ listArguments(id, ac.signal),
getTopic(id!, ac.signal), getVerdict(id, ac.signal),
listArguments(id!, ac.signal), ])
]); .then(([t, a, v]) => {
if (stopped.current) return;
setTopic(t); setTopic(t);
setArgs(a.arguments ?? []); setArgs(a.arguments);
setLastTick(Date.now()); setVerdict(v);
setErr(null); setErr(null);
} catch (e) { })
const err = e as Error; .catch((e: Error) => {
if (err.name !== 'AbortError') setErr(err.message); if (e.name !== 'AbortError') setErr(e.message);
} })
} .finally(() => setLoading(false));
return () => ac.abort();
tick();
const h = setInterval(tick, POLL_MS);
return () => {
stopped.current = true;
ac.abort();
clearInterval(h);
};
}, [id]); }, [id]);
if (!id) return <div className="err">missing topic id in url</div>; if (!id) return <div className="err">missing topic id in url</div>;
if (loading) return <div className="muted">loading</div>;
if (err && !topic) return <div className="err">{err}</div>; if (err && !topic) return <div className="err">{err}</div>;
if (!topic) return <div className="muted">loading</div>; if (!topic) return <div className="err">topic not found</div>;
const proCamp = topic.camps?.find((c) => c.camp === 'pro'); const proCamp = topic.camps?.find((c) => c.camp === 'pro');
const conCamp = topic.camps?.find((c) => c.camp === 'con'); const conCamp = topic.camps?.find((c) => c.camp === 'con');
@@ -60,7 +50,7 @@ export function TopicDetailPage() {
<div className="td"> <div className="td">
<div> <div>
<span className="eyebrow"> <span className="eyebrow">
topic · <span className={`pill pill-${topic.status}`}>{topic.status}</span> debate · <span className={`pill pill-${topic.status}`}>{topic.status}</span>
</span> </span>
<h1>{topic.title}</h1> <h1>{topic.title}</h1>
<p className="td-summary">{topic.summary}</p> <p className="td-summary">{topic.summary}</p>
@@ -69,19 +59,13 @@ export function TopicDetailPage() {
<div className="panel"> <div className="panel">
<div className="panel-header">metadata</div> <div className="panel-header">metadata</div>
<div className="td-meta"> <div className="td-meta">
<div>
<div className="label">visibility</div>
<div className="value">{topic.visibility}</div>
</div>
<div> <div>
<div className="label">verdict schema</div> <div className="label">verdict schema</div>
<div className="value">{topic.verdict_schema_id}</div> <div className="value">{topic.verdict_schema_id}</div>
</div> </div>
<div> <div>
<div className="label">signup window</div> <div className="label">visibility</div>
<div className="value"> <div className="value">{topic.visibility}</div>
{fmtTime(topic.signup_open_at)} {fmtTime(topic.signup_close_at)}
</div>
</div> </div>
<div> <div>
<div className="label">debate window</div> <div className="label">debate window</div>
@@ -93,20 +77,12 @@ export function TopicDetailPage() {
<div className="label">creator</div> <div className="label">creator</div>
<div className="value">{topic.creator_user_id}</div> <div className="value">{topic.creator_user_id}</div>
</div> </div>
{topic.cancelled_reason && (
<div>
<div className="label">cancelled reason</div>
<div className="value err" style={{ padding: '4px 8px' }}>
{topic.cancelled_reason}
</div>
</div>
)}
</div> </div>
</div> </div>
{topic.camps && topic.camps.length > 0 && ( {topic.camps && topic.camps.length > 0 && (
<div className="panel"> <div className="panel">
<div className="panel-header">camps (allocated)</div> <div className="panel-header">camps</div>
<div className="td-camps"> <div className="td-camps">
<div className="td-camp"> <div className="td-camp">
<div className="td-camp-label camp-pro">pro</div> <div className="td-camp-label camp-pro">pro</div>
@@ -131,15 +107,9 @@ export function TopicDetailPage() {
)} )}
<div className="panel"> <div className="panel">
<div className="panel-header row"> <div className="panel-header">transcript ({args.length})</div>
<span>transcript ({args.length})</span>
<span className="spacer" />
<span className="muted">
polling every {POLL_MS / 1000}s · last refresh {fmtRelative(new Date(lastTick).toISOString())}
</span>
</div>
{args.length === 0 ? ( {args.length === 0 ? (
<div className="td-empty">no arguments posted yet</div> <div className="td-empty">no arguments were posted</div>
) : ( ) : (
args.map((a) => ( args.map((a) => (
<div key={a.id} className={`td-arg td-arg-${a.camp}`}> <div key={a.id} className={`td-arg td-arg-${a.camp}`}>
@@ -148,7 +118,7 @@ export function TopicDetailPage() {
<span className="muted">{a.agent_id}</span> <span className="muted">{a.agent_id}</span>
<span className="spacer" /> <span className="spacer" />
<span className="muted" title={fmtTime(a.posted_at)}> <span className="muted" title={fmtTime(a.posted_at)}>
{fmtRelative(a.posted_at)} {fmtTime(a.posted_at)}
</span> </span>
</div> </div>
<div className="td-arg-content">{a.content}</div> <div className="td-arg-content">{a.content}</div>
@@ -157,14 +127,34 @@ export function TopicDetailPage() {
)} )}
</div> </div>
{topic.status === 'completed' && ( {verdict && (
<>
<div className="panel"> <div className="panel">
<div className="panel-header">verdict</div> <div className="panel-header">verdict</div>
<Link to={`/topics/${topic.id}/verdict`}>view verdict + rationale </Link> <pre className="vd-json">{JSON.stringify(verdict.verdict, null, 2)}</pre>
<div className="row muted" style={{ marginTop: 8, fontSize: '0.85em' }}>
<span>judge: {verdict.judge_agent_id}</span>
<span className="spacer" />
<span>produced: {fmtTime(verdict.produced_at)}</span>
{verdict.tokens_input + verdict.tokens_output > 0 && (
<span>
tokens: {verdict.tokens_input} in / {verdict.tokens_output} out
</span>
)}
</div> </div>
</div>
<div className="panel">
<div className="panel-header">rationale</div>
<div className="vd-rationale">{verdict.rationale}</div>
</div>
</>
)} )}
{err && <div className="err">refresh error: {err}</div>} {!verdict && topic.status === 'completed' && (
<div className="panel td-empty">
no verdict was recorded (judge may have failed to submit)
</div>
)}
</div> </div>
); );
} }

View File

@@ -1,20 +1,15 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { listTopics } from '../api'; import { listTopics } from '../api';
import type { Topic, TopicStatus } from '../types'; import type { Topic } from '../types';
import { fmtTime, fmtRelative } from '../util'; import { fmtTime, fmtRelative } from '../util';
const STATUS_OPTIONS: Array<TopicStatus | 'all'> = [ // v0.3.1: this list only shows COMPLETED debates. Topics in earlier
'all', // lifecycle states (signup_open / signup_closed / debating) are agent
'signup_open', // internals — human readers care about the finished record (full
'signup_closed', // transcript + verdict). Status filtering UI removed.
'debating',
'completed',
'cancelled',
];
export function TopicListPage() { export function TopicListPage() {
const [status, setStatus] = useState<TopicStatus | 'all'>('all');
const [topics, setTopics] = useState<Topic[]>([]); const [topics, setTopics] = useState<Topic[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
@@ -23,61 +18,44 @@ export function TopicListPage() {
const ac = new AbortController(); const ac = new AbortController();
setLoading(true); setLoading(true);
setErr(null); setErr(null);
listTopics({ listTopics({ status: 'completed', limit: 100, signal: ac.signal })
status: status === 'all' ? undefined : status,
limit: 100,
signal: ac.signal,
})
.then((r) => setTopics(r.topics)) .then((r) => setTopics(r.topics))
.catch((e: Error) => { .catch((e: Error) => {
if (e.name !== 'AbortError') setErr(e.message); if (e.name !== 'AbortError') setErr(e.message);
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
return () => ac.abort(); return () => ac.abort();
}, [status]); }, []);
return ( return (
<div> <div>
<span className="eyebrow">debates</span> <span className="eyebrow">archive</span>
<h1>topics</h1> <h1>completed debates</h1>
<p className="muted" style={{ marginBottom: 20 }}> <p className="muted" style={{ marginBottom: 20 }}>
Public + visible-to-you topics. Filter by lifecycle status. Click a row for the Each entry is a finished debate click for the full transcript + verdict.
live transcript.
</p> </p>
<div className="tl-filters"> <div className="tl-filters">
<label>
status
<select
value={status}
onChange={(e) => setStatus(e.target.value as TopicStatus | 'all')}
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</label>
<span className="spacer" /> <span className="spacer" />
<span className="muted"> <span className="muted">
{loading ? 'loading…' : `${topics.length} ${topics.length === 1 ? 'topic' : 'topics'}`} {loading
? 'loading…'
: `${topics.length} ${topics.length === 1 ? 'debate' : 'debates'}`}
</span> </span>
</div> </div>
{err && <div className="err">{err}</div>} {err && <div className="err">{err}</div>}
{!err && !loading && topics.length === 0 && ( {!err && !loading && topics.length === 0 && (
<div className="td-empty">no topics match this filter</div> <div className="td-empty">no completed debates yet</div>
)} )}
{topics.length > 0 && ( {topics.length > 0 && (
<table className="tl-table"> <table className="tl-table">
<thead> <thead>
<tr> <tr>
<th style={{ width: '60%' }}>topic</th> <th style={{ width: '60%' }}>debate</th>
<th>status</th> <th>schema</th>
<th>signup close</th>
<th>debate window</th> <th>debate window</th>
</tr> </tr>
</thead> </thead>
@@ -90,14 +68,12 @@ export function TopicListPage() {
</Link> </Link>
<div className="tl-row-summary">{t.summary}</div> <div className="tl-row-summary">{t.summary}</div>
</td> </td>
<td> <td className="tl-row-time">{t.verdict_schema_id}</td>
<span className={`pill pill-${t.status}`}>{t.status}</span> <td
</td> className="tl-row-time"
<td className="tl-row-time" title={fmtTime(t.signup_close_at)}> title={`${fmtTime(t.debate_start_at)}${fmtTime(t.debate_end_at)}`}
{fmtRelative(t.signup_close_at)} >
</td> {fmtRelative(t.debate_end_at)}
<td className="tl-row-time" title={`${fmtTime(t.debate_start_at)}${fmtTime(t.debate_end_at)}`}>
{fmtTime(t.debate_start_at)} {fmtTime(t.debate_end_at)}
</td> </td>
</tr> </tr>
))} ))}