Skip to content
Star on GitHub
Sources and outputTask table
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

Task 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.

$npx designme add task-table
Agent tasks6 tasks, 2 running
2 selected
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add task-table

React

$npx designme add task-table --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.

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.
03

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>
  );
}
04

Props

Everything TaskTable takes.

PropTypeWhat it does
tasksrequiredTask[]
titlestringThe name in the header. Defaults to "Agent tasks"
summarystringThe quiet line beside the name. Defaults to a count of tasks and how many are running
selectedstring[]Ids of the selected tasks. Leave it out to let the checkboxes and rows toggle it
defaultSelectedstring[]
onSelectedChange(ids: string[]) => void
sortTaskSortThe column the rows are sorted by. Leave it out to let the headers set it. Defaults to updated, newest first
defaultSortTaskSort
onSortChange(sort: TaskSort) => void
onAction(action: TaskAction, ids: string[]) => voidA button in the header while tasks are selected. The selection clears after it; cancelled rows slide away first
emptyReactNodeWhat 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 }
classNamestring
05

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(); }); });
});
06

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 from tasks in that handler or they come back.
How is "Updated" shown?
updated is 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 selected with an array of task ids and update it from onSelectedChange. The demo selects a failed task and a running task this way, then retries them.
07

More in Sources and output

What the agent read, what it produced and where each answer came from.

Code viewerCode the agent wrote, with line notes you can ask about.
Terminal streamShell commands the agent runs, with their output as it streams in.
Citation hover cardHover a citation to see the exact passage it came from.
Source stackEverything the agent read, folded into one quiet pill.
Field extractionThe agent pulls fields out of a document and shows where each came from.
Metric insightA number, its trend, and the agent's read on why it moved.
Details panelProperties of a run you can change in place.