---
/**
* TaskRows: live agent task status as expandable rows.
*
* Each row has a status badge, a label, an amount and a status pill. Click a
* row to show its steps. Layouts: separate capsules, or one divided card.
*
* Statuses: "done", "running", "pending", "failed", and "sequence", which
* plays pending, failed (retrying), then completed when the rows scroll into
* view. `loop` replays it. Fires `vs-task-rows:toggle-row` (detail { key,
* open }) on the root.
*
* Ported from Beautiful UI `components/primitives/TaskRows.tsx`
* (MIT, © 2026 Shane Levine).
*
* @example
* <TaskRows rows={[{ label: "Pull GA4 data", amount: "3 properties", status: "running", step: 2 }]} />
*/
import type { HTMLAttributes } from "astro/types";
export interface TaskDetail {
label: string;
meta: string;
}
export interface TaskRow {
/** Stable id, reported in events. Defaults to the row index. */
key?: string;
label: string;
/** Muted figure beside the label, e.g. "12 suppliers". */
amount?: string;
status: "done" | "running" | "pending" | "failed" | "sequence";
/** Number shown inside the spinner ring. Defaults to the row position. */
step?: number;
details?: TaskDetail[];
}
export interface TaskRowsLabels {
completed: string;
failed: string;
/** Screen-reader status for running and pending rows (they have no pill). */
running: string;
pending: string;
retrying: string;
}
type Props = HTMLAttributes<"div"> & {
rows: TaskRow[];
variant?: "capsules" | "list";
labels?: Partial<TaskRowsLabels>;
/** Play the status run when scrolled into view. */
autoplay?: boolean;
/** Replay after a hold, never while someone is using it. */
loop?: boolean;
};
const { rows = [], variant = "capsules", labels, autoplay = true, loop = false, class: className, ...rest } = Astro.props;
const l: TaskRowsLabels = {
completed: "Completed",
failed: "Failed",
running: "In progress",
pending: "Queued",
retrying: "retrying",
...labels,
};
const uid = `vs-task-${Math.random().toString(36).slice(2, 8)}`;
const R = 11; // (24 - stroke 2) / 2
const C = 2 * Math.PI * R;
---
<div
class:list={["vs-task-rows", `vs-task-rows--${variant}`, className]}
data-autoplay={autoplay ? "" : undefined}
data-loop={loop ? "" : undefined}
data-labels={JSON.stringify({ completed: l.completed, failed: l.failed, retrying: l.retrying })}
{...rest}
>
{rows.map((row, i) => {
const phase = row.status === "sequence" ? "done" : row.status;
const step = row.step ?? i + 1;
const details = row.details ?? [];
return (
<div
class="task"
style={`--vs-tr-i: ${i}`}
data-task={row.key ?? String(i)}
data-phase={phase}
data-sequence={row.status === "sequence" ? "" : undefined}
data-running={row.status === "running" ? "" : undefined}
>
<button
type="button"
class="task-row"
aria-expanded="false"
aria-controls={details.length ? `${uid}-${i}` : undefined}
disabled={!details.length}
data-task-toggle
>
<span class="badge-slot" aria-hidden="true">
{(row.status === "running" || row.status === "pending" || row.status === "sequence") && (
<span class:list={["ring", row.status === "running" && "is-active"]}>
<svg width="24" height="24" viewBox="0 0 24 24">
<circle class="ring-track" cx="12" cy="12" r={R} fill="none" stroke-width="2"></circle>
{row.status === "running" && (
<circle
class="ring-arc"
cx="12"
cy="12"
r={R}
fill="none"
stroke-width="2"
stroke-linecap="round"
stroke-dasharray={`${C * 0.28} ${C * 0.72}`}
></circle>
)}
</svg>
<span class="ring-step">{step}</span>
</span>
)}
{(row.status === "failed" || row.status === "sequence") && (
<span class="badge badge--red">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round">
<path d="M18 6L6 18M6 6l12 12"></path>
</svg>
</span>
)}
{(row.status === "done" || row.status === "sequence") && (
<span class="badge badge--green">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6L9 17l-5-5"></path>
</svg>
</span>
)}
</span>
<span class="task-label">{row.label}</span>
{row.status === "running" && <span class="visually-hidden">, {l.running}</span>}
{row.status === "pending" && <span class="visually-hidden">, {l.pending}</span>}
{row.status === "sequence" && <span class="visually-hidden sr-pending">, {l.pending}</span>}
{row.amount && <span class="task-amount">{row.amount}</span>}
{(row.status === "failed" || row.status === "sequence") && (
<span class="pill pill--red">
{l.failed}
{row.status === "sequence" && <span class="visually-hidden">, {l.retrying}</span>}
<svg class="retry" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"></path>
</svg>
</span>
)}
{(row.status === "done" || row.status === "sequence") && <span class="pill pill--green">{l.completed}</span>}
<span class="chevron" aria-hidden="true">
{details.length > 0 && (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9l6 6 6-6"></path>
</svg>
)}
</span>
</button>
{details.length > 0 && (
<div class="detail" id={`${uid}-${i}`}>
<div class="detail-clip" data-detail-clip>
<div class="detail-grid">
<span class="detail-line" aria-hidden="true"></span>
<div class="detail-items">
{details.map((d, j) => (
<div class="detail-item" style={`--vs-tr-j: ${j}`}>
<span class="detail-label">{d.label}</span>
<span class="detail-meta">{d.meta}</span>
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
);
})}
<span class="visually-hidden" role="status" data-live></span>
</div>
<style>
.vs-task-rows {
display: flex;
width: 100%;
max-width: 440px;
flex-direction: column;
}
.vs-task-rows--capsules {
min-height: 196px;
gap: 8px;
}
.vs-task-rows--list {
align-self: flex-start;
overflow: hidden;
border-radius: var(--vs-radius-card);
background: var(--vs-surface);
box-shadow: var(--vs-shadow-card);
}
/* ── row shell ── */
.task {
align-self: stretch;
overflow: hidden;
transition:
border-radius 300ms ease-out,
background-color 300ms ease-out;
}
.task:hover {
background: var(--vs-inset);
}
.vs-task-rows--capsules .task {
border-radius: 22px;
background: var(--vs-surface);
box-shadow: var(--vs-shadow-card);
}
.vs-task-rows--capsules .task:hover {
background: var(--vs-inset);
}
.vs-task-rows--capsules .task[data-open] {
border-radius: 14px;
}
.vs-task-rows--list .task {
border-bottom: 1px solid var(--vs-line);
}
.vs-task-rows--list .task:last-of-type {
border-bottom: 0;
}
/* rows wait offscreen, then enter staggered when the run starts */
[data-idle] .task {
opacity: 0;
}
[data-run] .task {
animation: vs-fade-up 450ms var(--vs-ease-out) calc(var(--vs-tr-i) * 80ms) both;
}
.task-row {
display: flex;
width: 100%;
height: 44px;
align-items: center;
gap: 10px;
padding: 0 10px;
border: none;
background: transparent;
color: inherit;
font: inherit;
border-radius: inherit;
text-align: left;
cursor: pointer;
}
.task-row:disabled {
cursor: default;
}
.task-row:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--vs-accent) 70%, transparent);
}
.badge-slot {
display: flex;
width: 24px;
height: 24px;
flex-shrink: 0;
align-items: center;
justify-content: center;
}
.task-label {
min-width: 0;
flex: 1;
overflow: hidden;
color: var(--vs-ink);
font-size: 13px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-amount {
color: var(--vs-ink-2);
font-size: 12.5px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* spinner ring */
.ring {
position: relative;
display: inline-flex;
width: 24px;
height: 24px;
flex-shrink: 0;
align-items: center;
justify-content: center;
}
.ring svg {
position: absolute;
inset: 0;
}
.ring.is-active svg {
animation: vs-spin 1.1s linear infinite;
}
.ring-track {
stroke: var(--vs-line);
}
.ring-arc {
stroke: var(--vs-ink-3);
}
.ring-step {
position: relative;
color: var(--vs-ink);
font-size: 10.5px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.badge {
display: flex;
width: 22px;
height: 22px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--vs-surface);
animation: vs-pop-in 300ms var(--vs-ease-out) both;
}
.badge--red {
background: var(--vs-red);
}
.badge--green {
background: var(--vs-green);
}
.pill {
display: inline-flex;
height: 22px;
flex-shrink: 0;
align-items: center;
gap: 6px;
padding: 0 8px;
border-radius: var(--vs-radius-pill);
font-size: 11.5px;
font-weight: 500;
white-space: nowrap;
}
.pill--green {
background: var(--vs-green-tint);
color: var(--vs-green);
}
.pill--red {
background: var(--vs-red-tint);
color: var(--vs-red);
}
[data-sequence] .pill {
animation: vs-fade-in 200ms ease-out both;
}
.retry {
display: none;
}
[data-sequence] .retry {
display: block;
animation: vs-spin 1.2s linear infinite;
}
/* each phase shows only its own badge, pill and screen-reader status */
.task:not([data-phase="pending"]):not([data-phase="running"]) .ring,
.task:not([data-phase="pending"]) .sr-pending,
.task:not([data-phase="failed"]) .badge--red,
.task:not([data-phase="failed"]) .pill--red,
.task:not([data-phase="done"]) .badge--green,
.task:not([data-phase="done"]) .pill--green {
display: none;
}
.chevron {
display: flex;
width: 28px;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
margin-left: -8px;
border-radius: 50%;
color: var(--vs-ink-3);
}
.chevron svg {
transition: transform 300ms ease-out;
}
[data-open] .chevron svg {
transform: rotate(180deg);
}
/* ── dropdown detail ── */
.detail {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition:
grid-template-rows 300ms var(--vs-ease-out),
opacity 300ms var(--vs-ease-out);
}
[data-open] .detail {
grid-template-rows: 1fr;
opacity: 1;
}
.detail-clip {
min-height: 0;
overflow: hidden;
}
.detail-grid {
display: grid;
grid-template-columns: 24px 1fr;
gap: 10px;
margin-bottom: 10px;
padding: 0 10px;
}
.detail-line {
width: 1px;
height: 100%;
margin: 0 auto;
background: var(--vs-line);
}
.detail-items {
display: flex;
flex-direction: column;
gap: 6px;
}
.detail-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
[data-open] .detail-item {
animation: vs-fade-up 300ms var(--vs-ease-out) calc(120ms + var(--vs-tr-j) * 100ms) both;
}
.detail-label {
color: var(--vs-ink-2);
font-size: 12px;
}
.detail-meta {
color: var(--vs-ink-3);
font-family: var(--vs-font-sans);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* offscreen: hold the spinners */
[data-paused] .ring svg,
[data-paused] .retry {
animation-play-state: paused;
}
/* hide the settled rows for the instant before the script rewinds them */
@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
.vs-task-rows[data-autoplay]:not([data-vs-ready]) {
visibility: hidden;
}
}
/* without script the rows can't toggle, so show their steps */
@media (scripting: none) {
.detail {
grid-template-rows: 1fr;
opacity: 1;
}
.chevron svg {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
[data-run] .task,
.badge,
[data-sequence] .pill,
[data-open] .detail-item {
animation: none;
}
.ring.is-active svg,
[data-sequence] .retry {
animation: none;
}
}
</style>
<script>
/* rows enter → ring sweeps → running row opens → it closes and sequence rows fail → they resolve */
const TICKS = [600, 900, 2400, 1400, 2400];
const FINAL = TICKS.length;
const LOOP_HOLD = 4000;
/** One pending timeout that can pause and resume where it left off. */
function createTimer() {
let id: number | undefined;
let fn: (() => void) | undefined;
let left = 0;
let due = 0;
let paused = true;
const arm = () => {
due = performance.now() + left;
id = window.setTimeout(() => {
const run = fn;
id = fn = undefined;
run?.();
}, left);
};
return {
set(ms: number, next: () => void) {
window.clearTimeout(id);
id = undefined;
fn = next;
left = ms;
if (!paused) arm();
},
pause() {
if (paused) return;
paused = true;
if (id === undefined) return;
window.clearTimeout(id);
id = undefined;
left = Math.max(0, due - performance.now());
},
resume() {
if (!paused) return;
paused = false;
if (fn) arm();
},
clear() {
window.clearTimeout(id);
id = fn = undefined;
},
};
}
function init(root: HTMLElement) {
const tasks = [...root.querySelectorAll<HTMLElement>("[data-task]")];
const sequence = tasks.filter((t) => t.hasAttribute("data-sequence"));
const autoOpen = tasks.find((t) => t.hasAttribute("data-running") && t.querySelector("[data-detail-clip]"));
const live = root.querySelector<HTMLElement>("[data-live]")!;
const labels = JSON.parse(root.dataset.labels || "{}") as { completed?: string; failed?: string; retrying?: string };
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const loop = root.hasAttribute("data-loop");
const timer = createTimer();
const manual = new Map<HTMLElement, boolean>();
let tick = FINAL;
let firstRun = true;
let touched = false;
const render = () => {
const phase = tick < 3 ? "pending" : tick === 3 ? "failed" : "done";
sequence.forEach((task) => {
if (task.dataset.phase === phase) return;
task.dataset.phase = phase;
if (firstRun && phase !== "pending") {
const name = task.querySelector(".task-label")?.textContent ?? "";
live.textContent =
phase === "failed" ? `${name}: ${labels.failed}, ${labels.retrying}` : `${name}: ${labels.completed}`;
}
});
tasks.forEach((task) => {
const open = manual.get(task) ?? (task === autoOpen && tick === 2);
task.toggleAttribute("data-open", open);
const button = task.querySelector("[data-task-toggle]")!;
button.setAttribute("aria-expanded", String(open));
const clip = task.querySelector<HTMLElement>("[data-detail-clip]");
if (clip) clip.inert = !open;
});
};
tasks.forEach((task) => {
const button = task.querySelector<HTMLButtonElement>("[data-task-toggle]")!;
button.addEventListener("click", () => {
const open = !task.hasAttribute("data-open");
touched = true;
manual.set(task, open);
render();
root.dispatchEvent(new CustomEvent("vs-task-rows:toggle-row", { bubbles: true, detail: { key: task.dataset.task, open } }));
});
});
const step = () => {
tick += 1;
render();
if (tick < FINAL) timer.set(TICKS[tick]!, step);
else {
firstRun = false;
if (loop) timer.set(LOOP_HOLD, restart);
}
};
const start = () => {
tick = 0;
manual.clear();
root.removeAttribute("data-run");
void root.offsetWidth; // restart the entrance animation
root.setAttribute("data-run", "");
root.removeAttribute("data-idle");
render();
timer.set(TICKS[0]!, step);
};
function restart() {
if (touched) return;
if (root.matches(":hover") || root.contains(document.activeElement)) return timer.set(LOOP_HOLD, restart);
start();
}
if (reduced || !root.hasAttribute("data-autoplay")) {
render();
return;
}
/* hold the rows back until the run is seen, then play it */
tick = 0;
root.setAttribute("data-idle", "");
render();
let started = false;
const io = new IntersectionObserver(([entry]) => {
if (!root.isConnected) return teardown();
if (entry?.isIntersecting) {
root.removeAttribute("data-paused");
if (!started) {
started = true;
timer.resume();
start();
} else timer.resume();
} else {
root.setAttribute("data-paused", "");
timer.pause();
}
}, { threshold: 0.2 });
io.observe(root);
function teardown() {
timer.clear();
io.disconnect();
}
document.addEventListener("astro:before-swap", teardown, { once: true });
}
const boot = () =>
document.querySelectorAll<HTMLElement>(".vs-task-rows:not([data-vs-ready])").forEach((root) => {
root.dataset.vsReady = "";
init(root);
});
boot();
document.addEventListener("astro:page-load", boot);
</script>