Skip to content
Star on GitHub
CollaborationTicket list
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Ticket list

Support tickets the agent has triaged, with suggested replies. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add ticket-list
Support
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add ticket-list

React

$npx designme add ticket-list --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Ticket list is a support queue where the agent has already triaged each ticket and drafted a reply. Rows show a priority dot, the customer, how long they have waited and who the ticket is assigned to. Opening a ticket shows the customer's message and the suggested reply, which a person can edit before choosing Send and solve.

  • When an agent triages incoming support tickets and drafts answers for a person to approve
  • When a support lead wants urgent tickets to stand out by colour
  • When replies should be edited in place before they go out
  • When solved tickets should leave the open queue but stay one click away
03

Usage

Import it and drive it from your own state.

Import

import { TicketList } from '@/components/designme/ticket-list/TicketList';

Example

The demo above as a React component, 29 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { TicketList, type TicketListTicket } from './TicketList';
import { useStory } from '../lib/useScript';

/* ---------- Ticket list: four triaged tickets, the urgent one opens with the agent's reply ---------- */
const MAYA = { initials: 'MK', color: 'var(--dm-av-l)' };
const TICKETS: TicketListTicket[] = [
  { id: 'export', priority: 'urgent', title: 'Agent stopped mid export', from: 'Northwind', assignee: MAYA, waiting: '4m', status: 'open',
    question: 'The token export stopped at 60% and nothing has happened for 20 minutes.',
    reply: 'Sorry about that. The run paused at your $5 spend cap. I have raised it for this run and it finished just now. Your tokens are in the Exports tab.' },
  { id: 'figma', priority: 'high', title: 'Figma frames not syncing', from: 'Lumen Labs', assignee: { initials: 'JO', color: 'var(--dm-av-m)' }, waiting: '22m', status: 'open',
    question: 'New frames from this morning are not showing up in DesignMe.',
    reply: 'The Figma token for your workspace expired yesterday. Reconnect it from Settings, then the frames will sync within a minute.' },
  { id: 'share', priority: 'normal', title: 'How do I share a run?', from: 'Orbit', assignee: { initials: 'DA', color: 'var(--dm-av-b)' }, waiting: '1h', status: 'open',
    question: 'Can I send an agent run to someone outside my team?',
    reply: 'Yes. Open the run, click Share, and turn on "Anyone with the link". They can view it without an account.' },
  { id: 'invoice', priority: 'low', title: 'Invoice address change', from: 'Kite Health', assignee: MAYA, waiting: '2d', status: 'done' }
];

export default function TicketListDemo() {
  const [open, setOpen] = useState<string | null>(null);
  // the preview opens the first ticket on its own, unless you take over first
  const ref = useRef<HTMLDivElement>(null);
  useStory(ref, async ({ wait }) => { await wait(1000); setOpen(o => o ?? 'export'); });
  return <div ref={ref} style={{ display: 'contents' }}><TicketList defaultTickets={TICKETS} open={open} onOpenChange={setOpen} onSend={() => new Promise(res => setTimeout(res, 700))} /></div>;
}
04

Props

Everything TicketList takes.

PropTypeWhat it does
ticketsTicketListTicket[]The tickets. Leave it out to let Send and solve move them to Solved by itself
defaultTicketsTicketListTicket[]
onTicketsChange(tickets: TicketListTicket[]) => void
filterTicketListStatusWhich tickets show. Leave it out to let the switch change it
defaultFilterTicketListStatus
onFilterChange(filter: TicketListStatus) => void
openstring | nullThe id of the open ticket, or null. Leave it out to let the rows open and shut
defaultOpenstring | null
onOpenChange(id: string | null) => void
onSend(ticket: TicketListTicket, reply: string) => void | Promise<void>Sends the reply, edited or not. Return a promise and the button spins until it settles; then the ticket fades out and is solved
titlestring
filterLabelsRecord<TicketListStatus, string>The switch's two labels
emptyLabelstringShown when no ticket matches the filter
replyLabelstring
editLabelstring
sendLabelstring
classNamestring
05

Code

The full source, as the CLI installs it.

