import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link, useParams } from 'react-router-dom' import { api } from '../api/client' import type { Action, Detail, Event, Task } from '../api/types' import { Chip, EndpointGap, M, Panel, PhasePath } from '../components/Primitives' import { Icon } from '../components/Icon' import './TaskDetail.css' /** work_phase is on the wire (domain.Task) but not yet in api/types.ts, which * another screen owns. Read it through a local widening rather than editing a * shared file out from under someone. */ type Phased = Task & { work_phase?: string; last_lease_epoch?: string } const time = (iso?: string) => (iso ? new Date(iso).toISOString().slice(11, 19) : '—') function ago(iso?: string) { if (!iso) return '—' const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000)) if (s < 60) return `${s}s ago` if (s < 3600) return `${Math.floor(s / 60)}m ago` return `${Math.floor(s / 3600)}h ago` } /** ponytail: recomputed on each 5s poll rather than ticking per second. The * operator reads a lease in minutes; a second-accurate countdown would need a * timer for no decision it changes. */ function remaining(iso?: string) { if (!iso) return undefined const s = Math.round((Date.parse(iso) - Date.now()) / 1000) if (s <= 0) return 'expired' return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` } type Decision = { id: string kind: string subject: string value: string at: string standing: boolean } /** The standing authority for this task, reduced from its own log. Mirrors * domain.ReduceIntent: a decision is retired only by being named, never by a * newer decision on the same subject. Superseded records are kept so the * screen can show them muted instead of pretending they never existed. */ function decisionsOf(events: Event[]): Decision[] { const out: Decision[] = [] const retired = new Set() for (const e of events) { const p = (e.payload ?? {}) as { decision_id?: string kind?: string subject?: string value?: string supersedes?: string[] } if (!p.decision_id) continue if (e.type === 'HumanDecisionRecorded') { out.push({ id: p.decision_id, kind: p.kind ?? 'decision', subject: p.subject ?? '', value: p.value ?? '', at: e.at, standing: true, }) p.supersedes?.forEach((id) => retired.add(id)) } else if (e.type === 'HumanDecisionSuperseded') { retired.add(p.decision_id) } } return out.map((d) => ({ ...d, standing: !retired.has(d.id) })) } /** When each phase was entered, from the events Orchestra itself appended. * Verified state, not a claim. */ function phaseEntries(events: Event[]) { return events .filter((e) => e.type === 'WorkPhaseChanged') .map((e) => ({ phase: (e.payload as { phase?: string })?.phase ?? '', at: e.at })) .filter((p) => p.phase) } /** The last thing the pane actually showed. Agent-authored text: it is * rendered as a claim, never as orchestra state. */ function lastLine(text?: string) { const lines = (text ?? '').split('\n').map((l) => l.trimEnd()).filter((l) => l.trim()) return lines[lines.length - 1] } function stateTone(task: Task) { if (task.state === 'blocked' || task.state === 'failed' || task.state === 'needs_attention') { return 'fault' as const } if (task.state === 'completed') return 'done' as const if (task.state === 'leased') return 'accent' as const return undefined } export function TaskDetail() { const { id = '' } = useParams() const client = useQueryClient() const query = useQuery({ queryKey: ['task', id], queryFn: () => api.detail(id), refetchInterval: 5000, }) const act = useMutation({ mutationFn: (action: string) => api.action(id, action), onSuccess: () => client.invalidateQueries({ queryKey: ['task', id] }), }) if (query.isError) { return (

Task

{String((query.error as Error).message)}

) } if (!query.data) { return (

reading /v1/ui/tasks/{id}…

) } const detail: Detail = query.data const task = detail.task as Phased const session = detail.session const capture = session?.capture const approval = session?.pending_approval const decisions = decisionsOf(detail.events) const standing = decisions.filter((d) => d.standing) const constraints = standing.filter((d) => d.kind === 'constraint') const effective = standing.filter((d) => d.kind !== 'constraint') const superseded = decisions.filter((d) => !d.standing) const acceptance = task.acceptance ?? [] const shown = acceptance.slice(0, 4) const lease = session?.lease_until ?? task.lease?.until const activity = lastLine(capture?.text) const entries = phaseEntries(detail.events) return (
{/* ── top area ──────────────────────────────────────────────────── */}

{task.title || task.external_id || 'untitled task'}

{task.work_phase || 'frame'} phase {task.state}
task id {task.id} epoch {task.lease?.epoch || task.last_lease_epoch || '—'} project {task.project} version {task.version}
{act.isError &&

{String((act.error as Error).message)}

}
{/* ── what we're doing ────────────────────────────────────────── */}

goal

{task.description || task.title || 'No goal recorded.'}

acceptance {acceptance.length}

{acceptance.length === 0 ? (

No acceptance criteria on the contract.

) : ( <>
    {shown.map((c) => (
  • {c}
  • ))}
{acceptance.length > shown.length && (
+{acceptance.length - shown.length} more criteria
    {acceptance.slice(shown.length).map((c) => (
  • {c}
  • ))}
)} )} {task.quality_gate && (

quality gate {task.quality_gate}

)} {effective.length > 0 && ( <>

effective human decisions

    {effective.map((d) => (
  • {d.kind} {d.subject} {d.value} {time(d.at)}
  • ))}
)} {constraints.length > 0 && ( <>

active constraints

    {constraints.map((d) => (
  • constraint {d.subject} {d.value} {time(d.at)}
  • ))}
)} {superseded.length > 0 && (
{superseded.length} superseded {superseded.length === 1 ? 'decision' : 'decisions'}
    {superseded.map((d) => (
  • {d.kind} {d.subject} {d.value} {time(d.at)}
  • ))}
)}
{/* ── live execution ──────────────────────────────────────────── */} {session.agent_status} ) : ( no session ) } >
worker / harness {session?.harness_id || task.lease?.harness_id || task.last_harness_id || '—'}
pane {session?.pane_id || task.last_pane_id || '—'} {task.pane_state || 'unknown'}
lease remaining {remaining(lease) ?? '—'} until {time(lease)}
last progress {ago(capture?.at)} capture rev {capture?.revision ?? '—'}
{/* Context occupancy is the one number this panel is supposed to carry and the read model does not produce it. Say so rather than compute a plausible-looking figure. */}

current activity agent-supplied · pane capture{capture?.source ? ` · ${capture.source}` : ''}

{activity ? (

{activity} {time(capture?.at)}

) : (

No capture from the owning worker.

)} {approval && (
pending approval

{approval.summary}

{approval.command && {approval.command}}
{approval.pane_id} · rev {approval.capture_revision}
)}
Open live pane {task.handoff_ref && ( Handoff )} {detail.report_ref && ( Report )}
{/* ── workflow ──────────────────────────────────────────────────── */} ace-fca}>
{entries.length > 0 ? (
{entries.map((e) => ( {e.phase} entered {time(e.at)} ))}
) : (

No WorkPhaseChanged event in this task's log — the path shows the current phase only.

)}
) } /** Operator actions, exactly as the server declares them. An action that * needs input the console cannot collect stays disabled and says what it * wants — offering a button that will 409 is the dishonest option. */ function ActionsMenu({ actions, pending, run, }: { actions: Action[] pending: boolean run: (action: string) => void }) { return (
Actions
{actions.map((a) => { const blocked = !a.enabled || (a.needs?.length ?? 0) > 0 return ( ) })}
) }