Sources and outputTask table
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callTask table
Agent tasks in a sortable table with bulk actions. 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 task-tableReact
npx designme add task-table --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.
Task table lists the tasks your agents are working on, with each task's status, the agent on it, its cost and when it last changed. Select some rows and the header turns into bulk actions to pause, retry or cancel them.
- When several agents run tasks at once and people need one place to see them all.
- When someone wants to retry every failed task, or pause a batch, in one go.
- When sorting by cost or status helps people find what needs attention.
- When the list can run empty and should say so clearly.
- Columns for task, status, agent, cost and updated. Click a header to sort by it, and click again to reverse the order.
- Five statuses, each with its own glyph: running, failed, queued, paused and done. Sorting by status follows that order.
- Click a row or its checkbox to select it. The select-all box shows a dash when only some rows are picked.
- While rows are selected, the header shows the count with Pause, Retry, Cancel and Clear. An action calls
onActionwith the ids and clears the selection. - Rows glide to their new places when the order changes, and cancelled rows slide away before
onActionruns. - With no tasks, the table shows a "No agent tasks" note, or your own
emptycontent, with an optionalemptyActionbutton. - Under 560px wide, on a phone or in a narrow card, each task stacks: its name and cost on top, and its status, agent and last update on a line under it. The header row keeps select all and the sort buttons, so nothing is dropped.
- Each header cell sets
aria-sortto ascending, descending or none. - Row checkboxes are buttons with
role="checkbox",aria-checkedand anaria-labellike "Select Sync Figma frames". - The select-all box uses
aria-checked="mixed"when only some rows are selected, and the hidden half of the header isinert. - With reduced motion turned on, rows skip the glide and cancelled rows leave without sliding.
Usage
Import it and drive it from your own state.
Import
import { TaskTable } from '@/components/designme/task-table/TaskTable';Example
The demo above as a React component, 45 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 { TaskTable, type Task, type TaskAction } from './TaskTable';
import { useStory } from '../lib/useScript';
/* ---------- Task table: six agent tasks; two get picked after a moment, then retried ---------- */
const DESIGN = { name: 'Design', initials: 'DS', color: 'var(--dm-av-b)' };
const TASKS: Task[] = [
{ id: '0', name: 'Audit pricing contrast', status: 'running', agent: DESIGN, cost: 0.42, updated: 2 },
{ id: '1', name: 'Export tokens to CSS', status: 'done', agent: { name: 'Code', initials: 'CO', color: 'var(--dm-av-m)' }, cost: 0.18, updated: 14 },
{ id: '2', name: 'Sync Figma frames', status: 'failed', agent: DESIGN, cost: 0.07, updated: 26 },
{ id: '3', name: 'Draft launch email', status: 'queued', agent: { name: 'Writer', initials: 'WR', color: 'var(--dm-av-l)' }, cost: 0, updated: 31 },
{ id: '4', name: 'Compare checkout flows', status: 'done', agent: { name: 'Research', initials: 'RE', color: 'var(--dm-av-t)' }, cost: 0.66, updated: 48 },
{ id: '5', name: 'Rename layers in Hero', status: 'running', agent: DESIGN, cost: 0.11, updated: 5 }
];
// what each action does to the selected tasks
const apply = (list: Task[], a: TaskAction, ids: string[]): Task[] => a === 'cancel' ? list.filter(t => !ids.includes(t.id)) : list.map(t => {
if (!ids.includes(t.id)) return t;
if (a === 'pause' && (t.status === 'running' || t.status === 'queued')) return { ...t, status: 'paused' };
if (a === 'retry' && (t.status === 'failed' || t.status === 'paused')) return { ...t, status: 'running', updated: 0 };
return t;
});
export default function TaskTableDemo() {
const [tasks, setTasks] = useState(TASKS);
const [selected, setSelected] = useState<string[]>([]);
// the preview picks two tasks and retries them, unless you take over first
const ref = useRef<HTMLDivElement>(null);
useStory(ref, async ({ wait }) => {
await wait(1600);
setSelected(s => s.length ? s : ['2', '5']);
await wait(1900);
setTasks(list => apply(list, 'retry', ['2', '5'])); setSelected([]);
});
return (
<div ref={ref} style={{ width: '100%' }}>
<TaskTable tasks={tasks} selected={selected} onSelectedChange={setSelected}
onAction={(a, ids) => setTasks(list => apply(list, a, ids))}
emptyAction={{ label: 'Bring them back', onClick: () => setTasks(TASKS) }} />
</div>
);
}
Props
Everything TaskTable takes.
| Prop | Type | What it does |
|---|---|---|
tasksrequired | Task[] | |
title | string | The name in the header. Defaults to "Agent tasks" |
summary | string | The quiet line beside the name. Defaults to a count of tasks and how many are running |
selected | string[] | Ids of the selected tasks. Leave it out to let the checkboxes and rows toggle it |
defaultSelected | string[] | |
onSelectedChange | (ids: string[]) => void | |
sort | TaskSort | The column the rows are sorted by. Leave it out to let the headers set it. Defaults to updated, newest first |
defaultSort | TaskSort | |
onSortChange | (sort: TaskSort) => void | |
onAction | (action: TaskAction, ids: string[]) => void | A button in the header while tasks are selected. The selection clears after it; cancelled rows slide away first |
empty | ReactNode | What shows when there are no tasks. Defaults to a short "No agent tasks" note |
emptyAction | { label: string; onClick: () => void } | A button under the empty note, like { label: 'Bring them back', onClick } |
className | string |
Code
The full source, as the CLI installs it.
TaskTable.tsxReact · 197 lines
'use client';
import { useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect, useReducedMotion } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Avatar, Spin, Swap } from '../lib/motion';
import '../designme.css';
import './task-table.css';
export type TaskStatus = 'running' | 'failed' | 'queued' | 'paused' | 'done';
export interface Task {
id: string;
/** What the agent is doing, like "Audit pricing contrast" */
name: string;
status: TaskStatus;
/** The agent on it, shown as a small avatar and a name */
agent: { name: string; initials: string; color: string };
/** In dollars, shown as $0.42 */
cost: number;
/** Minutes since it last changed, shown as "14m ago", or "Just now" at 0 */
updated: number;
}
export type TaskSortKey = 'name' | 'status' | 'agent' | 'cost' | 'updated';
export interface TaskSort { key: TaskSortKey; desc?: boolean }
export type TaskAction = 'pause' | 'retry' | 'cancel';
export interface TaskTableProps {
tasks: Task[];
/** The name in the header. Defaults to "Agent tasks" */
title?: string;
/** The quiet line beside the name. Defaults to a count of tasks and how many are running */
summary?: string;
/** Ids of the selected tasks. Leave it out to let the checkboxes and rows toggle it */
selected?: string[];
defaultSelected?: string[];
onSelectedChange?: (ids: string[]) => void;
/** The column the rows are sorted by. Leave it out to let the headers set it. Defaults to updated, newest first */
sort?: TaskSort;
defaultSort?: TaskSort;
onSortChange?: (sort: TaskSort) => void;
/** A button in the header while tasks are selected. The selection clears after it; cancelled rows slide away first */
onAction?: (action: TaskAction, ids: string[]) => void;
/** What shows when there are no tasks. Defaults to a short "No agent tasks" note */
empty?: ReactNode;
/** A button under the empty note, like { label: 'Bring them back', onClick } */
emptyAction?: { label: string; onClick: () => void };
className?: string;
}
const STATUS: Record<TaskStatus, { label: string; rank: number; tone: string; glyph: () => ReactNode }> = {
running: { label: 'Running', rank: 0, tone: 'run', glyph: () => <Spin /> },
failed: { label: 'Failed', rank: 1, tone: 'fail', glyph: () => <Icon name="alert" size="xs" /> },
queued: { label: 'Queued', rank: 2, tone: 'wait', glyph: () => <span className="dm-dot" /> },
paused: { label: 'Paused', rank: 3, tone: 'pause', glyph: () => <Icon name="pause" size="xs" /> },
done: { label: 'Done', rank: 4, tone: 'done', glyph: () => <Icon name="ok" size="xs" /> }
};
const COLUMNS: { key: TaskSortKey; label: string; right?: boolean }[] = [
{ key: 'name', label: 'Task' }, { key: 'status', label: 'Status' }, { key: 'agent', label: 'Agent' },
{ key: 'cost', label: 'Cost', right: true }, { key: 'updated', label: 'Updated', right: true }
];
const FIELD: Record<TaskSortKey, (t: Task) => string | number> = {
name: t => t.name, status: t => STATUS[t.status].rank, agent: t => t.agent.name, cost: t => t.cost, updated: t => t.updated
};
const ACTIONS: { key: TaskAction; label: string; icon: 'pause' | 'refresh' | 'x' }[] = [
{ key: 'pause', label: 'Pause', icon: 'pause' }, { key: 'retry', label: 'Retry', icon: 'refresh' }, { key: 'cancel', label: 'Cancel', icon: 'x' }
];
const ago = (m: number) => (m ? m + 'm ago' : 'Just now');
/** A tick that springs in, and the dash select-all shows while only some rows are picked */
const Tick = () => <><Icon name="check" size="xs" className="dm-ck-y" /><Icon name="minus" size="xs" className="dm-ck-m" /></>;
/**
* Agent tasks in a sortable table. Pick rows and the header turns into what you can do to them; rows glide when the
* order changes, cancelled ones slide away, and an empty table says so.
*/
export function TaskTable({
tasks, title = 'Agent tasks', summary, selected, defaultSelected = [], onSelectedChange, sort, defaultSort = { key: 'updated' }, onSortChange,
onAction, empty, emptyAction, className
}: TaskTableProps) {
const [sel, setSel] = useControllable(selected, defaultSelected, onSelectedChange);
const [by, setBy] = useControllable(sort, defaultSort, onSortChange);
const [leaving, setLeaving] = useState<string[]>([]);
const reduce = useReducedMotion();
const f = FIELD[by.key];
const rows = [...tasks].sort((a, b) => { const x = f(a), y = f(b); return (x > y ? 1 : x < y ? -1 : 0) * (by.desc ? -1 : 1); });
const picked = sel.filter(id => tasks.some(t => t.id === id) && !leaving.includes(id));
const n = picked.length, all = n === 0 ? 'false' : n === tasks.length ? 'true' : 'mixed';
// the header keeps its count while it slides back to the title
const [shown, setShown] = useState(n);
if (n && n !== shown) setShown(n);
const running = tasks.filter(t => t.status === 'running').length;
const line = summary ?? (tasks.length ? `${tasks.length} task${tasks.length === 1 ? '' : 's'}, ${running} running` : 'No tasks');
// rows glide to their new places: where each row sat in the table is kept after every render and eased from on the next
const body = useRef<HTMLTableSectionElement>(null);
const was = useRef(new Map<string, number>());
useIsoLayoutEffect(() => {
const now = new Map<string, number>();
body.current?.querySelectorAll<HTMLTableRowElement>('tr[data-id]').forEach(tr => {
const id = tr.dataset.id!, top = tr.offsetTop, before = was.current.get(id);
now.set(id, top);
if (!reduce && before !== undefined && Math.abs(before - top) > 1) tr.animate([{ transform: `translateY(${before - top}px)` }, { transform: 'none' }], { duration: 500, easing: 'cubic-bezier(.22,1,.36,1)' });
});
was.current = now;
});
// the empty state keeps the height the table had, centred in it, so the card doesn't shrink when the last task goes
const panel = useRef<HTMLDivElement>(null), emptyCell = useRef<HTMLTableCellElement>(null);
const full = useRef<number | undefined>(undefined);
const [pad, setPad] = useState<{ paddingTop: number; paddingBottom: number }>();
useIsoLayoutEffect(() => { if (tasks.length && panel.current) full.current = panel.current.offsetHeight; });
useIsoLayoutEffect(() => {
const p = panel.current, td = emptyCell.current;
if (tasks.length || !p || !td || !full.current) { setPad(undefined); return; }
const d = full.current - p.offsetHeight, cs = getComputedStyle(td);
setPad(d > 1 ? { paddingTop: parseFloat(cs.paddingTop) + d / 2, paddingBottom: parseFloat(cs.paddingBottom) + d / 2 } : undefined);
}, [tasks.length === 0]); // eslint-disable-line react-hooks/exhaustive-deps
const toggle = (id: string) => setSel(sel.includes(id) ? sel.filter(x => x !== id) : [...sel, id]);
const sortBy = (key: TaskSortKey) => setBy(key === by.key ? { key, desc: !by.desc } : { key, desc: false });
const act = (a: TaskAction) => {
const ids = picked; setSel([]);
if (a !== 'cancel' || reduce) { onAction?.(a, ids); return; }
setLeaving(ids);
setTimeout(() => { onAction?.(a, ids); setLeaving([]); }, 250);
};
return (
<div data-dm="" className={cx('dm-ttx-box', className)} style={{ containerType: 'inline-size', width: '100%' }}>
<div ref={panel} className={cx('dm-ttx dm-panel', n > 0 && 'dm-selecting')}>
<div className="dm-ttx-hd">
<div className="dm-ttx-ti" inert={n > 0}><b>{title}</b><Swap className="dm-muted dm-num dm-ttx-sum">{line}</Swap></div>
<div className="dm-ttx-sb" inert={n === 0}>
<span className="dm-ttx-n dm-num">{shown} selected</span>
{ACTIONS.map(a => (
<button key={a.key} type="button" className={cx('dm-pill dm-ghost dm-sm', a.key === 'cancel' && 'dm-ttx-del')} onClick={() => act(a.key)}><Icon name={a.icon} size="xs" />{a.label}</button>
))}
<span className="dm-ttx-sp" />
<button type="button" className="dm-pill dm-ghost dm-sm dm-ttx-clr" onClick={() => setSel([])}>Clear</button>
</div>
</div>
<div className="dm-ttx-sc">
<table className="dm-ttx-t">
<thead>
<tr>
<th className="dm-ck">
<button type="button" className="dm-ttx-cb dm-ttx-all" role="checkbox" aria-checked={all} aria-label="Select all" onClick={() => setSel(all === 'true' ? [] : tasks.map(t => t.id))}><Tick /></button>
</th>
{COLUMNS.map(c => (
<th key={c.key} className={c.right ? 'dm-r' : undefined} aria-sort={by.key === c.key ? (by.desc ? 'descending' : 'ascending') : 'none'}>
<button type="button" className={cx('dm-ttx-s', by.key === c.key && 'dm-on', by.desc && 'dm-desc')} onClick={() => sortBy(c.key)}>
{c.label}<span className="dm-ar"><Icon name="chev-u" size="xs" /></span>
</button>
</th>
))}
</tr>
</thead>
<tbody ref={body}>
{rows.map((t, i) => {
const on = picked.includes(t.id), st = STATUS[t.status];
return (
<tr key={t.id} data-id={t.id} className={cx('dm-enter', on && 'dm-sel', leaving.includes(t.id) && 'dm-ttx-out')} style={{ animationDelay: i * 30 + 'ms' }} onClick={() => toggle(t.id)}>
<td className="dm-ck">
<button type="button" className="dm-ttx-cb" role="checkbox" aria-checked={on} aria-label={'Select ' + t.name}><Tick /></button>
</td>
<td className="dm-nm">{t.name}</td>
<td><span className="dm-ttx-st" data-s={st.tone}><span key={t.status} className="dm-sv dm-ttx-sin">{st.glyph()}<span>{st.label}</span></span></span></td>
<td><span className="dm-ag"><Avatar initials={t.agent.initials} color={t.agent.color} />{t.agent.name}</span></td>
<td className="dm-r dm-num">${t.cost.toFixed(2)}</td>
<td className="dm-r dm-num dm-up"><Swap>{ago(t.updated)}</Swap></td>
</tr>
);
})}
{!tasks.length && (
<tr className="dm-ttx-empty dm-enter">
<td colSpan={6} ref={emptyCell} style={pad}>
{empty ?? (
<div className="dm-ttx-em">
<span className="dm-ic"><Icon name="check" /></span>
<b>No agent tasks</b>
<span>Everything has finished or been cancelled.</span>
{emptyAction && <button type="button" className="dm-pill dm-ghost dm-sm" onClick={emptyAction.onClick}><Icon name="refresh" size="xs" />{emptyAction.label}</button>}
</div>
)}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
task-table.cssReact · 92 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Task table ---------- */
.dm-ttx{overflow:hidden;position:relative;container:ttx/inline-size}
/* the header holds the table's name and a summary, or, while rows are picked, what you can do to them */
.dm-ttx-hd{position:relative;height:50px;border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-ttx-ti,.dm-ttx-sb{position:absolute;inset:0;display:flex;align-items:center;transition:opacity .28s var(--dm-ease),transform .45s var(--dm-ease),filter .28s var(--dm-ease)}
.dm-ttx-ti{gap:10px;padding:0 var(--dm-s-4)}
.dm-ttx-ti b{font-size:13.5px;font-weight:500}
.dm-ttx-sum{font-size:12.5px}
.dm-ttx-sb{gap:2px;padding:0 10px 0 var(--dm-s-4);opacity:0;transform:translateY(10px);filter:blur(3px);pointer-events:none}
.dm-ttx.dm-selecting .dm-ttx-ti{opacity:0;transform:translateY(-10px);filter:blur(3px);pointer-events:none}
.dm-ttx.dm-selecting .dm-ttx-sb{opacity:1;transform:none;filter:none;pointer-events:auto}
.dm-ttx-n{margin-right:var(--dm-s-2);font-size:13px;font-weight:500;white-space:nowrap}
.dm-ttx-sp{flex:1}
.dm-ttx-sb .dm-pill.dm-ttx-del{color:var(--dm-bad)}
@media (hover:hover) and (pointer:fine){.dm-ttx-sb .dm-pill.dm-ttx-del:hover{background:color-mix(in srgb,var(--dm-bad) 10%,transparent);color:var(--dm-bad)}}
.dm-ttx-sc{overflow-x:auto}
.dm-ttx-t{width:100%;border-collapse:collapse;font-size:13px;min-width:560px}
.dm-ttx-t th{height:36px;padding:0 var(--dm-s-2);text-align:left;font-weight:500;font-size:12px;color:var(--dm-muted);border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);white-space:nowrap}
.dm-ttx-t th.dm-ck,.dm-ttx-t td.dm-ck{width:36px;padding-left:var(--dm-s-4)}
.dm-ttx-t th.dm-r,.dm-ttx-t td.dm-r{text-align:right}
.dm-ttx-t th:last-child,.dm-ttx-t td:last-child{padding-right:var(--dm-s-4)}
/* a header sorts its column: a quiet arrow shows on hover and stays on the column the rows are sorted by */
.dm-ttx-s{display:inline-flex;align-items:center;gap:3px;height:24px;margin:0 -6px;padding:0 6px;border-radius:6px;color:inherit;font:inherit;transition:color .2s,background-color .2s}
@media (hover:hover) and (pointer:fine){.dm-ttx-s:hover{color:var(--dm-fg);background:var(--dm-row)}}
.dm-ttx-s .dm-ar{display:grid;opacity:0;transition:opacity .2s,transform .35s var(--dm-ease)}
@media (hover:hover) and (pointer:fine){.dm-ttx-s:hover .dm-ar{opacity:.45}}
.dm-ttx-s.dm-on{color:var(--dm-fg)}
.dm-ttx-s.dm-on .dm-ar{opacity:1}
.dm-ttx-s.dm-on.dm-desc .dm-ar{transform:rotate(180deg)}
.dm-ttx-t td{height:46px;padding:0 var(--dm-s-2);border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);white-space:nowrap}
.dm-ttx-t tbody tr:last-child td{border-bottom:0}
/* rows: the shared hover, and a slightly deeper grey while picked */
.dm-ttx-t tbody tr[data-id]{cursor:pointer;transition:background-color .25s var(--dm-ease)}
@media (hover:hover) and (pointer:fine){.dm-ttx-t tbody tr[data-id]:hover{background:var(--dm-row)}}
.dm-ttx-t tbody tr.dm-sel{background:color-mix(in srgb,var(--dm-fg) 6%,transparent)}
@media (hover:hover) and (pointer:fine){.dm-ttx-t tbody tr.dm-sel:hover{background:color-mix(in srgb,var(--dm-fg) 8%,transparent)}}
.dm-ttx-t .dm-nm{font-weight:500}
.dm-ttx-t .dm-ag{display:flex;align-items:center;gap:7px;color:var(--dm-fg2)}
.dm-ttx-t .dm-ag .dm-av{width:20px;height:20px;font-size:9.5px}
.dm-ttx-t td.dm-num{color:var(--dm-fg2)}
.dm-ttx-t td.dm-up{color:var(--dm-muted)}
/* checkboxes: the tick springs in; select-all shows a dash while only some rows are picked */
.dm-ttx-cb{display:grid;place-items:center;width:16px;height:16px;border-radius:5px;box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--dm-muted) 80%,var(--dm-surface));color:var(--dm-on-accent,#fff);transition:background-color .2s,box-shadow .2s}
@media (hover:hover) and (pointer:fine){.dm-ttx-cb:hover{box-shadow:inset 0 0 0 1.5px var(--dm-muted)}}
.dm-ttx-cb[aria-checked="true"],.dm-ttx-cb[aria-checked="mixed"]{background:var(--dm-accent);box-shadow:inset 0 0 0 1px var(--dm-accent-mark,var(--dm-accent))}
.dm-ttx-cb svg{grid-area:1/1;width:11px;height:11px;opacity:0;transform:scale(.4);transition:opacity .15s,transform .3s cubic-bezier(.34,1.4,.64,1)}
.dm-ttx-cb[aria-checked="true"] .dm-ck-y,.dm-ttx-cb[aria-checked="mixed"] .dm-ck-m{opacity:1;transform:none}
/* status: a soft pill in the status colour; Running, Done and Failed have their own, Queued and Paused stay grey */
.dm-ttx-st{--dm-sc:var(--dm-muted);display:inline-flex;align-items:center;height:22px;padding:0 9px 0 7px;border-radius:var(--dm-r-pill);font-size:12px;font-weight:500;color:var(--dm-sc);background:color-mix(in srgb,var(--dm-sc) 11%,transparent);transition:color .35s var(--dm-ease),background-color .35s var(--dm-ease)}
.dm-ttx-st .dm-sv{display:inline-flex;align-items:center;gap:6px}
/* running reads in the accent's text colour on a tint of its mark, so a light accent like lime stays legible */
.dm-ttx-st[data-s="run"]{--dm-sc:var(--dm-accent-fg,var(--dm-accent));background:color-mix(in srgb,var(--dm-accent-mark,var(--dm-accent)) 11%,transparent)}
.dm-ttx-st[data-s="done"]{--dm-sc:var(--dm-ok)}
.dm-ttx-st[data-s="fail"]{--dm-sc:var(--dm-bad)}
.dm-ttx-st .dm-spin{width:10px;height:10px}
/* a new status fades in out of a blur; a cancelled row slides away before it goes */
.dm-ttx-sin{animation:dm-ttxsin .32s var(--dm-ease)}
@keyframes dm-ttxsin{from{opacity:0;filter:blur(3px)}}
.dm-ttx-t tbody tr.dm-ttx-out{opacity:0;transform:translateX(14px);transition:opacity .24s var(--dm-ease),transform .24s var(--dm-ease)}
.dm-ttx-st .dm-i{width:12px;height:12px}
/* when every task is gone */
.dm-ttx-t tbody tr.dm-ttx-empty td{height:auto;padding:36px var(--dm-s-4) 34px;text-align:center;white-space:normal}
.dm-ttx-em{display:flex;flex-direction:column;align-items:center;gap:3px}
.dm-ttx-em .dm-ic{display:grid;place-items:center;width:36px;height:36px;margin-bottom:var(--dm-s-2);border-radius:var(--dm-r-in);background:var(--dm-chip);color:var(--dm-muted)}
.dm-ttx-em b{font-size:13.5px;font-weight:500}
.dm-ttx-em span{font-size:12.5px;color:var(--dm-muted)}
.dm-ttx-em .dm-pill{margin-top:10px}
/* narrow, on a phone or in a narrow card: each task stacks, its name and cost on top and its status, agent and last update
on a line under it; the header row keeps select all and the sort buttons, and the bulk actions stay in the header above */
@container ttx (max-width:560px){
.dm-ttx-t{display:block;min-width:0}
.dm-ttx-t thead,.dm-ttx-t tbody{display:block}
.dm-ttx-t tr{position:relative;padding:var(--dm-s-3) var(--dm-s-4) var(--dm-s-3) 44px;border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-ttx-t thead tr{display:flex;flex-wrap:wrap;align-items:center;gap:var(--dm-s-1) var(--dm-s-2);padding-top:var(--dm-s-2);padding-bottom:var(--dm-s-2)}
.dm-ttx-t tbody tr{display:flex;flex-wrap:wrap;align-items:center;gap:var(--dm-s-1) var(--dm-s-3)}
.dm-ttx-t tbody tr:last-child{border-bottom:0}
.dm-ttx-t th,.dm-ttx-t td,.dm-ttx-t th:last-child,.dm-ttx-t td:last-child{display:block;height:auto;padding:0;border-bottom:0}
.dm-ttx-t th.dm-ck,.dm-ttx-t td.dm-ck{position:absolute;left:16px;top:14px;width:auto;padding:0}
.dm-ttx-t thead th.dm-ck{top:50%;transform:translateY(-50%)}
.dm-ttx-t td.dm-nm{order:1;flex:1 0 calc(100% - 64px);min-width:0;white-space:normal;line-height:1.45}
.dm-ttx-t td:nth-child(5){order:2;margin-left:auto;font-size:12px}
.dm-ttx-t td:nth-child(3){order:3}
.dm-ttx-t td:nth-child(4){order:4}
.dm-ttx-t td.dm-up{order:5;margin-left:auto;font-size:12px}
.dm-ttx-s:not(.dm-on) .dm-ar{display:none}
.dm-ttx-t tbody tr.dm-ttx-empty{display:block;padding:0}
.dm-ttx-t tbody tr.dm-ttx-empty td{padding:36px var(--dm-s-4) var(--dm-s-8)}
}
@container ttx (max-width:420px){.dm-ttx-sb{padding-right:var(--dm-s-1)}.dm-ttx-sb .dm-pill{padding:0 var(--dm-s-2)}.dm-ttx-sb .dm-pill .dm-i{display:none}}
@container ttx (max-width:340px){.dm-ttx-ti{padding:0 var(--dm-s-3)}.dm-ttx-sb{gap:0;padding:0 var(--dm-s-1) 0 var(--dm-s-3)}.dm-ttx-n{margin-right:var(--dm-s-1)}.dm-ttx-t tr{padding-left:40px}.dm-ttx-t th.dm-ck,.dm-ttx-t td.dm-ck{left:12px}}
task-table.htmlHTML · 15 lines
<div class="card wide" data-c="task-table">
<div class="stage-in">
<div class="ttx panel" data-reveal="flat">
<div class="ttx-hd">
<div class="ttx-ti"><b>Agent tasks</b><span class="muted num ttx-sum">6 tasks, 2 running</span></div>
<div class="ttx-sb" inert><span class="ttx-n num">2 selected</span><button class="pill ghost sm" data-a="pause"><svg class="i xs"><use href="#i-pause"/></svg>Pause</button><button class="pill ghost sm" data-a="retry"><svg class="i xs"><use href="#i-refresh"/></svg>Retry</button><button class="pill ghost sm ttx-del" data-a="cancel"><svg class="i xs"><use href="#i-x"/></svg>Cancel</button><span class="ttx-sp"></span><button class="pill ghost sm ttx-clr">Clear</button></div>
</div>
<div class="ttx-sc"><table class="ttx-t">
<thead><tr><th class="ck"><button class="ttx-cb ttx-all" role="checkbox" aria-checked="false" aria-label="Select all"></button></th><th><button class="ttx-s" data-k="0">Task</button></th><th><button class="ttx-s" data-k="1">Status</button></th><th><button class="ttx-s" data-k="2">Agent</button></th><th class="r"><button class="ttx-s" data-k="3">Cost</button></th><th class="r"><button class="ttx-s" data-k="4">Updated</button></th></tr></thead>
<tbody></tbody>
</table></div>
</div>
</div>
</div>
task-table.cssHTML · 91 lines
/* ---------- Task table ---------- */
.ttx{overflow:hidden;position:relative;container:ttx/inline-size}
/* the header holds the table's name and a summary, or, while rows are picked, what you can do to them */
.ttx-hd{position:relative;height:50px;border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.ttx-ti,.ttx-sb{position:absolute;inset:0;display:flex;align-items:center;transition:opacity .28s var(--ease),transform .45s var(--ease),filter .28s var(--ease)}
.ttx-ti{gap:10px;padding:0 var(--s-4)}
.ttx-ti b{font-size:13.5px;font-weight:500}
.ttx-sum{font-size:12.5px}
.ttx-sb{gap:2px;padding:0 10px 0 var(--s-4);opacity:0;transform:translateY(10px);filter:blur(3px);pointer-events:none}
.ttx.selecting .ttx-ti{opacity:0;transform:translateY(-10px);filter:blur(3px);pointer-events:none}
.ttx.selecting .ttx-sb{opacity:1;transform:none;filter:none;pointer-events:auto}
.ttx-n{margin-right:var(--s-2);font-size:13px;font-weight:500;white-space:nowrap}
.ttx-sp{flex:1}
.ttx-sb .pill.ttx-del{color:var(--bad)}
@media (hover:hover) and (pointer:fine){.ttx-sb .pill.ttx-del:hover{background:color-mix(in srgb,var(--bad) 10%,transparent);color:var(--bad)}}
.ttx-sc{overflow-x:auto}
.ttx-t{width:100%;border-collapse:collapse;font-size:13px;min-width:560px}
.ttx-t th{height:36px;padding:0 var(--s-2);text-align:left;font-weight:500;font-size:12px;color:var(--muted);border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent);white-space:nowrap}
.ttx-t th.ck,.ttx-t td.ck{width:36px;padding-left:var(--s-4)}
.ttx-t th.r,.ttx-t td.r{text-align:right}
.ttx-t th:last-child,.ttx-t td:last-child{padding-right:var(--s-4)}
/* a header sorts its column: a quiet arrow shows on hover and stays on the column the rows are sorted by */
.ttx-s{display:inline-flex;align-items:center;gap:3px;height:24px;margin:0 -6px;padding:0 6px;border-radius:6px;color:inherit;font:inherit;transition:color .2s,background-color .2s}
@media (hover:hover) and (pointer:fine){.ttx-s:hover{color:var(--fg);background:var(--row)}}
.ttx-s .ar{display:grid;opacity:0;transition:opacity .2s,transform .35s var(--ease)}
@media (hover:hover) and (pointer:fine){.ttx-s:hover .ar{opacity:.45}}
.ttx-s.on{color:var(--fg)}
.ttx-s.on .ar{opacity:1}
.ttx-s.on.desc .ar{transform:rotate(180deg)}
.ttx-t td{height:46px;padding:0 var(--s-2);border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent);white-space:nowrap}
.ttx-t tbody tr:last-child td{border-bottom:0}
/* rows: the shared hover, and a slightly deeper grey while picked */
.ttx-t tbody tr[data-id]{cursor:pointer;transition:background-color .25s var(--ease)}
@media (hover:hover) and (pointer:fine){.ttx-t tbody tr[data-id]:hover{background:var(--row)}}
.ttx-t tbody tr.sel{background:color-mix(in srgb,var(--fg) 6%,transparent)}
@media (hover:hover) and (pointer:fine){.ttx-t tbody tr.sel:hover{background:color-mix(in srgb,var(--fg) 8%,transparent)}}
.ttx-t .nm{font-weight:500}
.ttx-t .ag{display:flex;align-items:center;gap:7px;color:var(--fg2)}
.ttx-t .ag .av{width:20px;height:20px;font-size:9.5px}
.ttx-t td.num{color:var(--fg2)}
.ttx-t td.up{color:var(--muted)}
/* checkboxes: the tick springs in; select-all shows a dash while only some rows are picked */
.ttx-cb{display:grid;place-items:center;width:16px;height:16px;border-radius:5px;box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--muted) 80%,var(--surface));color:var(--on-accent,#fff);transition:background-color .2s,box-shadow .2s}
@media (hover:hover) and (pointer:fine){.ttx-cb:hover{box-shadow:inset 0 0 0 1.5px var(--muted)}}
.ttx-cb[aria-checked="true"],.ttx-cb[aria-checked="mixed"]{background:var(--accent);box-shadow:inset 0 0 0 1px var(--accent-mark,var(--accent))}
.ttx-cb svg{grid-area:1/1;width:11px;height:11px;opacity:0;transform:scale(.4);transition:opacity .15s,transform .3s cubic-bezier(.34,1.4,.64,1)}
.ttx-cb[aria-checked="true"] .ck-y,.ttx-cb[aria-checked="mixed"] .ck-m{opacity:1;transform:none}
/* status: a soft pill in the status colour; Running, Done and Failed have their own, Queued and Paused stay grey */
.ttx-st{--sc:var(--muted);display:inline-flex;align-items:center;height:22px;padding:0 9px 0 7px;border-radius:var(--r-pill);font-size:12px;font-weight:500;color:var(--sc);background:color-mix(in srgb,var(--sc) 11%,transparent);transition:color .35s var(--ease),background-color .35s var(--ease)}
.ttx-st .sv{display:inline-flex;align-items:center;gap:6px}
/* running reads in the accent's text colour on a tint of its mark, so a light accent like lime stays legible */
.ttx-st[data-s="run"]{--sc:var(--accent-fg,var(--accent));background:color-mix(in srgb,var(--accent-mark,var(--accent)) 11%,transparent)}
.ttx-st[data-s="done"]{--sc:var(--ok)}
.ttx-st[data-s="fail"]{--sc:var(--bad)}
.ttx-st .spin{width:10px;height:10px}
/* a new status fades in out of a blur; a cancelled row slides away before it goes */
.ttx-sin{animation:ttxsin .32s var(--ease)}
@keyframes ttxsin{from{opacity:0;filter:blur(3px)}}
.ttx-t tbody tr.ttx-out{opacity:0;transform:translateX(14px);transition:opacity .24s var(--ease),transform .24s var(--ease)}
.ttx-st .i{width:12px;height:12px}
/* when every task is gone */
.ttx-t tbody tr.ttx-empty td{height:auto;padding:36px var(--s-4) 34px;text-align:center;white-space:normal}
.ttx-em{display:flex;flex-direction:column;align-items:center;gap:3px}
.ttx-em .ic{display:grid;place-items:center;width:36px;height:36px;margin-bottom:var(--s-2);border-radius:var(--r-in);background:var(--chip);color:var(--muted)}
.ttx-em b{font-size:13.5px;font-weight:500}
.ttx-em span{font-size:12.5px;color:var(--muted)}
.ttx-em .pill{margin-top:10px}
/* narrow, on a phone or in a narrow card: each task stacks, its name and cost on top and its status, agent and last update
on a line under it; the header row keeps select all and the sort buttons, and the bulk actions stay in the header above */
@container ttx (max-width:560px){
.ttx-t{display:block;min-width:0}
.ttx-t thead,.ttx-t tbody{display:block}
.ttx-t tr{position:relative;padding:var(--s-3) var(--s-4) var(--s-3) 44px;border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.ttx-t thead tr{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-1) var(--s-2);padding-top:var(--s-2);padding-bottom:var(--s-2)}
.ttx-t tbody tr{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-1) var(--s-3)}
.ttx-t tbody tr:last-child{border-bottom:0}
.ttx-t th,.ttx-t td,.ttx-t th:last-child,.ttx-t td:last-child{display:block;height:auto;padding:0;border-bottom:0}
.ttx-t th.ck,.ttx-t td.ck{position:absolute;left:16px;top:14px;width:auto;padding:0}
.ttx-t thead th.ck{top:50%;transform:translateY(-50%)}
.ttx-t td.nm{order:1;flex:1 0 calc(100% - 64px);min-width:0;white-space:normal;line-height:1.45}
.ttx-t td:nth-child(5){order:2;margin-left:auto;font-size:12px}
.ttx-t td:nth-child(3){order:3}
.ttx-t td:nth-child(4){order:4}
.ttx-t td.up{order:5;margin-left:auto;font-size:12px}
.ttx-s:not(.on) .ar{display:none}
.ttx-t tbody tr.ttx-empty{display:block;padding:0}
.ttx-t tbody tr.ttx-empty td{padding:36px var(--s-4) var(--s-8)}
}
@container ttx (max-width:420px){.ttx-sb{padding-right:var(--s-1)}.ttx-sb .pill{padding:0 var(--s-2)}.ttx-sb .pill .i{display:none}}
@container ttx (max-width:340px){.ttx-ti{padding:0 var(--s-3)}.ttx-sb{gap:0;padding:0 var(--s-1) 0 var(--s-3)}.ttx-n{margin-right:var(--s-1)}.ttx-t tr{padding-left:40px}.ttx-t th.ck,.ttx-t td.ck{left:12px}}
task-table.jsHTML · 86 lines
DM.def('task-table', (r, c) => {
r.innerHTML = r.innerHTML.replace('{pause}', I('pause','xs')).replace('{refresh}', I('refresh','xs')).replace('{x}', I('x','xs'));
const ST = { run:['Running','<span class="spin"></span>'], done:['Done', I('ok','xs')], fail:['Failed', I('alert','xs')], wait:['Queued','<span class="dot"></span>'], pause:['Paused', I('pause','xs')] };
const SEED = [
['Audit pricing contrast','run','Design','DS','var(--av-b)',0.42,2],
['Export tokens to CSS','done','Code','CO','var(--av-m)',0.18,14],
['Sync Figma frames','fail','Design','DS','var(--av-b)',0.07,26],
['Draft launch email','wait','Writer','WR','var(--av-l)',0,31],
['Compare checkout flows','done','Research','RE','var(--av-t)',0.66,48],
['Rename layers in Hero','run','Design','DS','var(--av-b)',0.11,5]
];
const seed = () => SEED.map((x, i) => ({ id:i, n:x[0], s:x[1], a:x[2], ai:x[3], ac:x[4], cost:x[5], up:x[6], sel:false }));
let T = seed();
const box = $('.ttx', r), tb = $('tbody', r), all = $('.ttx-all', r), sb = $('.ttx-sb', r), sum = $('.ttx-sum', r), cnt = $('.ttx-n', r);
const ease = getComputedStyle(r).getPropertyValue('--ease').trim() || 'ease';
const tick = I('check','xs').replace('class="i xs"', 'class="i xs ck-y"') + I('minus','xs').replace('class="i xs"', 'class="i xs ck-m"');
all.innerHTML = tick;
$$('.ttx-s', r).forEach(b => b.insertAdjacentHTML('beforeend', `<span class="ar">${I('chev-u','xs')}</span>`));
let key = 4, desc = false;
const fields = [t => t.n, t => ['run','fail','wait','pause','done'].indexOf(t.s), t => t.a, t => t.cost, t => t.up];
const ago = m => m ? m + 'm ago' : 'Just now';
const face = s => `${ST[s][1]}<span>${ST[s][0]}</span>`, pill = s => `<span class="sv">${face(s)}</span>`;
// each row is made once and then kept: picking, sorting and actions change it in place, so it can ease between states
const rows = new Map();
function make(t){
const tr = html(`<tr data-id="${t.id}"><td class="ck"><button class="ttx-cb" role="checkbox" aria-checked="false" aria-label="Select ${t.n}">${tick}</button></td><td class="nm">${t.n}</td><td><span class="ttx-st" data-s="${t.s}">${pill(t.s)}</span></td><td><span class="ag">${AV(t.ai, t.ac)}${t.a}</span></td><td class="r num">$${t.cost.toFixed(2)}</td><td class="r num up"><span>${ago(t.up)}</span></td></tr>`);
tr.onclick = () => { t.sel = !t.sel; sync(); };
rows.set(t.id, tr); return tr;
}
// rows glide to their new places: measured before and after the move, then eased from where they were
function order(anim){
const f = fields[key], list = [...T].sort((a, b) => { const x = f(a), y = f(b); return (x > y ? 1 : x < y ? -1 : 0) * (desc ? -1 : 1); });
const first = anim && !REDUCE ? new Map(list.map(t => [t.id, rows.get(t.id).getBoundingClientRect().top])) : null;
list.forEach(t => tb.appendChild(rows.get(t.id)));
if (first) list.forEach(t => { const el = rows.get(t.id), d = first.get(t.id) - el.getBoundingClientRect().top; if (Math.abs(d) > 1) el.animate([{ transform:`translateY(${d}px)` }, { transform:'none' }], { duration:520, easing:ease }); });
$$('.ttx-s', r).forEach(b => { b.classList.toggle('on', +b.dataset.k === key); b.classList.toggle('desc', desc); b.closest('th').setAttribute('aria-sort', +b.dataset.k === key ? (desc ? 'descending' : 'ascending') : 'none'); });
}
let said = '';
function sync(){
const n = T.filter(t => t.sel).length;
T.forEach(t => { const tr = rows.get(t.id); tr.classList.toggle('sel', t.sel); $('.ttx-cb', tr).setAttribute('aria-checked', t.sel); });
all.setAttribute('aria-checked', n === 0 ? 'false' : n === T.length ? 'true' : 'mixed');
box.classList.toggle('selecting', n > 0); sb.inert = n === 0; if (n) cnt.textContent = n + ' selected';
const run = T.filter(t => t.s === 'run').length, line = T.length ? `${T.length} task${T.length === 1 ? '' : 's'}, ${run} running` : 'No tasks';
if (line !== said){ said ? swap(sum, line) : sum.textContent = line; said = line; }
}
const status = (t, s) => { t.s = s; const el = $('.ttx-st', rows.get(t.id)); el.dataset.s = s; el.innerHTML = `<span class="sv ttx-sin">${face(s)}</span>`; };
// cancelled rows slide away and the rest close up; when nothing is left the table says so
function leave(ids){
const out = T.filter(t => ids.includes(t.id)); T = T.filter(t => !ids.includes(t.id));
// the table's height as it was, for the empty state that takes the last rows' place
const had = T.length ? 0 : box.getBoundingClientRect().height;
const go = () => { const first = new Map(T.map(t => [t.id, rows.get(t.id).getBoundingClientRect().top]));
out.forEach(t => { rows.get(t.id).remove(); rows.delete(t.id); });
if (!REDUCE) T.forEach(t => { const el = rows.get(t.id), d = first.get(t.id) - el.getBoundingClientRect().top; if (Math.abs(d) > 1) el.animate([{ transform:`translateY(${d}px)` }, { transform:'none' }], { duration:480, easing:ease }); });
if (!T.length) empty(had); sync(); };
if (REDUCE) return go();
out.forEach(t => rows.get(t.id).classList.add('ttx-out'));
c.later(250, go);
}
// the empty state keeps the height the table had, centred in it, so the card doesn't shrink to the top of its stage
function empty(had){
const tr = html(`<tr class="ttx-empty"><td colspan="6"><div class="ttx-em"><span class="ic">${I('check')}</span><b>No agent tasks</b><span>Everything has finished or been cancelled.</span><button class="pill ghost sm">${I('refresh','xs')}Bring them back</button></div></td></tr>`);
tb.appendChild(tr); enter(tr);
const d = had - box.getBoundingClientRect().height, td = tr.firstElementChild;
if (d > 1){ const cs = getComputedStyle(td); td.style.paddingTop = parseFloat(cs.paddingTop) + d / 2 + 'px'; td.style.paddingBottom = parseFloat(cs.paddingBottom) + d / 2 + 'px'; }
$('.pill', tr).onclick = e => { e.stopPropagation(); tr.remove(); T = seed(); T.forEach(make); order(false); sync(); stagger([...tb.children], 30); };
}
$$('.ttx-s', r).forEach(b => b.onclick = () => { const k = +b.dataset.k; if (k === key) desc = !desc; else { key = k; desc = false; } order(true); });
all.onclick = () => { const on = all.getAttribute('aria-checked') !== 'true'; T.forEach(t => t.sel = on); sync(); };
$('.ttx-clr', r).onclick = () => { T.forEach(t => t.sel = false); sync(); };
$$('.ttx-sb [data-a]', r).forEach(b => b.onclick = () => {
const a = b.dataset.a, ids = T.filter(t => t.sel).map(t => t.id);
T.forEach(t => t.sel = false);
if (a === 'cancel'){ sync(); leave(ids); return; }
T.forEach(t => { if (!ids.includes(t.id)) return;
if (a === 'pause' && (t.s === 'run' || t.s === 'wait')) status(t, 'pause');
if (a === 'retry' && (t.s === 'fail' || t.s === 'pause')){ status(t, 'run'); t.up = 0; swap($('.up span', rows.get(t.id)), ago(0)); } });
sync(); if (key === 1 || key === 4) c.later(320, () => order(true));
});
T.forEach(make); order(false); sync(); stagger([...tb.children], 30);
// the preview picks two tasks and retries them, unless you start clicking first
c.auto(1600, () => { if (T.some(t => t.sel)) return; T.forEach(t => t.sel = t.id === 2 || t.id === 5); sync();
c.auto(1900, () => { if (T.filter(t => t.sel).length === 2) $('.ttx-sb [data-a="retry"]', r).click(); }); });
});
Questions
What people ask about it.
- Does Cancel remove the rows for me?
- No. The rows slide out, then
onAction('cancel', ids)is called. Remove those tasks fromtasksin that handler or they come back. - How is "Updated" shown?
updatedis the number of minutes since the task last changed. It reads as "14m ago", or "Just now" at 0, and the default sort puts the smallest number first.- Does it work on a phone?
- Yes. Below 560px wide the rows stack instead of scrolling sideways, and every field stays: name, cost, status, agent and when it changed. Selecting rows and the bulk actions work the same way.
- Can I select rows from my own code?
- Yes. Control
selectedwith an array of task ids and update it fromonSelectedChange. The demo selects a failed task and a running task this way, then retries them.
More in Sources and output
What the agent read, what it produced and where each answer came from.