34f3c2888f
Nine screens against orchestra-ui-spec.md and the accepted mockups, on the ethos design system: signal violet #8F7AE5, the routing fork motif, the 64px rail and 56px top bar, mono for every machine value and sans for every human one. Each screen was built by its own agent against a fixed foundation, so the shell, tokens and primitives have one author and the screens cannot drift into nine dialects. Real data only. Where no endpoint exists the screen says which one it needs instead of inventing a value. That is most of what was learned here: Steer / Correct is disabled because nothing records a human decision from the web, take-control is disabled because nothing forwards keystrokes to a pane, context occupancy is missing from three screens, and projects can show no repo, remote, quality gate or verification policy because those live only in config.jsonc. Three bugs the render caught that no computed value would have: The previous stylesheet fought every shared class name and leaked properties the new rules never mention, which is how position:fixed survived on .topbar. It is now scoped under .legacy and applies only to the login route, which also stops its green accent and its backdrop-filter from reaching the console. Go marshals a zero time.Time as "0001-01-01T00:00:00Z" and omitempty does not omit a struct, so absent timestamps arrived populated-looking and rendered as "739855d ago". Stripped once at the API boundary every screen reads through, with a test. Long machine ids overflowed their cards and painted under the next one. Verified by rendering: chromium screenshots of the dashboard, tasks, task detail, terminal, workers and review at 1440px, and the dashboard at 390px. Geist is still not on disk, so both stacks fall back to the system faces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
455 lines
17 KiB
TypeScript
455 lines
17 KiB
TypeScript
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<string>()
|
|
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 (
|
|
<main className="page">
|
|
<div className="page-head">
|
|
<h1>Task</h1>
|
|
</div>
|
|
<Panel>
|
|
<div className="panel-body">
|
|
<p className="gap-note">{String((query.error as Error).message)}</p>
|
|
</div>
|
|
</Panel>
|
|
</main>
|
|
)
|
|
}
|
|
if (!query.data) {
|
|
return (
|
|
<main className="page">
|
|
<p className="mono">reading /v1/ui/tasks/{id}…</p>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<main className="page">
|
|
{/* ── top area ──────────────────────────────────────────────────── */}
|
|
<header className="td-top">
|
|
<div className="page-head">
|
|
<h1>{task.title || task.external_id || 'untitled task'}</h1>
|
|
<Chip tone="accent">{task.work_phase || 'frame'} phase</Chip>
|
|
<Chip tone={stateTone(task)}>{task.state}</Chip>
|
|
</div>
|
|
<div className="td-actions">
|
|
<ActionsMenu actions={detail.actions} pending={act.isPending} run={act.mutate} />
|
|
<button className="btn" data-variant="primary" disabled title="no endpoint yet">
|
|
<Icon name="decision" size={16} />
|
|
Steer / Correct
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="td-ids">
|
|
<span className="label">task id</span>
|
|
<M>{task.id}</M>
|
|
<span className="label">epoch</span>
|
|
<M>{task.lease?.epoch || task.last_lease_epoch || '—'}</M>
|
|
<span className="label">project</span>
|
|
<M>{task.project}</M>
|
|
<span className="label">version</span>
|
|
<M>{task.version}</M>
|
|
</div>
|
|
|
|
{act.isError && <p className="gap-note">{String((act.error as Error).message)}</p>}
|
|
|
|
<div className="td-columns">
|
|
{/* ── what we're doing ────────────────────────────────────────── */}
|
|
<Panel title="What we're doing">
|
|
<div className="panel-body td-doing">
|
|
<h3 className="label">goal</h3>
|
|
<p className="td-prose">{task.description || task.title || 'No goal recorded.'}</p>
|
|
|
|
<h3 className="label">
|
|
acceptance <M>{acceptance.length}</M>
|
|
</h3>
|
|
{acceptance.length === 0 ? (
|
|
<p className="td-muted">No acceptance criteria on the contract.</p>
|
|
) : (
|
|
<>
|
|
<ul className="td-list">
|
|
{shown.map((c) => (
|
|
<li key={c}>{c}</li>
|
|
))}
|
|
</ul>
|
|
{acceptance.length > shown.length && (
|
|
<details className="td-more">
|
|
<summary>
|
|
<M>+{acceptance.length - shown.length}</M> more criteria
|
|
</summary>
|
|
<ul className="td-list">
|
|
{acceptance.slice(shown.length).map((c) => (
|
|
<li key={c}>{c}</li>
|
|
))}
|
|
</ul>
|
|
</details>
|
|
)}
|
|
</>
|
|
)}
|
|
{task.quality_gate && (
|
|
<p className="td-muted">
|
|
<span className="label">quality gate</span> <M>{task.quality_gate}</M>
|
|
</p>
|
|
)}
|
|
|
|
{effective.length > 0 && (
|
|
<>
|
|
<h3 className="label">effective human decisions</h3>
|
|
<ul className="td-decisions">
|
|
{effective.map((d) => (
|
|
<li key={d.id}>
|
|
<span className="td-kind">{d.kind}</span>
|
|
<span className="td-subject">{d.subject}</span>
|
|
<span className="td-value">{d.value}</span>
|
|
<M>{time(d.at)}</M>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
|
|
{constraints.length > 0 && (
|
|
<>
|
|
<h3 className="label">active constraints</h3>
|
|
<ul className="td-decisions">
|
|
{constraints.map((d) => (
|
|
<li key={d.id}>
|
|
<span className="td-kind">constraint</span>
|
|
<span className="td-subject">{d.subject}</span>
|
|
<span className="td-value">{d.value}</span>
|
|
<M>{time(d.at)}</M>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
|
|
{superseded.length > 0 && (
|
|
<details className="td-more td-superseded">
|
|
<summary>
|
|
<M>{superseded.length}</M> superseded {superseded.length === 1 ? 'decision' : 'decisions'}
|
|
</summary>
|
|
<ul className="td-decisions">
|
|
{superseded.map((d) => (
|
|
<li key={d.id}>
|
|
<span className="td-kind">{d.kind}</span>
|
|
<span className="td-subject">{d.subject}</span>
|
|
<span className="td-value">{d.value}</span>
|
|
<M>{time(d.at)}</M>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</details>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
|
|
{/* ── live execution ──────────────────────────────────────────── */}
|
|
<Panel
|
|
title="Live execution"
|
|
trailing={
|
|
session?.agent_status ? (
|
|
<Chip tone={session.agent_status === 'blocked' ? 'fault' : 'accent'}>
|
|
{session.agent_status}
|
|
</Chip>
|
|
) : (
|
|
<span className="mono">no session</span>
|
|
)
|
|
}
|
|
>
|
|
<div className="panel-body">
|
|
<div className="td-exec">
|
|
<div className="stat">
|
|
<span className="label">worker / harness</span>
|
|
<M>{session?.harness_id || task.lease?.harness_id || task.last_harness_id || '—'}</M>
|
|
</div>
|
|
<div className="stat">
|
|
<span className="label">pane</span>
|
|
<M>{session?.pane_id || task.last_pane_id || '—'}</M>
|
|
<span className="td-muted">
|
|
<M>{task.pane_state || 'unknown'}</M>
|
|
</span>
|
|
</div>
|
|
<div className="stat">
|
|
<span className="label">lease remaining</span>
|
|
<span className="stat-value">{remaining(lease) ?? '—'}</span>
|
|
<span className="td-muted">
|
|
until <M>{time(lease)}</M>
|
|
</span>
|
|
</div>
|
|
<div className="stat">
|
|
<span className="label">last progress</span>
|
|
<span className="stat-value">{ago(capture?.at)}</span>
|
|
<span className="td-muted">
|
|
capture rev <M>{capture?.revision ?? '—'}</M>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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. */}
|
|
<EndpointGap
|
|
path="/v1/ui/tasks/:id (no context accounting on session)"
|
|
what="Context occupancy — tokens used against the harness window — has no field in the read model, so no meter is drawn."
|
|
/>
|
|
|
|
<h3 className="label td-claim-head">
|
|
current activity
|
|
<span className="td-claim-tag">
|
|
agent-supplied · pane capture{capture?.source ? ` · ${capture.source}` : ''}
|
|
</span>
|
|
</h3>
|
|
{activity ? (
|
|
<p className="td-claim">
|
|
<M>{activity}</M>
|
|
<span className="td-claim-at">
|
|
<M>{time(capture?.at)}</M>
|
|
</span>
|
|
</p>
|
|
) : (
|
|
<p className="td-muted">No capture from the owning worker.</p>
|
|
)}
|
|
|
|
{approval && (
|
|
<div className="td-approval">
|
|
<span className="label">pending approval</span>
|
|
<p className="td-prose">{approval.summary}</p>
|
|
{approval.command && <M>{approval.command}</M>}
|
|
<div className="td-approval-row">
|
|
<span className="mono">
|
|
{approval.pane_id} · rev {approval.capture_revision}
|
|
</span>
|
|
<button
|
|
className="btn"
|
|
data-variant="primary"
|
|
disabled={act.isPending}
|
|
onClick={() => act.mutate('grant_approval')}
|
|
>
|
|
Grant
|
|
</button>
|
|
<button className="btn" disabled={act.isPending} onClick={() => act.mutate('deny_approval')}>
|
|
Deny
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="td-links">
|
|
<Link className="btn" to={`/tasks/${task.id}/terminal`}>
|
|
<Icon name="terminal" size={16} />
|
|
Open live pane
|
|
</Link>
|
|
{task.handoff_ref && (
|
|
<a className="btn" href={`/v1/ui/artifacts/${task.handoff_ref}`}>
|
|
<Icon name="file" size={16} />
|
|
Handoff
|
|
</a>
|
|
)}
|
|
{detail.report_ref && (
|
|
<a className="btn" href={`/v1/ui/artifacts/${detail.report_ref}`}>
|
|
<Icon name="file" size={16} />
|
|
Report
|
|
</a>
|
|
)}
|
|
</div>
|
|
<EndpointGap
|
|
path="/v1/ui/tasks/:id/{logs,launch_context,diff}"
|
|
what="Worker logs, launch context and git diff have no endpoint, so those links are not offered."
|
|
/>
|
|
</div>
|
|
</Panel>
|
|
</div>
|
|
|
|
{/* ── workflow ──────────────────────────────────────────────────── */}
|
|
<Panel title="Workflow" trailing={<span className="mono">ace-fca</span>}>
|
|
<div className="panel-body td-flow">
|
|
<PhasePath current={task.work_phase} />
|
|
{entries.length > 0 ? (
|
|
<div className="td-entries">
|
|
{entries.map((e) => (
|
|
<span key={`${e.phase}-${e.at}`}>
|
|
<span className="td-entry-phase">{e.phase}</span> entered <M>{time(e.at)}</M>
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="td-muted">
|
|
No WorkPhaseChanged event in this task's log — the path shows the current phase only.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
/** 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 (
|
|
<details className="td-menu">
|
|
<summary className="btn">
|
|
Actions <Icon name="chevron-right" size={16} />
|
|
</summary>
|
|
<div className="td-menu-body">
|
|
{actions.map((a) => {
|
|
const blocked = !a.enabled || (a.needs?.length ?? 0) > 0
|
|
return (
|
|
<button
|
|
key={a.id}
|
|
className="row"
|
|
disabled={blocked || pending}
|
|
onClick={() => run(a.id)}
|
|
title={a.needs?.length ? `needs ${a.needs.join(', ')}` : a.reason}
|
|
>
|
|
<span className="row-main">
|
|
<span className="row-title">{a.id}</span>
|
|
<span className="row-sub">
|
|
{a.needs?.length ? `needs ${a.needs.join(', ')}` : a.enabled ? a.reason || '' : a.reason || 'unavailable'}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</details>
|
|
)
|
|
}
|