Files
orchestra/web/src/screens/Tasks.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

330 lines
10 KiB
TypeScript

import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
import type { Overview, Task } from '../api/types'
import { Chip, Empty, EndpointGap, M, PHASES, PhasePath } from '../components/Primitives'
import { Icon } from '../components/Icon'
import './Tasks.css'
type GroupKey = 'attention' | 'running' | 'waiting' | 'completed' | 'failed'
const GROUPS: { key: GroupKey; title: string; icon: string; open: boolean }[] = [
{ key: 'attention', title: 'Needs attention', icon: 'alert', open: true },
{ key: 'running', title: 'Running', icon: 'play', open: true },
{ key: 'waiting', title: 'Waiting / queued', icon: 'queue', open: true },
{ key: 'completed', title: 'Completed', icon: 'check', open: false },
{ key: 'failed', title: 'Failed', icon: 'x', open: false },
]
function groupOf(task: Task): GroupKey {
switch (task.state) {
case 'needs_attention':
case 'blocked':
return 'attention'
case 'leased':
return 'running'
case 'completed':
return 'completed'
case 'failed':
return 'failed'
default:
return 'waiting'
}
}
/** The harness holding the lease, or the last one that did. Nothing is
* invented: a task that never ran reports no worker. */
function harnessOf(task: Task) {
return task.lease?.harness_id || task.last_harness_id || ''
}
function stateTone(task: Task) {
if (task.state === 'failed' || task.state === 'blocked') return 'fault' as const
if (task.state === 'needs_attention') return 'warn' as const
if (task.state === 'completed') return 'done' as const
if (task.state === 'leased') return 'accent' as const
return undefined
}
function ago(at?: string) {
if (!at) return ''
const ms = Date.now() - Date.parse(at)
if (!Number.isFinite(ms)) return ''
return `${span(ms)} ago`
}
function span(ms: number) {
const s = Math.max(0, Math.round(ms / 1000))
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.round(s / 60)}m`
if (s < 86400) return `${Math.round(s / 3600)}h`
return `${Math.round(s / 86400)}d`
}
/** The one time value a task actually carries per state. There is no
* per-task updated_at on the overview, so this never claims one. */
function timing(task: Task): { label: string; value: string } | undefined {
if (task.lease?.until) {
const left = Date.parse(task.lease.until) - Date.now()
return left > 0
? { label: 'lease', value: `${span(left)} left` }
: { label: 'lease', value: `expired ${span(-left)} ago` }
}
if (task.blocked_at) return { label: 'blocked', value: ago(task.blocked_at) }
if (task.next_retry_at) {
const left = Date.parse(task.next_retry_at) - Date.now()
return { label: 'retry', value: left > 0 ? `in ${span(left)}` : 'due' }
}
if (task.due) return { label: 'due', value: task.due.slice(0, 10) }
return undefined
}
function unique(values: (string | undefined)[]) {
return [...new Set(values.filter((value): value is string => !!value))].sort()
}
function Select({
label,
value,
options,
onChange,
}: {
label: string
value: string
options: string[]
onChange: (next: string) => void
}) {
return (
<label className="tasks-filter">
<span className="label">{label}</span>
<select value={value} onChange={(event) => onChange(event.target.value)}>
<option value="">any</option>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
)
}
function Row({ task, agentStatus }: { task: Task; agentStatus?: string }) {
const harness = harnessOf(task)
const when = timing(task)
return (
<Link className="row task-row" to={`/tasks/${task.id}`}>
<span className="task-row-main">
<span className="row-title">{task.title || task.external_id || 'untitled task'}</span>
<span className="row-sub">
{task.project} · <M>{task.external_id || task.id}</M>
</span>
</span>
<span className="task-row-phase tasks-phases">
<PhasePath current={task.lifecycle_phase} labels={false} />
<span className="task-row-phase-name mono">{task.lifecycle_phase || '—'}</span>
</span>
<span className="task-row-worker">
{harness ? (
<>
<M>{harness}</M>
{task.last_pane_id && <span className="row-sub mono">{task.last_pane_id}</span>}
</>
) : (
<span className="row-sub">unassigned</span>
)}
</span>
<span className="task-row-when">
{when && (
<>
<span className="label">{when.label}</span>
<M>{when.value}</M>
</>
)}
</span>
<span className="task-row-status">
{agentStatus && <Chip>{agentStatus}</Chip>}
<Chip tone={stateTone(task)}>{task.block_reason || task.state}</Chip>
</span>
<Icon name="chevron-right" size={16} />
</Link>
)
}
function Group({
title,
icon,
open,
tasks,
statusFor,
}: {
title: string
icon: string
open: boolean
tasks: Task[]
statusFor: (task: Task) => string | undefined
}) {
return (
<details className="panel tasks-group" open={open && tasks.length > 0}>
<summary className="panel-head">
<Icon name={icon} size={17} />
<h2>{title}</h2>
<span className="count">{tasks.length}</span>
<Icon name="chevron-right" size={16} />
</summary>
{tasks.length ? (
<div className="rows">
{tasks.map((task) => (
<Row key={task.id} task={task} agentStatus={statusFor(task)} />
))}
</div>
) : (
<Empty title="Nothing here" />
)}
</details>
)
}
export function Tasks() {
const { data, isLoading, error } = useQuery<Overview>({
queryKey: ['overview'],
queryFn: api.overview,
refetchInterval: 5000,
})
const [project, setProject] = useState('')
const [phase, setPhase] = useState('')
const [worker, setWorker] = useState('')
const [attention, setAttention] = useState('')
const [group, setGroup] = useState<GroupKey | ''>('')
const tasks = useMemo(() => data?.tasks ?? [], [data])
/** Agent status comes from the live session, matched by pane. It is an
* agent claim, so it stays a separate chip from orchestra's own state. */
const statusFor = useMemo(() => {
const byPane = new Map((data?.sessions ?? []).map((s) => [s.pane_id, s]))
return (task: Task) =>
task.state === 'leased' ? byPane.get(task.last_pane_id)?.agent_status : undefined
}, [data])
const filtered = tasks.filter((task) => {
if (project && task.project !== project) return false
if (phase && task.lifecycle_phase !== phase) return false
if (worker && harnessOf(task) !== worker) return false
if (attention === 'needs' && groupOf(task) !== 'attention') return false
if (attention === 'clear' && groupOf(task) === 'attention') return false
if (attention && attention !== 'needs' && attention !== 'clear' && task.block_reason !== attention)
return false
return true
})
const counts = (key: GroupKey) => filtered.filter((task) => groupOf(task) === key).length
const shown = GROUPS.filter((g) => !group || g.key === group)
return (
<main className="page">
<div className="page-head">
<h1>Tasks</h1>
<p>All work under orchestration.</p>
<span className="count mono">{tasks.length}</span>
</div>
<div className="tasks-controls">
<div className="tasks-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={group === ''}
className="tasks-tab"
onClick={() => setGroup('')}
>
All <span className="mono">{filtered.length}</span>
</button>
{GROUPS.map((g) => (
<button
key={g.key}
type="button"
role="tab"
aria-selected={group === g.key}
className="tasks-tab"
onClick={() => setGroup(group === g.key ? '' : g.key)}
>
{g.title} <span className="mono">{counts(g.key)}</span>
</button>
))}
</div>
<div className="tasks-filters">
<Select
label="Project"
value={project}
options={unique(tasks.map((task) => task.project))}
onChange={setProject}
/>
<Select label="Phase" value={phase} options={[...PHASES]} onChange={setPhase} />
<Select
label="Worker"
value={worker}
options={unique(tasks.map(harnessOf))}
onChange={setWorker}
/>
<Select
label="Attention"
value={attention}
options={['needs', 'clear', ...unique(tasks.map((task) => task.block_reason))]}
onChange={setAttention}
/>
</div>
</div>
{error && (
<div className="panel panel-body">
<p className="gap-note">/v1/ui/overview failed: {(error as Error).message}</p>
</div>
)}
{isLoading && !data && (
<div className="panel panel-body">
<p className="gap-note">loading /v1/ui/overview</p>
</div>
)}
{data && !tasks.length && (
<div className="panel">
<Empty title="No tasks yet">
<p>Orchestra has no work on record. Ingest a task to start.</p>
</Empty>
</div>
)}
{data &&
tasks.length > 0 &&
shown.map((g) => (
<Group
key={g.key}
title={g.title}
icon={g.icon}
open={g.open || !!group}
tasks={filtered.filter((task) => groupOf(task) === g.key)}
statusFor={statusFor}
/>
))}
{data && tasks.length > 0 && (
<div className="panel">
<EndpointGap
path="/v1/ui/overview carries no per-task progress, ETA or context occupancy"
what="Rows show lease, block and retry times because those are the only per-task clocks orchestra records. Progress bars and context meters are left out rather than estimated."
/>
</div>
)}
</main>
)
}