Task Rows
AI & AgentsLive agent task status — running, failed, completed.
Interactive Live Preview
components/ui/task-rows.tsxTypeScript · React 19
1"use client";23import { useEffect, useState } from "react";45/* ─────────────────────────────────────────────────────────6 * TASK ROWS7 *8 * 0ms rows enter staggered (80ms apart)9 * 600ms row 1 ring sweeps 0 → 66%10 * 1500ms row 1 expands — detail steps drop down11 * 3900ms row 1 collapses; row 2 flips to Failed + retry12 * 5300ms row 2 resolves to Completed13 * The status run completes once; task details stay clickable.14 * ───────────────────────────────────────────────────────── */1516const TICKS = [600, 900, 2400, 1400, 2400, 600];1718function useTick(intervals: number[]) {19 const [tick, setTick] = useState(0);20 useEffect(() => {21 if (tick >= intervals.length - 1) return;22 const t = setTimeout(() => setTick((x) => x + 1), intervals[tick]);23 return () => clearTimeout(t);24 }, [tick, intervals]);25 return tick;26}2728function SpinnerRing({ active, children }: { active?: boolean; children?: React.ReactNode }) {29 const size = 24, stroke = 2;30 const r = (size - stroke) / 2;31 const c = 2 * Math.PI * r;32 return (33 <span className="relative inline-flex shrink-0 items-center justify-center" style={{ width: size, height: size }}>34 <svg35 width={size} height={size} className="absolute inset-0"36 style={active ? { animation: "spin 1.1s linear infinite" } : undefined}37 >38 <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--line)" strokeWidth={stroke} />39 {active && (40 <circle41 cx={size / 2} cy={size / 2} r={r} fill="none"42 stroke="var(--ink-3)" strokeWidth={stroke} strokeLinecap="round"43 strokeDasharray={`${c * 0.28} ${c * 0.72}`}44 />45 )}46 </svg>47 <span className="relative text-[10.5px] font-semibold tabular-nums text-ink">{children}</span>48 </span>49 );50}5152function Badge({ tone, children }: { tone: "red" | "green"; children: React.ReactNode }) {53 return (54 <span55 className={`flex size-5.5 shrink-0 items-center justify-center rounded-full text-white56 ${tone === "red" ? "bg-red" : "bg-green"}`}57 style={{ animation: "pop-in 300ms cubic-bezier(0.23,1,0.32,1) both" }}58 >59 {children}60 </span>61 );62}6364const XIcon = (65 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round"><path d="M18 6L6 18M6 6l12 12" /></svg>66);67const CheckIcon = (68 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>69);70const RetryIcon = (71 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6" /></svg>72);7374export default function TaskRows({ variant = "Capsules" }: { variant?: string }) {75 const tick = useTick(TICKS);76 const [manualOpen, setManualOpen] = useState<Record<string, boolean>>({});77 const row2: "pending" | "failed" | "done" = tick < 3 ? "pending" : tick === 3 ? "failed" : "done";7879 const rows = [80 {81 key: "verify",82 badge: <Badge tone="green">{CheckIcon}</Badge>,83 label: "Verified vendor records",84 amount: "12 suppliers",85 pill: (86 <span className="inline-flex h-5.5 items-center rounded-full bg-green-tint px-2 text-[11.5px] font-medium text-green">87 Completed88 </span>89 ),90 details: [91 { label: "Matched tax and contact IDs", meta: "12/12" },92 { label: "Flagged stale records", meta: "0" },93 ],94 },95 {96 key: "index",97 badge: <SpinnerRing active>2</SpinnerRing>,98 label: "Build reorder task list",99 amount: "7 SKUs",100 pill: null,101 details: [102 { label: "Reading POS export", meta: "3 files" },103 { label: "Scoring stockout risk", meta: "68%" },104 ],105 },106 {107 key: "draft",108 badge:109 row2 === "pending" ? (110 <SpinnerRing>3</SpinnerRing>111 ) : row2 === "failed" ? (112 <Badge tone="red">{XIcon}</Badge>113 ) : (114 <Badge tone="green">{CheckIcon}</Badge>115 ),116 label: "Draft supplier emails",117 amount: "2 messages",118 pill:119 row2 === "failed" ? (120 <span className="inline-flex h-5.5 items-center gap-1.5 rounded-full bg-red-tint px-2 text-[11.5px] font-medium text-red" style={{ animation: "fade-in 200ms ease-out both" }}>121 Failed <span style={{ animation: "spin 1.2s linear infinite" }} className="flex">{RetryIcon}</span>122 </span>123 ) : row2 === "done" ? (124 <span className="inline-flex h-5.5 items-center gap-1.5 rounded-full bg-green-tint px-2 text-[11.5px] font-medium text-green" style={{ animation: "fade-in 200ms ease-out both" }}>125 Completed126 </span>127 ) : null,128 details: [129 { label: "Cone supplier follow-up", meta: "draft" },130 { label: "Pistachio reorder note", meta: "draft" },131 ],132 },133 ];134135 const list = variant === "List";136 return (137 <div138 className={`flex w-full max-w-110 flex-col ${139 list ? "gap-0 self-start overflow-hidden rounded-card bg-surface shadow-card" : "min-h-[196px] gap-2"140 }`}141 >142 {rows.map((row, i) => {143 const open = manualOpen[row.key] ?? (row.key === "index" && tick === 2);144 return (145 <div146 key={row.key}147 className={`self-stretch overflow-hidden transition-[border-radius,background-color] duration-300 hover:bg-inset ${148 list ? "border-b border-line last:border-0" : "bg-surface shadow-card"149 }`}150 style={{151 borderRadius: list ? 0 : open ? 14 : 22,152 animation: `fade-up 450ms cubic-bezier(0.23,1,0.32,1) ${i * 80}ms both`,153 }}154 >155 <button156 type="button"157 aria-expanded={open}158 onClick={() => setManualOpen((current) => ({ ...current, [row.key]: !open }))}159 className="flex h-11 w-full items-center gap-2.5 px-2.5 text-left"160 >161 <span className="flex size-6 shrink-0 items-center justify-center">162 {row.badge}163 </span>164 <span className="min-w-0 flex-1 truncate text-[13px] font-medium text-ink">165 {row.label}166 </span>167 <span className="text-[12.5px] text-ink-2 tabular-nums">{row.amount}</span>168 {row.pill}169 <span170 aria-hidden="true"171 className="-ml-2 flex size-7 shrink-0 items-center justify-center rounded-full text-ink-3"172 >173 <svg174 width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"175 className="transition-transform duration-300"176 style={{ transform: open ? "rotate(180deg)" : "rotate(0)" }}177 >178 <path d="M6 9l6 6 6-6" />179 </svg>180 </span>181 </button>182183 {/* dropdown detail — same expandable grammar as Chain of Thought */}184 <div185 className="grid transition-[grid-template-rows,opacity] duration-300"186 style={{187 gridTemplateRows: open ? "1fr" : "0fr",188 opacity: open ? 1 : 0,189 transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",190 }}191 >192 <div className="overflow-hidden">193 <div className="mb-2.5 grid grid-cols-[24px_1fr] gap-2.5 px-2.5">194 <span aria-hidden className="mx-auto h-full w-px bg-line" />195 <div className="flex flex-col gap-1.5">196 {row.details.map((d, j) => (197 <div198 key={d.label}199 className="flex items-center justify-between"200 style={201 open202 ? { animation: `fade-up 300ms cubic-bezier(0.23,1,0.32,1) ${120 + j * 100}ms both` }203 : undefined204 }205 >206 <span className="text-[12px] text-ink-2">{d.label}</span>207 <span className="font-mono text-[11.5px] text-ink-3 tabular-nums">208 {d.meta}209 </span>210 </div>211 ))}212 </div>213 </div>214 </div>215 </div>216 </div>217 );218 })}219 </div>220 );221}222
Props & Specification
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | Capsules | List | Capsules | Visual state and layout presentation mode. |
| className | string | "" | Optional Tailwind CSS wrapper class overrides. |