Files
2026-11-14 09:56:45 +00:00

97 lines
3.3 KiB
TypeScript

"use client"
// The project's runs, live (umbrella TS6.4 and TS6.6): the page's view renders the first page and hands it
// over as the seed, so the page arrives complete; this keeps it current through the organization's live
// stream -- a run another member starts appears, and each run's status and progress move as its steps
// commit, without a reload. The rows come from `agent.run.getMany`, under the same permission as the list,
// so the stream itself never carries a run's goal or plan.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { liveCollection, useDataClient, useLive } from "@conusai-kit/ui/data"
import { PageSection, type ResourceColumn, ResourceList } from "@conusai-kit/ui/patterns"
import { type ReactElement, useState } from "react"
import { RUN_COLLECTION, type RunView } from "../contract/index.js"
export interface RunListLabels {
readonly heading: string
/** What to say when there is no run. */
readonly empty: string
readonly goal: string
readonly status: string
readonly progress: string
readonly startedBy: string
/** The message of each run status, by status. */
readonly statuses: Record<RunView["status"], string>
}
export function useLiveRuns(slugs: ProjectSlugs, seed: readonly RunView[]): readonly RunView[] {
const client = useDataClient()
// One collection for the life of the page; the seed is the first render's.
const [runs] = useState(() =>
liveCollection<RunView>({
client,
org: slugs.org,
topic: { ws: slugs.workspace, project: slugs.project },
entity: RUN_COLLECTION.entity,
key: RUN_COLLECTION.key,
seed,
list: {
procedure: RUN_COLLECTION.list,
input: { ...slugs },
rows: (out) => (out as { runs: RunView[] }).runs,
},
getMany: {
procedure: RUN_COLLECTION.getMany,
input: { ...slugs },
rows: (out) => (out as { items: RunView[] }).items,
},
}),
)
// Newest first, as the list procedure orders them.
const { data, isReady } = useLive(runs, (q, rows) =>
q.from({ run: rows }).orderBy(({ run }: { run: RunView }) => run.createdAt, "desc"),
)
// Until the collection is ready the server's rows are the answer, so the first paint matches it.
return isReady ? data : seed
}
export function RunList({
slugs,
seed,
labels,
}: {
slugs: ProjectSlugs
seed: readonly RunView[]
labels: RunListLabels
}): ReactElement {
const rows = useLiveRuns(slugs, seed)
const pageHref = `/${slugs.org}/${slugs.workspace}/${slugs.project}/agent`
const columns: ResourceColumn<RunView>[] = [
{ id: "goal", header: labels.goal, wrap: true },
{ id: "status", header: labels.status, cell: (run) => labels.statuses[run.status] },
{
id: "percent",
header: labels.progress,
cell: (run) => (
<progress value={run.percent} max={1}>
{Math.round(run.percent * 100)}%
</progress>
),
},
{ id: "actorId", header: labels.startedBy },
]
return (
<PageSection title={labels.heading}>
<ResourceList
label={labels.heading}
columns={columns}
data={rows}
rowId={(run) => run.runId}
rowHref={(run) => `${pageHref}/${run.runId}`}
rowLabel={(run) => run.goal}
empty={{ title: labels.empty }}
/>
</PageSection>
)
}