@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
// The approval queue: what a run's tool asked a person to decide. The panel renders the EXACT
|
||||
// stored proposed arguments — a pretty-printed JSON of what the tool was called with — never a
|
||||
// model's narration of them, and it carries the AI disclosure a reviewer reads before deciding.
|
||||
//
|
||||
// The queue is live: the page's view hands over the approvals it rendered as the seed, and this reads them
|
||||
// again whenever the live stream announces a change of a run (`agent.run`) -- a run that parks an approval
|
||||
// or leaves one changes its status -- and after this person's own decision, so nothing reloads the page.
|
||||
|
||||
import { AiDisclosureBadge, type ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useRead, useWrite } from "@conusai-kit/ui/data"
|
||||
import { PageSection, PageState } from "@conusai-kit/ui/patterns"
|
||||
import { Button, ItemActions } from "@conusai-kit/ui/primitives"
|
||||
import { type ReactElement, useState } from "react"
|
||||
import { type ApprovalView, RUN_COLLECTION } from "../contract/index.js"
|
||||
|
||||
export interface ApprovalQueueLabels {
|
||||
readonly heading: string
|
||||
readonly empty: string
|
||||
readonly proposed: string
|
||||
readonly review: string
|
||||
readonly approve: string
|
||||
readonly reject: string
|
||||
readonly decided: string
|
||||
readonly disclosure: string
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
export function ApprovalQueue({
|
||||
slugs,
|
||||
seed,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
/** The approvals the server rendered: shown at once, then kept current. */
|
||||
seed: readonly ApprovalView[]
|
||||
labels: ApprovalQueueLabels
|
||||
}): ReactElement {
|
||||
const queue = useRead<{ approvals: readonly ApprovalView[] }>(
|
||||
"agent.approval.list",
|
||||
{ ...slugs },
|
||||
{
|
||||
live: {
|
||||
org: slugs.org,
|
||||
topic: { ws: slugs.workspace, project: slugs.project },
|
||||
entities: [RUN_COLLECTION.entity],
|
||||
},
|
||||
initial: { approvals: seed },
|
||||
},
|
||||
)
|
||||
const approvals = queue.data?.approvals ?? seed
|
||||
const review = useWrite<ProjectSlugs & { runId: string; stepKey: string; decision: "approve" | "reject" }, unknown>(
|
||||
"agent.approval.review",
|
||||
)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
const decide = async (approval: ApprovalView, decision: "approve" | "reject"): Promise<void> => {
|
||||
setBusy(`${approval.runId}:${approval.stepKey}`)
|
||||
setMessage(null)
|
||||
try {
|
||||
await review.mutate({ ...slugs, runId: approval.runId, stepKey: approval.stepKey, decision })
|
||||
setMessage(labels.decided)
|
||||
// The decision is a step's, which the stream does not announce until the run moves: read it again now.
|
||||
await queue.refetch()
|
||||
} catch (error) {
|
||||
setMessage(labels.failed.replace("{detail}", describeError(error)))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection title={labels.heading}>
|
||||
{approvals.length === 0 ? <PageState title={labels.empty} /> : null}
|
||||
{approvals.map((approval) => (
|
||||
<PageSection
|
||||
key={`${approval.runId}:${approval.stepKey}`}
|
||||
title={approval.tool}
|
||||
description={approval.goal}
|
||||
level={3}
|
||||
variant="card"
|
||||
>
|
||||
<AiDisclosureBadge label={labels.disclosure} />
|
||||
<h4>{labels.proposed}</h4>
|
||||
<pre>{JSON.stringify(approval.arguments, null, 2)}</pre>
|
||||
<ItemActions>
|
||||
<Button disabled={busy !== null} onClick={() => void decide(approval, "approve")}>
|
||||
{labels.approve}
|
||||
</Button>
|
||||
<Button variant="outline" disabled={busy !== null} onClick={() => void decide(approval, "reject")}>
|
||||
{labels.reject}
|
||||
</Button>
|
||||
{approval.reviewUrl !== null ? (
|
||||
<Button asChild variant="link">
|
||||
<a href={approval.reviewUrl} target="_blank" rel="noreferrer">
|
||||
{labels.review}
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</ItemActions>
|
||||
</PageSection>
|
||||
))}
|
||||
{message !== null ? (
|
||||
<p role="status" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { ApprovalQueue } from "./approval-queue.js"
|
||||
export { RunList, type RunListLabels, useLiveRuns } from "./live-runs.js"
|
||||
export { agentPage, runStatusKey } from "./pages.js"
|
||||
export { RunControls } from "./run-controls.js"
|
||||
export { StartForm } from "./start-form.js"
|
||||
@@ -0,0 +1,77 @@
|
||||
// What the run page shows, from the server's first render (`agent.run.status`) and the run's AG-UI
|
||||
// events as `@conusai-kit/agent/client` folds them. Until the first event arrives the seed is the
|
||||
// answer, so the hydrated page matches the server's; from then on the events are: the stream replays
|
||||
// the run from its start, so the view holds every step that began.
|
||||
|
||||
import type { AgentRunView } from "@conusai-kit/agent/client"
|
||||
import type { RunView } from "../contract/index.js"
|
||||
|
||||
const FAILURE_CODES = ["tool_error", "budget_exhausted", "plan_refused", "approval_unavailable", "internal"] as const
|
||||
type FailureCode = (typeof FAILURE_CODES)[number]
|
||||
const isFailureCode = (code: string): code is FailureCode => (FAILURE_CODES as readonly string[]).includes(code)
|
||||
|
||||
export interface LiveRunState {
|
||||
readonly status: RunView["status"]
|
||||
readonly failureCode: RunView["failureCode"]
|
||||
readonly failureDetail: string | null
|
||||
readonly steps: readonly {
|
||||
readonly key: string
|
||||
readonly status: RunView["plan"][number]["status"]
|
||||
readonly error: string | null
|
||||
}[]
|
||||
readonly percent: number
|
||||
/** The AI-generated answer of a completed run. */
|
||||
readonly answer: string | null
|
||||
/**
|
||||
* The answer's provenance (AI Act Art. 50(2)), from the server's render: a run that completes
|
||||
* while the page is open shows its answer marked without it until the page is loaded again.
|
||||
*/
|
||||
readonly answerProvenance: RunView["answerProvenance"]
|
||||
/** The stream could not be read: the page still shows the last state it knew. */
|
||||
readonly streamError: string | null
|
||||
}
|
||||
|
||||
export function liveRunState(seed: RunView, view: AgentRunView): LiveRunState {
|
||||
const streamError = view.error?.source === "transport" ? view.error.code : null
|
||||
if (view.events === 0) {
|
||||
return {
|
||||
status: seed.status,
|
||||
failureCode: seed.failureCode,
|
||||
failureDetail: seed.failureDetail,
|
||||
steps: seed.plan.map((step) => ({ key: step.key, status: step.status, error: step.error })),
|
||||
percent: seed.percent,
|
||||
answer: seed.answer,
|
||||
answerProvenance: seed.answerProvenance,
|
||||
streamError,
|
||||
}
|
||||
}
|
||||
const steps = seed.plan.map((planned) => {
|
||||
const step = view.steps.find((candidate) => candidate.key === planned.key)
|
||||
if (step === undefined || !step.finished) return { key: planned.key, status: "pending" as const, error: null }
|
||||
return { key: planned.key, status: step.outcome ?? ("committed" as const), error: step.error }
|
||||
})
|
||||
const committed = steps.filter((step) => step.status === "committed").length
|
||||
const runError = view.error?.source === "run" ? view.error : null
|
||||
return {
|
||||
status: statusOf(seed, view),
|
||||
failureCode: runError !== null && isFailureCode(runError.code) ? runError.code : null,
|
||||
failureDetail: runError?.message ?? null,
|
||||
steps,
|
||||
percent: seed.plan.length === 0 ? 0 : committed / seed.plan.length,
|
||||
answer: view.phase === "completed" && view.text !== "" ? view.text : null,
|
||||
answerProvenance: view.phase === "completed" ? seed.answerProvenance : null,
|
||||
streamError,
|
||||
}
|
||||
}
|
||||
|
||||
function statusOf(seed: RunView, view: AgentRunView): RunView["status"] {
|
||||
switch (view.phase) {
|
||||
case "connecting":
|
||||
return seed.status
|
||||
// The stream opens every run with RUN_STARTED; a run the worker has not claimed is still queued.
|
||||
case "running":
|
||||
return seed.status === "queued" && view.steps.length === 0 ? "queued" : "running"
|
||||
default:
|
||||
return view.phase
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// The agent module's page, on the shell's F10 contract (docs/reference/app-kit.md): a loader the web shell
|
||||
// runs on the server under `/<org>/<workspace>/<project>/agent`, and the view of what it returned.
|
||||
//
|
||||
// /agent the run list, the approval queue and the start form
|
||||
// /agent/<runId> one run: the plan with per-step progress, the budget, the recovery controls and
|
||||
// a completed run's AI-generated answer, live through the run's event stream
|
||||
//
|
||||
// Every read is a procedure through the same caller the API uses, and every label is translated in the
|
||||
// loader. The run page's first render is `agent.run.status`, the seed of a client island that follows the
|
||||
// run's AG-UI events (live-run.tsx); both come from the durable record, so a reload after a restart tells
|
||||
// the truth. The progress bar is a real <progress> element, keyboard-operable controls only.
|
||||
|
||||
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
|
||||
import type { I18n } from "@conusai-kit/i18n"
|
||||
import { type ProjectPage, type ProjectPageViewProps, projectPage } from "@conusai-kit/ui"
|
||||
import { DetailLayout, FactList, Page, PageHeader } from "@conusai-kit/ui/patterns"
|
||||
import type { ReactElement } from "react"
|
||||
import { type ApprovalView, approvalList, type RunView, runList, runStatus } from "../contract/index.js"
|
||||
import { ApprovalQueue, type ApprovalQueueLabels } from "./approval-queue.js"
|
||||
import { LiveRun, type LiveRunLabels } from "./live-run.js"
|
||||
import { RunList, type RunListLabels } from "./live-runs.js"
|
||||
import { StartForm, type StartFormLabels } from "./start-form.js"
|
||||
|
||||
/** The run list, the approval queue and the start form. */
|
||||
export interface AgentListData {
|
||||
readonly kind: "list"
|
||||
readonly title: string
|
||||
readonly intro: string
|
||||
readonly approvals: readonly ApprovalView[]
|
||||
readonly approvalLabels: ApprovalQueueLabels
|
||||
readonly start: StartFormLabels
|
||||
readonly runLabels: RunListLabels
|
||||
/** The first page of runs: the live list's seed. */
|
||||
readonly runs: readonly RunView[]
|
||||
}
|
||||
|
||||
/** One run: the seed of the live island, and the facts that do not change while it runs. */
|
||||
export interface AgentRunData {
|
||||
readonly kind: "run"
|
||||
readonly back: string
|
||||
readonly startedBy: string
|
||||
readonly usageLabel: string
|
||||
readonly usage: string
|
||||
readonly run: RunView
|
||||
readonly labels: LiveRunLabels
|
||||
}
|
||||
|
||||
export type AgentPageData = AgentListData | AgentRunData
|
||||
|
||||
export type AgentPage = ProjectPage<TransportCaller, I18n, Container, AgentPageData>
|
||||
|
||||
export const runStatusKey: Record<RunView["status"], string> = {
|
||||
queued: "runs.statusQueued",
|
||||
running: "runs.statusRunning",
|
||||
awaiting_approval: "runs.statusAwaitingApproval",
|
||||
completed: "runs.statusCompleted",
|
||||
failed: "runs.statusFailed",
|
||||
cancelled: "runs.statusCancelled",
|
||||
uncertain: "runs.statusUncertain",
|
||||
}
|
||||
|
||||
const stepStatusKey: Record<RunView["plan"][number]["status"], string> = {
|
||||
pending: "runs.stepPending",
|
||||
committed: "runs.stepCommitted",
|
||||
failed: "runs.stepFailed",
|
||||
cancelled: "runs.stepCancelled",
|
||||
uncertain: "runs.stepUncertain",
|
||||
}
|
||||
|
||||
const mapValues = <K extends string>(keys: Record<K, string>, t: (key: string) => string): Record<K, string> =>
|
||||
Object.fromEntries(Object.entries<string>(keys).map(([status, key]) => [status, t(key)])) as Record<K, string>
|
||||
|
||||
export const agentPage: AgentPage = projectPage({
|
||||
async load({ slugs, segments, caller, i18n }): Promise<AgentPageData> {
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`agent.${key}`, values)
|
||||
const failed = i18n.message("app.failed")
|
||||
|
||||
if (segments.length === 0) {
|
||||
const [runs, approvals] = await Promise.all([caller.call(runList, slugs), caller.call(approvalList, slugs)])
|
||||
return {
|
||||
kind: "list",
|
||||
title: t("title"),
|
||||
intro: t("intro"),
|
||||
approvals: approvals.approvals as ApprovalView[],
|
||||
approvalLabels: {
|
||||
heading: t("approvals.heading"),
|
||||
empty: t("approvals.empty"),
|
||||
proposed: t("approvals.proposed"),
|
||||
review: t("approvals.review"),
|
||||
approve: t("approvals.approve"),
|
||||
reject: t("approvals.reject"),
|
||||
decided: t("approvals.decided"),
|
||||
disclosure: t("approvals.disclosure"),
|
||||
failed,
|
||||
},
|
||||
start: {
|
||||
heading: t("start.heading"),
|
||||
goalLabel: t("start.goalLabel"),
|
||||
goalHint: t("start.goalHint"),
|
||||
planLabel: t("start.planLabel"),
|
||||
planHint: t("start.planHint"),
|
||||
submit: t("start.submit"),
|
||||
starting: t("start.starting"),
|
||||
failed,
|
||||
},
|
||||
runLabels: {
|
||||
heading: t("runs.heading"),
|
||||
empty: t("runs.empty"),
|
||||
goal: t("runs.goal"),
|
||||
status: t("runs.status"),
|
||||
progress: t("runs.progress"),
|
||||
startedBy: t("runs.startedBy"),
|
||||
statuses: mapValues(runStatusKey, t),
|
||||
},
|
||||
runs: runs.runs,
|
||||
}
|
||||
}
|
||||
|
||||
// A path that is not one run id is a page that does not exist.
|
||||
const [runId, ...rest] = segments
|
||||
if (runId === undefined || rest.length > 0) throw notFound("no such page")
|
||||
const run = await caller.call(runStatus, { ...slugs, runId })
|
||||
return {
|
||||
kind: "run",
|
||||
back: t("runs.back"),
|
||||
startedBy: t("runs.startedBy"),
|
||||
usageLabel: t("runs.usageLabel"),
|
||||
usage: t("runs.usage", { steps: run.usageSteps, cost: run.usageCostMicrocents }),
|
||||
run,
|
||||
labels: {
|
||||
status: t("runs.status"),
|
||||
progress: t("runs.progress"),
|
||||
plan: t("runs.plan"),
|
||||
step: t("runs.step"),
|
||||
tool: t("runs.tool"),
|
||||
needsApproval: t("runs.needsApproval"),
|
||||
answer: t("runs.answer"),
|
||||
answerDisclosure: t("runs.answerDisclosure"),
|
||||
streamLost: t("runs.streamLost"),
|
||||
statuses: mapValues(runStatusKey, t),
|
||||
stepStatuses: mapValues(stepStatusKey, t),
|
||||
controls: {
|
||||
cancel: t("runs.cancel"),
|
||||
cancelling: t("runs.cancelling"),
|
||||
failedHint: t("recovery.failedHint"),
|
||||
cancelledHint: t("recovery.cancelledHint"),
|
||||
uncertainHint: t("recovery.uncertainHint"),
|
||||
budgetExhausted: t("recovery.budgetExhausted"),
|
||||
approvalUnavailable: t("recovery.approvalUnavailable"),
|
||||
toolError: t("recovery.toolError"),
|
||||
planRefused: t("recovery.planRefused"),
|
||||
internal: t("recovery.internal"),
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
View({ slugs, data }: ProjectPageViewProps<AgentPageData>): ReactElement {
|
||||
if (data.kind === "list") {
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title={data.title} description={data.intro} />
|
||||
<ApprovalQueue slugs={slugs} seed={data.approvals} labels={data.approvalLabels} />
|
||||
<DetailLayout aside={<StartForm slugs={slugs} labels={data.start} />}>
|
||||
<RunList slugs={slugs} seed={data.runs} labels={data.runLabels} />
|
||||
</DetailLayout>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
const { run } = data
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
title={run.goal}
|
||||
breadcrumb={<a href={`/${slugs.org}/${slugs.workspace}/${slugs.project}/agent`}>{data.back}</a>}
|
||||
/>
|
||||
<FactList
|
||||
facts={[
|
||||
{ label: data.startedBy, value: run.actorId },
|
||||
{ label: data.usageLabel, value: data.usage },
|
||||
]}
|
||||
/>
|
||||
<LiveRun slugs={slugs} seed={run} labels={data.labels} />
|
||||
</Page>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
|
||||
// One run's controls: cancel while it runs, and recovery from its terminal states. The run page is
|
||||
// live (live-run.tsx), so a cancel shows up there without a reload. The recovery
|
||||
// copy tells the person what the state means and what they can do — failed runs can be started
|
||||
// again from the same plan, cancelled runs are simply over, and an uncertain run (a timeout after
|
||||
// an external effect) is reconciled by a person, not retried blindly.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useWrite } from "@conusai-kit/ui/data"
|
||||
import { type ReactElement, useState } from "react"
|
||||
import type { RunView } from "../contract/index.js"
|
||||
|
||||
export interface RunControlsLabels {
|
||||
readonly cancel: string
|
||||
readonly cancelling: string
|
||||
readonly failedHint: string
|
||||
readonly cancelledHint: string
|
||||
readonly uncertainHint: string
|
||||
readonly budgetExhausted: string
|
||||
readonly approvalUnavailable: string
|
||||
readonly toolError: string
|
||||
readonly planRefused: string
|
||||
readonly internal: string
|
||||
}
|
||||
|
||||
export function RunControls({
|
||||
slugs,
|
||||
run,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
run: Pick<RunView, "runId" | "status" | "failureCode">
|
||||
labels: RunControlsLabels
|
||||
}): ReactElement {
|
||||
const cancelRun = useWrite<ProjectSlugs & { runId: string }, unknown>("agent.run.cancel")
|
||||
const [problem, setProblem] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const cancel = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
setProblem(false)
|
||||
try {
|
||||
await cancelRun.mutate({ ...slugs, runId: run.runId })
|
||||
} catch {
|
||||
setProblem(true)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const active = run.status === "queued" || run.status === "running" || run.status === "awaiting_approval"
|
||||
const failureText = (code: RunView["failureCode"]): string => {
|
||||
switch (code) {
|
||||
case "budget_exhausted":
|
||||
return labels.budgetExhausted
|
||||
case "approval_unavailable":
|
||||
return labels.approvalUnavailable
|
||||
case "tool_error":
|
||||
return labels.toolError
|
||||
case "plan_refused":
|
||||
return labels.planRefused
|
||||
default:
|
||||
return labels.internal
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{problem ? <p role="alert">{labels.internal}</p> : null}
|
||||
{active ? (
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => void cancel()}>
|
||||
{busy ? labels.cancelling : labels.cancel}
|
||||
</Button>
|
||||
) : null}
|
||||
{run.status === "failed" ? (
|
||||
<p role="alert">
|
||||
{failureText(run.failureCode)} {labels.failedHint}
|
||||
</p>
|
||||
) : null}
|
||||
{run.status === "cancelled" ? <p role="status">{labels.cancelledHint}</p> : null}
|
||||
{run.status === "uncertain" ? <p role="alert">{labels.uncertainHint}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
// Starting a run: the goal and the plan as JSON. The plan is what the run will do, shown to the
|
||||
// person before they start it, and admitted by the server against the hard ceilings. The start resolves
|
||||
// once the live stream has seen the new run, so the page's live run list already shows it.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useWrite } from "@conusai-kit/ui/data"
|
||||
import { FormLayout, PageSection, TextField } from "@conusai-kit/ui/patterns"
|
||||
import type { ReactElement } from "react"
|
||||
|
||||
export interface StartFormLabels {
|
||||
readonly heading: string
|
||||
readonly goalLabel: string
|
||||
readonly goalHint: string
|
||||
readonly planLabel: string
|
||||
readonly planHint: string
|
||||
readonly submit: string
|
||||
readonly starting: string
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
const EXAMPLE = JSON.stringify([
|
||||
{ key: "read", label: "Read the document", tool: "documents.read", args: { fileId: "…" } },
|
||||
])
|
||||
|
||||
const parses = (text: string): boolean => {
|
||||
try {
|
||||
JSON.parse(text)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function StartForm({ slugs, labels }: { slugs: ProjectSlugs; labels: StartFormLabels }): ReactElement {
|
||||
const startRun = useWrite<ProjectSlugs & { goal: string; plan: unknown }, unknown>("agent.run.start", {
|
||||
org: slugs.org,
|
||||
})
|
||||
return (
|
||||
<PageSection title={labels.heading} variant="card">
|
||||
<FormLayout
|
||||
submitLabel={labels.submit}
|
||||
busyLabel={labels.starting}
|
||||
validate={({ plan = "" }) =>
|
||||
parses(plan) ? undefined : { plan: labels.failed.replace("{detail}", "invalid JSON") }
|
||||
}
|
||||
onSubmit={async ({ goal = "", plan = "" }) => {
|
||||
await startRun.mutate({ ...slugs, goal, plan: JSON.parse(plan) })
|
||||
}}
|
||||
>
|
||||
<TextField name="goal" label={labels.goalLabel} description={labels.goalHint} required maxLength={2_000} />
|
||||
<TextField
|
||||
multiline
|
||||
name="plan"
|
||||
label={labels.planLabel}
|
||||
description={labels.planHint.replace("{example}", EXAMPLE)}
|
||||
required
|
||||
rows={6}
|
||||
defaultValue={EXAMPLE}
|
||||
/>
|
||||
</FormLayout>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user