TicketList.tsxReact · 162 lines
'use client';
import { useEffect, useRef, useState, type AnimationEvent } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useReducedMotion } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Avatar, enter, Seg, Spin, Tip } from '../lib/motion';
import '../designme.css';
import './ticket-list.css';

export type TicketListPriority = 'urgent' | 'high' | 'normal' | 'low';
export type TicketListStatus = 'open' | 'done';

export interface TicketListTicket {
  id: string;
  /** What it is about, like "Figma frames not syncing" */
  title: string;
  /** Who asked, like "Lumen Labs" */
  from: string;
  /** What they wrote. Shown after the customer while the ticket is shut, and above the reply when it opens */
  question?: string;
  /** The reply the agent suggests. People can edit it before sending */
  reply?: string;
  /** The coloured dot in front: urgent red, high amber, normal accent, low grey */
  priority?: TicketListPriority;
  /** Who it is assigned to, shown as a small avatar */
  assignee: { initials: string; color: string };
  /** How long it has waited, like "4m" */
  waiting: string;
  status: TicketListStatus;
}

export interface TicketListProps {
  /** The tickets. Leave it out to let Send and solve move them to Solved by itself */
  tickets?: TicketListTicket[];
  defaultTickets?: TicketListTicket[];
  onTicketsChange?: (tickets: TicketListTicket[]) => void;
  /** Which tickets show. Leave it out to let the switch change it */
  filter?: TicketListStatus;
  defaultFilter?: TicketListStatus;
  onFilterChange?: (filter: TicketListStatus) => void;
  /** The id of the open ticket, or null. Leave it out to let the rows open and shut */
  open?: string | null;
  defaultOpen?: string | null;
  onOpenChange?: (id: string | null) => void;
  /** Sends the reply, edited or not. Return a promise and the button spins until it settles; then the ticket fades out and is solved */
  onSend?: (ticket: TicketListTicket, reply: string) => void | Promise<void>;
  title?: string;
  /** The switch's two labels */
  filterLabels?: Record<TicketListStatus, string>;
  /** Shown when no ticket matches the filter */
  emptyLabel?: string;
  replyLabel?: string;
  editLabel?: string;
  sendLabel?: string;
  className?: string;
}

const PRIORITY: Record<TicketListPriority, { label: string; color: string }> = {
  urgent: { label: 'Urgent', color: 'var(--dm-bad)' },
  high: { label: 'High', color: 'var(--dm-warn)' },
  normal: { label: 'Normal', color: 'var(--dm-accent)' },
  low: { label: 'Low', color: 'var(--dm-faint)' }
};

// how long a solved ticket takes to fade, then to fold its height away
const FADE = 330, FOLD = 260;

