1import { useAtomValue } from "@effect/atom-react";
2import { ThreadDetailsControl } from "./ThreadDetailsControl";
3import { ThreadHoverCardPopup } from "../ThreadHoverCard";
4import { ThreadDetailsSection } from "./ThreadDetailsSection";
5import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible-section-header";
6import { SubagentTooltipContent } from "./SubagentTooltipContent";
7import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
8import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
9import {
10 deriveWorkflowGroups,
11 projectedSubagentsToRuntime,
12 type RuntimeSubagent,
13} from "@t3tools/client-runtime/state/subagentRuntime";
14import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
15import {
16 deriveThreadRelationshipGraph,
17 immediateThreadRelationships,
18 isParentThreadRelationship,
19 threadRelationshipRowStatus,
20 orderWebThreadLineageRows,
21 resolveMergeBackTargetThreadId,
22 type ThreadRelationshipEdge,
23 type ThreadRelationshipWalkRow,
24} from "@t3tools/client-runtime/state/thread-relationships";
25import {
26 canDetachThreadProviderSession,
27 resolveLatestMergeBackRun,
28} from "@t3tools/client-runtime/state/thread-workflows";
29import {
30 NodeId,
31 type EnvironmentId,
32 type OrchestrationV2Subagent,
33 type OrchestrationV2ThreadShell,
34 type ThreadId,
35} from "@t3tools/contracts";
36import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming";
37import { groupBy } from "effect/Array";
38import * as DateTime from "effect/DateTime";
39import { useNavigate } from "@tanstack/react-router";
40import {
41 ArrowRightIcon,
42 BotIcon,
43 CornerLeftUpIcon,
44 GitForkIcon,
45 LoaderCircleIcon,
46 MoreHorizontalIcon,
47 PlusIcon,
48 SquareIcon,
49 UnplugIcon,
50} from "lucide-react";
51import { useMemo, useState, type ReactNode } from "react";
52
53import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
54import { buildThreadRouteParams } from "../../threadRoutes";
55import {
56 useProjects,
57 useServerConfigs,
58 useThreadProjection,
59 useThreadShells,
60} from "../../state/entities";
61import { threadEnvironment } from "../../state/threads";
62import { orchestrationEnvironment } from "../../state/orchestration";
63import { useAtomCommand } from "../../state/use-atom-command";
64import { AgentElapsed } from "./AgentElapsed";
65import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadRelationshipIcon";
66import { ThreadLineageWorkflowRow } from "./ThreadLineageWorkflowRow";
67
68import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
69import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
70import { toastManager } from "../ui/toast";
71import {
72 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
73 THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS,
74 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
75} from "./threadDetailsPanelStyles";
76
77// Lineage paging: a busy thread can accumulate dozens of forks and subagents,
78// and the panel it lives in already scrolls. Show a workable window, keep the
79// rest behind Show more, and bound what is shown so the sections below Lineage
80// stay reachable.
81const THREAD_LINEAGE_INITIAL_COUNT = 6;
82const THREAD_LINEAGE_PAGE_COUNT = 12;
83
84export function resolveThreadLineageWindow<Row>(
85 rows: ReadonlyArray<Row>,
86 visibleCount: number,
87): { readonly visibleRows: ReadonlyArray<Row>; readonly hiddenCount: number } {
88 const visibleRows = rows.slice(0, visibleCount);
89 return { visibleRows, hiddenCount: rows.length - visibleRows.length };
90}
91
92export function ThreadLineageRowList(props: {
93 readonly hiddenCount: number;
94 readonly onShowMore: () => void;
95 readonly children: ReactNode;
96}) {
97 return (
98 <>
99 {/*
100 Bounded rather than free-growing so Lineage cannot push the rest of the
101 thread details panel out of view. Plain overflow, not a ScrollArea
102 component: this sits inside an already scrolling panel, where a
103 max-height-only virtual viewport measures badly. Every row is a focusable
104 button, so keyboard users reach and scroll the region through the rows
105 themselves and the container needs no extra tab stop of its own. An
106 expanded workflow asks for a taller window, so it raises the bound while
107 its tree is open.
108 */}
109 <ul
110 aria-label="Related threads"
111 className="m-0 max-h-[13.5rem] list-none overflow-y-auto overscroll-contain p-0 has-data-workflow-expanded:max-h-[min(28rem,55dvh)]"
112 >
113 {props.children}
114 </ul>
115 {props.hiddenCount > 0 ? (
116 <button
117 type="button"
118 onClick={props.onShowMore}
119 className={`flex h-8 w-full cursor-pointer items-center rounded-lg ${THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS} text-sm font-medium text-muted-foreground/70 hover:bg-black/[0.055] hover:text-foreground/80 dark:hover:bg-white/[0.075]`}
120 >
121 <PlusIcon aria-hidden className="size-4 shrink-0" />
122 Show {Math.min(props.hiddenCount, THREAD_LINEAGE_PAGE_COUNT)} more
123 </button>
124 ) : null}
125 </>
126 );
127}
128
129function ThreadLineageGroup(props: {
130 readonly label: string | null;
131 readonly rows: ReadonlyArray<ThreadRelationshipWalkRow>;
132 readonly expanded: boolean;
133 readonly children: (rows: ReadonlyArray<ThreadRelationshipWalkRow>) => ReactNode;
134}) {
135 const [expanded, setExpanded] = useState(props.expanded);
136 const [visibleCount, setVisibleCount] = useState(THREAD_LINEAGE_INITIAL_COUNT);
137 const { visibleRows, hiddenCount } = resolveThreadLineageWindow(props.rows, visibleCount);
138 const failedCount = props.rows.filter(
139 ({ edge }) => edge.status === "failed" || edge.status === "error",
140 ).length;
141 if (props.rows.length === 0) return null;
142 return (
143 <div>
144 {props.label ? (
145 <CollapsibleSectionHeader
146 variant="panel"
147 expanded={expanded}
148 onClick={() => setExpanded(!expanded)}
149 accessory={
150 failedCount > 0 ? <SectionHeaderStatus>{failedCount} failed</SectionHeaderStatus> : null
151 }
152 >
153 {props.label}
154 {!expanded && ` (${props.rows.length})`}
155 </CollapsibleSectionHeader>
156 ) : null}
157 {expanded ? (
158 <ThreadLineageRowList
159 hiddenCount={hiddenCount}
160 onShowMore={() => setVisibleCount((count) => count + THREAD_LINEAGE_PAGE_COUNT)}
161 >
162 {props.children(visibleRows)}
163 </ThreadLineageRowList>
164 ) : null}
165 </div>
166 );
167}
168
169function relationshipLabel(edge: ThreadRelationshipEdge, currentThreadId: ThreadId) {
170 if (edge.kind === "transfer") return "Context transfer";
171 if (edge.kind === "subagent") {
172 return edge.sourceThreadId === currentThreadId ? "Subagent" : "Parent agent";
173 }
174 return edge.sourceThreadId === currentThreadId ? "Fork" : "Parent thread";
175}
176
177function relationshipThreadTitle(input: {
178 readonly title: string;
179 readonly isSubagent: boolean;
180}): string {
181 if (!input.isSubagent) return input.title;
182 return formatSubagentDisplayTitle(input.title);
183}
184
185/**
186 * The row's timer and hover card follow current child work, including queued
187 * follow-ups whose provider turn has not started yet.
188 */
189function currentSubagent<Agent extends RuntimeSubagent>(
190 agent: Agent | undefined,
191 childThread: OrchestrationV2ThreadShell | null | undefined,
192): Agent | undefined {
193 const liveStatus =
194 childThread?.activityRunStatus ?? (childThread?.status === "queued" ? "queued" : null);
195 if (!agent || !childThread) return agent;
196 const newerRun =
197 childThread.latestRunRequestedAt &&
198 DateTime.toEpochMillis(childThread.latestRunRequestedAt) >
199 Date.parse(agent.completedAt ?? agent.startedAt ?? agent.updatedAt);
200 if (!liveStatus && !newerRun) return agent;
201 const status = liveStatus ?? childThread.status;
202 const startedAt = liveStatus ? childThread.activityRunStartedAt : childThread.latestRunStartedAt;
203 const completedAt = liveStatus ? null : childThread.latestRunCompletedAt;
204 return {
205 ...agent,
206 status:
207 status === "preparing" || status === "starting" || status === "queued"
208 ? "pending"
209 : status === "rolled_back"
210 ? "interrupted"
211 : status,
212 startedAt: startedAt ? DateTime.formatIso(startedAt) : null,
213 completedAt: completedAt ? DateTime.formatIso(completedAt) : null,
214 // The task's output belongs to its recorded run, not newer child work.
215 progress: null,
216 result: null,
217 error: null,
218 };
219}
220
221export function ThreadRelationshipsPanel(props: {
222 readonly environmentId: EnvironmentId;
223 readonly threadId: ThreadId;
224}) {
225 const ref = scopeThreadRef(props.environmentId, props.threadId);
226 const projection = useThreadProjection(ref)?.projection ?? null;
227 const providers = useServerConfigs().get(props.environmentId)?.providers;
228 const runtimeSubagents = useMemo(
229 () => projectedSubagentsToRuntime(projection?.subagents ?? []),
230 [projection?.subagents],
231 );
232 const subagentsByThreadId = useMemo(() => {
233 const byId = new Map(runtimeSubagents.map((agent) => [agent.id, agent]));
234 return new Map(
235 (projection?.subagents ?? [])
236 .filter((subagent) => subagent.childThreadId !== null)
237 .map((subagent) => [
238 subagent.childThreadId,
239 {
240 ...byId.get(subagent.id)!,
241 id: subagent.id,
242 threadId: subagent.threadId,
243 runId: subagent.runId,
244 nativeTaskRef: subagent.nativeTaskRef,
245 nativeStatus: subagent.status,
246 driver: subagent.driver,
247 providerInstanceId: subagent.providerInstanceId,
248 origin: subagent.origin,
249 modelSelection: subagent.modelSelection,
250 },
251 ]),
252 );
253 }, [projection?.subagents, runtimeSubagents]);
254 // A workflow coordinator is already a subagent row here; the grouped model
255 // is what lets that row unfold into the phases and members it ran.
256 const workflowGroupsById = useMemo(
257 () =>
258 new Map(deriveWorkflowGroups(runtimeSubagents).map((group) => [group.workflow.id, group])),
259 [runtimeSubagents],
260 );
261 const threadShells = useThreadShells();
262 const projects = useProjects().filter((project) => project.environmentId === props.environmentId);
263 const archived = useArchivedThreadSnapshots([props.environmentId]);
264 const archivedShells = archived.snapshots.find(
265 (entry) => entry.environmentId === props.environmentId,
266 )?.snapshot.threads;
267 const graph = useMemo(() => {
268 const shells: ReadonlyArray<OrchestrationV2ThreadShell> = [
269 ...threadShells
270 .filter((thread) => thread.environmentId === props.environmentId)
271 .map((thread) => thread.source),
272 ...(archivedShells ?? []),
273 ];
274 return deriveThreadRelationshipGraph({ threads: shells, projection });
275 }, [archivedShells, projection, props.environmentId, threadShells]);
276 const currentThread = projection?.thread ?? graph.nodes.get(props.threadId)?.thread;
277 const currentProject = projects.find((project) => project.id === currentThread?.projectId);
278 const navigate = useNavigate();
279 const mergeBack = useAtomCommand(threadEnvironment.mergeBack);
280 const stopSession = useAtomCommand(threadEnvironment.stopSession);
281 const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn);
282 const stopWorkflowCommand = useAtomCommand(orchestrationEnvironment.stopWorkflow);
283 const canOperateWorkflow = useAtomValue(
284 orchestrationEnvironment.stopWorkflow.permissionAtom(props.environmentId),
285 );
286 const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null);
287 const [stoppingId, setStoppingId] = useState<string | null>(null);
288 const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection);
289 const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection);
290 const relationshipRows = useMemo(
291 () =>
292 orderWebThreadLineageRows({
293 graph,
294 rows: immediateThreadRelationships(graph, props.threadId),
295 currentThreadId: props.threadId,
296 mergeTargetThreadId,
297 }).map((row) => {
298 const agent =
299 row.edge.kind === "subagent" && !isParentThreadRelationship(row.edge, props.threadId)
300 ? subagentsByThreadId.get(row.threadId)
301 : undefined;
302 const workflowGroup = agent === undefined ? undefined : workflowGroupsById.get(agent.id);
303 return workflowGroup === undefined
304 ? row
305 : { ...row, edge: { ...row.edge, status: workflowGroup.workflow.status } };
306 }),
307 [graph, mergeTargetThreadId, props.threadId, subagentsByThreadId, workflowGroupsById],
308 );
309 const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null;
310 const canDetach = projection ? canDetachThreadProviderSession(projection) : false;
311
312 const {
313 related = [],
314 active = [],
315 previous = [],
316 } = groupBy(relationshipRows, ({ edge }) => {
317 if (edge.kind !== "subagent" || isParentThreadRelationship(edge, props.threadId))
318 return "related";
319 return ["completed", "failed", "error", "cancelled", "interrupted", "idle"].includes(
320 edge.status ?? "",
321 )
322 ? "previous"
323 : "active";
324 });
325 const groups = [
326 { id: "related", label: null, rows: related, expanded: true },
327 { id: "active", label: null, rows: active, expanded: true },
328 { id: "previous", label: "Previous agents", rows: previous, expanded: false },
329 ];
330 // Subagents without a child thread yet have no row, so count them separately.
331 const runningCount =
332 (projection?.subagents.filter(
333 (agent) => agent.childThreadId === null && agent.status === "running",
334 ).length ?? 0) + active.filter(({ edge }) => edge.status === "running").length;
335
336 if (relationshipRows.length === 0 && runningCount === 0) {
337 return null;
338 }
339
340 const openThread = (threadId: ThreadId) => {
341 void navigate({
342 to: "/$environmentId/$threadId",
343 params: buildThreadRouteParams(scopeThreadRef(props.environmentId, threadId)),
344 });
345 };
346
347 const merge = async () => {
348 if (!latestMergeBackRun || mergeTargetThreadId === null || busyAction !== null) return;
349 setBusyAction("merge");
350 const result = await mergeBack({
351 environmentId: props.environmentId,
352 input: {
353 sourceThreadId: props.threadId,
354 targetThreadId: mergeTargetThreadId,
355 runId: latestMergeBackRun.id,
356 },
357 });
358 setBusyAction(null);
359 if (result._tag === "Success") openThread(mergeTargetThreadId);
360 };
361
362 const detach = async () => {
363 if (!canDetach || busyAction !== null) return;
364 setBusyAction("detach");
365 await stopSession({
366 environmentId: props.environmentId,
367 input: { threadId: props.threadId },
368 });
369 setBusyAction(null);
370 };
371
372 const stopSubagent = async (
373 childThreadId: ThreadId,
374 agent: Pick<OrchestrationV2Subagent, "id" | "origin" | "threadId">,
375 ) => {
376 if (stoppingId !== null) return;
377 setStoppingId(childThreadId);
378 const result = await interruptTurn({
379 environmentId: props.environmentId,
380 input:
381 agent.origin === "provider_native"
382 ? { threadId: agent.threadId, subagentId: agent.id }
383 : { threadId: childThreadId },
384 });
385 setStoppingId(null);
386 if (result._tag === "Failure") {
387 toastManager.add({ type: "error", title: "Could not stop subagent" });
388 }
389 };
390
391 const stopWorkflow = async (subagentId: string) => {
392 if (stoppingId !== null) return;
393 setStoppingId(subagentId);
394 const result = await stopWorkflowCommand({
395 environmentId: props.environmentId,
396 input: { threadId: props.threadId, subagentId: NodeId.make(subagentId) },
397 });
398 setStoppingId(null);
399 if (result._tag === "Failure") {
400 toastManager.add({ type: "error", title: "Could not stop workflow" });
401 }
402 };
403
404 const parentTitle =
405 mergeTargetThreadId === null
406 ? null
407 : (graph.nodes.get(mergeTargetThreadId)?.thread?.title ?? null);
408
409 return (
410 <ThreadDetailsSection
411 headingId="thread-details-lineage-heading"
412 title={runningCount > 0 ? `Lineage · ${runningCount} running` : "Lineage"}
413 data-thread-relationships-panel
414 actions={
415 canDetach ? (
416 <Menu>
417 <MenuTrigger
418 render={
419 <ThreadDetailsControl
420 size="icon-xs"
421 variant="ghost"
422 part="icon"
423 aria-label="More thread actions"
424 disabled={busyAction !== null}
425 />
426 }
427 >
428 <MoreHorizontalIcon className="size-3.5" />
429 </MenuTrigger>
430 <MenuPopup align="end" className="min-w-60 max-w-(--available-width)">
431 <MenuItem onClick={() => void detach()}>
432 <UnplugIcon className="size-3.5" />
433 Disconnect agent session
434 </MenuItem>
435 </MenuPopup>
436 </Menu>
437 ) : null
438 }
439 >
440 {groups.map((group) => (
441 <ThreadLineageGroup key={`${scopedThreadKey(ref)}:${group.id}`} {...group}>
442 {(visibleRows) =>
443 visibleRows.map(({ threadId, edge }) => {
444 const node = graph.nodes.get(threadId);
445 const isSubagent = edge.kind === "subagent";
446 const isMergeTarget = threadId === mergeTargetThreadId;
447 const isParent = isParentThreadRelationship(edge, props.threadId);
448 const rowStatus = threadRelationshipRowStatus(graph, { threadId, edge });
449 const RelationshipIcon = isParent
450 ? CornerLeftUpIcon
451 : isSubagent
452 ? BotIcon
453 : GitForkIcon;
454 const relationship = relationshipLabel(edge, props.threadId);
455 const projectedAgent =
456 isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined;
457 const workflowGroup =
458 projectedAgent === undefined
459 ? undefined
460 : workflowGroupsById.get(projectedAgent.id);
461 const agent = workflowGroup
462 ? projectedAgent
463 : currentSubagent(projectedAgent, node?.thread);
464 const failed = rowStatus === "failed" || rowStatus === "error";
465 const canStop =
466 agent &&
467 (agent.origin === "app_owned" ||
468 (agent.origin === "provider_native" &&
469 agent.driver === "claudeAgent" &&
470 agent.runId != null &&
471 agent.nativeTaskRef?.strength === "strong" &&
472 agent.nativeTaskRef.nativeId !== null &&
473 ["pending", "running", "waiting"].includes(agent.nativeStatus))) &&
474 agent.startedAt &&
475 ["pending", "running", "waiting"].includes(agent.status);
476 const threadTitle = relationshipThreadTitle({
477 // A workflow's name is shorter than its child thread's title.
478 title:
479 workflowGroup?.workflow.workflowName ??
480 node?.thread?.title ??
481 agent?.title ??
482 threadId,
483 isSubagent,
484 });
485 const provider = providers?.find(
486 (entry) =>
487 entry.instanceId ===
488 (agent?.providerInstanceId ?? node?.thread?.providerInstanceId),
489 );
490 const providerDriver = agent?.driver ?? provider?.driver;
491 const canStopWorkflow =
492 canOperateWorkflow &&
493 workflowGroup?.workflow.status === "running" &&
494 agent?.origin === "provider_native" &&
495 providerDriver === "claudeAgent";
496 const trailingVisibilityClass = (workflowGroup ? canStopWorkflow : canStop)
497 ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0"
498 : "";
499 const project = projects.find((project) => project.id === node?.thread?.projectId);
500 const relationshipHint = node?.missing
501 ? "This related thread is unavailable"
502 : `Open ${relationship.toLowerCase()} in this chat`;
503 const RelationshipPopup = agent ? ThreadHoverCardPopup : TooltipPopup;
504 const relationshipTooltip = agent ? (
505 <SubagentTooltipContent
506 title={threadTitle}
507 model={agent.model}
508 providerInstanceId={agent.providerInstanceId}
509 origin={agent.origin}
510 modelSelection={agent.modelSelection}
511 provider={provider}
512 providers={providers}
513 driver={providerDriver}
514 elapsed={<AgentElapsed agent={agent} />}
515 status={agent.status}
516 result={agent.result}
517 progress={agent.progress}
518 parentThread={currentThread ?? undefined}
519 childThread={node?.thread ?? undefined}
520 parentProject={currentProject}
521 childProject={project}
522 />
523 ) : (
524 relationshipHint
525 );
526 const relationshipContent = (
527 <>
528 <ThreadRelationshipIcon
529 driver={isSubagent && !isParent ? providerDriver : undefined}
530 provider={provider}
531 fallbackIcon={RelationshipIcon}
532 status={rowStatus}
533 />
534 <span className="min-w-0 flex-1">
535 <span className="block truncate text-left text-sm font-medium leading-4 text-foreground/85">
536 {threadTitle}
537 </span>
538 </span>
539 {agent ? null : (
540 <ArrowRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
541 )}
542 {agent &&
543 !failed &&
544 (deriveSubagentElapsedMs(agent, 0) !== null || canStopWorkflow) ? (
545 <span
546 className={`shrink-0 text-2xs font-normal tabular-nums text-muted-foreground ${canStopWorkflow ? "min-w-6 text-end" : ""} ${trailingVisibilityClass}`}
547 >
548 <AgentElapsed agent={agent} compact />
549 </span>
550 ) : !isMergeTarget ? (
551 <span
552 className={
553 workflowGroup
554 ? "sr-only"
555 : `shrink-0 text-2xs ${failed ? "text-destructive" : "text-muted-foreground"} ${trailingVisibilityClass}`
556 }
557 >
558 {threadRelationshipStatusLabel(rowStatus)}
559 </span>
560 ) : null}
561 </>
562 );
563 // A workflow pairs this row with a disclosure, so it renders as the
564 // leading half of a split row; everything else renders it directly.
565 const relationshipLink = (
566 <Tooltip>
567 <TooltipTrigger
568 delay={200}
569 render={
570 <ThreadDetailsControl
571 size="sm"
572 variant="ghost"
573 part={workflowGroup ? "primary" : "row"}
574 disabled={node?.missing === true}
575 onClick={() => openThread(threadId)}
576 />
577 }
578 >
579 {relationshipContent}
580 </TooltipTrigger>
581 <RelationshipPopup side="left">{relationshipTooltip}</RelationshipPopup>
582 </Tooltip>
583 );
584 if (workflowGroup && agent) {
585 return (
586 <ThreadLineageWorkflowRow
587 key={threadId}
588 group={workflowGroup}
589 providerInstanceId={agent.providerInstanceId}
590 provider={provider}
591 providers={providers}
592 driver={providerDriver}
593 onOpenThread={(memberThreadId) => openThread(memberThreadId as ThreadId)}
594 isThreadAvailable={(memberThreadId) =>
595 graph.nodes.get(memberThreadId as ThreadId)?.missing === false
596 }
597 onStop={canStopWorkflow ? () => void stopWorkflow(agent.id) : undefined}
598 stopping={stoppingId === agent.id}
599 stopDisabled={stoppingId !== null}
600 header={relationshipLink}
601 />
602 );
603 }
604 return (
605 <li key={threadId} className="group relative flex h-8 items-center rounded-lg">
606 {isMergeTarget ? (
607 <div className={THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS}>
608 <Tooltip>
609 <TooltipTrigger
610 delay={200}
611 render={
612 <ThreadDetailsControl
613 size="sm"
614 variant="ghost"
615 part="primary"
616 aria-label={`${threadTitle} ${threadRelationshipStatusLabel(rowStatus)}`}
617 disabled={node?.missing === true}
618 onClick={() => openThread(threadId)}
619 />
620 }
621 >
622 {relationshipContent}
623 </TooltipTrigger>
624 <RelationshipPopup side="left">{relationshipTooltip}</RelationshipPopup>
625 </Tooltip>
626 <span
627 aria-hidden="true"
628 className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS}
629 />
630 <Tooltip>
631 <TooltipTrigger
632 render={
633 <ThreadDetailsControl
634 size="sm"
635 variant="ghost"
636 part="secondary"
637 aria-label={
638 parentTitle
639 ? `Merge back to ${parentTitle}`
640 : "Merge back to source conversation"
641 }
642 disabled={!canMerge || busyAction !== null}
643 onClick={() => void merge()}
644 >
645 {busyAction === "merge" ? (
646 <LoaderCircleIcon className="size-3 animate-spin" />
647 ) : (
648 <PullRequestGlyph.merged className="size-3" />
649 )}
650 </ThreadDetailsControl>
651 }
652 />
653 <TooltipPopup side="left">
654 {latestMergeBackRun === null
655 ? "Complete a run in this fork before merging it back"
656 : parentTitle
657 ? `Merge this conversation back into ${parentTitle}`
658 : "Merge this conversation back into its source"}
659 </TooltipPopup>
660 </Tooltip>
661 <span className="shrink-0 border border-transparent ps-1 pe-2.5 text-2xs font-medium text-muted-foreground">
662 {threadRelationshipStatusLabel(rowStatus)}
663 </span>
664 </div>
665 ) : (
666 relationshipLink
667 )}
668 {canStop && agent ? (
669 <div className="pointer-events-none absolute right-1 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">
670 <Tooltip>
671 <TooltipTrigger
672 render={
673 <ThreadDetailsControl
674 size="icon-xs"
675 variant="ghost"
676 part="icon"
677 tone="destructive"
678 aria-label={`Stop subagent ${threadTitle}`}
679 disabled={stoppingId !== null}
680 onClick={() => void stopSubagent(threadId, agent)}
681 />
682 }
683 >
684 {stoppingId === threadId ? (
685 <LoaderCircleIcon aria-hidden className="size-3 animate-spin" />
686 ) : (
687 <SquareIcon aria-hidden className="size-3 fill-current" />
688 )}
689 </TooltipTrigger>
690 <TooltipPopup side="left">Stop subagent</TooltipPopup>
691 </Tooltip>
692 </div>
693 ) : null}
694 </li>
695 );
696 })
697 }
698 </ThreadLineageGroup>
699 ))}
700 </ThreadDetailsSection>
701 );
702}