Review canvas

Pro

The governed review surface (Candor's ReviewCanvas): the plan on one side; select a step and its log, diff, blast radius and reasoning appear on the other. The approval sits under the evidence, so a decision is made looking at exactly what it covers. Tabs with nothing for the step say so instead of disappearing.

Markdown

Preview

Install

With the Candor CLI

First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/review-canvas

Brings in: approval-request, blast-radius, chat, locale, log-stream, metadata-diff, plan, reasoning, tabs, work, utils

Usage

TSX · The example above
"use client"

import type { AgentIdentity, ReasoningEntry } from "@/lib/chat"
import type { ApprovalRequest, BlastRadiusScope, FileDiff, LogItem, PlanStep } from "@/lib/work"
import { ReviewCanvas } from "@/components/review-canvas"

const dev: AgentIdentity = { id: "a-dev", name: "Dev agent", role: "dev" }
const migration = { kind: "migration", name: "0142_add_customer_region", label: "Add customers.region" }
const header = { kind: "component", name: "web/src/customers/CustomerHeader.tsx", label: "CustomerHeader" }

const steps: PlanStep[] = [
  { id: "s1", title: "Add the region column", status: "succeeded", agent: dev, duration: "14s", touches: [migration] },
  { id: "s2", title: "Show it on the customer page", status: "succeeded", agent: dev, duration: "9s", dependsOn: ["s1"], touches: [header] },
  { id: "s3", title: "Backfill from billing country", status: "running", agent: dev, dependsOn: ["s1"] },
  { id: "s4", title: "Test on Staging", status: "blocked", agent: { id: "a-qa", name: "QA agent", role: "qa" }, dependsOn: ["s2", "s3"] },
]

const logs: LogItem[] = [
  { kind: "line", id: "l1", at: "10:42:01", severity: "info", actor: "Dev agent", stepId: "s1", message: "Running migration 0142 on Staging" },
  { kind: "line", id: "l2", at: "10:42:14", severity: "info", actor: "staging", stepId: "s1", message: "Migration applied: 1 column, 1 index" },
  { kind: "line", id: "l3", at: "10:42:15", severity: "info", actor: "Dev agent", stepId: "s2", message: "Updating CustomerHeader" },
  { kind: "line", id: "l4", at: "10:42:33", severity: "info", actor: "Dev agent", stepId: "s3", message: "Backfill: batch 1 of 19 (1,000 customers)" },
  { kind: "line", id: "l5", at: "10:43:44", severity: "warn", actor: "staging", stepId: "s3", message: "Lock timeout on customers id 48213. Retrying batch 12." },
]

const diffs: Record<string, FileDiff[]> = {
  s1: [
    {
      path: "db/migrations/0142_add_customer_region.sql",
      entity: migration,
      change: "create",
      language: "sql",
      hunks: [{ lines: [{ kind: "add", text: "ALTER TABLE customers ADD COLUMN region region;", newNo: 1 }] }],
    },
  ],
  s2: [
    {
      path: "web/src/customers/CustomerHeader.tsx",
      entity: header,
      change: "update",
      language: "tsx",
      hunks: [
        {
          lines: [
            { kind: "remove", text: '<Fact label="Partner tier" value={customer.partnerTierOld} />', oldNo: 44 },
            { kind: "add", text: '<Fact label="Region" value={regionLabel(customer.region)} />', newNo: 44 },
          ],
        },
      ],
    },
  ],
}

const reach: Record<string, BlastRadiusScope> = {
  s1: { kind: "measured", unread: [], components: [{ entity: migration, change: "create", diff: { added: 1, removed: 0 } }] },
  s2: { kind: "measured", unread: ["Mobile app screens"], components: [{ entity: header, change: "update", diff: { added: 1, removed: 1 } }] },
  s3: { kind: "unknown", reason: "A backfill touches rows, not code. Which rows change depends on billing country at run time." },
}

const reasoning: Record<string, ReasoningEntry[]> = {
  s3: [
    { id: "x1", title: "Map billing country to region", basis: "verified", evidence: "Data model notes v3 lists the country table" },
    { id: "x2", title: "Use other for countries not in the table", basis: "assumed", assumption: "The team prefers a catch-all to leaving region empty" },
  ],
}

const approval: ApprovalRequest = {
  id: "ap1",
  title: "Add region to customers",
  summary: "A region column on customers, shown on the customer page and backfilled from billing country.",
  requester: { agent: { id: "a-orch", name: "Keel", role: "orchestrator" } },
  environment: "Acme web",
  production: true,
  artifactHash: "9f2c4e81b7a03d55c6e1f0a9b8d7c6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9",
  impact: { components: [migration, header], rows: "unknown" },
  rollback: { available: true, how: "Revert the pull request and run the migration's down step." },
  createdAt: new Date(Date.now() - 25 * 60_000).toISOString(),
  expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString(),
  state: "pending",
}

export default function Example() {
  return (
    <ReviewCanvas
      title="Add region to customers"
      steps={steps}
      logs={logs}
      diffs={diffs}
      reach={reach}
      reasoning={reasoning}
      approval={approval}
      onApprove={() => {}}
      onReject={() => {}}
      live
    />
  )
}

<ReviewCanvas>

PropTypeDefaultDescription
titlerequiredstring—The plan's title.
stepsrequiredPlanStep[]—The plan's steps; the first running, waiting or failed one is selected to start.
logsrequiredLogItem[]—The run's log, filtered to the selected step.
diffsRecord<string, FileDiff[]> | undefined{}File diffs per step id. A step without any says it changes no metadata.
reachRecord<string, BlastRadiusScope> | undefined{}Blast radius per step id. A step without one says reach isn't measured.
reasoningRecord<string, ReasoningEntry[]> | undefined{}The agent's reasoning per step id. A step without any says none was recorded.
approvalApprovalData | undefined—The approval request, shown under the evidence.
onApprove((hash: string) => void) | undefined—Called on approve with the change hash.
onReject((hash: string) => void) | undefined—Called on reject with the change hash.
liveboolean | undefinedfalseShows the log as live while the selected step is running. Defaults to false.
classNamestring | undefined—Extra classes for the outer element.

Strings

The fixed words, in English by default. Change any of them under reviewCanvas in <LocaleProvider strings={{ … }}>; the table is exported as reviewCanvasStrings.

KeyEnglish
evidenceForEvidence for {step}
evidenceEvidence
selectStepSelect a step
logLog
diffDiff
reachReach
whyWhy
noDiffThis step doesn’t change any metadata.
noReachReach isn’t measured for this step.
noReasoningThe agent didn’t record reasoning for this step.