function Ticket({ ticket, index, isOpen, leaving, busy, onToggle, onSend, labels }: {
  ticket: TicketListTicket; index: number; isOpen: boolean; leaving: boolean; busy: boolean;
  onToggle: () => void; onSend: (reply: string) => void; labels: { reply: string; edit: string; send: string };
}) {
  // rows come in one after another, then drop the entrance so a later fade-out starts on time
  const [entering, setEntering] = useState(true);
  const text = useRef<HTMLDivElement>(null);
  const row = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();
  // a solved ticket fades, then folds its height away, so the tickets under it slide up instead of jumping
  useEffect(() => {
    const el = row.current; if (!leaving || !el || reduce) return;
    el.style.overflow = 'hidden';
    const a = el.animate([{ height: el.offsetHeight + 'px' }, { height: '0px' }], { duration: FOLD, delay: FADE, easing: 'cubic-bezier(.22,1,.36,1)', fill: 'forwards' });
    return () => a.cancel();
  }, [leaving, reduce]);
  const p = PRIORITY[ticket.priority ?? 'normal'];
  const edit = () => {
    const el = text.current; if (!el) return;
    el.focus();
    const sel = document.getSelection(); sel?.selectAllChildren(el); sel?.collapseToEnd();
  };
  return (
    <div ref={row} className={cx('dm-tkx-t', isOpen && 'dm-open', entering && 'dm-enter', leaving && 'dm-leave')} style={entering ? enter(index * 40).style : undefined}
      onAnimationEnd={(e: AnimationEvent) => { if (e.target === e.currentTarget && entering) setEntering(false); }}>
      <button type="button" className="dm-tkx-r" aria-expanded={isOpen} onClick={leaving ? undefined : onToggle}>
        <Tip label={p.label}><span className="dm-pr"><span className="dm-dot" style={{ color: p.color }} /></span></Tip>
        <span className="dm-t"><b>{ticket.title}</b><span>{ticket.from}{ticket.question && !isOpen ? ' · ' + ticket.question : ''}</span></span>
        <span className="dm-m"><span className="dm-w">{ticket.waiting}</span><Avatar initials={ticket.assignee.initials} color={ticket.assignee.color} /></span>
      </button>
      {isOpen && (
        <div className="dm-tkx-d">
          <div className="dm-tkx-q dm-enter" style={enter(0).style}>{ticket.question}</div>
          <div className="dm-tkx-s dm-enter" style={enter(50).style}>
            <div className="dm-lb"><Icon name="spark" size="xs" />{labels.reply}</div>
            <div ref={text} className="dm-tx" contentEditable suppressContentEditableWarning spellCheck={false}>{ticket.reply}</div>
          </div>
          <div className="dm-tkx-a dm-enter" style={enter(100).style}>
            <button type="button" className="dm-pill dm-ghost dm-sm" onClick={edit}><Icon name="edit" size="xs" />{labels.edit}</button>
            <button type="button" className="dm-pill dm-accent dm-sm" aria-busy={busy} onClick={() => { if (!busy) onSend(text.current?.textContent ?? ticket.reply ?? ''); }}>
              {busy ? <Spin /> : <><Icon name="send" size="xs" />{labels.send}</>}
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

/** Support tickets the agent has triaged, with suggested replies. */
export function TicketList({ tickets, defaultTickets = [], onTicketsChange, filter, defaultFilter = 'open', onFilterChange, open, defaultOpen = null, onOpenChange, onSend,
  title = 'Support', filterLabels = { open: 'Open', done: 'Solved' }, emptyLabel = 'All caught up', replyLabel = 'Suggested reply', editLabel = 'Edit', sendLabel = 'Send and solve', className }: TicketListProps) {
  const [list, setList] = useControllable(tickets, defaultTickets, onTicketsChange);
  const [shown, setShown] = useControllable(filter, defaultFilter, onFilterChange);
  const [openId, setOpen] = useControllable(open, defaultOpen, onOpenChange);
  const [busy, setBusy] = useState<string | null>(null);
  const [leaving, setLeaving] = useState<string | null>(null);
  const reduce = useReducedMotion();
  const alive = useRef(true);
  useEffect(() => () => { alive.current = false; }, []);
  // the latest list, for when a send settles after other changes
  const latest = useRef(list);
  useEffect(() => { latest.current = list; });

  const send = async (t: TicketListTicket, reply: string) => {
    setBusy(t.id);
    try { await onSend?.(t, reply); } catch { if (alive.current) setBusy(null); return; }
    if (!alive.current) return;
    setLeaving(t.id);
    await new Promise(res => setTimeout(res, reduce ? 0 : FADE + FOLD));
    if (!alive.current) return;
    setBusy(null); setLeaving(null); setOpen(null);
    setList(latest.current.map(x => x.id === t.id ? { ...x, status: 'done', reply } : x));
  };
  const rows = list.filter(t => t.status === shown);

  return (
    <div data-dm="" className={cx('dm-tkx dm-panel', className)}>
      <div className="dm-tkx-h">
        <b>{title}</b>
        <Seg className="dm-tkx-sg" label="Filter" value={shown} onChange={v => { setOpen(null); setShown(v); }}
          options={[{ value: 'open', label: filterLabels.open }, { value: 'done', label: filterLabels.done }]} />
      </div>
      {/* a new filter brings its rows in again */}
      <div className="dm-tkx-list" key={shown}>
        {rows.length ? rows.map((t, i) => (
          <Ticket key={t.id} ticket={t} index={i} isOpen={openId === t.id} leaving={leaving === t.id} busy={busy === t.id}
            onToggle={() => setOpen(openId === t.id ? null : t.id)} onSend={reply => send(t, reply)}
            labels={{ reply: replyLabel, edit: editLabel, send: sendLabel }} />
        )) : <div className="dm-tkx-e">{emptyLabel}</div>}
      </div>
    </div>
  );
}
ticket-list.cssReact · 34 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Ticket list ---------- */
/* rows run edge to edge inside the panel, so there is one surface instead of nested ones */
/* the panel fits the tickets it lists, however few are left */
.dm-tkx{padding:var(--dm-s-3) 0 0;display:flex;flex-direction:column;gap:6px;overflow:hidden}
.dm-tkx-h{display:flex;align-items:center;justify-content:space-between;padding:0 var(--dm-s-4) var(--dm-s-1);font-size:14px}
.dm-tkx-h b{font-weight:500}
.dm-tkx-sg button{font-size:12px;height:26px;padding:0 9px}
.dm-tkx-list{display:flex;flex-direction:column}
.dm-tkx-t{transition:background .2s;box-shadow:inset 0 1px 0 color-mix(in srgb,var(--dm-line) 50%,transparent)}
@media (hover:hover) and (pointer:fine){.dm-tkx-t:hover{background:var(--dm-row)}}
.dm-tkx-t.dm-open{background:color-mix(in srgb,var(--dm-wash) 50%,var(--dm-surface))}
.dm-tkx-r{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:10px;width:100%;padding:10px var(--dm-s-4);text-align:left}
/* rows fill the card, whose rounded edge would cut an outside ring: theirs is drawn inside */
.dm-tkx-r:focus-visible{outline-offset:-2px}
.dm-tkx-r .dm-pr{display:flex;align-items:center;justify-content:center;width:18px;height:18px}
.dm-tkx-r .dm-pr .dm-dot{width:8px;height:8px}
.dm-tkx-r .dm-t{display:flex;flex-direction:column;min-width:0;font-size:13px}
.dm-tkx-r .dm-t b{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-tkx-r .dm-t span{font-size:12px;color:var(--dm-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-tkx-r .dm-m{display:flex;align-items:center;gap:6px}
.dm-tkx-r .dm-m .dm-av{width:20px;height:20px;font-size:9px}
.dm-tkx-r .dm-w{font-size:11.5px;color:var(--dm-muted)}
.dm-tkx-d{padding:0 var(--dm-s-4) 14px 44px;display:flex;flex-direction:column;gap:var(--dm-s-2)}
.dm-tkx-q{font-size:12.5px;line-height:1.5;color:var(--dm-fg2)}
.dm-tkx-s{padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:12.5px;line-height:1.5;color:var(--dm-fg)}
.dm-tkx-s .dm-lb{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--dm-accent-fg,var(--dm-accent));font-weight:500;margin-bottom:var(--dm-s-1)}
/* the reply reads as text you can edit: the text cursor over it, and an accent caret once it has focus */
.dm-tkx-s [contenteditable]{outline:0;border-radius:6px;cursor:text;caret-color:var(--dm-accent-fg,var(--dm-accent))}
.dm-tkx-s [contenteditable]:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent);padding:2px var(--dm-s-1);margin:-2px -4px}
.dm-tkx-a{display:flex;gap:6px;justify-content:flex-end}
.dm-tkx-e{padding:40px 10px;text-align:center;font-size:13px;color:var(--dm-muted)}
.dm-tkx-t.dm-leave{animation:dm-leave .35s var(--dm-ease) both}
ticket-list.htmlHTML · 9 lines
<div class="card" data-c="ticket-list">
  <div class="stage-in">
    <div class="tkx panel">
      <div class="tkx-h"><b>Support</b><div class="seg2 tkx-sg" role="group" aria-label="Filter"><button aria-pressed="true" data-v="open">Open</button><button aria-pressed="false" data-v="done">Solved</button></div></div>
      <div class="tkx-list"></div>
    </div>
  </div>
</div>
ticket-list.cssHTML · 33 lines
/* ---------- Ticket list ---------- */
/* rows run edge to edge inside the panel, so there is one surface instead of nested ones */
/* the panel fits the tickets it lists, however few are left */
.tkx{padding:var(--s-3) 0 0;display:flex;flex-direction:column;gap:6px;overflow:hidden}
.tkx-h{display:flex;align-items:center;justify-content:space-between;padding:0 var(--s-4) var(--s-1);font-size:14px}
.tkx-h b{font-weight:500}
.tkx-sg button{font-size:12px;height:26px;padding:0 9px}
.tkx-list{display:flex;flex-direction:column}
.tkx-t{transition:background .2s;box-shadow:inset 0 1px 0 color-mix(in srgb,var(--line) 50%,transparent)}
@media (hover:hover) and (pointer:fine){.tkx-t:hover{background:var(--row)}}
.tkx-t.open{background:color-mix(in srgb,var(--wash) 50%,var(--surface))}
.tkx-r{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:10px;width:100%;padding:10px var(--s-4);text-align:left}
/* rows fill the card, whose rounded edge would cut an outside ring: theirs is drawn inside */
.tkx-r:focus-visible{outline-offset:-2px}
.tkx-r .pr{display:flex;align-items:center;justify-content:center;width:18px;height:18px}
.tkx-r .pr .dot{width:8px;height:8px}
.tkx-r .t{display:flex;flex-direction:column;min-width:0;font-size:13px}
.tkx-r .t b{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tkx-r .t span{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tkx-r .m{display:flex;align-items:center;gap:6px}
.tkx-r .m .av{width:20px;height:20px;font-size:9px}
.tkx-r .w{font-size:11.5px;color:var(--muted)}
.tkx-d{padding:0 var(--s-4) 14px 44px;display:flex;flex-direction:column;gap:var(--s-2)}
.tkx-q{font-size:12.5px;line-height:1.5;color:var(--fg2)}
.tkx-s{padding:10px var(--s-3);border-radius:var(--r-in);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--line) 50%,transparent);font-size:12.5px;line-height:1.5;color:var(--fg)}
.tkx-s .lb{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--accent-fg,var(--accent));font-weight:500;margin-bottom:var(--s-1)}
/* the reply reads as text you can edit: the text cursor over it, and an accent caret once it has focus */
.tkx-s [contenteditable]{outline:0;border-radius:6px;cursor:text;caret-color:var(--accent-fg,var(--accent))}
.tkx-s [contenteditable]:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent);padding:2px var(--s-1);margin:-2px -4px}
.tkx-a{display:flex;gap:6px;justify-content:flex-end}
.tkx-e{padding:40px 10px;text-align:center;font-size:13px;color:var(--muted)}
.tkx-t.leave{animation:leave .35s var(--ease) both}
ticket-list.jsHTML · 29 lines
DM.def('ticket-list', (r, c) => {
  const T = [
    { p:'var(--bad)', pn:'Urgent', t:'Agent stopped mid export', f:'Northwind', a:['MK','var(--av-l)'], w:'4m', q:'The token export stopped at 60% and nothing has happened for 20 minutes.', s:'Sorry about that. The run paused at your $5 spend cap. I have raised it for this run and it finished just now. Your tokens are in the Exports tab.', st:'open' },
    { p:'var(--warn)', pn:'High', t:'Figma frames not syncing', f:'Lumen Labs', a:['JO','var(--av-m)'], w:'22m', q:'New frames from this morning are not showing up in DesignMe.', s:'The Figma token for your workspace expired yesterday. Reconnect it from Settings, then the frames will sync within a minute.', st:'open' },
    { p:'var(--accent-mark,var(--accent))', pn:'Normal', t:'How do I share a run?', f:'Orbit', a:['DA','var(--av-b)'], w:'1h', q:'Can I send an agent run to someone outside my team?', s:'Yes. Open the run, click Share, and turn on "Anyone with the link". They can view it without an account.', st:'open' },
    { p:'var(--faint)', pn:'Low', t:'Invoice address change', f:'Kite Health', a:['MK','var(--av-l)'], w:'2d', q:'', s:'', st:'done' }
  ];
  const list = $('.tkx-list', r); let f = 'open', open = null;
  function paint(anim){
    const L = T.filter(t => t.st === f);
    list.innerHTML = L.length ? L.map(t => `<div class="tkx-t${open === t ? ' open' : ''}"><button class="tkx-r"><span class="pr" data-tip="${t.pn}"><span class="dot" style="color:${t.p}"></span></span><span class="t"><b>${t.t}</b><span>${t.f}${t.q && open !== t ? ' · ' + t.q : ''}</span></span><span class="m"><span class="w">${t.w}</span>${AV(t.a[0], t.a[1])}</span></button>${open === t ? `<div class="tkx-d"><div class="tkx-q">${t.q}</div><div class="tkx-s"><div class="lb">${I('spark','xs')}Suggested reply</div><div class="tx" contenteditable="true" spellcheck="false">${t.s}</div></div><div class="tkx-a"><button class="pill ghost sm ed">${I('edit','xs')}Edit</button><button class="pill accent sm sd">${I('send','xs')}Send and solve</button></div></div>` : ''}</div>`).join('') : '<div class="tkx-e">All caught up</div>';
    if (anim) stagger($$('.tkx-t', list), 40);
    $$('.tkx-t', list).forEach((el, i) => { const t = L[i];
      $('.tkx-r', el).onclick = () => { open = open === t ? null : t; paint(); const d = $('.tkx-d', list); if (d) stagger([...d.children], 50); };
      // a solved ticket fades, then folds its height away, so the tickets under it slide up instead of jumping
      const sd = $('.sd', el); if (sd) sd.onclick = () => c.run(async () => { sd.innerHTML = '<span class="spin"></span>'; await c.wait(700); t.s = $('.tx', el).textContent; t.st = 'done'; open = null; el.classList.add('leave'); await c.wait(330);
        if (!REDUCE){ el.style.overflow = 'hidden'; await el.animate([{ height:el.offsetHeight + 'px' }, { height:'0px' }], { duration:260, easing:'cubic-bezier(.22,1,.36,1)', fill:'forwards' }).finished.catch(() => {}); }
        if (!c.dead) paint(); });
      const ed = $('.ed', el); if (ed) ed.onclick = () => { const tx = $('.tx', el); tx.focus({ preventScroll:true }); const s = document.getSelection(); s.selectAllChildren(tx); s.collapseToEnd(); };
    });
  }
  seg($('.tkx-sg', r), v => { f = v; open = null; paint(true); });
  paint(true);
  // as tickets open, close, leave or change with the filter, the panel stays centred in the room its stage keeps for it
  const root = $('.tkx', r); centre(c, root, () => root.offsetHeight);
  // the preview opens the first ticket on its own, unless you take over first
  c.auto(1000, () => { if (!open){ open = T[0]; paint(); stagger([...$('.tkx-d', list).children], 50); } });
});
06

Questions

What people ask about it.

Does the reply that gets sent include the person's edits?
Yes. onSend receives the text currently in the reply box, edited or not. That text is also saved on the ticket when it moves to Solved.
What happens if sending fails?
If the promise from onSend rejects, the spinner stops and the ticket stays open, so the person can try again.
How do I open the most urgent ticket for the person?
Control open with onOpenChange and set it to that ticket's id. The example opens the urgent ticket a second after it loads.
07

More in Collaboration

Threads, inboxes and boards for teams working alongside an agent.

CommentsA design thread where you can @mention the agent and it replies.
NotificationsAn inbox where agent updates and mentions arrive live.
BannerAn announcement on a tint of its tone, with its own line drawing.
Invite to collaborateAdd teammates by email as chips, set their role, send.
ReactionsQuick reactions on an agent update, with counts that pop.
Profile menuThe account menu behind an avatar: switch workspace, theme, shortcuts and sign out.
Context switcherThe organisation, project and environment you are in, each part a searchable switcher.
Filter menuAdd filters from a menu, see them as chips, and watch the count change.
All 10 in Collaboration →