Files
orchestra/web/src/screens/Decisions.tsx
T
kami 34f3c2888f Rebuild the operator console on the ethos shell
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
2026-08-29 02:44:43 +04:00

459 lines
17 KiB
TypeScript

import { Fragment, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
import type { Event, Task } from '../api/types'
import { Chip, Empty, EndpointGap, M, Panel } from '../components/Primitives'
import './Decisions.css'
/** The payload of a HumanDecisionRecorded event. There is no decisions
* endpoint, so this screen reduces the log the way domain.ReduceIntent does:
* a decision stands until something names it. */
interface DecisionPayload {
decision_id?: string
kind?: string
subject?: string
value?: string
supersedes?: string[]
source?: { provider?: string; external_id?: string }
}
/** The coordinator serves these on the task; api/types.ts does not model them
* yet, so the shapes this screen needs are narrowed here rather than assumed. */
interface BlockedTask extends Task {
work_phase?: string
decision_request?: { question?: string; why?: string }
}
interface Decision {
id: string
taskId: string
kind: string
subject: string
value: string
supersedes: string[]
provider: string
externalId: string
at: string
eventId: string
seq?: number
}
/** A question the operator has not answered yet. It is not a decision — it
* has no id and no value — but it is the same authority, pending. */
interface Waiting {
taskId: string
question: string
why: string
at: string
}
function payload(event: Event): DecisionPayload {
return (event.payload ?? {}) as DecisionPayload
}
function stamp(at: string) {
return at.replace('T', ' ').slice(0, 19) + ' UTC'
}
function shortId(id: string) {
return id.length > 8 ? id.slice(-6) : id
}
interface Reduced {
decisions: Decision[]
/** decision id -> the decision that retired it, or '' for a standalone
* HumanDecisionSuperseded event with no successor. */
retiredBy: Map<string, string>
events: Map<string, Event[]>
}
function reduce(events: Event[]): Reduced {
const decisions: Decision[] = []
const retiredBy = new Map<string, string>()
const byDecision = new Map<string, Event[]>()
const touch = (id: string, event: Event) => {
const list = byDecision.get(id)
if (list) list.push(event)
else byDecision.set(id, [event])
}
for (const event of events) {
const p = payload(event)
if (!p.decision_id) continue
if (event.type === 'HumanDecisionRecorded') {
const supersedes = p.supersedes ?? []
decisions.push({
id: p.decision_id,
taskId: event.task_id ?? '',
kind: p.kind ?? '',
subject: p.subject ?? '',
value: p.value ?? '',
supersedes,
provider: p.source?.provider ?? '',
externalId: p.source?.external_id ?? '',
at: event.at,
eventId: event.id,
seq: event.seq,
})
touch(p.decision_id, event)
for (const target of supersedes) {
retiredBy.set(target, p.decision_id)
touch(target, event)
}
} else if (event.type === 'HumanDecisionSuperseded') {
if (!retiredBy.has(p.decision_id)) retiredBy.set(p.decision_id, '')
touch(p.decision_id, event)
}
}
decisions.sort((a, b) => b.at.localeCompare(a.at) || b.id.localeCompare(a.id))
return { decisions, retiredBy, events: byDecision }
}
function Select({
label,
value,
options,
onChange,
}: {
label: string
value: string
options: string[]
onChange: (next: string) => void
}) {
return (
<label className="dec-filter">
<span className="label">{label}</span>
<select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">all</option>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
)
}
export function Decisions() {
const events = useQuery({ queryKey: ['events'], queryFn: () => api.events(), refetchInterval: 5000 })
const overview = useQuery({ queryKey: ['overview'], queryFn: api.overview, refetchInterval: 5000 })
const [status, setStatus] = useState('')
const [kind, setKind] = useState('')
const [project, setProject] = useState('')
const [source, setSource] = useState('')
const [query, setQuery] = useState('')
const [open, setOpen] = useState<string>()
const tasks = useMemo(() => {
const map = new Map<string, BlockedTask>()
for (const task of (overview.data?.tasks ?? []) as BlockedTask[]) map.set(task.id, task)
return map
}, [overview.data])
const { decisions, retiredBy, events: trail } = useMemo(
() => reduce(events.data ?? []),
[events.data],
)
const waiting = useMemo<Waiting[]>(
() =>
[...tasks.values()]
.filter((t) => t.block_reason === 'human_decision' && t.decision_request)
.map((t) => ({
taskId: t.id,
question: t.decision_request?.question ?? '',
why: t.decision_request?.why ?? '',
at: t.blocked_at ?? '',
})),
[tasks],
)
const kinds = useMemo(
() => [...new Set(decisions.map((d) => d.kind).filter(Boolean))].sort(),
[decisions],
)
const sources = useMemo(
() => [...new Set(decisions.map((d) => d.provider).filter(Boolean))].sort(),
[decisions],
)
const projects = useMemo(
() => [...new Set([...tasks.values()].map((t) => t.project).filter(Boolean))].sort(),
[tasks],
)
const needle = query.trim().toLowerCase()
const matches = (d: Decision) => {
const task = tasks.get(d.taskId)
const retired = retiredBy.has(d.id)
if (status === 'active' && retired) return false
if (status === 'superseded' && !retired) return false
if (status === 'waiting') return false
if (kind && d.kind !== kind) return false
if (source && d.provider !== source) return false
if (project && task?.project !== project) return false
if (needle) {
const hay = [d.id, d.taskId, d.subject, d.value, task?.title ?? ''].join(' ').toLowerCase()
if (!hay.includes(needle)) return false
}
return true
}
const visible = decisions.filter(matches)
const visibleWaiting = waiting.filter((w) => {
if (status && status !== 'waiting') return false
if (kind || source) return false
if (project && tasks.get(w.taskId)?.project !== project) return false
if (needle) return [w.taskId, w.question, w.why].join(' ').toLowerCase().includes(needle)
return true
})
const active = decisions.length - retiredBy.size
const loading = events.isLoading || overview.isLoading
return (
<main className="page">
<div className="page-head">
<h1>Decisions</h1>
<p>Durable human authority: what the operator decided, and what still stands.</p>
</div>
<div className="grid stats">
<div className="stat">
<span className="label">Recorded</span>
<span className="stat-value">{decisions.length}</span>
</div>
<div className="stat">
<span className="label">Standing</span>
<span className="stat-value" data-accent="true">
{active}
</span>
</div>
<div className="stat">
<span className="label">Superseded</span>
<span className="stat-value">{retiredBy.size}</span>
</div>
<div className="stat">
<span className="label">Waiting on you</span>
<span className="stat-value">{waiting.length}</span>
</div>
</div>
<Panel
title="Decision log"
count={visible.length + visibleWaiting.length}
trailing={
events.isError || overview.isError ? 'read failed' : `reduced from ${(events.data ?? []).length} events`
}
>
<div className="dec-filters">
<input
className="dec-search"
placeholder="Search decisions…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<Select
label="Status"
value={status}
options={['active', 'superseded', 'waiting']}
onChange={setStatus}
/>
<Select label="Kind" value={kind} options={kinds} onChange={setKind} />
<Select label="Project" value={project} options={projects} onChange={setProject} />
<Select label="Source" value={source} options={sources} onChange={setSource} />
</div>
{loading ? (
<div className="panel-body">
<p className="gap-note">reading /v1/events</p>
</div>
) : visible.length + visibleWaiting.length === 0 ? (
<Empty title="No decisions match">
<p>
The log holds <M>{decisions.length}</M> recorded decisions. Clear the filters to see
them.
</p>
</Empty>
) : (
<div className="table-scroll">
<table className="table dec-table">
<thead>
<tr>
<th>ID</th>
<th>Decision</th>
<th>Task</th>
<th>Phase</th>
<th>Provenance</th>
<th>Recorded</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{visibleWaiting.map((w) => (
<tr key={`w-${w.taskId}`} data-status="waiting">
<td className="mono"></td>
<td>
<div className="row-title">{w.question}</div>
<div className="row-sub">{w.why}</div>
</td>
<td>
<Link className="dec-task" to={`/tasks/${w.taskId}`}>
<M>{shortId(w.taskId)}</M>
<span className="row-sub">{tasks.get(w.taskId)?.title ?? ''}</span>
</Link>
</td>
<td className="mono">{tasks.get(w.taskId)?.work_phase ?? '—'}</td>
<td className="mono"></td>
<td className="mono">{w.at ? stamp(w.at) : '—'}</td>
<td>
<Chip tone="warn">waiting</Chip>
</td>
</tr>
))}
{visible.map((d) => {
const retired = retiredBy.has(d.id)
const successor = retiredBy.get(d.id)
const task = tasks.get(d.taskId)
const isOpen = open === d.id
return (
<Fragment key={d.id}>
<tr
data-status={retired ? 'superseded' : 'active'}
data-open={isOpen ? 'true' : undefined}
onClick={() => setOpen(isOpen ? undefined : d.id)}
>
<td className="mono" title={d.id}>
{shortId(d.id)}
</td>
<td>
<div className="row-title">{d.value}</div>
<div className="row-sub">{d.subject}</div>
</td>
<td>
<Link
className="dec-task"
to={`/tasks/${d.taskId}`}
onClick={(e) => e.stopPropagation()}
>
<M>{shortId(d.taskId)}</M>
<span className="row-sub">{task?.title ?? ''}</span>
</Link>
</td>
<td className="mono">{task?.work_phase ?? '—'}</td>
<td className="mono" title={d.externalId}>
{d.provider || '—'}
</td>
<td className="mono">{stamp(d.at)}</td>
<td>
{retired ? (
<Chip>superseded</Chip>
) : (
<Chip tone="accent">{d.kind || 'active'}</Chip>
)}
</td>
</tr>
{isOpen && (
<tr className="dec-detail-row">
<td colSpan={7}>
<div className="dec-detail">
<div>
<span className="label">Value</span>
<p className="dec-value">{d.value}</p>
<span className="label">Subject</span>
<p className="dec-subject">{d.subject || '—'}</p>
<span className="label">Effective authority</span>
<p className="dec-impact" data-retired={retired ? 'true' : undefined}>
{retired
? successor
? 'Retired. It is out of the effective intent for this task.'
: 'Retired by an explicit supersede. It no longer binds the agent.'
: 'Standing. It is in the effective intent handed to every session of this task.'}
</p>
</div>
<div className="dec-facts">
<span className="label">Decision id</span>
<div>
<M>{d.id}</M>
</div>
<span className="label">Kind</span>
<div>
<M>{d.kind}</M>
</div>
<span className="label">Task</span>
<div>
<Link to={`/tasks/${d.taskId}`}>
<M>{d.taskId}</M>
</Link>
</div>
<span className="label">Provenance</span>
<div>
<M>{d.provider || 'unknown'}</M>{' '}
{d.externalId && <M>· {d.externalId}</M>}
</div>
<span className="label">Supersedes</span>
<div>
{d.supersedes.length === 0 ? (
<M>none</M>
) : (
d.supersedes.map((s) => (
<button
key={s}
className="dec-link mono"
onClick={() => setOpen(s)}
>
{s}
</button>
))
)}
</div>
<span className="label">Superseded by</span>
<div>
{!retired ? (
<M>none</M>
) : successor ? (
<button className="dec-link mono" onClick={() => setOpen(successor)}>
{successor}
</button>
) : (
<M>explicit supersede event</M>
)}
</div>
</div>
<div className="dec-trail">
<span className="label">Event sequence</span>
{(trail.get(d.id) ?? []).map((e) => (
<div key={e.id} className="dec-event">
<M>{e.seq !== undefined ? `#${e.seq}` : '—'}</M>
<span className="dec-event-type">{e.type}</span>
<M>{stamp(e.at)}</M>
<M>{e.id}</M>
</div>
))}
</div>
</div>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
)}
{/* Law 5: the mockup's approval columns have no source in the log. */}
<EndpointGap
path="/v1/ui/decisions"
what="Recorded, superseded and waiting are reduced from the event log. Approved, rejected and auto are not: the domain has no approval state on a decision, so those statuses are omitted rather than guessed. The acting human is not recorded either — source.provider is the channel the decision arrived on, not the person. Phase is the task's current work phase, not the phase the decision was made in."
/>
</Panel>
</main>
)
}