1import type {
2 SubagentWorkflowGroup,
3 RuntimeSubagent,
4} from "@t3tools/client-runtime/state/subagentRuntime";
5import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts";
6import { ChevronDownIcon, LoaderCircleIcon, SquareIcon } from "lucide-react";
7import { useState, type ReactNode } from "react";
8
9import { AgentElapsed } from "./AgentElapsed";
10import { SubagentTooltipContent } from "./SubagentTooltipContent";
11import { ThreadHoverCardPopup } from "../ThreadHoverCard";
12import { ThreadRelationshipIcon } from "./ThreadRelationshipIcon";
13import { cn } from "../../lib/utils";
14import { ThreadDetailsControl } from "./ThreadDetailsControl";
15import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible-section-header";
16import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
17import {
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 */
27function 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. */
45function 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. */
57function 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
119export 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}