AI Task List
A plan the model is working through. Steps can nest one level, the header count is derived from the tasks rather than passed in, and a completed step's tick draws itself before the row settles back so the step that is actually running is the only thing asking for attention.
Use it above an AI Message to show the shape of the work before the prose starts, or on its own while a long-running step is in progress.
Usage
Pass tasks and mutate the array as the plan progresses — status, not keyframes,
is what drives everything. Keep nesting to one level and put counts in note.
Audit theme tokens
3/8
- Read every component source
- Walk the theme tokens
- colors18/18
- spacing12/12
- typography7/9
- motion tokens2/6
- Diff the rendered output3 screenshots
- Post the report to the PR
<template>
<div class="w-full max-w-md">
<MAiTaskList
label="Audit theme tokens"
:tasks="tasks"
/>
</div>
</template>
<script setup lang="ts">
type Status = 'pending' | 'running' | 'done' | 'failed'
type Task = {
children?: Task[]
id: string
label: string
note?: string
status: Status
}
const tasks = ref<Task[]>([
{
id: 't-1',
label: 'Read every component source',
status: 'done',
},
{
id: 't-2',
label: 'Walk the theme tokens',
status: 'running',
children: [
{ id: 't-2a', label: 'colors', note: '18/18', status: 'done' },
{ id: 't-2b', label: 'spacing', note: '12/12', status: 'done' },
{ id: 't-2c', label: 'typography', note: '7/9', status: 'running' },
{ id: 't-2d', label: 'motion tokens', note: '2/6', status: 'pending' },
],
},
{
id: 't-3',
label: 'Diff the rendered output',
note: '3 screenshots',
status: 'pending',
},
{
id: 't-4',
label: 'Post the report to the PR',
status: 'failed',
},
])
const order: Status[] = ['pending', 'running', 'done']
onMounted(() => {
window.setInterval(() => {
tasks.value = tasks.value.map(task => ({
...task,
status: advance(task.status),
children: task.children?.map(child => ({
...child,
status: advance(child.status),
})),
}))
}, 900)
})
const advance = (status: Status): Status => {
if (status === 'failed') {
return status
}
const index = order.indexOf(status)
return order[Math.min(index + 1, order.length - 1)]
}
</script>
Customize
Audit theme tokens
3/8
- Read every component source
- Walk the theme tokens
- colors18/18
- spacing12/12
- typography7/9
- motion tokens2/6
- Diff the rendered output3 screenshots
- Post the report to the PR
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
tasks | AiTask[] | - | Required. Each task is { id, label, status, note?, children? }, where status
is 'pending' | 'running' | 'done' | 'failed' and children nests one
level. Grandchildren are not flattened into the rows — they are silently left
out of the display and still counted, which is why one level is the documented
limit rather than an incidental one. |
label | string | 'Plan' | The heading text. The counts beside it are always derived; there is no prop for them, and there should not be. |
class | string | '' | Merged onto the panel, which also carries
w-full rounded-xl border border-default bg-elevated p-3. |
note is a short right-aligned string — "12/12", "3 files" — rendered with
tabular-nums so digits do not reflow as the counts climb. The component does not
parse it or infer anything from it.
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.aiTaskList in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | w-full rounded-xl border border-default bg-elevated p-3 |
header | - | mb-1.5 flex items-baseline justify-between |
label | - | font-medium text-default text-sm |
count | - | text-muted text-xs tabular-nums |
list | - | list-none |
row | - | list-none |
rowInner | - | relative flex items-start gap-2 py-1 |
box | - | mt-0.5 flex size-3.5 shrink-0 items-center justify-center |
boxSvg | - | size-3.5 |
boxDraw | - | ai-task-draw |
text | - | min-w-0 flex-1 text-default text-sm leading-snug |
note | - | shrink-0 text-muted text-xs tabular-nums |
underline | - | pointer-events-none absolute inset-x-0 bottom-0 h-px |
Accessibility
- The check and cross are drawn in an SVG that is
aria-hidden, so the row's status is carried by its text and colour rather than by a path nobody can announce. If your surface needs the state spelled out, put it innote. - Done and failed use distinct hues — a green and a red — and distinct glyphs, a tick and a cross. Neither state depends on colour alone.
- The running row keeps full opacity while completed rows settle to 65%, so the active step stays the highest-contrast text on the panel.
- The travelling underline is
pointer-events-noneand carries no text, so it cannot swallow a click or be read. - Under
prefers-reduced-motion: reducethe draw is disabled in CSS with no JS branch: the tick and cross are simply already drawn, rows change opacity instantly, and the underline does not run at all. - Indented children are indented visually, not semantically. The list is one flat
<ul>of<li>s, so a screen reader announces the steps in order without claiming a tree structure the component does not model. - The style block is intentionally unscoped. The class is specific enough not to
leak, and a scoped block would need
:deep()to reach paths built inside a render function.
Performance
- The box and its tick are a plain
<svg>with a CSS transition, not<Motion>. motion-v'sasdoes not resolve to SVG elements —<Motion as="rect">renders a<div>, which is exactly how a<div>once ended up inside a<clipPath>.fill-opacityandstrokeare plain CSS properties on SVG, so the 200ms cross-fade works identically without depending on that. - The tick is drawn by a CSS keyframe on mount rather than by a
pathLengthanimation target, because both motion'spathLengthshorthand and an explicitstrokeDashoffsettarget failed to resolve on these paths and the check rendered as a stub. A keyframe is deterministic, and it gives the reduced-motion case for free inside@media (prefers-reduced-motion: no-preference). - The travelling underline animates
backgroundPositionXfrom0%to200%on a half-width gradient, so one band crosses the row and is replaced by the next. It reads as travel rather than a loop. The indefinite repeat has no CSS equivalent, which is why this one stays in Motion. - The underline is a paint-only animation on a 1px element: no layout, no reflow,
and it is only mounted for rows whose status is
running. allTasksandrowsboth flatten the task list in computeds, so the header count and the rendered rows are derived from one source. A"3/7"that has drifted from what is on screen destroys trust in the whole panel.useReducedMotion()is read once into a plain boolean rather than being reactive, because the reduced-motion preference does not change mid-session in any browser that matters and reading it per row would cost more than it saves.