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 (
{label}{value}
)
}
export function Terminal() {
const { id = '' } = useParams()
const queries = useQueryClient()
const detail = useQuery({
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()
const [lostAt, setLostAt] = useState()
const [copied, setCopied] = useState(false)
const [pinned, setPinned] = useState(true)
const view = useRef(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 (
Terminal
Reading the pane…
)
}
if (detail.isError || !task) {
return (
Terminal
{id} is not in the store, so there is no pane to show.
)
}
return (
Terminal — live pane
{lost ? Pane lost : capture ? Live : No session}
{session?.harness_id || task.last_harness_id || 'no worker'}/{session?.pane_id || task.last_pane_id || 'no pane'}
This task holds no leased session, so no worker is publishing pane text for{' '}
{task.id}.
)}
{frame?.truncated && frame truncated by the worker}
{!pinned && (
)}
{lost && (
The frame above is the last one Orchestra received. It is history, not the
present.
{clock(frame?.at)} UTC} />
{clock(lostAt)} UTC} />
the lease still runs until {clock(task.lease.until)} UTC; nothing is
rerouted before it expires
) : (
no lease is held, so this task can be routed again immediately
)
}
/>