Skip to content

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.

Routes built on the workspace
RouteWhat it shows
/contact-centerThe Contact Center workspace: queue, conversation and customer context, with incoming transfers from Nora.
/contact-center/cc-2044The same workspace deep-linked to one conversation; on a phone it opens on the conversation pane.
/inboxThe 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 ⌘↵.

Contact Center workspace

Tomás Ferreira

Strategic
WhatsAppHalcyonPayments and remittances

WhatsApp conversation started today

Nora, voice agent

Hi Tomás, I've found Halcyon's account. Let me bring in someone from accounts payable.

9:02 AM
Tomás Ferreira

Thanks. Can you confirm the Sep 30 remittance went out? Our bank shows nothing yet.

9:04 AM

Aisha Bello Internal note, 9:05 AM

PR-0930 cleared on our side at 6:10 AM. Trace number is in the payment record.

Tomás Ferreira

It's $48,210.00 across 6 invoices.

9:06 AM
via WhatsApp
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.

  1. App header. Crumbs, the h1 as screen-reader text, today's handled count, handle time and CSAT from 1280px, the queue selector and the presence control.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. Context tabs. Customer and AI assist in the default Tabs track. Accepting a transfer switches to AI assist; finishing wrap-up switches back.
  8. AI assist. Intent and sentiment with labels, a Recommendation card, suggested replies that insert into the composer, knowledge sources and Ask AI.
  9. 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.

Workspace measurements
PartValueNotes
Column100svhflex h-svh min-h-0 flex-col; the window never scrolls.
Queue pane25%, 240px to 36%Hairline right edge; the handle beside it is transparent.
Conversation pane45%, at least 30%A 1px handle on its right.
Context pane30%, 260px to 40%bg-background, scrolls on its own.
Pane toolbars44pxh-11 border-b px-2
Conversation header56px minimummin-h-14 px-3 py-2
Queue rows8px padding, 30px avatarrounded-lg px-2 py-2, selected bg-primary/6
Composer64px to 192px tallmin-h-16 max-h-48, [field-sizing:content]
Offer card8px from the queue's edgesabsolute inset-x-2 bottom-2

Responsive behavior#

Below 768px the workspace is one pane with a back affordance. From 768px all three panes show.

Contact Center workspace

Tomás Ferreira

Strategic
WhatsAppHalcyonPayments and remittances

WhatsApp conversation started today

Nora, voice agent

Hi Tomás, I've found Halcyon's account. Let me bring in someone from accounts payable.

9:02 AM
Tomás Ferreira

Thanks. Can you confirm the Sep 30 remittance went out? Our bank shows nothing yet.

9:04 AM

Aisha Bello Internal note, 9:05 AM

PR-0930 cleared on our side at 6:10 AM. Trace number is in the payment record.

Tomás Ferreira

It's $48,210.00 across 6 invoices.

9:06 AM
via WhatsApp

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.

Workspace at each width
WidthWhat changes
390pxOne 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.
768pxThree panes at their minimums. Resolve moves into the more menu below 640px.
1024pxThree panes with room for the conversation.
1440pxPanes 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.

States by region
RegionStates it owns
PresenceAvailable (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.
QueueLoading 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 rowsSelected, unread, live, service level near breach (amber, two minutes left) and breached (red, with over).
ConversationNothing selected (Pick a conversation), someone else's, on a call, in wrap-up, waiting on the customer and resolved.
ComposerReply and Note modes, drafting with AI (shimmering text), the macro menu, and disabled Send while empty.
ContextNo 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.

Keyboard interactions
KeysAction
JOpen the next conversation in queue order.
KOpen the previous conversation.
AAccept the incoming offer.
DDecline 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.
EscClose 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 alertdialog that 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.

A three-pane workspace
// 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>

Rules that apply#

The Label-Beside-Color Rule

Status is never color alone. A dot, tint or ring always travels with a text label (On track, At risk, Running, Passed), so the state reads in grayscale.

The Quiet Indigo Rule

Indigo is spent on the view's one filled primary action, on selection, on focus, and on live state that carries a text label (running, active, upcoming), plus the viewer's own markers: the unread dot, an @mention of you, the unsaved-changes dot. It never fills data, tints an icon tile, colors a heading or decorates.

The One Filled Button Rule

Each view has at most one filled indigo button, the action the view exists for. Everything else is outline, secondary, ghost, link or a destructive tint. In a stack of proposals, only the expanded one shows its filled button.

The Tint Well Rule

Inside a card, a sub-region is a Well Gray tint at 70% with a 10px radius, never a second bordered or shadowed card. No card is ever nested in a card.

Do and don't#

Available42:10
Do. Pair presence with its label and time in status: a dot, Available, 42:10.
PR
Don't. Show presence as a colored dot alone on the avatar. Amber and green read the same to many people.
Do. Keep AI assist in the context pane, where suggestions insert into the composer for the agent to send.
Don't. Send AI replies straight to the customer from the rail, or open a chat panel over the conversation.
Do. Collapse to one pane below 768px, with a back button in each pane's header.
Don't. Shrink three panes onto a phone so every one is too narrow to read.