1import {
2 workflowMemberModelSelection,
3 type SubagentWorkflowGroup,
4 type RuntimeSubagent,
5} from "@t3tools/client-runtime/state/subagentRuntime";
6import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts";
7import { ChevronDownIcon, SquareIcon } from "lucide-react";
8import { useState, type ReactNode } from "react";
9
10import { AgentElapsed } from "./AgentElapsed";
11import { SubagentTooltipContent } from "./SubagentTooltipContent";
12import { ThreadHoverCardPopup } from "../ThreadHoverCard";
13import { ThreadRelationshipIcon } from "./ThreadRelationshipIcon";
14import type { ProviderInstanceEntry } from "../../providerInstances";
15import { cn } from "../../lib/utils";
16import { ThreadDetailsControl } from "./ThreadDetailsControl";
17import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible-section-header";
18import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
19import { Spinner } from "../ui/spinner";
20import {
21 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
22 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
23} from "./threadDetailsPanelStyles";
24
25function phaseElapsed(phase: SubagentWorkflowGroup["phases"][number]) {
26 const instants = (key: "startedAt" | "completedAt") =>
27 phase.members
28 .map((member) => member[key])
29 .filter((value) => value !== null)
30 .sort();
31 const running = phase.state === "running";
32 const startedAt = instants("startedAt")[0] ?? null;
33 const completedAt = running ? null : (instants("completedAt").at(-1) ?? null);
34 return {
35 status: running ? ("running" as const) : ("completed" as const),
36 startedAt: running || completedAt !== null ? startedAt : null,
37 completedAt,
38 };
39}
40
41function phaseStatusLabel(phase: SubagentWorkflowGroup["phases"][number]) {
42 if (phase.state === "running") return "running";
43 if (phase.members.some((member) => member.status === "failed")) return "failed";
44 if (
45 phase.members.some((member) => member.status === "cancelled" || member.status === "interrupted")
46 ) {
47 return "stopped";
48 }
49 return phase.state === "done" ? "done" : "not started";
50}
51
52function WorkflowMemberRow({
53 member,
54 providerInstanceId,
55 provider,
56 providers,
57 providerEntries,
58 driver,
59 onOpen,
60}: {
61 member: RuntimeSubagent;
62 providerInstanceId: ProviderInstanceId;
63 provider: ServerProvider | undefined;
64 providers: ReadonlyArray<ServerProvider> | undefined;
65 providerEntries: ReadonlyArray<ProviderInstanceEntry>;
66 driver: ProviderDriverKind | undefined;
67 onOpen: (threadId: string) => void;
68}) {
69 const threadId = member.childThreadId;
70 const canOpen = threadId !== null;
71 return (
72 <li>
73 <Tooltip>
74 <TooltipTrigger
75 delay={200}
76 render={
77 <ThreadDetailsControl
78 part="row"
79 aria-label={`Open ${member.title} chat`}
80 disabled={!canOpen}
81 onClick={() => canOpen && onOpen(threadId)}
82 />
83 }
84 >
85 <ThreadRelationshipIcon
86 driver={driver}
87 provider={provider}
88 providerEntries={providerEntries}
89 status={member.status}
90 />
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 modelSelection={workflowMemberModelSelection(member, providerInstanceId)}
106 provider={provider}
107 providers={providers}
108 driver={driver}
109 elapsed={<AgentElapsed agent={member} />}
110 status={member.status}
111 result={member.result ?? member.error}
112 progress={member.progress}
113 />
114 </ThreadHoverCardPopup>
115 </Tooltip>
116 </li>
117 );
118}
119
120export function ThreadLineageWorkflowRow({
121 group,
122 header,
123 providerInstanceId,
124 provider,
125 providers,
126 providerEntries,
127 driver,
128 onOpenThread,
129 onStop,
130 stopping,
131 stopDisabled,
132}: {
133 readonly group: SubagentWorkflowGroup;
134 readonly header: ReactNode;
135 readonly providerInstanceId: ProviderInstanceId;
136 readonly provider: ServerProvider | undefined;
137 readonly providers: ReadonlyArray<ServerProvider> | undefined;
138 readonly providerEntries: ReadonlyArray<ProviderInstanceEntry>;
139 readonly driver: ProviderDriverKind | undefined;
140 readonly onOpenThread: (threadId: string) => void;
141 readonly onStop?: (() => void) | undefined;
142 readonly stopping: boolean;
143 readonly stopDisabled: boolean;
144}) {
145 const [expanded, setExpanded] = useState(false);
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 <li className="group" data-workflow-expanded={expanded ? "" : undefined}>
151 <div className={cn("relative", THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS)}>
152 {header}
153 {onStop ? (
154 <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">
155 <Tooltip>
156 <TooltipTrigger
157 render={
158 <ThreadDetailsControl
159 size="icon-xs"
160 variant="ghost"
161 part="icon"
162 tone="destructive"
163 aria-label={`Stop workflow ${label}`}
164 disabled={stopDisabled}
165 onClick={onStop}
166 />
167 }
168 >
169 {stopping ? (
170 <Spinner aria-hidden size="xs" />
171 ) : (
172 <SquareIcon aria-hidden className="size-3 fill-current" />
173 )}
174 </TooltipTrigger>
175 <TooltipPopup side="left">Stop entire workflow</TooltipPopup>
176 </Tooltip>
177 </div>
178 ) : null}
179 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
180 <ThreadDetailsControl
181 size="sm"
182 variant="ghost"
183 part="secondary"
184 aria-expanded={expanded}
185 aria-label={`${expanded ? "Collapse" : "Expand"} ${label}`}
186 onClick={() => setExpanded((value) => !value)}
187 >
188 <ChevronDownIcon
189 aria-hidden
190 className={cn(
191 "size-3.5 text-muted-foreground transition-transform",
192 expanded && "rotate-180",
193 )}
194 />
195 </ThreadDetailsControl>
196 </div>
197 {expanded ? (
198 <ul className="m-0 list-none p-0 ps-6.5">
199 {phases.map((phase) => {
200 const open = phaseOpen.get(phase.index) ?? phase.state === "running";
201 const status = phaseStatusLabel(phase);
202 return (
203 <li key={phase.index}>
204 <CollapsibleSectionHeader
205 variant="panel"
206 tone={phase.state === "running" ? "emphasized" : "muted"}
207 expanded={open}
208 aria-label={`${open ? "Collapse" : "Expand"} ${phase.title} phase, ${status}`}
209 onClick={() => setPhaseOpen((phases) => new Map(phases).set(phase.index, !open))}
210 accessory={
211 <>
212 {status === "failed" ? (
213 <SectionHeaderStatus>Failed</SectionHeaderStatus>
214 ) : null}
215 <span className="shrink-0 text-2xs font-normal tabular-nums">
216 {phase.settledCount}/{phase.members.length}{" "}
217 {phase.members.length === 1 ? "agent" : "agents"}
218 </span>
219 <span className="shrink-0 text-2xs font-normal tabular-nums empty:hidden">
220 <AgentElapsed agent={phaseElapsed(phase)} />
221 </span>
222 </>
223 }
224 >
225 {phase.title}
226 </CollapsibleSectionHeader>
227 {open ? (
228 <ul className="m-0 list-none p-0">
229 {phase.members.map((member) => (
230 <WorkflowMemberRow
231 key={member.id}
232 member={member}
233 providerInstanceId={providerInstanceId}
234 provider={provider}
235 providers={providers}
236 providerEntries={providerEntries}
237 driver={driver}
238 onOpen={onOpenThread}
239 />
240 ))}
241 </ul>
242 ) : null}
243 </li>
244 );
245 })}
246 {group.unphasedMembers.map((member) => (
247 <WorkflowMemberRow
248 key={member.id}
249 member={member}
250 providerInstanceId={providerInstanceId}
251 provider={provider}
252 providers={providers}
253 providerEntries={providerEntries}
254 driver={driver}
255 onOpen={onOpenThread}
256 />
257 ))}
258 {phases.length === 0 && group.unphasedMembers.length === 0 ? (
259 <li className="px-2.5 py-1.5 text-2xs text-muted-foreground/70">No agents yet</li>
260 ) : null}
261 </ul>
262 ) : null}
263 </li>
264 );
265}