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
459 lines
17 KiB
TypeScript
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>
|
|
)
|
|
}
|