| 1 | import type { |
| 2 | SubagentWorkflowGroup, |
| 3 | RuntimeSubagent, |
| 4 | } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 5 | import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; |
| 6 | import { ChevronDownIcon, LoaderCircleIcon, SquareIcon } from "lucide-react"; |
| 7 | import { useState, type ReactNode } from "react"; |
| 8 | |
| 9 | import { AgentElapsed } from "./AgentElapsed"; |
| 10 | import { SubagentTooltipContent } from "./SubagentTooltipContent"; |
| 11 | import { ThreadHoverCardPopup } from "../ThreadHoverCard"; |
| 12 | import { ThreadRelationshipIcon } from "./ThreadRelationshipIcon"; |
| 13 | import { cn } from "../../lib/utils"; |
| 14 | import { ThreadDetailsControl } from "./ThreadDetailsControl"; |
| 15 | import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible-section-header"; |
| 16 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 17 | import { |
| 18 | THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, |
| 19 | THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, |
| 20 | } from "./threadDetailsPanelStyles"; |
| 21 | |
| 22 | /** |
| 23 | * Wall clock for the phase: its members run in parallel, so the span from the |
| 24 | * first start to the last finish is the time the phase actually took. Shaped |
| 25 | * for AgentElapsed, which ticks it while the phase is still running. |
| 26 | */ |
| 27 | function phaseElapsed(phase: SubagentWorkflowGroup["phases"][number]) { |
| 28 | const instants = (key: "startedAt" | "completedAt") => |
| 29 | phase.members |
| 30 | .map((member) => member[key]) |
| 31 | .filter((value) => value !== null) |
| 32 | .sort(); |
| 33 | const running = phase.state === "running"; |
| 34 | const startedAt = instants("startedAt")[0] ?? null; |
| 35 | const completedAt = running ? null : (instants("completedAt").at(-1) ?? null); |
| 36 | return { |
| 37 | status: running ? ("running" as const) : ("completed" as const), |
| 38 | // A settled phase whose members never reported an end has no span to show. |
| 39 | startedAt: running || completedAt !== null ? startedAt : null, |
| 40 | completedAt, |
| 41 | }; |
| 42 | } |
| 43 | |
| 44 | /** Phase state for the header's label; the tone and member badges show it visually. */ |
| 45 | function phaseStatusLabel(phase: SubagentWorkflowGroup["phases"][number]) { |
| 46 | if (phase.state === "running") return "running"; |
| 47 | if (phase.members.some((member) => member.status === "failed")) return "failed"; |
| 48 | if ( |
| 49 | phase.members.some((member) => member.status === "cancelled" || member.status === "interrupted") |
| 50 | ) { |
| 51 | return "stopped"; |
| 52 | } |
| 53 | return phase.state === "done" ? "done" : "not started"; |
| 54 | } |
| 55 | |
| 56 | /** Members run under the coordinator's provider, so they share its glyph. */ |
| 57 | function WorkflowMemberRow({ |
| 58 | member, |
| 59 | providerInstanceId, |
| 60 | provider, |
| 61 | providers, |
| 62 | driver, |
| 63 | onOpen, |
| 64 | isThreadAvailable, |
| 65 | }: { |
| 66 | member: RuntimeSubagent; |
| 67 | providerInstanceId: ProviderInstanceId; |
| 68 | provider: ServerProvider | undefined; |
| 69 | providers: ReadonlyArray<ServerProvider> | undefined; |
| 70 | driver: ProviderDriverKind | undefined; |
| 71 | onOpen: (threadId: string) => void; |
| 72 | isThreadAvailable: (threadId: string) => boolean; |
| 73 | }) { |
| 74 | const threadId = member.childThreadId; |
| 75 | const canOpen = threadId !== null && isThreadAvailable(threadId); |
| 76 | return ( |
| 77 | <li> |
| 78 | <Tooltip> |
| 79 | <TooltipTrigger |
| 80 | delay={200} |
| 81 | render={ |
| 82 | <ThreadDetailsControl |
| 83 | part="row" |
| 84 | aria-label={`Open ${member.title} chat`} |
| 85 | disabled={!canOpen} |
| 86 | onClick={() => canOpen && onOpen(threadId)} |
| 87 | /> |
| 88 | } |
| 89 | > |
| 90 | <ThreadRelationshipIcon driver={driver} provider={provider} status={member.status} /> |
| 91 | <span className="min-w-0 flex-1 truncate">{member.title}</span> |
| 92 | <span className="sr-only">{member.status}</span> |
| 93 | {member.startedAt ? ( |
| 94 | <span className="shrink-0 text-2xs font-normal tabular-nums text-muted-foreground"> |
| 95 | <AgentElapsed agent={member} /> |
| 96 | </span> |
| 97 | ) : null} |
| 98 | </TooltipTrigger> |
| 99 | <ThreadHoverCardPopup side="left"> |
| 100 | <SubagentTooltipContent |
| 101 | title={member.title} |
| 102 | model={member.model} |
| 103 | providerInstanceId={providerInstanceId} |
| 104 | origin="provider_native" |
| 105 | provider={provider} |
| 106 | providers={providers} |
| 107 | driver={driver} |
| 108 | elapsed={<AgentElapsed agent={member} />} |
| 109 | status={member.status} |
| 110 | result={member.result ?? member.error} |
| 111 | progress={member.progress} |
| 112 | /> |
| 113 | </ThreadHoverCardPopup> |
| 114 | </Tooltip> |
| 115 | </li> |
| 116 | ); |
| 117 | } |
| 118 | |
| 119 | export function ThreadLineageWorkflowRow({ |
| 120 | group, |
| 121 | header, |
| 122 | providerInstanceId, |
| 123 | provider, |
| 124 | providers, |
| 125 | driver, |
| 126 | onOpenThread, |
| 127 | isThreadAvailable, |
| 128 | onStop, |
| 129 | stopping, |
| 130 | stopDisabled, |
| 131 | }: { |
| 132 | readonly group: SubagentWorkflowGroup; |
| 133 | readonly header: ReactNode; |
| 134 | readonly providerInstanceId: ProviderInstanceId; |
| 135 | readonly provider: ServerProvider | undefined; |
| 136 | readonly providers: ReadonlyArray<ServerProvider> | undefined; |
| 137 | readonly driver: ProviderDriverKind | undefined; |
| 138 | readonly onOpenThread: (threadId: string) => void; |
| 139 | readonly isThreadAvailable: (threadId: string) => boolean; |
| 140 | readonly onStop?: (() => void) | undefined; |
| 141 | readonly stopping: boolean; |
| 142 | readonly stopDisabled: boolean; |
| 143 | }) { |
| 144 | const [expanded, setExpanded] = useState(false); |
| 145 | // The running phase opens itself until the user picks a side for it. |
| 146 | const [phaseOpen, setPhaseOpen] = useState<ReadonlyMap<number, boolean>>(() => new Map()); |
| 147 | const label = group.workflow.workflowName ?? group.workflow.title; |
| 148 | const phases = group.phases.filter((phase) => phase.members.length > 0); |
| 149 | return ( |
| 150 | // The flag lets the lineage list trade its compact height for the open tree. |
| 151 | <li className="group" data-workflow-expanded={expanded ? "" : undefined}> |
| 152 | <div className={cn("relative", THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS)}> |
| 153 | {header} |
| 154 | {onStop ? ( |
| 155 | <div className="pointer-events-none absolute right-9 top-1/2 -translate-y-1/2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100"> |
| 156 | <Tooltip> |
| 157 | <TooltipTrigger |
| 158 | render={ |
| 159 | <ThreadDetailsControl |
| 160 | size="icon-xs" |
| 161 | variant="ghost" |
| 162 | part="icon" |
| 163 | tone="destructive" |
| 164 | aria-label={`Stop workflow ${label}`} |
| 165 | disabled={stopDisabled} |
| 166 | onClick={onStop} |
| 167 | /> |
| 168 | } |
| 169 | > |
| 170 | {stopping ? ( |
| 171 | <LoaderCircleIcon aria-hidden className="size-3 animate-spin" /> |
| 172 | ) : ( |
| 173 | <SquareIcon aria-hidden className="size-3 fill-current" /> |
| 174 | )} |
| 175 | </TooltipTrigger> |
| 176 | <TooltipPopup side="left">Stop entire workflow</TooltipPopup> |
| 177 | </Tooltip> |
| 178 | </div> |
| 179 | ) : null} |
| 180 | <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} /> |
| 181 | <ThreadDetailsControl |
| 182 | size="sm" |
| 183 | variant="ghost" |
| 184 | part="secondary" |
| 185 | aria-expanded={expanded} |
| 186 | aria-label={`${expanded ? "Collapse" : "Expand"} ${label}`} |
| 187 | onClick={() => setExpanded((value) => !value)} |
| 188 | > |
| 189 | <ChevronDownIcon |
| 190 | aria-hidden |
| 191 | className={cn( |
| 192 | "size-3.5 text-muted-foreground transition-transform", |
| 193 | expanded && "rotate-180", |
| 194 | )} |
| 195 | /> |
| 196 | </ThreadDetailsControl> |
| 197 | </div> |
| 198 | {expanded ? ( |
| 199 | <ul className="m-0 list-none p-0 ps-6.5"> |
| 200 | {phases.map((phase) => { |
| 201 | const open = phaseOpen.get(phase.index) ?? phase.state === "running"; |
| 202 | const status = phaseStatusLabel(phase); |
| 203 | return ( |
| 204 | <li key={phase.index}> |
| 205 | <CollapsibleSectionHeader |
| 206 | variant="panel" |
| 207 | tone={phase.state === "running" ? "emphasized" : "muted"} |
| 208 | expanded={open} |
| 209 | aria-label={`${open ? "Collapse" : "Expand"} ${phase.title} phase, ${status}`} |
| 210 | onClick={() => setPhaseOpen((phases) => new Map(phases).set(phase.index, !open))} |
| 211 | accessory={ |
| 212 | <> |
| 213 | {status === "failed" ? ( |
| 214 | <SectionHeaderStatus>Failed</SectionHeaderStatus> |
| 215 | ) : null} |
| 216 | <span className="shrink-0 text-2xs font-normal tabular-nums"> |
| 217 | {phase.settledCount}/{phase.members.length}{" "} |
| 218 | {phase.members.length === 1 ? "agent" : "agents"} |
| 219 | </span> |
| 220 | <span className="shrink-0 text-2xs font-normal tabular-nums empty:hidden"> |
| 221 | <AgentElapsed agent={phaseElapsed(phase)} /> |
| 222 | </span> |
| 223 | </> |
| 224 | } |
| 225 | > |
| 226 | {phase.title} |
| 227 | </CollapsibleSectionHeader> |
| 228 | {open ? ( |
| 229 | <ul className="m-0 list-none p-0"> |
| 230 | {phase.members.map((member) => ( |
| 231 | <WorkflowMemberRow |
| 232 | key={member.id} |
| 233 | member={member} |
| 234 | providerInstanceId={providerInstanceId} |
| 235 | provider={provider} |
| 236 | providers={providers} |
| 237 | driver={driver} |
| 238 | onOpen={onOpenThread} |
| 239 | isThreadAvailable={isThreadAvailable} |
| 240 | /> |
| 241 | ))} |
| 242 | </ul> |
| 243 | ) : null} |
| 244 | </li> |
| 245 | ); |
| 246 | })} |
| 247 | {group.unphasedMembers.map((member) => ( |
| 248 | <WorkflowMemberRow |
| 249 | key={member.id} |
| 250 | member={member} |
| 251 | providerInstanceId={providerInstanceId} |
| 252 | provider={provider} |
| 253 | providers={providers} |
| 254 | driver={driver} |
| 255 | onOpen={onOpenThread} |
| 256 | isThreadAvailable={isThreadAvailable} |
| 257 | /> |
| 258 | ))} |
| 259 | {phases.length === 0 && group.unphasedMembers.length === 0 ? ( |
| 260 | <li className="px-2.5 py-1.5 text-2xs text-muted-foreground/70">No agents yet</li> |
| 261 | ) : null} |
| 262 | </ul> |
| 263 | ) : null} |
| 264 | </li> |
| 265 | ); |
| 266 | } |