Files
template-app/modules/agent/ui/live-run.tsx
T
2026-11-14 09:56:45 +00:00

114 lines
4.4 KiB
TypeScript

"use client"
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@conusai-kit/ui/primitives"
// One run, live (umbrella TS5.8): the page's loader reads the run from `agent.run.status` and its view
// hands it over as the seed, so the page arrives complete; this island then follows the run's AG-UI
// event stream (`agent.run.events`) through TanStack AI's resumable connection
// (`agentRunConnection`, `watchAgentRun` from `@conusai-kit/agent/client`) — step states, the
// status, a parked approval and the completed run's answer appear without a reload, and no request
// polls the run. A dropped stream reconnects with its last offset inside the connection.
import { agentRunConnection, initialAgentRunView, watchAgentRun } from "@conusai-kit/agent/client"
import { AiDisclosureBadge, type ProjectSlugs } from "@conusai-kit/ui"
import { type ReactElement, useEffect, useState } from "react"
import type { RunView } from "../contract/index.js"
import { liveRunState } from "./live-run-state.js"
import { RunControls, type RunControlsLabels } from "./run-controls.js"
export interface LiveRunLabels {
readonly status: string
readonly progress: string
readonly plan: string
readonly step: string
readonly tool: string
readonly needsApproval: string
readonly answer: string
readonly answerDisclosure: string
readonly streamLost: string
/** The message of each run status, by status. */
readonly statuses: Record<RunView["status"], string>
/** The message of each step status, by status. */
readonly stepStatuses: Record<RunView["plan"][number]["status"], string>
readonly controls: RunControlsLabels
}
export function LiveRun({
slugs,
seed,
labels,
}: {
slugs: ProjectSlugs
seed: RunView
labels: LiveRunLabels
}): ReactElement {
const [view, setView] = useState(() => initialAgentRunView(seed.runId))
useEffect(() => {
const query = new URLSearchParams({ org: slugs.org, workspace: slugs.workspace, project: slugs.project })
const connection = agentRunConnection({ url: `/api/v1/agent.run.events?${query.toString()}` })
const stop = new AbortController()
void watchAgentRun({ connection, runId: seed.runId, signal: stop.signal, onChange: setView })
return () => stop.abort()
}, [slugs.org, slugs.workspace, slugs.project, seed.runId])
const state = liveRunState(seed, view)
return (
<div data-live="agent.run">
<dl>
<dt>{labels.status}</dt>
<dd role="status">{labels.statuses[state.status]}</dd>
</dl>
<progress value={state.percent} max={1} aria-label={labels.progress}>
{Math.round(state.percent * 100)}%
</progress>
{state.streamError !== null ? <p role="alert">{labels.streamLost}</p> : null}
<RunControls
slugs={slugs}
run={{ runId: seed.runId, status: state.status, failureCode: state.failureCode }}
labels={labels.controls}
/>
<section aria-labelledby="plan-heading">
<h2 id="plan-heading">{labels.plan}</h2>
<Table>
<TableCaption>{labels.plan}</TableCaption>
<TableHeader>
<TableRow>
<TableHead scope="col">{labels.step}</TableHead>
<TableHead scope="col">{labels.tool}</TableHead>
<TableHead scope="col">{labels.status}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{seed.plan.map((step, index) => {
const live = state.steps[index]
return (
<TableRow key={step.key}>
<TableCell>
{step.label}
{step.needsApproval ? ` (${labels.needsApproval})` : ""}
</TableCell>
<TableCell>
<code>{step.tool}</code>
</TableCell>
<TableCell>
{labels.stepStatuses[live?.status ?? step.status]}
{live?.error ? `: ${live.error}` : ""}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</section>
{state.answer !== null ? (
<section aria-labelledby="answer-heading">
<h2 id="answer-heading">{labels.answer}</h2>
<AiDisclosureBadge label={labels.answerDisclosure} provenance={state.answerProvenance} />
<p>{state.answer}</p>
</section>
) : null}
</div>
)
}