CollaborationTicket list
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callTicket 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add ticket-listReact
npx designme add ticket-list --reactThe 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.
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
- A coloured dot marks
priority: urgent red, high amber, normal accent and low grey, with the name in a tooltip. - While shut, each row shows the customer and a preview of their
question, plus thewaitingtime and assignee avatar. - Opening a row shows the question and the agent's
replyin an editable box; Edit puts the cursor at the end of it. - Send and solve calls
onSendwith the ticket and the reply as edited; a promise shows a spinner, then the row fades out, folds its height away so the rows under it slide up, and moves to Solved. - An Open and Solved switch filters the list, and an empty filter shows
emptyLabel, "All caught up" by default. tickets,filterandopencan each be controlled or left to the component.
- Each ticket row is a button with
aria-expandedreporting whether it is open. - The Send and solve button sets
aria-busywhile sending. - The Open and Solved switch is a
grouplabelled Filter, witharia-pressedon each option.
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>;
}
Props
Everything TicketList takes.
| Prop | Type | What it does |
|---|---|---|
tickets | TicketListTicket[] | The tickets. Leave it out to let Send and solve move them to Solved by itself |
defaultTickets | TicketListTicket[] | |
onTicketsChange | (tickets: TicketListTicket[]) => void | |
filter | TicketListStatus | Which tickets show. Leave it out to let the switch change it |
defaultFilter | TicketListStatus | |
onFilterChange | (filter: TicketListStatus) => void | |
open | string | null | The id of the open ticket, or null. Leave it out to let the rows open and shut |
defaultOpen | string | 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 |
title | string | |
filterLabels | Record<TicketListStatus, string> | The switch's two labels |
emptyLabel | string | Shown when no ticket matches the filter |
replyLabel | string | |
editLabel | string | |
sendLabel | string | |
className | string |
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); } });
});
Questions
What people ask about it.
- Does the reply that gets sent include the person's edits?
- Yes.
onSendreceives 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
onSendrejects, 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
openwithonOpenChangeand set it to that ticket's id. The example opens the urgent ticket a second after it loads.
More in Collaboration
Threads, inboxes and boards for teams working alongside an agent.