AI assistance
AI everywhere without noise: generate buttons, streaming summaries, approval cards and the one Copilot.
The problem#
AI can help with almost every screen in the suite. Bolted on everywhere, it turns into noise: a chat bubble per page, gradient buttons, changes nobody approved.
Cedarline's team should meet AI where the work already is: a draft beside the field they're filling, a summary on the record they opened, a proposal waiting for their decision. It should look like the rest of the product, say where its answers came from, and never change a record behind anyone's back.
The solution#
Seven rules, from the suite brief, that decide every AI surface in Oration.
| Rule | In practice |
|---|---|
| Draft where the text goes | Any free-text field that describes something (an agent, a tool, a ticket summary, a procedure step, a scorecard criterion, a campaign message) gets an AI generate button inside or beside it. |
| Long prompts get the editor | Agent prompts and skill instructions use the Prompt editor, whose AI actions arrive as diffs to accept or reject. |
| Summaries cite their sources | Lists and records that summarize a conversation, a ticket or a supplier stream the summary with Streaming text and numbered source cards. |
| People approve changes | An agent that wants to change something proposes it in an Approval card. Approve, Edit and Skip; Undo afterwards. |
| One chat surface | The Copilot, on ⌘J from anywhere, knows the current app and record. No page builds its own chat panel, except the knowledge base query and the Contact Center assist panel. |
| Show uncertainty | Recommendations carry a confidence meter in ink with a word: high, medium or low. |
| Never act silently | Every AI change is visible where it happened, logged with the agent as the actor, and reversible. |
The Quiet Indigo Rule
The One Filled Button Rule
The Machine Mono Rule
Choosing the AI surface#
Start from what the person is doing, not from the model's capability.
| When people are | Use | Not |
|---|---|---|
| Filling a short descriptive field | AIGenerateButton beside the label, or AIFieldAction in the field's corner | A dialog that asks for a prompt first. |
| Writing a long prompt or instructions | PromptEditor with onAIAction: slash menu, bubble actions on a selection, inline diffs | A textarea with a Generate button that overwrites everything. |
| Reading a record, ticket or call | StreamingText with citations in a summary block at the top | An unsourced paragraph, or a chat answer. |
| Deciding on something an agent wants to do | ApprovalCard with the change as fields, Approve, Edit and Skip | A toast after the agent already did it. |
| Weighing a suggested next step | RecommendationCard with confidence, impact and sources | A suggestion with no reason or certainty. |
| Asking an open question | The Copilot, ⌘J, with tool chips and thinking in the transcript | A chat panel on this page. |
| Editing selected text | Selection actions: rewrite, shorten, translate, ask | A separate AI editor. |
Drafting in a field#
The sparkles button is ghost, small and beside the field's label. It drafts, shimmers while working, fills the field and toasts Draft added with Undo.
Draft a summary or a description
Draft fills the ticket summary; the corner button fills the tool description. Undo in the toast restores what was there.
import { AIFieldAction, AIGenerateButton } from "@oration/canon/components/ai/ai-button";import { Input } from "@oration/canon/components/input";import { Textarea } from "@oration/canon/components/textarea";import * as React from "react";export function DraftInField() { const summaryId = React.useId(); const toolId = React.useId(); const [summary, setSummary] = React.useState(""); const [tool, setTool] = React.useState(""); const previousSummary = React.useRef(""); const previousTool = React.useRef(""); return ( <div className="flex w-full max-w-md flex-col gap-5 rounded-xl bg-card p-4 text-left shadow-border"> <div className="flex flex-col gap-2"> <div className="flex items-center justify-between gap-3"> <label htmlFor={summaryId} className="text-sm font-medium text-foreground" > Ticket summary </label> <AIGenerateButton label="Draft" onGenerate={() => "Halcyon Packaging says INV-20931 was paid $412.50 short. The difference is the 2% early-pay discount from their 2/10 net 30 terms, applied on September 25. They need remittance advice that shows the discount as its own line." } onResult={(text) => { previousSummary.current = summary; setSummary(text); }} onUndo={() => setSummary(previousSummary.current)} /> </div> <Textarea id={summaryId} value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="What the supplier needs, in a sentence or two" className="min-h-24" /> </div> <div className="flex flex-col gap-2"> <label htmlFor={toolId} className="text-sm font-medium text-foreground" > Tool description </label> <div className="relative"> <Input id={toolId} value={tool} onChange={(event) => setTool(event.target.value)} placeholder="When should Nora call get_payment_status?" className="pr-9" /> <AIFieldAction label="Draft a description" onGenerate={() => "Look up when an invoice will be paid. Use it when a supplier asks about a payment date." } onResult={(text) => { previousTool.current = tool; setTool(text); }} onUndo={() => setTool(previousTool.current)} /> </div> </div> </div> );}The sparkles icon#
It is the only visual marker of AI. It stays in Slate Meta at the size of the icons around it, never indigo, and only pulses softly while a draft is generating.
Two drafting buttons, one look
AIGenerateButton shows Generating… in a shimmer, while AIFieldAction swaps its icon for a pixel grid and keeps its tooltip as the only label. Prefer the labelled button where there's room beside the field.
Long prompts#
Prompts for Nora and the other agents are documents. The editor tracks changes against the published version and proposes AI edits as diffs, so nothing is replaced without a look.
AI actions in the prompt editor
Type slash for blocks and AI actions, or two opening braces to insert a variable. Select text for AI rewrites, then press Control J or Command J to reach them.
Select the sentence under How you sound and choose Rewrite, or type / on the empty last line and choose Add guardrails. Every change arrives as a diff to accept or reject.
import { PromptEditor } from "@oration/canon/components/prompt-editor/prompt-editor";import * as React from "react";export function LongPrompt() { const [value, setValue] = React.useState( [ "# How you sound", "Be nice to callers and help them with whatever they need about payments.", "", "# What you do", "1. Greet {{supplier_name}} and ask for the invoice number.", "2. Look up the payment and tell them when it goes out.", "", ].join("\n"), ); return ( <div className="flex w-full flex-col gap-2"> <PromptEditor value={value} onChange={setValue} variables={[ { name: "supplier_name", description: "The supplier's name from the vendor record.", example: "Northwind Freight", }, ]} minHeight={200} aria-label="Nora prompt" onAIAction={async (request) => { await new Promise((resolve) => window.setTimeout(resolve, 500), ); if (request.kind === "guardrails") return [ "# Guardrails", "- Never read out full bank account or routing numbers.", "- If the caller disputes an amount, transfer them to Priya Raman's team.", ].join("\n"); if (request.selection?.includes("Be nice")) return "Warm, unhurried and exact. Read amounts and dates back slowly, and never guess."; return request.selection ?? request.document; }} /> <p className="text-13 text-pretty text-muted-foreground"> Select the sentence under How you sound and choose Rewrite, or type / on the empty last line and choose Add guardrails. Every change arrives as a diff to accept or reject. </p> </div> );}Summaries with sources#
A summary streams in with a soft caret, cites each claim with a numbered marker, and lists the sources underneath. Hovering a marker highlights its card.
A supplier summary
Hover a citation to preview its source. Summarize again replays the stream; the follow-ups open in the Copilot.
Summary
Halcyon Packaging, last 30 daysSources3
import type { Source } from "@oration/canon/components/ai/source-cards";import { StreamingText } from "@oration/canon/components/ai/streaming-text";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { RotateCcwIcon, SparklesIcon } from "lucide-react";import * as React from "react";export function RecordSummary() { const [run, setRun] = React.useState(0); const sources: Source[] = [ { id: "call-0924", kind: "transcript", title: "Call with Halcyon AR", snippet: "“We sent the new W-9 to the old portal address. Can you check if it arrived?”", meta: "Call on Sep 24", }, { id: "inv-20431", kind: "record", title: "Invoice INV-20431", snippet: "$4,912.50, due Oct 5. On hold: W-9 on file expired Aug 31.", meta: "Invoice", }, { id: "policy-w9", kind: "doc", title: "Supplier onboarding policy", snippet: "Payments are held until a current W-9 is on file. Requests go out from the supplier portal.", meta: "Knowledge base", }, ]; return ( <section aria-labelledby="record-summary-title" className="w-full max-w-xl rounded-xl bg-card p-4 text-left shadow-border" > <header className="mb-2 flex items-center gap-2"> <SparklesIcon aria-hidden="true" className="size-4 text-muted-foreground" /> <h3 id="record-summary-title" className="text-13 font-medium text-foreground" > Summary </h3> <span className="text-xs text-muted-foreground"> Halcyon Packaging, last 30 days </span> <Tooltip> <TooltipTrigger render={ <Button type="button" variant="ghost" size="icon-xs" aria-label="Summarize again" className="ml-auto text-muted-foreground" onClick={() => setRun((current) => current + 1)} /> } > <RotateCcwIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>Summarize again</TooltipContent> </Tooltip> </header> <StreamingText key={run} text="Halcyon's invoice **INV-20431** for $4,912.50 is on hold because their W-9 expired on August 31 [2]. Their AR team says they sent a new one to the old portal address on September 24 [1]. Payments stay held until a current W-9 is on file [3].\n\n**Next step:** send a W-9 request from the supplier portal so the form lands in the right place." speed={run === 0 ? 260 : 200} citations={sources} followUps={[ "Draft the W-9 request", "Show other invoices on hold", ]} onFollowUp={(text) => toast.add({ title: "Asked Copilot", description: text }) } className="text-sm leading-relaxed text-pretty" /> </section> );}- Put the summary at the top of the record or list it summarizes, under a 13px Summary heading with the sparkles icon in Slate Meta and the scope beside it: Halcyon Packaging, last 30 days.
- Every factual claim gets a citation. If the model can't cite it, it doesn't say it.
- End with one next step in bold, and at most two follow-up chips.
- Under reduced motion the whole text renders at once.
Proposals and approval#
Agents propose; people decide. The approval card shows what would change as before and after values, with Approve, Edit and Skip. Once resolved, it collapses to a receipt with Undo.
A stack of proposals
Only the expanded proposal shows its filled button. Approve or skip it and the next one opens; Undo on the receipt brings it back.
Update Halcyon's remit-to email
Wen Zhou's call at 10:42: Halcyon's AR team asked for remittance advice at a new address.
- Remit-to email
- Before: ap@halcyonpack.comAfter: remittance@halcyonpack.com
Updates 1 supplier record
import { ApprovalCard, type ApprovalStatus } from "@oration/canon/components/ai/approval-card";import { toast } from "@oration/canon/components/toast";import { BanknoteIcon, ChevronRightIcon, FileWarningIcon, MailIcon } from "lucide-react";import * as React from "react";export function ProposalStack() { const proposals = [ { id: "remit-email", icon: <MailIcon aria-hidden="true" />, agent: "Nora", title: "Update Halcyon's remit-to email", description: "Wen Zhou's call at 10:42: Halcyon's AR team asked for remittance advice at a new address.", fields: [ { label: "Remit-to email", before: "ap@halcyonpack.com", after: "remittance@halcyonpack.com", }, ], footnote: "Updates 1 supplier record", approveLabel: "Approve", done: "Halcyon's remit-to email updated", }, { id: "hold", icon: <FileWarningIcon aria-hidden="true" />, agent: "Matching agent", title: "Hold INV-20447 until Bayline sends a W-9", description: "Bayline Logistics' W-9 expired on September 1. Paying without one breaks the onboarding policy.", fields: [{ label: "Status", before: "Matched", after: "On hold" }], footnote: "Leaves Thursday's payment run", approveLabel: "Hold invoice", done: "INV-20447 is on hold", }, { id: "early-pay", icon: <BanknoteIcon aria-hidden="true" />, agent: "Cash agent", title: "Pay Northwind Freight early for 2%", description: "INV-20417 has 2/10 net 30 terms. Paying Thursday instead of October 2 saves $364.80.", fields: [ { label: "Pay date", before: "Oct 2", after: "Thursday, Oct 1", }, ], footnote: "Moves 1 invoice to Thursday's run", approveLabel: "Approve", done: "INV-20417 pays on Thursday", }, ]; const [status, setStatus] = React.useState<Record<string, ApprovalStatus>>( {}, ); const [activeId, setActiveId] = React.useState<string | null>( proposals[0]?.id ?? null, ); const resolve = (id: string, next: ApprovalStatus) => { const updated = { ...status, [id]: next }; setStatus(updated); const pending = proposals.find( (proposal) => (updated[proposal.id] ?? "pending") === "pending", ); setActiveId(pending?.id ?? null); }; return ( <ul aria-label="Needs your call" className="flex w-full max-w-lg flex-col gap-2 text-left" > {proposals.map((proposal) => { const current = status[proposal.id] ?? "pending"; if (current === "pending" && proposal.id !== activeId) { return ( <li key={proposal.id}> <button type="button" onClick={() => setActiveId(proposal.id)} className="flex w-full items-center gap-2.5 rounded-xl bg-card px-3 py-2.5 text-left shadow-border transition-shadow duration-150 outline-none hover:shadow-border-hover focus-visible:ring-3 focus-visible:ring-ring/40" > <span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground [&_svg]:size-4"> {proposal.icon} </span> <span className="flex min-w-0 flex-1 flex-col"> <span className="truncate text-13 font-medium text-foreground"> {proposal.title} </span> <span className="text-xs text-muted-foreground"> Proposed by {proposal.agent} </span> </span> <ChevronRightIcon aria-hidden="true" className="size-4 text-muted-foreground" /> </button> </li> ); } return ( <li key={proposal.id}> <ApprovalCard icon={proposal.icon} title={proposal.title} description={proposal.description} fields={proposal.fields} footnote={proposal.footnote} approveLabel={proposal.approveLabel} status={current} emphasis="primary" onApprove={() => { resolve(proposal.id, "approved"); toast.add({ type: "success", title: proposal.done, description: `${proposal.agent} logged the change on the record.`, }); }} onSkip={() => resolve(proposal.id, "skipped")} onEdit={() => toast.add({ title: "Editing the proposal", description: "Change the values, then approve.", }) } onUndo={() => { setStatus((all) => ({ ...all, [proposal.id]: "pending", })); setActiveId(proposal.id); }} /> </li> ); })} </ul> );}Update Halcyon's remit-to email
- Remit-to email
- Before: ap@halcyonpack.comAfter: remittance@halcyonpack.com
Agents that act on their own
Some agent runs act without a card because a workspace policy allows it, such as Nora logging a call note. Those changes still show the agent as the actor in the record's activity and in the agent's run log, and they can be reversed from there.
The Copilot#
One chat surface for the whole suite: a right-side panel on ⌘J, or the sparkles button in the sidebar header, or any Ask Copilot affordance. It knows the current app and record.
A Copilot transcript
Thinking collapses to Thought for 4s. Each tool call is a chip whose input and output open in Geist Mono. Actions arrive as approval cards inside the answer.
Copilot
on Halcyon Packaging⌘Jimport { ApprovalCard } from "@oration/canon/components/ai/approval-card";import { AssistantMessage, UserMessage } from "@oration/canon/components/ai/message";import { Thinking } from "@oration/canon/components/ai/thinking";import { ToolChip } from "@oration/canon/components/ai/tool-chip";import { Kbd, KbdGroup } from "@oration/canon/components/kbd";import { toast } from "@oration/canon/components/toast";import { MailIcon, SparklesIcon } from "lucide-react";export function CopilotTranscript() { return ( <section aria-label="Copilot" className="flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-card text-left shadow-border" > <header className="flex h-11 items-center gap-2 border-b border-border px-4"> <SparklesIcon aria-hidden="true" className="size-4 text-muted-foreground" /> <h3 className="text-sm font-semibold text-foreground"> Copilot </h3> <span className="text-xs text-muted-foreground"> on Halcyon Packaging </span> <KbdGroup className="ml-auto"> <Kbd>⌘</Kbd> <Kbd>J</Kbd> </KbdGroup> </header> <div className="flex flex-col gap-5 p-4"> <UserMessage> Why is INV-20431 on hold? Chase it if you can. </UserMessage> <AssistantMessage name="Copilot" actionsVisibility="hover"> <div className="flex flex-col gap-3"> <Thinking status="done" elapsed={4} steps={[ { label: "Read the invoice and its hold reason", }, { label: "Checked Halcyon's documents", detail: "The W-9 on file expired on August 31.", }, { label: "Looked for a newer W-9 in the portal", }, ]} /> <div className="flex flex-col gap-1.5"> <ToolChip name="get_invoice" label="Looked up INV-20431" status="done" duration={240} input={{ invoice_id: "INV-20431" }} output={{ status: "on_hold", reason: "w9_expired", amount: 4912.5, }} /> <ToolChip name="list_supplier_documents" label="Checked Halcyon's documents" status="done" duration={610} input={{ supplier_id: "SUP-1184", type: "w9" }} output={{ latest: "2025-08-31", portal_uploads: 0, }} /> </div> <p className="text-sm leading-relaxed text-pretty"> It's held because Halcyon's W-9 expired on August 31, and nothing new has reached the portal. I can email their AR team a W-9 request. </p> <ApprovalCard icon={<MailIcon aria-hidden="true" />} title="Email Halcyon a W-9 request" description="To ar@halcyonpack.com from ap@cedarline.com, with the portal upload link." footnote="Sends 1 email" approveLabel="Send email" onApprove={() => toast.add({ type: "success", title: "W-9 request sent to Halcyon", description: "Copilot will tell you when the form arrives.", }) } onSkip={() => undefined} onUndo={() => toast.add({ title: "Email recalled", description: "It hadn't left the outbox yet.", }) } /> </div> </AssistantMessage> </div> </section> );}| Keys | Action |
|---|---|
| ⌘J | Opens or closes the Copilot from anywhere in the suite. |
| Esc | Closes the Copilot and returns focus to the page. |
Confidence#
A recommendation says how sure it is in words and in an ink meter, never in indigo. Low confidence asks a person to check rather than offering to act.
High and low confidence
Pay Northwind Freight early
Halcyon may be changing banks
import { RecommendationCard } from "@oration/canon/components/ai/recommendation-card";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { BanknoteIcon, FileWarningIcon } from "lucide-react";export function Confidence() { return ( <div className="grid w-full gap-3 text-left md:grid-cols-2"> <RecommendationCard icon={<BanknoteIcon aria-hidden="true" />} title="Pay Northwind Freight early" confidence={0.86} rationale="INV-20417 has 2/10 net 30 terms and Thursday's run has room. Northwind has accepted early payment on its last six invoices." impact={{ label: "Saves", value: "$364.80" }} actions={ <> <Button size="sm" onClick={() => toast.add({ type: "success", title: "Added to Thursday's run", description: "INV-20417 now pays on October 1.", actionProps: { children: "Undo", onClick: () => toast.add({ title: "INV-20417 pays on October 2 again", }), }, }) } > Add to Thursday's run </Button> <Button size="sm" variant="ghost" onClick={() => toast.add({ title: "Dismissed for this invoice", }) } > Not now </Button> </> } /> <RecommendationCard icon={<FileWarningIcon aria-hidden="true" />} title="Halcyon may be changing banks" confidence={0.48} rationale="One call mentioned “our new account”, but no bank change request has arrived. Confirm with Halcyon before changing anything." actions={ <Button size="sm" variant="outline" onClick={() => toast.add({ title: "Task created for Priya Raman", description: "Confirm Halcyon's bank details by phone.", }) } > Ask Priya to confirm </Button> } /> </div> );}| Value | Label | What the card offers |
|---|---|---|
| 0.80 and up | High confidence | The action itself, as the filled button when the card is the view's main decision. |
| 0.55 to 0.79 | Medium confidence | The action as an outline button, with the reasoning above it. |
| Below 0.55 | Low confidence | A way to check: a task, a question or a link to the evidence. Not the change. |
Undo, and never acting silently#
Whatever the AI did, the person can see it where it happened and take it back.
| The AI | People see | To reverse it |
|---|---|---|
| Drafted text into a field | The field filled, and a Draft added toast. | Undo in the toast, or edit the text. |
| Proposed a change that was approved | The card's receipt, Approved, and the change on the record with the agent as the actor. | Undo on the receipt, or revert from the record's activity. |
| Rewrote part of a prompt | An inline diff to accept or reject, then a changes count on the save bar. | Reject the diff, or Discard on the save bar. |
| Acted under a workspace policy | An entry in the record's activity and the agent's run log. | Revert from the activity entry. |
| Failed | A toast that says nothing changed: Couldn't generate a draft. Nothing changed. | Try again from the toast. |
Accessibility#
AI surfaces are built from the same primitives as everything else, so the same rules apply, plus a few about time and change.
- Generate buttons set
aria-busywhile drafting and announce Generating a draft through a polite status region. - Streaming text is
aria-busyuntil it finishes. Screen readers read the finished text, not each token. - Citations are buttons or links named Source 2: Invoice INV-20431, and preview on focus as well as hover.
- The approval card moves focus to Undo when it resolves, so a keyboard user can reverse the decision straight away, and announces Approved or Skipped.
- Tool chips read their status after the name (get_invoice, Done) and expand with the keyboard.
- Confidence is a meter with a text label and a number, so it reads without color.
Components#
The AI primitives live in @oration/canon/components/ai/.
- AI generate buttonThe sparkles button beside any descriptive field: it drafts text, shimmers while working and offers undo.
- Prompt editorThe Tiptap editor for long prompts: variables, a slash menu, AI rewrites with inline diffs and change tracking.
- Streaming textModel output that streams in with a caret, citations and follow-up chips.
- Source cardsRetrieved context as cards, with inline citation markers that point at them.
- Approval cardHuman in the loop: a proposed action with its fields, Approve, Edit and Skip.
- Recommendation cardAn AI recommendation with its rationale, confidence and impact.
- ThreadThe Copilot conversation: welcome, suggestions, messages and scroll to bottom.
- Tool chipA tool call in a transcript: pending, running, done or failed, with its input and output.
- ThinkingA collapsible reasoning trace with elapsed time.
- Selection actionsA floating toolbar on selected text: rewrite, shorten, translate or ask.