113 lines
4.0 KiB
TypeScript
113 lines
4.0 KiB
TypeScript
"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>
|
|
)
|
|
}
|