Three-pane workspace
Queue, conversation and context with AI assist, collapsing to one pane with a back affordance.
What it's for#
The three-pane workspace is where a person works a queue of live conversations: pick the next one, answer it with the customer's history and AI suggestions in view, then move on without leaving the page.
It is a full-bleed tool. The queue is on the left, the conversation in the middle, and the customer's context with AI assist on the right. Everything is reachable from the keyboard, because agents handle dozens of conversations a shift. Presence lives in the header, since it decides what the queue offers next. Below 768px the three panes collapse to one, with a back button in each pane's header.
| Route | What it shows |
|---|---|
| /contact-center | The Contact Center workspace: queue, conversation and customer context, with incoming transfers from Nora. |
| /contact-center/cc-2044 | The same workspace deep-linked to one conversation; on a phone it opens on the conversation pane. |
| /inbox | The same shape with two panes: a 380px list and the thread, collapsing to one pane below 768px. |
Live composition#
Priya Raman's workspace at 1440px. Click inside, then press J and K to move through the queue. Change your presence, draft a reply with AI, insert a suggestion, and send with ⌘↵.
import { AIGenerateButton } from "@oration/canon/components/ai/ai-button";import { RecommendationCard } from "@oration/canon/components/ai/recommendation-card";import { Button } from "@oration/canon/components/button";import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { Kbd } from "@oration/canon/components/kbd";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { StatusDot, StatusLabel, type StatusTone } from "@oration/canon/components/status-dot";import { Tabs, TabsContent, TabsList, TabsTrigger } from "@oration/canon/components/tabs";import { Tag, type TagColor } from "@oration/canon/components/tag";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { CheckCircle2Icon, CheckIcon, ChevronDownIcon, ChevronLeftIcon, GlobeIcon, LayersIcon, ListFilterIcon, MailIcon, MessageCircleIcon, PanelRightIcon, PhoneIcon, ReceiptIcon, SendIcon, SparklesIcon, StickyNoteIcon, UserRoundIcon,} from "lucide-react";import * as React from "react";import { AppShell } from "../document-page/frame";export function Workspace() { type Message = { id: string; from: "customer" | "agent" | "nora" | "note"; author: string; text: string; at: string; }; type Conversation = { id: string; name: string; company: string; color: TagColor; channel: "whatsapp" | "email" | "voice" | "chat"; queue: string; preview: string; slot: | { kind: "live" } | { kind: "sla"; left: string; tone: "neutral" | "warning" | "danger"; } | { kind: "time"; at: string }; unread: number; priority?: "Urgent" | "High"; strategic?: boolean; group: "assigned" | "unassigned"; thread: Message[]; }; const channels = { whatsapp: { label: "WhatsApp", icon: MessageCircleIcon }, email: { label: "Email", icon: MailIcon }, voice: { label: "Voice", icon: PhoneIcon }, chat: { label: "Web chat", icon: GlobeIcon }, }; const seed: Conversation[] = [ { id: "cc-2041", name: "Tomás Ferreira", company: "Halcyon", color: "violet", channel: "whatsapp", queue: "Payments and remittances", preview: "Can you confirm the Sep 30 remittance went out?", slot: { kind: "sla", left: "1:12", tone: "warning" }, unread: 2, priority: "High", strategic: true, group: "assigned", thread: [ { id: "m1", from: "nora", author: "Nora", text: "Hi Tomás, I've found Halcyon's account. Let me bring in someone from accounts payable.", at: "9:02 AM", }, { id: "m2", from: "customer", author: "Tomás Ferreira", text: "Thanks. Can you confirm the Sep 30 remittance went out? Our bank shows nothing yet.", at: "9:04 AM", }, { id: "m3", from: "note", author: "Aisha Bello", text: "PR-0930 cleared on our side at 6:10 AM. Trace number is in the payment record.", at: "9:05 AM", }, { id: "m4", from: "customer", author: "Tomás Ferreira", text: "It's $48,210.00 across 6 invoices.", at: "9:06 AM", }, ], }, { id: "cc-2046", name: "Jordan Lee", company: "Northwind Freight", color: "teal", channel: "voice", queue: "Invoice disputes", preview: "Transferred from Nora: INV-20944 is on hold", slot: { kind: "live" }, unread: 0, priority: "Urgent", group: "assigned", thread: [ { id: "m1", from: "nora", author: "Nora", text: "Handoff: Jordan is calling about INV-20944 for $8,212.75. It's on hold because the PO number doesn't match. Identity verified.", at: "9:11 AM", }, { id: "m2", from: "customer", author: "Jordan Lee", text: "The PO on the invoice is right. Your system has the old one.", at: "9:12 AM", }, ], }, { id: "cc-2039", name: "Wen Zhou", company: "Orchard Street", color: "green", channel: "email", queue: "W-9 and tax forms", preview: "W-9 resent with the corrected TIN", slot: { kind: "time", at: "8:47 AM" }, unread: 0, group: "assigned", thread: [ { id: "m1", from: "customer", author: "Wen Zhou", text: "Attached is our W-9 with the corrected TIN. Please re-run the check.", at: "8:47 AM", }, ], }, { id: "cc-2050", name: "Aisha Bello", company: "Kestrel Marine", color: "orange", channel: "chat", queue: "Supplier onboarding", preview: "Do you accept invoices from a second entity?", slot: { kind: "sla", left: "4:05", tone: "neutral" }, unread: 1, group: "unassigned", thread: [ { id: "m1", from: "customer", author: "Aisha Bello", text: "Do you accept invoices from a second entity? We bill from Kestrel Marine UK too.", at: "9:09 AM", }, ], }, ]; const presences: { id: string; label: string; tone: StatusTone }[] = [ { id: "available", label: "Available", tone: "success" }, { id: "busy", label: "Busy", tone: "warning" }, { id: "away", label: "Away", tone: "neutral" }, { id: "offline", label: "Offline", tone: "neutral" }, ]; const [conversations, setConversations] = React.useState(seed); const [activeId, setActiveId] = React.useState("cc-2041"); const [pane, setPane] = React.useState<"list" | "conversation" | "context">( "conversation", ); const [presence, setPresence] = React.useState("available"); const [mode, setMode] = React.useState<"reply" | "note">("reply"); const [draft, setDraft] = React.useState(""); const [tab, setTab] = React.useState("customer"); const active = conversations.find((c) => c.id === activeId) ?? seed[0]; const status = presences.find((p) => p.id === presence) ?? presences[0]; const order = [...conversations].sort((a, b) => a.group === b.group ? 0 : a.group === "assigned" ? -1 : 1, ); const select = (id: string) => { setActiveId(id); setDraft(""); setPane("conversation"); setConversations((list) => list.map((c) => (c.id === id ? { ...c, unread: 0 } : c)), ); }; const send = () => { if (!draft.trim() || !active) return; const message: Message = { id: `m${Date.now()}`, from: mode === "note" ? "note" : "agent", author: "Priya Raman", text: draft.trim(), at: "9:14 AM", }; setConversations((list) => list.map((c) => c.id === active.id ? { ...c, thread: [...c.thread, message] } : c, ), ); setDraft(""); toast.add({ title: mode === "note" ? "Note added" : "Reply sent", description: mode === "note" ? "Only your team sees it." : `To ${active.name} on ${channels[active.channel].label}.`, }); }; const onKeyDown = (event: React.KeyboardEvent) => { const target = event.target as HTMLElement; if (target.closest("input, textarea, [role=combobox]")) return; if (event.metaKey || event.ctrlKey || event.altKey) return; if (event.key !== "j" && event.key !== "k") return; event.preventDefault(); const index = order.findIndex((c) => c.id === activeId); const next = order[ Math.min( order.length - 1, Math.max(0, index + (event.key === "j" ? 1 : -1)), ) ]; if (next) select(next.id); }; const avatar = (c: Conversation, size: string) => ( <span aria-hidden="true" className={cn( "flex shrink-0 items-center justify-center rounded-full text-xs font-medium", size, )} style={{ background: `var(--tag-${c.color})`, color: `var(--tag-${c.color}-fg)`, }} > {c.name .split(" ") .map((part) => part[0]) .join("")} </span> ); return ( <AppShell active="Contact Center" heading="Contact Center workspace" crumbs={[{ label: "Contact Center" }, { label: "Workspace" }]} headerContent={ <dl aria-label="Your stats today" className="ml-3 hidden items-center gap-4 text-xs @min-[1280px]:flex" > {[ ["Handled", "14"], ["AHT", "5:48"], ["CSAT", "4.7"], ].map(([label, value]) => ( <div key={label} className="flex items-baseline gap-1.5" > <dt className="text-muted-foreground">{label}</dt> <dd className="text-13 font-medium tabular-nums"> {value} </dd> </div> ))} </dl> } actions={ <> <Button variant="ghost" size="sm" className="gap-1.5 text-muted-foreground @max-[640px]:hidden" > <LayersIcon aria-hidden="true" /> All my queues <ChevronDownIcon aria-hidden="true" /> </Button> <DropdownMenu> <DropdownMenuTrigger render={ <Button variant="outline" size="sm" className="gap-2 pr-1.5" aria-label={`Presence: ${status?.label}. Change status`} /> } > <StatusDot tone={status?.tone} /> <span className="font-medium">{status?.label}</span> <span className="text-muted-foreground tabular-nums"> 42:10 </span> <ChevronDownIcon aria-hidden="true" /> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-60"> <DropdownMenuGroup> <DropdownMenuLabel> Set your status </DropdownMenuLabel> {presences.map((item) => ( <DropdownMenuItem key={item.id} onClick={() => { setPresence(item.id); toast.add({ title: `You're ${item.label.toLowerCase()}`, description: item.id === "available" ? "New calls and chats from your queues will be offered to you." : item.id === "offline" ? "You won't receive conversations until you go available." : "Routing pauses offers to you. Open conversations stay assigned.", }); }} > {item.id === "offline" ? ( <span className="size-2 rounded-full ring-1 ring-subtle-foreground ring-inset" /> ) : ( <StatusDot tone={item.tone} /> )} {item.label} {item.id === presence ? ( <CheckIcon aria-hidden="true" className="ml-auto" /> ) : null} </DropdownMenuItem> ))} </DropdownMenuGroup> <DropdownMenuSeparator /> <DropdownMenuItem> Status and reason codes </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </> } > <section aria-label="Workspace panes" className="flex min-h-0 flex-1" onKeyDown={onKeyDown} > <section aria-label="Queue" className={cn( "flex w-full min-w-0 flex-col border-border @min-[768px]:w-[25%] @min-[768px]:min-w-60 @min-[768px]:border-r", pane !== "list" && "hidden @min-[768px]:flex", )} > <div className="flex h-11 shrink-0 items-center gap-1 border-b border-border px-2"> <Button variant="ghost" size="sm" className="gap-1 px-2 font-medium" > My work <ChevronDownIcon aria-hidden="true" className="text-muted-foreground" /> </Button> <span className="text-xs text-muted-foreground tabular-nums"> {conversations.length} </span> <Button variant="ghost" size="icon-sm" aria-label="Filter by channel" className="ml-auto text-muted-foreground" > <ListFilterIcon aria-hidden="true" /> </Button> </div> <div className="min-h-0 flex-1 overflow-y-auto px-1.5 pb-6"> {(["assigned", "unassigned"] as const).map((group) => { const items = order.filter( (c) => c.group === group, ); return ( <section key={group} aria-label={ group === "assigned" ? "Assigned to me" : "Unassigned in my queues" } className="pt-2" > <p className="flex h-7 items-center gap-1.5 px-2 text-xs font-medium text-muted-foreground"> {group === "assigned" ? "Assigned to me" : "Unassigned in my queues"} <span className="tabular-nums"> {items.length} </span> </p> <ul className="flex flex-col gap-px"> {items.map((c) => { const Icon = channels[c.channel].icon; const selected = c.id === activeId; return ( <li key={c.id}> <button type="button" aria-current={ selected || undefined } onClick={() => select(c.id) } className={cn( "relative flex w-full items-start gap-2.5 rounded-lg px-2 py-2 text-left outline-none transition-colors duration-150 focus-visible:ring-3 focus-visible:ring-ring/40", selected ? "bg-primary/6" : "hover:bg-muted/60", )} > <span className="relative mt-0.5 shrink-0"> {avatar( c, "size-[30px]", )} <span aria-hidden="true" className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-background text-muted-foreground shadow-border" > <Icon className="size-2.5" /> </span> </span> <span className="min-w-0 flex-1"> <span className="flex items-baseline gap-2"> <span className={cn( "min-w-0 truncate text-13", c.unread && "font-medium", )} > {c.unread ? ( <span className="sr-only"> Unread:{" "} </span> ) : null} {c.name} </span> <span className="ml-auto shrink-0 text-xs tabular-nums"> {c.slot .kind === "live" ? ( <span className="font-medium text-primary"> Live </span> ) : c.slot .kind === "time" ? ( <span className="text-muted-foreground"> { c .slot .at } </span> ) : ( <span className={cn( "flex items-center gap-1", c .slot .tone === "warning" ? "font-medium text-warning-foreground" : "text-muted-foreground", )} > <span className="sr-only"> Service level{" "} </span> {c .slot .tone === "warning" ? ( <span className="size-1.5 rounded-full bg-warning" /> ) : null} { c .slot .left } </span> )} </span> </span> <span className="mt-0.5 flex items-center gap-2"> <span className="min-w-0 flex-1 truncate text-13 text-muted-foreground"> {c.preview} </span> {c.unread ? ( <span className="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-primary px-1 text-2xs font-medium text-primary-foreground tabular-nums"> { c.unread } <span className="sr-only"> {" "} unread </span> </span> ) : null} </span> <span className="mt-1 flex items-center gap-2 text-xs text-muted-foreground"> <span className="truncate"> {c.queue} </span> {c.priority ? ( <StatusLabel tone={ c.priority === "Urgent" ? "danger" : "warning" } className="ml-auto text-xs" > { c.priority } </StatusLabel> ) : null} </span> </span> </button> </li> ); })} </ul> </section> ); })} </div> </section> <section aria-label="Conversation" className={cn( "flex min-w-0 flex-1 flex-col", pane !== "conversation" && "hidden @min-[768px]:flex", )} > <header className="flex min-h-14 shrink-0 items-center gap-2 border-b border-border px-3 py-2"> <Button variant="ghost" size="icon-sm" aria-label="Back to conversations" className="@min-[768px]:hidden" onClick={() => setPane("list")} > <ChevronLeftIcon aria-hidden="true" /> </Button> {active ? avatar(active, "size-8") : null} <div className="min-w-0 flex-1"> <div className="flex items-center gap-2"> <h2 className="truncate text-sm font-semibold"> {active?.name} </h2> {active?.strategic ? ( <Tag color="violet">Strategic</Tag> ) : null} </div> <div className="flex min-w-0 items-center gap-2.5 text-xs text-muted-foreground"> <span> {active ? channels[active.channel].label : null} </span> <span className="truncate @max-[640px]:hidden"> {active?.company} </span> <span className="truncate @max-[768px]:hidden"> {active?.queue} </span> </div> </div> <Button variant="outline" size="sm" className="@max-[640px]:hidden" onClick={() => toast.add({ title: "Wrap-up started", description: "Pick a disposition to finish.", }) } > <CheckCircle2Icon data-icon="inline-start" aria-hidden="true" /> Resolve </Button> <Button variant="ghost" size="icon-sm" aria-label="Customer and AI assist" className="@min-[768px]:hidden" onClick={() => setPane("context")} > <PanelRightIcon aria-hidden="true" /> </Button> </header> <div className="min-h-0 flex-1 overflow-y-auto"> <div className="flex flex-col gap-2 p-4"> <p className="py-1 text-center text-xs text-muted-foreground"> {active ? channels[active.channel].label : ""}{" "} conversation started today </p> {active?.thread.map((message) => message.from === "note" ? ( <div key={message.id} className="flex gap-2.5 rounded-[10px] bg-muted/70 p-3 text-13" > <StickyNoteIcon aria-hidden="true" className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" /> <div className="min-w-0"> <p className="text-xs text-muted-foreground"> <span className="font-medium text-foreground"> {message.author} </span>{" "} Internal note, {message.at} </p> <p className="mt-0.5"> {message.text} </p> </div> </div> ) : ( <div key={message.id} className={cn( "flex max-w-[80%] flex-col gap-1", message.from === "customer" ? "items-start self-start" : "items-end self-end", )} > <span className="px-1 text-xs text-muted-foreground"> {message.author} {message.from === "nora" ? ", voice agent" : ""} </span> <p className={cn( "rounded-2xl px-3 py-2 text-sm whitespace-pre-line text-pretty", message.from === "customer" ? "rounded-bl-md bg-muted" : message.from === "nora" ? "rounded-br-md bg-muted/60" : "rounded-br-md bg-card shadow-border", )} > {message.text} </p> <span className="px-1 text-2xs text-muted-foreground tabular-nums"> {message.at} </span> </div> ), )} </div> </div> <div className="shrink-0 border-t border-border bg-background p-3"> <div className={cn( "relative rounded-xl bg-card shadow-border transition-[background-color,box-shadow] duration-150 focus-within:shadow-[0_0_0_1px_var(--ring)]", mode === "note" && "bg-muted/70", )} > <div className="flex items-center gap-2 px-2 pt-2"> <SegmentedControl label="Composer mode" value={mode} onValueChange={setMode} options={[ { value: "reply", label: "Reply" }, { value: "note", label: "Note", icon: ( <StickyNoteIcon className="size-3.5" /> ), }, ]} /> <span className="truncate text-xs text-muted-foreground"> {mode === "note" ? "Only your team sees notes." : `via ${active ? channels[active.channel].label : ""}`} </span> </div> <label htmlFor="workspace-composer" className="sr-only" > {mode === "note" ? "Internal note" : `Reply to ${active?.name}`} </label> <textarea id="workspace-composer" value={draft} onChange={(event) => setDraft(event.target.value) } onKeyDown={(event) => { if ( event.key === "Enter" && (event.metaKey || event.ctrlKey) ) { event.preventDefault(); send(); } }} placeholder={ mode === "note" ? "Add an internal note" : `Reply to ${active?.name.split(" ")[0]}. Type / for macros` } className="block max-h-48 min-h-16 w-full resize-none bg-transparent px-3 py-2 text-sm outline-none [field-sizing:content] placeholder:text-muted-foreground" /> <div className="flex items-center gap-0.5 px-1.5 pb-1.5"> {mode === "reply" ? ( <AIGenerateButton label="Draft reply" pendingLabel="Drafting…" simulateMs={1100} toastTitle="Draft ready" toastDescription="Written from the conversation and the payment record. Review before sending." onGenerate={() => `Hi ${active?.name.split(" ")[0]}, the Sep 30 payment run cleared at 6:10 AM. It covers 6 invoices for $48,210.00, and the trace number is TRN-88412. It should post to your bank today.` } onResult={setDraft} /> ) : null} <span className="ml-auto hidden items-center gap-1 text-xs text-muted-foreground @min-[768px]:flex"> <Kbd>⌘</Kbd> <Kbd>↵</Kbd> </span> <Button size="sm" className="ml-2" disabled={!draft.trim()} onClick={send} > <SendIcon data-icon="inline-start" aria-hidden="true" /> {mode === "note" ? "Add note" : "Send"} </Button> </div> </div> </div> </section> <aside aria-label="Customer context and AI assist" className={cn( "flex w-full min-w-0 flex-col border-border bg-background @min-[768px]:w-[30%] @min-[768px]:min-w-64 @min-[768px]:border-l", pane !== "context" && "hidden @min-[768px]:flex", )} > <Tabs value={tab} onValueChange={(value) => setTab(String(value))} className="min-h-0 flex-1 gap-0" > <div className="flex h-11 shrink-0 items-center gap-1 border-b border-border px-2"> <Button variant="ghost" size="icon-sm" aria-label="Back to the conversation" className="@min-[768px]:hidden" onClick={() => setPane("conversation")} > <ChevronLeftIcon aria-hidden="true" /> </Button> <TabsList> <TabsTrigger value="customer"> <UserRoundIcon aria-hidden="true" /> Customer </TabsTrigger> <TabsTrigger value="assist"> <SparklesIcon aria-hidden="true" /> AI assist </TabsTrigger> </TabsList> </div> <TabsContent value="customer" className="min-h-0 flex-1 overflow-y-auto" > <div className="flex flex-col gap-5 p-4"> <div className="flex items-center gap-3"> {active ? avatar(active, "size-10 text-sm") : null} <div className="min-w-0"> <p className="truncate text-sm font-semibold"> {active?.name} </p> <p className="truncate text-13 text-muted-foreground"> AP lead, {active?.company} </p> </div> </div> <dl className="grid grid-cols-3 gap-2"> {[ ["Conversations", "12"], ["CSAT", "4.8"], ["Supplier since", "2023"], ].map(([label, value]) => ( <div key={label} className="flex min-w-0 flex-col gap-0.5 rounded-[10px] bg-muted/70 px-2.5 py-2" > <dt className="truncate text-xs text-muted-foreground"> {label} </dt> <dd className="text-13 font-medium tabular-nums"> {value} </dd> </div> ))} </dl> <section className="flex flex-col gap-2"> <h3 className="text-13 font-semibold"> Invoices </h3> <ul className="flex flex-col"> {[ [ "INV-20931", "$12,480.00", "Paid", "success", ], [ "INV-20944", "$8,212.75", "On hold", "warning", ], [ "INV-20957", "$1,904.50", "Scheduled", "neutral", ], ].map(([id, amount, label, tone]) => ( <li key={id} className="flex min-h-8 items-center gap-2 text-13" > <ReceiptIcon aria-hidden="true" className="size-3.5 text-muted-foreground" /> <span className="font-mono text-xs"> {id} </span> <span className="ml-auto tabular-nums"> {amount} </span> <StatusLabel tone={tone as StatusTone} className="w-24 justify-end" > {label} </StatusLabel> </li> ))} </ul> </section> </div> </TabsContent> <TabsContent value="assist" className="min-h-0 flex-1 overflow-y-auto" > <div className="flex flex-col gap-5 p-4"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-13"> <span className="text-muted-foreground"> Detected intent </span> <span className="font-medium"> Payment status </span> <StatusLabel tone="neutral" className="ml-auto" > Neutral </StatusLabel> </div> <RecommendationCard icon={<ReceiptIcon />} title="Send the trace number for PR-0930" rationale="The run cleared this morning. Suppliers who get a trace number rarely call back about the same payment." confidence={0.86} impact={{ label: "Saves", value: "a callback, about 6 min", }} actions={ <> <Button variant="outline" size="sm" onClick={() => setDraft( "The trace number for the Sep 30 payment is TRN-88412. Your bank can use it to find the deposit.", ) } > Insert in reply </Button> <Button variant="ghost" size="sm"> Not now </Button> </> } /> <section className="flex flex-col gap-2"> <h3 className="text-13 font-semibold"> Suggested replies </h3> {[ "It cleared at 6:10 AM and should post today.", "I'll email you the remittance advice now.", ].map((reply) => ( <div key={reply} className="flex items-start gap-2 rounded-[10px] bg-muted/70 p-2.5 text-13" > <span className="min-w-0 flex-1"> {reply} </span> <Button variant="ghost" size="xs" onClick={() => setDraft(reply)} > Insert </Button> </div> ))} </section> </div> </TabsContent> </Tabs> </aside> </section> </AppShell> );}The panes here are flex columns at the product's default sizes, so they fold with container queries inside the frame. The product uses Resizable panels and switches to one pane with useIsMobile; the starter below has both.
Anatomy#
Nine parts across three panes. Each pane has its own 44 to 56px header and scrolls on its own.
- App header. Crumbs, the
h1as screen-reader text, today's handled count, handle time and CSAT from 1280px, the queue selector and the presence control. - Queue. A view menu with a count and a channel filter, then sections: Assigned to me, Unassigned in my queues, Waiting on customer. Sorted by priority, then service level.
- Queue row. A 30px avatar with a channel badge, the name (medium when unread), Live or the service-level timer, a one-line preview with an unread count, the queue and a priority label.
- Conversation header. Back on phones, the customer, tier, channel, company and queue, then outline actions such as Assign to me and Resolve, and the context button on phones.
- Thread. Inbound bubbles on Well Gray, outbound on a lifted card, internal notes and Nora's handoffs in tint wells. On calls, the live transcript replaces it.
- Composer. Reply or Note, the reply channel, a growing textarea with / for macros, Draft reply with AI, the ⌘↵ hint and Send, the pane's one filled button.
- Context tabs. Customer and AI assist in the default Tabs track. Accepting a transfer switches to AI assist; finishing wrap-up switches back.
- AI assist. Intent and sentiment with labels, a Recommendation card, suggested replies that insert into the composer, knowledge sources and Ask AI.
- Offer card. An incoming transfer floats at the bottom of the queue with a countdown, Decline with D and Accept with A. Below 768px it pins to the bottom of the screen.
Measurements#
From the workspace layout and DESIGN.md's Layout section.
| Part | Value | Notes |
|---|---|---|
| Column | 100svh | flex h-svh min-h-0 flex-col; the window never scrolls. |
| Queue pane | 25%, 240px to 36% | Hairline right edge; the handle beside it is transparent. |
| Conversation pane | 45%, at least 30% | A 1px handle on its right. |
| Context pane | 30%, 260px to 40% | bg-background, scrolls on its own. |
| Pane toolbars | 44px | h-11 border-b px-2 |
| Conversation header | 56px minimum | min-h-14 px-3 py-2 |
| Queue rows | 8px padding, 30px avatar | rounded-lg px-2 py-2, selected bg-primary/6 |
| Composer | 64px to 192px tall | min-h-16 max-h-48, [field-sizing:content] |
| Offer card | 8px from the queue's edges | absolute inset-x-2 bottom-2 |
Responsive behavior#
Below 768px the workspace is one pane with a back affordance. From 768px all three panes show.
Below 768px the workspace shows one pane at a time. Pick a conversation from the queue, open the customer and AI assist from the header, and step back with the back buttons. The presence control drops its reason.
| Width | What changes |
|---|---|
| 390px | One pane. Choosing a row opens the conversation; the conversation header has Back to conversations and Customer and AI assist; the context pane has Back to the conversation. The offer card pins to the bottom of the screen. |
| 768px | Three panes at their minimums. Resolve moves into the more menu below 640px. |
| 1024px | Three panes with room for the conversation. |
| 1440px | Panes grow; from 1280px today's stats show in the header. |
Between 768 and 1024px, with the sidebar open, the queue and context minimums leave the conversation only a sliver. There is no two-pane middle step. Until there is, agents at that width should collapse the sidebar to the icon rail.
States#
Each pane owns its states, and presence changes what the queue will offer.
| Region | States it owns |
|---|---|
| Presence | Available (Ledger Green), Busy with a reason (amber), On a call (indigo, pulsing), Wrap-up (blue), Away (Faint Slate) and Offline (a hollow dot). Locked during calls and wrap-up. |
| Queue | Loading as skeleton rows, filtered to nothing (No conversations match these filters, with Clear filters), empty (You're all caught up) and error (Couldn't load your queue). |
| Queue rows | Selected, unread, live, service level near breach (amber, two minutes left) and breached (red, with over). |
| Conversation | Nothing selected (Pick a conversation), someone else's, on a call, in wrap-up, waiting on the customer and resolved. |
| Composer | Reply and Note modes, drafting with AI (shimmering text), the macro menu, and disabled Send while empty. |
| Context | No conversation, loading, no supplier matched, and AI assist unavailable, each in the pane's place. |
Keyboard#
Keyboard-first: an agent can work the whole queue without the mouse. Single-letter keys are ignored while typing.
| Keys | Action |
|---|---|
| J | Open the next conversation in queue order. |
| K | Open the previous conversation. |
| A | Accept the incoming offer. |
| D | Decline the incoming offer. |
| ⌘↵ | Send the reply or note; complete wrap-up. |
| / | Open macros from an empty composer. |
| ↑↓ | Move through macros; Enter or Tab inserts one. |
| Esc | Close the macro menu and clear the slash. |
- J and K select without moving focus, and scroll the row into view with
block: "nearest". Nothing animates on a keyboard move. - The offer card is an
alertdialogthat doesn't take focus, so typing a reply is never interrupted by a ring.
Starter code#
The layout switch between three resizable panes and one pane, and the page around it.
// components/contact-center/workspace/workspace-layout.tsximport { useIsMobile } from "@oration/canon/hooks/use-mobile";import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@oration/canon/components/resizable";export function WorkspaceLayout({ list, center, rail, mobilePane }: WorkspaceLayoutProps) { const isMobile = useIsMobile(); // below 768px if (isMobile) { // One pane at a time. Each pane's header carries its own back button. return ( <div className="relative min-h-0 flex-1"> {mobilePane === "list" ? list : mobilePane === "conversation" ? center : rail} </div> ); } return ( <ResizablePanelGroup orientation="horizontal" className="min-h-0 flex-1"> <ResizablePanel id="cc-queue" defaultSize="25%" minSize="240px" maxSize="36%"> <div className="h-full border-r border-border">{list}</div> </ResizablePanel> <ResizableHandle className="bg-transparent" /> <ResizablePanel id="cc-conversation" defaultSize="45%" minSize="30%"> <div className="h-full min-w-0">{center}</div> </ResizablePanel> <ResizableHandle /> <ResizablePanel id="cc-context" defaultSize="30%" minSize="260px" maxSize="40%"> {rail} </ResizablePanel> </ResizablePanelGroup> );}// The page: a full-height tool with presence in the header<div className="flex h-svh min-h-0 flex-col"> <AppHeader heading="Contact Center workspace" crumbs={[{ label: "Contact Center", href: "/contact-center" }, { label: "Workspace" }]} actions={<><QueueSelector /><PresenceControl compact={isMobile} /></>} > <TodayStats /> </AppHeader> <WorkspaceLayout list={<QueueList />} center={<ConversationPane />} rail={<ContextRail />} mobilePane={pane} /></div>