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
321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { Link, useParams } from 'react-router-dom'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { api } from '../api/client'
|
|
import type { Capture, Detail } from '../api/types'
|
|
import { Chip, Dot, Empty, EndpointGap, M, Panel } from '../components/Primitives'
|
|
import { Icon } from '../components/Icon'
|
|
import './Terminal.css'
|
|
|
|
/** This is the screen that must not lie. Everything inside the frame is the
|
|
* pane's own bytes; everything outside it is state Orchestra can prove. */
|
|
|
|
function ago(at?: string) {
|
|
if (!at) return '—'
|
|
const seconds = Math.max(0, Math.round((Date.now() - new Date(at).getTime()) / 1000))
|
|
if (seconds < 60) return `${seconds}s ago`
|
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
|
|
return `${Math.floor(seconds / 3600)}h ago`
|
|
}
|
|
|
|
function clock(at?: string) {
|
|
return at ? new Date(at).toISOString().slice(11, 19) : '—'
|
|
}
|
|
|
|
function until(at?: string) {
|
|
if (!at) return '—'
|
|
const seconds = Math.round((new Date(at).getTime() - Date.now()) / 1000)
|
|
if (seconds <= 0) return 'expired'
|
|
const m = Math.floor(seconds / 60)
|
|
return `${String(m).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
|
|
}
|
|
|
|
function Field({ label, value }: { label: string; value: React.ReactNode }) {
|
|
return (
|
|
<div className="term-field">
|
|
<span className="label">{label}</span>
|
|
<span className="term-field-value">{value}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function Terminal() {
|
|
const { id = '' } = useParams()
|
|
const queries = useQueryClient()
|
|
const detail = useQuery<Detail>({
|
|
queryKey: ['detail', id],
|
|
queryFn: () => api.detail(id),
|
|
refetchInterval: 3000,
|
|
})
|
|
|
|
// The last frame we actually received, kept across refreshes so a lost pane
|
|
// still shows what was true when it was lost instead of an empty box.
|
|
const [frame, setFrame] = useState<Capture | undefined>()
|
|
const [lostAt, setLostAt] = useState<string | undefined>()
|
|
const [copied, setCopied] = useState(false)
|
|
const [pinned, setPinned] = useState(true)
|
|
const view = useRef<HTMLPreElement>(null)
|
|
|
|
const task = detail.data?.task
|
|
const session = detail.data?.session
|
|
const capture = session?.capture
|
|
|
|
useEffect(() => {
|
|
if (capture) {
|
|
setFrame(capture)
|
|
setLostAt(undefined)
|
|
return
|
|
}
|
|
// A leased task with no capture is a lost pane, not an empty one.
|
|
if (detail.isSuccess && task?.state === 'leased') {
|
|
setLostAt((was) => was ?? new Date().toISOString())
|
|
}
|
|
}, [capture, detail.isSuccess, task?.state])
|
|
|
|
// Scrollback inspection must not disturb the live view: we only follow the
|
|
// tail while the operator is already sitting at the tail.
|
|
useEffect(() => {
|
|
const el = view.current
|
|
if (el && pinned) el.scrollTop = el.scrollHeight
|
|
}, [frame?.revision, pinned])
|
|
|
|
const resubmit = useMutation({
|
|
mutationFn: () => api.action(id, 'resubmit'),
|
|
onSuccess: (next) => queries.setQueryData(['detail', id], next),
|
|
})
|
|
|
|
const canResubmit = detail.data?.actions.find((a) => a.id === 'resubmit')?.enabled ?? false
|
|
const lost = Boolean(lostAt) && Boolean(frame)
|
|
const status = session?.agent_status || task?.pane_state || 'unknown'
|
|
const health = lost ? 'error' : status === 'blocked' ? 'degraded' : status ? 'healthy' : 'unknown'
|
|
|
|
if (detail.isLoading) {
|
|
return (
|
|
<main className="page">
|
|
<div className="page-head">
|
|
<h1>Terminal</h1>
|
|
</div>
|
|
<Panel>
|
|
<div className="panel-body">Reading the pane…</div>
|
|
</Panel>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
if (detail.isError || !task) {
|
|
return (
|
|
<main className="page">
|
|
<div className="page-head">
|
|
<h1>Terminal</h1>
|
|
</div>
|
|
<Panel>
|
|
<Empty title="No such task">
|
|
<p>
|
|
<M>{id}</M> is not in the store, so there is no pane to show.
|
|
</p>
|
|
</Empty>
|
|
</Panel>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<main className="page term">
|
|
<div className="page-head term-head">
|
|
<h1>Terminal — live pane</h1>
|
|
{lost ? <Chip tone="fault">Pane lost</Chip> : capture ? <Chip tone="accent">Live</Chip> : <Chip>No session</Chip>}
|
|
<span className="term-identity">
|
|
<M>{session?.harness_id || task.last_harness_id || 'no worker'}</M>
|
|
<span className="term-sep">/</span>
|
|
<M>{session?.pane_id || task.last_pane_id || 'no pane'}</M>
|
|
</span>
|
|
<div className="term-controls">
|
|
<span className="chip term-mode" data-mode="read-only">
|
|
<Icon name="terminal" size={14} /> Read-only
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
disabled
|
|
title="Orchestra has no keystroke-forwarding endpoint"
|
|
>
|
|
Take control
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
disabled={!frame}
|
|
onClick={() => {
|
|
if (!frame) return
|
|
void navigator.clipboard.writeText(frame.text).then(() => {
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 1500)
|
|
})
|
|
}}
|
|
>
|
|
{copied ? 'Copied' : 'Copy frame'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="term-layout">
|
|
<div className="term-frame" data-lost={lost}>
|
|
<div className="term-bar">
|
|
<M>{session?.pane_id || task.last_pane_id || '—'}</M>
|
|
<span className="term-bar-right">
|
|
<M>rev {frame ? frame.revision : '—'}</M>
|
|
<M>{frame?.source || '—'}</M>
|
|
<M>{clock(frame?.at)}</M>
|
|
</span>
|
|
</div>
|
|
{frame ? (
|
|
<pre
|
|
ref={view}
|
|
className="term-text"
|
|
tabIndex={0}
|
|
aria-label="Pane capture, read-only"
|
|
onScroll={(event) => {
|
|
const el = event.currentTarget
|
|
setPinned(el.scrollHeight - el.scrollTop - el.clientHeight < 24)
|
|
}}
|
|
>
|
|
{frame.text}
|
|
</pre>
|
|
) : (
|
|
<Empty title="No capture">
|
|
<p>
|
|
This task holds no leased session, so no worker is publishing pane text for{' '}
|
|
<M>{task.id}</M>.
|
|
</p>
|
|
</Empty>
|
|
)}
|
|
<div className="term-foot">
|
|
{frame?.truncated && <span className="term-warn">frame truncated by the worker</span>}
|
|
{!pinned && (
|
|
<button
|
|
type="button"
|
|
className="btn term-jump"
|
|
onClick={() => {
|
|
setPinned(true)
|
|
const el = view.current
|
|
if (el) el.scrollTop = el.scrollHeight
|
|
}}
|
|
>
|
|
<Icon name="chevron-right" size={14} /> Scrollback held — jump to live
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="term-side">
|
|
{lost && (
|
|
<Panel title="Pane lost">
|
|
<div className="panel-body term-lost">
|
|
<p>
|
|
The frame above is the last one Orchestra received. It is history, not the
|
|
present.
|
|
</p>
|
|
<Field label="Frame captured" value={<M>{clock(frame?.at)} UTC</M>} />
|
|
<Field label="Capture stopped" value={<M>{clock(lostAt)} UTC</M>} />
|
|
<Field
|
|
label="Lease consequence"
|
|
value={
|
|
task.lease?.until ? (
|
|
<span>
|
|
the lease still runs until <M>{clock(task.lease.until)} UTC</M>; nothing is
|
|
rerouted before it expires
|
|
</span>
|
|
) : (
|
|
<span>no lease is held, so this task can be routed again immediately</span>
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
</Panel>
|
|
)}
|
|
|
|
<Panel title="Session">
|
|
<div className="panel-body term-fields">
|
|
<Field label="Task" value={<Link to={`/tasks/${task.id}`} className="term-link"><M>{task.id}</M></Link>} />
|
|
<Field label="Worker" value={<M>{session?.harness_id || task.last_harness_id || '—'}</M>} />
|
|
<Field label="Pane" value={<M>{session?.pane_id || task.last_pane_id || '—'}</M>} />
|
|
<Field label="Lease epoch" value={<M>{task.lease?.epoch || '—'}</M>} />
|
|
<Field
|
|
label="Lease"
|
|
value={
|
|
task.lease?.until ? (
|
|
<span>
|
|
<M>{until(task.lease.until)}</M> left · until <M>{clock(task.lease.until)} UTC</M>
|
|
</span>
|
|
) : (
|
|
<M>none</M>
|
|
)
|
|
}
|
|
/>
|
|
<Field label="Capture source" value={<M>{frame?.source || '—'}</M>} />
|
|
<Field label="Capture revision" value={<M>{frame ? frame.revision : '—'}</M>} />
|
|
<Field label="Last verified progress" value={<M>{ago(frame?.at)}</M>} />
|
|
<Field label="Mode" value={<M>read-only</M>} />
|
|
</div>
|
|
</Panel>
|
|
|
|
<Panel title="Health">
|
|
<div className="panel-body term-fields">
|
|
<Field
|
|
label="Agent"
|
|
value={
|
|
<span className="term-health">
|
|
<Dot health={health} />
|
|
<M>{status}</M>
|
|
</span>
|
|
}
|
|
/>
|
|
<Field label="Checked" value={<M>{ago(task.last_session?.checked_at || frame?.at)}</M>} />
|
|
{(session?.blocker || task.blocker) && (
|
|
<Field label="Blocker" value={<span>{session?.blocker || task.blocker}</span>} />
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
|
|
<Panel title="Keyboard forwarding">
|
|
<EndpointGap
|
|
path="POST /v1/ui/tasks/:id/actions/send_keys"
|
|
what="Orchestra exposes no endpoint that forwards arbitrary keystrokes to a pane. The only operator input that reaches a live pane today is resubmit (a single Enter on text Orchestra itself submitted) and approval grant/deny. Take control stays disabled rather than opening a text box that goes nowhere."
|
|
/>
|
|
<div className="panel-body term-resubmit">
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
data-variant="primary"
|
|
disabled={!canResubmit || resubmit.isPending}
|
|
onClick={() => resubmit.mutate()}
|
|
>
|
|
{resubmit.isPending ? 'Sending…' : 'Resubmit (send Enter)'}
|
|
</button>
|
|
<p>
|
|
Presses Enter once on input Orchestra placed in this pane. It sends no other key and
|
|
changes no lifecycle state.
|
|
</p>
|
|
{resubmit.isError && (
|
|
<p className="term-warn">{(resubmit.error as Error).message}</p>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
|
|
<Panel title="Recent events" count={detail.data?.events.length}>
|
|
<div className="panel-body term-events">
|
|
{(detail.data?.events ?? []).slice(-5).reverse().map((event) => (
|
|
<div key={event.id} className="term-event">
|
|
<M>{clock(event.at)}</M>
|
|
<span>{event.type}</span>
|
|
</div>
|
|
))}
|
|
{!detail.data?.events.length && <p className="term-muted">No events recorded yet.</p>}
|
|
</div>
|
|
</Panel>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|