{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"subagent-list","type":"registry:ui","title":"SubagentList","description":"Fan-out panel for parallel workers: one row per delegated agent with its status dot, model badge, task, and its own progress bar","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["ai-agents"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/subagent-list/SubagentList.tsx","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport type { RunStatus, SubagentData } from \"../../internals/ai-types\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport \"./subagent-list.css\";\n\n/** The small caption a row shows in place of a progress bar. */\nconst STATUS_WORDS: Record<RunStatus, string> = {\n\tpending: \"pending\",\n\trunning: \"running\",\n\tdone: \"done\",\n\terror: \"error\",\n\tcancelled: \"cancelled\",\n};\n\n/**\n * The spoken form, rendered wherever the row has no visible caption to speak\n * for it: under a replaced body, and beside the progress bar. A `progressbar`\n * is children-presentational, so its own name and value are dropped from the\n * name of a selectable row's button — without these words a running row would\n * be a name and nothing else.\n */\nconst STATUS_LABELS: Record<RunStatus, string> = {\n\tpending: \"Pending\",\n\trunning: \"Running\",\n\tdone: \"Completed\",\n\terror: \"Failed\",\n\tcancelled: \"Cancelled\",\n};\n\nfunction isFinished(status: RunStatus): boolean {\n\treturn status === \"done\" || status === \"error\" || status === \"cancelled\";\n}\n\nfunction noun(count: number): string {\n\treturn count === 1 ? \"agent\" : \"agents\";\n}\n\n/**\n * A bar is a claim that something is moving, so only a running agent that\n * reported a number gets one. Everything else — queued, finished, or running\n * blind — says where it stands in words instead.\n */\nfunction showsProgress(agent: SubagentData): boolean {\n\treturn agent.status === \"running\" && agent.progress != null && Number.isFinite(agent.progress);\n}\n\n/** Clamped, because a worker reporting 1.4 should fill the track, not overrun it. */\nfunction percent(progress: number | undefined): number {\n\treturn Math.round(Math.min(1, Math.max(0, progress ?? 0)) * 100);\n}\n\ninterface Row {\n\tagent: SubagentData;\n\tindex: number;\n\tkey: string;\n}\n\n/**\n * The first worker under an id keeps that id as its key, so a row survives a\n * reorder with its DOM node intact; only a repeat is suffixed, and by\n * occurrence rather than position, which would rename every row below an\n * insertion. The suffix also clears the ids actually in the list, since a\n * worker may genuinely be called \"x#1\" while two others are called \"x\".\n */\nfunction buildRows(agents: SubagentData[]): Row[] {\n\tconst ids = new Set(agents.map((agent) => agent.id));\n\tconst seen = new Map<string, number>();\n\tconst used = new Set<string>();\n\treturn agents.map((agent, index) => {\n\t\tconst occurrence = seen.get(agent.id) ?? 0;\n\t\tseen.set(agent.id, occurrence + 1);\n\t\tlet key = agent.id;\n\t\tif (occurrence > 0) {\n\t\t\tlet suffix = occurrence;\n\t\t\tdo {\n\t\t\t\tkey = `${agent.id}#${suffix}`;\n\t\t\t\tsuffix++;\n\t\t\t} while (ids.has(key) || used.has(key));\n\t\t}\n\t\tused.add(key);\n\t\treturn { agent, index, key };\n\t});\n}\n\nfunction RowBody({\n\tagent,\n\tindex,\n\tcompact,\n\tonSelect,\n\titem,\n}: {\n\tagent: SubagentData;\n\tindex: number;\n\tcompact: boolean;\n\tonSelect?: (agent: SubagentData, index: number) => void;\n\titem?: (agent: SubagentData, index: number) => ReactNode;\n}) {\n\treturn (\n\t\t<>\n\t\t\t{/*\n\t\t\t\tFilled versus hollow, not just hue: the five states stay apart for\n\t\t\t\tanyone who cannot tell the colours apart, and the row says which\n\t\t\t\tone it is in words beside it.\n\t\t\t*/}\n\t\t\t<span\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"ft-subagents-dot\",\n\t\t\t\t\tagent.status === \"pending\" && \"ft-status-pending\",\n\t\t\t\t\tagent.status === \"running\" && \"ft-status-running\",\n\t\t\t\t\tagent.status === \"done\" && \"ft-status-done\",\n\t\t\t\t\tagent.status === \"error\" && \"ft-status-error\",\n\t\t\t\t\tagent.status === \"cancelled\" && \"ft-status-cancelled\"\n\t\t\t\t)}\n\t\t\t\taria-hidden=\"true\"\n\t\t\t></span>\n\n\t\t\t{item ? (\n\t\t\t\t<>\n\t\t\t\t\t<span className=\"sr-only\">{STATUS_LABELS[agent.status]}</span>\n\t\t\t\t\t{item(agent, index)}\n\t\t\t\t</>\n\t\t\t) : (\n\t\t\t\t<>\n\t\t\t\t\t<span className=\"ft-subagents-main min-w-0 flex-1\">\n\t\t\t\t\t\t<span className=\"flex min-w-0 items-baseline gap-1.5\">\n\t\t\t\t\t\t\t<span className=\"ft-subagents-name truncate font-medium\" title={agent.name}>\n\t\t\t\t\t\t\t\t{agent.name}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t{agent.model ? (\n\t\t\t\t\t\t\t\t// `text-foreground/70` rather than `text-muted-foreground`: at\n\t\t\t\t\t\t\t\t// 10px the badge is not large text, so it owes 4.5:1, and the\n\t\t\t\t\t\t\t\t// muted token only reaches 4.33:1 on a light page. Seventy\n\t\t\t\t\t\t\t\t// percent of the foreground clears it on both themes.\n\t\t\t\t\t\t\t\t<span className=\"ft-subagents-model text-foreground/70 shrink-0 font-mono\">\n\t\t\t\t\t\t\t\t\t{agent.model}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t{!compact && agent.task ? (\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName=\"ft-subagents-task text-muted-foreground mt-0.5 block truncate\"\n\t\t\t\t\t\t\t\ttitle={agent.task}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{agent.task}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t</span>\n\n\t\t\t\t\t<span className=\"ft-subagents-meta flex shrink-0 items-center\">\n\t\t\t\t\t\t{showsProgress(agent) ? (\n\t\t\t\t\t\t\t(() => {\n\t\t\t\t\t\t\t\tconst value = percent(agent.progress);\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t{onSelect ? (\n\t\t\t\t\t\t\t\t\t\t\t// Only needed inside the selectable row's button: a `progressbar`\n\t\t\t\t\t\t\t\t\t\t\t// is a range role, so its own value feeds the button's accessible\n\t\t\t\t\t\t\t\t\t\t\t// name instead of being announced on its own, and these words are\n\t\t\t\t\t\t\t\t\t\t\t// what makes that name legible. A standalone row keeps the\n\t\t\t\t\t\t\t\t\t\t\t// `progressbar` as its own object, independently announced with\n\t\t\t\t\t\t\t\t\t\t\t// its label and value — this text would only repeat it.\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"sr-only\">\n\t\t\t\t\t\t\t\t\t\t\t\t{STATUS_LABELS[agent.status]}, {value}%\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"ft-subagents-progress\"\n\t\t\t\t\t\t\t\t\t\t\trole=\"progressbar\"\n\t\t\t\t\t\t\t\t\t\t\taria-label={STATUS_LABELS.running}\n\t\t\t\t\t\t\t\t\t\t\taria-valuemin={0}\n\t\t\t\t\t\t\t\t\t\t\taria-valuemax={100}\n\t\t\t\t\t\t\t\t\t\t\taria-valuenow={value}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"ft-subagents-progress-fill\"\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ width: `${value}%` }}\n\t\t\t\t\t\t\t\t\t\t\t></span>\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t})()\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<span className=\"ft-subagents-state text-muted-foreground\">\n\t\t\t\t\t\t\t\t{STATUS_WORDS[agent.status]}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</span>\n\t\t\t\t</>\n\t\t\t)}\n\t\t</>\n\t);\n}\n\n/**\n * Props for SubagentList\n */\nexport interface SubagentListProps {\n\t/** The delegated workers, in the order they were spawned */\n\tagents: SubagentData[];\n\t/**\n\t * Accessible name for the list. Left undefined it is derived from the\n\t * statuses — \"3 agents running\", then \"3 agents finished\".\n\t */\n\tlabel?: string;\n\t/** Called when a row is activated; supplying it turns every row into a button */\n\tonSelect?: (agent: SubagentData, index: number) => void;\n\t/** Replaces the built-in row body, keeping the status dot */\n\titem?: (agent: SubagentData, index: number) => ReactNode;\n\t/** Tighter rows with the task line dropped */\n\tcompact?: boolean;\n\t/** Additional CSS classes */\n\tclassName?: string;\n\t/**\n\t * Plays the matching interface cue through the sound controller. Off\n\t * by default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\nexport const SubagentList = forwardRef<HTMLDivElement, SubagentListProps>(function SubagentList(\n\t{ agents, label, onSelect, item, compact = false, className, sound = false },\n\tref\n) {\n\tconst playCue = useSoundCue(sound);\n\tconst runningCount = agents.filter((agent) => agent.status === \"running\").length;\n\tconst allFinished = agents.length > 0 && agents.every((agent) => isFinished(agent.status));\n\n\t/**\n\t * A fan-out is read for one number: how many workers are still out there. So\n\t * the running count leads while anything runs, and gives way to the total once\n\t * the last one lands. Anything in between — queued, nothing started — is just\n\t * the headcount, since \"0 agents running\" tells the reader nothing they want.\n\t */\n\tconst autoLabel = (() => {\n\t\tconst total = agents.length;\n\t\tif (total === 0) return \"No agents\";\n\t\tif (runningCount > 0) return `${runningCount} ${noun(runningCount)} running`;\n\t\tif (allFinished) return `${total} ${noun(total)} finished`;\n\t\treturn `${total} ${noun(total)}`;\n\t})();\n\n\tconst listLabel = label ?? autoLabel;\n\tconst rows = buildRows(agents);\n\n\t/** A row activation is a fresh gesture every time — mounting a new row or a\n\t *  status change never routes through here, only an actual pick does. */\n\tfunction selectAgent(agent: SubagentData, index: number) {\n\t\tplayCue(\"select\");\n\t\tonSelect?.(agent, index);\n\t}\n\n\treturn (\n\t\t<div ref={ref} className={cn(\"ft-subagents w-full text-sm\", compact && \"ft-subagents-compact\", className)}>\n\t\t\t{/*\n\t\t\t\t`role=\"list\"` is stated rather than left implicit: `list-style: none` strips\n\t\t\t\tlist semantics in Safari, and the count of workers is the point of this list.\n\t\t\t*/}\n\t\t\t<ul className=\"ft-subagents-list\" role=\"list\" aria-label={listLabel}>\n\t\t\t\t{rows.map(({ agent, index, key }) => (\n\t\t\t\t\t<li className=\"ft-subagents-row\" key={key}>\n\t\t\t\t\t\t{onSelect ? (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tclassName=\"ft-subagents-body hover:bg-muted/60 focus-visible:ring-ring flex w-full cursor-pointer items-start gap-2.5 rounded-md text-left transition-colors focus-visible:ring-1 focus-visible:outline-none\"\n\t\t\t\t\t\t\t\tonClick={() => selectAgent(agent, index)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<RowBody agent={agent} index={index} compact={compact} onSelect={onSelect} item={item} />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<span className=\"ft-subagents-body flex w-full items-start gap-2.5 rounded-md\">\n\t\t\t\t\t\t\t\t<RowBody agent={agent} index={index} compact={compact} item={item} />\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</li>\n\t\t\t\t))}\n\t\t\t</ul>\n\t\t</div>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/subagent-list/SubagentList.tsx"},{"path":"react/src/components/subagent-list/index.ts","content":"\"use client\";\n\nexport { SubagentList } from \"./SubagentList\";\nexport type { SubagentListProps } from \"./SubagentList\";\n","type":"registry:ui","target":"@ui/fancy/components/subagent-list/index.ts"},{"path":"react/src/components/subagent-list/subagent-list.css","content":".ft-subagents {\n\t/* Track and badge are mixes of currentColor, so one set of values reads\n\t   correctly in both themes. The dot and the bar fill cannot do that — they\n\t   are the status vocabulary — so they are read at their point of use off the\n\t   shared `--ft-status-*` names, whose defaults are `light-dark()` pairs. A\n\t   dot is a graphical object and owes 3:1, which is why the light half of the\n\t   grey pair is 0.5 rather than 0.55. */\n\t--ft-subagents-row-pad: 0.375rem;\n\t--ft-subagents-dot-size: 0.5rem;\n\t--ft-subagents-track: color-mix(in oklab, currentColor 14%, transparent);\n\t--ft-subagents-track-width: 3.5rem;\n\t--ft-subagents-track-height: 0.25rem;\n\t--ft-subagents-badge-bg: color-mix(in oklab, currentColor 8%, transparent);\n\t--ft-subagents-enter-duration: 320ms;\n\t--ft-subagents-fill-duration: 320ms;\n}\n\n.ft-subagents-compact {\n\t--ft-subagents-row-pad: 0.1875rem;\n}\n\n.ft-subagents-list {\n\tmargin: 0;\n\tpadding: 0;\n\tlist-style: none;\n}\n\n.ft-subagents-body {\n\tpadding: var(--ft-subagents-row-pad) 0.5rem;\n}\n\n.ft-subagents-dot {\n\tposition: relative;\n\tdisplay: inline-block;\n\tflex: none;\n\t/* Half a 1.25rem line box minus half the dot: the dot sits on the middle of\n\t   the name, not of the two-line row. */\n\tmargin-top: calc((1.25rem - var(--ft-subagents-dot-size)) / 2);\n\twidth: var(--ft-subagents-dot-size);\n\theight: var(--ft-subagents-dot-size);\n\tborder-radius: 9999px;\n}\n\n/* Hollow: nothing has happened yet, or nothing ever will. */\n.ft-subagents-dot.ft-status-pending {\n\tbox-shadow: inset 0 0 0 1.5px\n\t\tvar(\n\t\t\t--ft-subagents-pending,\n\t\t\tcolor-mix(\n\t\t\t\tin oklab,\n\t\t\t\tvar(--ft-status-pending, light-dark(oklch(0.5 0.02 260), oklch(0.72 0.02 260))) 55%,\n\t\t\t\ttransparent\n\t\t\t)\n\t\t);\n}\n\n.ft-subagents-dot.ft-status-cancelled {\n\tbox-shadow: inset 0 0 0 1.5px\n\t\tvar(\n\t\t\t--ft-subagents-cancelled,\n\t\t\tcolor-mix(\n\t\t\t\tin oklab,\n\t\t\t\tvar(--ft-status-cancelled, light-dark(oklch(0.5 0.02 260), oklch(0.72 0.02 260))) 40%,\n\t\t\t\ttransparent\n\t\t\t)\n\t\t);\n}\n\n.ft-subagents-dot.ft-status-running {\n\tbackground: var(\n\t\t--ft-subagents-running,\n\t\tvar(--ft-status-running, light-dark(oklch(0.5 0.18 265), oklch(0.72 0.15 265)))\n\t);\n}\n\n.ft-subagents-dot.ft-status-done {\n\tbackground: var(\n\t\t--ft-subagents-done,\n\t\tvar(--ft-status-done, light-dark(oklch(0.5 0.14 145), oklch(0.72 0.15 145)))\n\t);\n}\n\n.ft-subagents-dot.ft-status-error {\n\tbackground: var(\n\t\t--ft-subagents-error,\n\t\tvar(--ft-status-error, light-dark(oklch(0.5 0.19 25), oklch(0.7 0.18 25)))\n\t);\n}\n\n.ft-subagents-model {\n\tborder-radius: 0.25rem;\n\tbackground: var(--ft-subagents-badge-bg);\n\tpadding: 0 0.25rem;\n\tfont-size: 0.625rem;\n\tline-height: 1rem;\n}\n\n.ft-subagents-task {\n\tfont-size: 0.75rem;\n\tline-height: 1.25;\n}\n\n.ft-subagents-meta {\n\t/* Matches the name's line box, so the caption and the bar both land on the\n\t   first line rather than in the middle of a two-line row. */\n\tmin-height: 1.25rem;\n\tmargin-left: auto;\n}\n\n.ft-subagents-state {\n\tfont-size: 0.75rem;\n\tfont-variant-numeric: tabular-nums;\n}\n\n.ft-subagents-progress {\n\tdisplay: block;\n\twidth: var(--ft-subagents-track-width);\n\theight: var(--ft-subagents-track-height);\n\toverflow: hidden;\n\tborder-radius: 9999px;\n\tbackground: var(--ft-subagents-track);\n}\n\n.ft-subagents-progress-fill {\n\tdisplay: block;\n\theight: 100%;\n\tborder-radius: inherit;\n\tbackground: var(\n\t\t--ft-subagents-bar,\n\t\tvar(--ft-status-running, light-dark(oklch(0.5 0.18 265), oklch(0.72 0.15 265)))\n\t);\n}\n\n/*\n * Everything that moves lives behind the query, so reduced motion is not a\n * degraded variant to keep in sync: with these rules gone a new worker simply\n * appears at its final position, the bar jumps to its width, and a running dot\n * is still a filled accent dot — it just stops breathing.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-subagents-row {\n\t\tanimation: ft-subagents-enter var(--ft-subagents-enter-duration) cubic-bezier(0.16, 1, 0.3, 1)\n\t\t\tboth;\n\t}\n\n\t.ft-subagents-progress-fill {\n\t\ttransition: width var(--ft-subagents-fill-duration) cubic-bezier(0.4, 0, 0.2, 1);\n\t}\n\n\t.ft-subagents-dot.ft-status-running::after {\n\t\tcontent: \"\";\n\t\tposition: absolute;\n\t\tinset: 0;\n\t\tborder-radius: inherit;\n\t\tbackground: inherit;\n\t\tanimation: ft-subagents-ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite;\n\t}\n}\n\n@keyframes ft-subagents-enter {\n\tfrom {\n\t\topacity: 0;\n\t\ttransform: translateY(-0.25rem);\n\t}\n\tto {\n\t\topacity: 1;\n\t\ttransform: none;\n\t}\n}\n\n@keyframes ft-subagents-ping {\n\tfrom {\n\t\topacity: 0.55;\n\t\ttransform: scale(1);\n\t}\n\tto {\n\t\topacity: 0;\n\t\ttransform: scale(2.4);\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/subagent-list/subagent-list.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/subagent-list?framework=react"}}