Skip to content
Source
Sign inSign up

ThreadRelationshipsControl.tsx

1,060 lines40.3 KB

1import { useAtomValue } from "@effect/atom-react";
2import { threadKey } from "@t3tools/client-runtime/state/entities";
3import { ThreadDetailsControl } from "./ThreadDetailsControl";
4import { ThreadHoverCardPopup } from "../ThreadHoverCard";
5import { ThreadDetailsSection } from "./ThreadDetailsSection";
6import { SectionHeaderStatus } from "../ui/collapsible-section-header";
7import { SubagentTooltipContent } from "./SubagentTooltipContent";
8import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
9import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
10import {
11 deriveWorkflowGroups,
12 projectedSubagentsToRuntime,
13 type RuntimeSubagent,
14} from "@t3tools/client-runtime/state/subagentRuntime";
15import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
16import { deriveProviderSubagentStatus } from "@t3tools/client-runtime/state/thread-execution";
17import {
18 deriveThreadRelationshipGraph,
19 immediateThreadRelationships,
20 isParentThreadRelationship,
21 threadRelationshipRowStatus,
22 orderWebThreadLineageRows,
23 resolveMergeBackTargetThreadId,
24 type ThreadRelationshipEdge,
25 type ThreadRelationshipWalkRow,
26} from "@t3tools/client-runtime/state/thread-relationships";
27import {
28 canDetachThreadProviderSession,
29 resolveLatestMergeBackRun,
30} from "@t3tools/client-runtime/state/thread-workflows";
31import {
32 isOrchestrationV2WorkActive,
33 isProviderNativeSubagentThread,
34 NodeId,
35 type EnvironmentId,
36 type OrchestrationV2DelegatedFrom,
37 type OrchestrationV2RemoteTaskChild,
38 type OrchestrationV2Subagent,
39 type OrchestrationV2ThreadShell,
40 type ServerProvider,
41 type ThreadId,
42} from "@t3tools/contracts";
43import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming";
44import { groupBy } from "effect/Array";
45import * as DateTime from "effect/DateTime";
46import { useNavigate, useRouter } from "@tanstack/react-router";
47import {
48 ArrowRightIcon,
49 BotIcon,
50 ChevronDownIcon,
51 CornerLeftUpIcon,
52 GitForkIcon,
53 MessagesSquareIcon,
54 MoreHorizontalIcon,
55 PlusIcon,
56 SquareIcon,
57 UnplugIcon,
58} from "lucide-react";
59import { useRef, useState, type ReactNode } from "react";
60​
61import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
62import { buildThreadRouteParams } from "../../threadRoutes";
63import {
64 useProjects,
65 useQuietThreadShell,
66 useThreadProjection,
67 useThreadRelationshipShells,
68 useHiddenThreadKeys,
69 useAnyEnvironmentHiding,
70} from "../../state/entities";
71import { useEnvironment, useEnvironmentProviders } from "../../state/environments";
72import { serverEnvironment } from "../../state/server";
73import {
74 applyProviderInstanceSettings,
75 deriveProviderInstanceEntries,
76} from "../../providerInstances";
77import { threadEnvironment } from "../../state/threads";
78import { orchestrationEnvironment, threadTitlesRead } from "../../state/orchestration";
79import { useAtomCommand } from "../../state/use-atom-command";
80import { useEnvironmentQuery } from "../../state/query";
81import { AgentElapsed } from "./AgentElapsed";
82import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadRelationshipIcon";
83import { ThreadLineageWorkflowRow } from "./ThreadLineageWorkflowRow";
84import { openSideChatTab } from "../../lib/sideChatPane";
85​
86import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
87import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
88import { toastManager } from "../ui/toast";
89import { Spinner } from "../ui/spinner";
90import { cn } from "~/lib/utils";
91import {
92 THREAD_DETAILS_PANEL_CHEVRON_CLASS,
93 THREAD_DETAILS_PANEL_ICON_CLASS,
94 THREAD_DETAILS_PANEL_LABEL_CLASS,
95 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
96 THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS,
97 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
98} from "./threadDetailsPanelStyles";
99​
100const THREAD_LINEAGE_INITIAL_COUNT = 6;
101const THREAD_LINEAGE_PAGE_COUNT = 12;
102​
103function resolveThreadLineageWindow<Row>(
104 rows: ReadonlyArray<Row>,
105 visibleCount: number,
106): { readonly visibleRows: ReadonlyArray<Row>; readonly hiddenCount: number } {
107 const visibleRows = rows.slice(0, visibleCount);
108 return { visibleRows, hiddenCount: rows.length - visibleRows.length };
110​
111function ThreadLineageRowList(props: {
112 readonly hiddenCount: number;
113 readonly onShowMore: () => void;
114 readonly children: ReactNode;
115}) {
116 return (
117 <>
118 {/* ScrollArea's max-height viewport measures badly inside this scrolling panel. */}
119 <ul
120 aria-label="Related threads"
121 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)]"
122 >
123 {props.children}
124 </ul>
125 {props.hiddenCount > 0 ? (
126 <button
127 type="button"
128 onClick={props.onShowMore}
129 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]`}
130 >
131 <PlusIcon aria-hidden className="size-4 shrink-0" />
132 Show {Math.min(props.hiddenCount, THREAD_LINEAGE_PAGE_COUNT)} more
133 </button>
134 ) : null}
135 </>
136 );
138​
139type RemoteSubagent = OrchestrationV2Subagent & {
140 readonly remoteChild: OrchestrationV2RemoteTaskChild;
141};
142​
143/** A task delegated to a linked environment has no thread here, so it gets its own row kind. */
144type LineageRow = ThreadRelationshipWalkRow | { readonly remote: RemoteSubagent };
145​
146const lineageRowStatus = (row: LineageRow) =>
147 "remote" in row ? row.remote.status : row.edge.status;
148​
149const isFinishedAgentStatus = (status: string | null | undefined) =>
150 ["completed", "failed", "error", "cancelled", "interrupted", "idle"].includes(status ?? "");
151​
152function ThreadLineageGroup<Row extends LineageRow>(props: {
153 readonly label: string | null;
154 readonly rows: ReadonlyArray<Row>;
155 readonly expanded: boolean;
156 readonly children: (rows: ReadonlyArray<Row>) => ReactNode;
157}) {
158 const [expanded, setExpanded] = useState(props.expanded);
159 const [visibleCount, setVisibleCount] = useState(THREAD_LINEAGE_INITIAL_COUNT);
160 const { visibleRows, hiddenCount } = resolveThreadLineageWindow(props.rows, visibleCount);
161 const failedCount = props.rows.filter((row) => {
162 const status = lineageRowStatus(row);
163 return status === "failed" || status === "error";
164 }).length;
165 if (props.rows.length === 0) return null;
166 return (
167 <div>
168 {props.label ? (
169 <ThreadDetailsControl aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>
170 <BotIcon aria-hidden className={THREAD_DETAILS_PANEL_ICON_CLASS} />
171 <span className={cn(THREAD_DETAILS_PANEL_LABEL_CLASS, "min-w-0 flex-1 truncate")}>
172 {props.label}
173 </span>
174 {failedCount > 0 ? <SectionHeaderStatus>{failedCount} failed</SectionHeaderStatus> : null}
175 {expanded ? null : (
176 <span className="shrink-0 text-xs font-normal tabular-nums text-muted-foreground">
177 {props.rows.length}
178 </span>
179 )}
180 <ChevronDownIcon
181 aria-hidden
182 className={cn(
183 THREAD_DETAILS_PANEL_CHEVRON_CLASS,
184 "transition-transform",
185 !expanded && "-rotate-90",
186 )}
187 />
188 </ThreadDetailsControl>
189 ) : null}
190 {expanded ? (
191 <ThreadLineageRowList
192 hiddenCount={hiddenCount}
193 onShowMore={() => setVisibleCount((count) => count + THREAD_LINEAGE_PAGE_COUNT)}
194 >
195 {props.children(visibleRows)}
196 </ThreadLineageRowList>
197 ) : null}
198 </div>
199 );
201​
202function relationshipLabel(edge: ThreadRelationshipEdge, currentThreadId: ThreadId) {
203 if (edge.kind === "transfer") return "Context transfer";
204 if (edge.kind === "subagent") {
205 return edge.sourceThreadId === currentThreadId ? "Subagent" : "Parent agent";
206 }
207 return edge.sourceThreadId === currentThreadId ? "Fork" : "Parent thread";
209​
210function relationshipThreadTitle(input: {
211 readonly title: string;
212 readonly isSubagent: boolean;
213}): string {
214 if (!input.isSubagent) return input.title;
215 return formatSubagentDisplayTitle(input.title);
217​
218/** The full title, since the row truncates it, with what a click does. */
219function LineageTooltipContent(props: { readonly title: string; readonly hint: string }) {
220 return (
221 <>
222 <span className="block font-medium">{props.title}</span>
223 <span className="block text-muted-foreground">{props.hint}</span>
224 </>
225 );
227​
228function currentSubagent<Agent extends RuntimeSubagent>(
229 agent: Agent | undefined,
230 childThread: OrchestrationV2ThreadShell | null | undefined,
231): Agent | undefined {
232 if (!agent || !childThread || isOrchestrationV2WorkActive(agent.status)) return agent;
233 const liveStatus =
234 childThread.activityRunStatus ?? (childThread.status === "queued" ? "queued" : null);
235 const newerRun =
236 childThread.latestRunRequestedAt &&
237 DateTime.toEpochMillis(childThread.latestRunRequestedAt) >
238 Date.parse(agent.completedAt ?? agent.startedAt ?? agent.updatedAt);
239 if (!liveStatus && !newerRun) return agent;
240 const status = liveStatus ?? childThread.status;
241 const startedAt = liveStatus ? childThread.activityRunStartedAt : childThread.latestRunStartedAt;
242 const completedAt = liveStatus ? null : childThread.latestRunCompletedAt;
243 return {
244 ...agent,
245 status:
246 status === "preparing" || status === "starting" || status === "queued"
247 ? "pending"
248 : status === "rolled_back"
249 ? "interrupted"
250 : status,
251 startedAt: startedAt ? DateTime.formatIso(startedAt) : null,
252 completedAt: completedAt ? DateTime.formatIso(completedAt) : null,
253 progress: null,
254 result: null,
255 error: null,
256 };
258​
259/**
260 * A task delegated to a linked environment. It has no thread here, so it opens
261 * the thread there, when this client is connected to that environment.
262 */
263function RemoteSubagentRow(props: {
264 readonly agent: RemoteSubagent;
265 readonly provider: ServerProvider | undefined;
266}) {
267 const navigate = useNavigate();
268 const environment = useEnvironment(props.agent.remoteChild.environmentId);
269 const { remoteChild } = props.agent;
270 const status = props.agent.status;
271 const failed = status === "failed";
272 const title = relationshipThreadTitle({
273 title: props.agent.title ?? "Subagent",
274 isSubagent: true,
275 });
276 return (
277 <li className="group relative flex h-8 items-center rounded-lg">
278 <Tooltip>
279 <TooltipTrigger
280 delay={200}
281 render={
282 <ThreadDetailsControl
283 size="sm"
284 variant="ghost"
285 part="row"
286 aria-disabled={environment === null || undefined}
287 onClick={() => {
288 if (environment === null) return;
289 void navigate({
290 to: "/$environmentId/$threadId",
291 params: buildThreadRouteParams(
292 scopeThreadRef(remoteChild.environmentId, remoteChild.threadId),
293 ),
294 });
295 }}
296 />
297 }
298 >
299 <ThreadRelationshipIcon
300 // The task's own driver here is only a "remote" label.
301 driver={props.provider?.driver}
302 provider={props.provider}
303 fallbackIcon={BotIcon}
304 status={status}
305 />
306 <span className="min-w-0 flex-1">
307 <span className="block truncate text-left text-sm font-medium leading-4 text-foreground/85">
308 {title}
309 </span>
310 </span>
311 <span
312 className={`flex max-w-1/2 shrink-0 gap-1 text-2xs ${failed ? "text-destructive" : "text-muted-foreground"}`}
313 >
314 <span className="truncate">on {remoteChild.label}</span>
315 <span className="shrink-0">· {threadRelationshipStatusLabel(status)}</span>
316 </span>
317 </TooltipTrigger>
318 <TooltipPopup side="left">
319 <LineageTooltipContent
320 title={title}
321 hint={
322 environment === null
323 ? `Runs on ${remoteChild.label}. Connect to it to open the thread there.`
324 : `Open on ${remoteChild.label}`
325 }
326 />
327 </TooltipPopup>
328 </Tooltip>
329 </li>
330 );
332​
333/**
334 * The thread on a linked environment whose agent delegated this one. It opens
335 * there when this client is connected to that environment.
336 */
337function DelegatedFromRow(props: {
338 readonly delegatedFrom: OrchestrationV2DelegatedFrom;
339 readonly fallbackLabel: string | undefined;
340}) {
341 const navigate = useNavigate();
342 const router = useRouter();
343 const { delegatedFrom } = props;
344 const environment = useEnvironment(delegatedFrom.environmentId);
345 const parentRef = scopeThreadRef(delegatedFrom.environmentId, delegatedFrom.threadId);
346 // The title it had at delegation, until this client sees the parent itself.
347 const parent = useQuietThreadShell(parentRef);
348 const readParent = useAtomCommand(threadTitlesRead);
349 const [opening, setOpening] = useState(false);
350 const label = environment?.label ?? props.fallbackLabel;
351 const title = parent?.title ?? delegatedFrom.title;
352 // The parent may have been deleted there since delegation, and the shell can't
353 // tell a deleted parent from one this client never loaded, so ask its environment.
354 const openParent = async () => {
355 if (opening) return;
356 setOpening(true);
357 const location = router.latestLocation;
358 const result = await readParent({
359 environmentId: delegatedFrom.environmentId,
360 input: { threadIds: [delegatedFrom.threadId] },
361 });
362 setOpening(false);
363 // The read can outlast the user's stay on this thread; a late answer must not pull them back.
364 if (router.latestLocation !== location) return;
365 if (result._tag === "Failure") {
366 toastManager.add({ type: "error", title: "Could not open the parent thread" });
367 return;
368 }
369 // Deleted and unknown threads are absent from the titles.
370 if (result.value[delegatedFrom.threadId] === undefined) {
371 toastManager.add({ type: "info", title: "The parent thread was deleted" });
372 return;
373 }
374 void navigate({
375 to: "/$environmentId/$threadId",
376 params: buildThreadRouteParams(parentRef),
377 });
378 };
379 return (
380 <li className="group relative flex h-8 items-center rounded-lg">
381 <Tooltip>
382 <TooltipTrigger
383 delay={200}
384 render={
385 <ThreadDetailsControl
386 size="sm"
387 variant="ghost"
388 part="row"
389 aria-disabled={environment === null || undefined}
390 onClick={() => {
391 if (environment === null) return;
392 void openParent();
393 }}
394 />
395 }
396 >
397 <ThreadRelationshipIcon fallbackIcon={CornerLeftUpIcon} status={null} />
398 <span className="min-w-0 flex-1">
399 <span className="block truncate text-left text-sm font-medium leading-4 text-foreground/85">
400 {title}
401 </span>
402 </span>
403 {label ? (
404 <span className="max-w-1/2 shrink-0 truncate text-2xs text-muted-foreground">
405 on {label}
406 </span>
407 ) : null}
408 </TooltipTrigger>
409 <TooltipPopup side="left">
410 <LineageTooltipContent
411 title={title}
412 hint={
413 environment === null
414 ? `Delegated from ${label ?? "a linked environment"}. Connect to it to open the parent thread.`
415 : `Open parent agent on ${environment.label}`
416 }
417 />
418 </TooltipPopup>
419 </Tooltip>
420 </li>
421 );
423​
424export function ThreadRelationshipsPanel(props: {
425 readonly environmentId: EnvironmentId;
426 readonly threadId: ThreadId;
427 readonly showLineage: boolean;
428 readonly showSideChats: boolean;
429}) {
430 const ref = scopeThreadRef(props.environmentId, props.threadId);
431 const projection = useThreadProjection(ref)?.projection ?? null;
432 const providersByEnvironment = useEnvironmentProviders();
433 const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId));
434 const relationshipShells = useThreadRelationshipShells(ref);
435 const hiddenKeys = useHiddenThreadKeys();
436 const hidingAnywhere = useAnyEnvironmentHiding();
437 const allProjects = useProjects();
438 const archived = useArchivedThreadSnapshots([props.environmentId]);
439 const archivedShells = archived.snapshots.find(
440 (entry) => entry.environmentId === props.environmentId,
441 )?.snapshot.threads;
442 const currentThread =
443 projection?.thread ??
444 relationshipShells.find((thread) => thread.id === props.threadId) ??
445 archivedShells?.find((thread) => thread.id === props.threadId);
446 const parentThreadId =
447 currentThread?.forkedFrom?.type === "run"
448 ? currentThread.forkedFrom.threadId
449 : currentThread?.lineage.parentThreadId;
450 const parentShell = parentThreadId
451 ? (relationshipShells.find((thread) => thread.id === parentThreadId) ??
452 archivedShells?.find((thread) => thread.id === parentThreadId))
453 : undefined;
454 const parentRef =
455 parentShell && isProviderNativeSubagentThread(parentShell)
456 ? scopeThreadRef(props.environmentId, parentShell.id)
457 : null;
458 const parentProjection = useThreadProjection(parentRef)?.projection;
459 const navigate = useNavigate();
460 const mergeBack = useAtomCommand(threadEnvironment.mergeBack);
461 const stopSession = useAtomCommand(threadEnvironment.stopSession);
462 const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn);
463 const stopWorkflowCommand = useAtomCommand(orchestrationEnvironment.stopWorkflow);
464 const canOperateWorkflow = useAtomValue(
465 orchestrationEnvironment.stopWorkflow.permissionAtom(props.environmentId),
466 );
467 const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null);
468 const mergeErrorToastId = useRef<string | null>(null);
469 const detachErrorToastId = useRef<string | null>(null);
470 const [stoppingId, setStoppingId] = useState<string | null>(null);
471 // Derive graphs after hooks to preserve their compiler cache.
472 const providers = providersByEnvironment.get(props.environmentId);
473 const derivedProviderEntries = deriveProviderInstanceEntries(providers ?? []);
474 const providerEntries = settings
475 ? applyProviderInstanceSettings(
476 derivedProviderEntries,
477 settings,
478 currentThread?.projectId ?? null,
479 )
480 : derivedProviderEntries;
481 const projects = allProjects.filter((project) => project.environmentId === props.environmentId);
482 const parentNativeStatus = parentProjection
483 ? deriveProviderSubagentStatus(parentProjection)?.status
484 : undefined;
485 const projected = projection?.subagents ?? [];
486 const runtimeSubagents = projectedSubagentsToRuntime(projected);
487 const byId = new Map(runtimeSubagents.map((agent) => [agent.id, agent]));
488 const subagentsByThreadId = new Map(
489 projected
490 .filter((subagent) => subagent.childThreadId !== null)
491 .map((subagent) => [
492 subagent.childThreadId,
493 {
494 ...byId.get(subagent.id)!,
495 id: subagent.id,
496 threadId: subagent.threadId,
497 runId: subagent.runId,
498 nativeTaskRef: subagent.nativeTaskRef,
499 nativeStatus: subagent.status,
500 driver: subagent.driver,
501 providerInstanceId: subagent.providerInstanceId,
502 origin: subagent.origin,
503 modelSelection: subagent.modelSelection,
504 },
505 ]),
506 );
507 const workflowGroupsById = new Map(
508 deriveWorkflowGroups(runtimeSubagents).map((group) => [group.workflow.id, group]),
509 );
510 const shells: ReadonlyArray<OrchestrationV2ThreadShell> = [
511 ...relationshipShells,
512 ...(archivedShells ?? []),
513 ];
514 const graph = deriveThreadRelationshipGraph({ threads: shells, projection });
515 const isHiddenRelation = (threadId: ThreadId) =>
516 hiddenKeys.has(threadKey({ environmentId: props.environmentId, threadId }));
517 // While hiding, an unloaded parent, fork or transfer may be hidden; unloaded subagent
518 // children (idle, left out of the shell) follow this visible thread, so they stay.
519 const isHiddenUnloadedRelation = (row: { threadId: ThreadId; edge: ThreadRelationshipEdge }) =>
520 hidingAnywhere &&
521 graph.nodes.get(row.threadId)?.missing === true &&
522 (row.edge.kind !== "subagent" || isParentThreadRelationship(row.edge, props.threadId));
523 const currentProject = projects.find((project) => project.id === currentThread?.projectId);
524 const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection);
525 const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection);
526 const relationshipRows = orderWebThreadLineageRows({
527 graph,
528 rows: immediateThreadRelationships(graph, props.threadId),
529 currentThreadId: props.threadId,
530 mergeTargetThreadId,
531 })
532 // The graph rebuilds missing parents from lineage, so filter the rows it returns.
533 .filter((row) => !isHiddenRelation(row.threadId) && !isHiddenUnloadedRelation(row))
534 .map((row) => {
535 const agent =
536 row.edge.kind === "subagent" && !isParentThreadRelationship(row.edge, props.threadId)
537 ? subagentsByThreadId.get(row.threadId)
538 : undefined;
539 const workflowGroup = agent === undefined ? undefined : workflowGroupsById.get(agent.id);
540 return workflowGroup === undefined
541 ? row
542 : { ...row, edge: { ...row.edge, status: workflowGroup.workflow.status } };
543 });
544 const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null;
545 const unlistedThreadIds = relationshipRows
546 .filter(
547 ({ threadId, edge }) =>
548 !relationshipShells.some((thread) => thread.id === threadId) &&
549 (edge.kind !== "subagent" || !subagentsByThreadId.get(threadId)?.title),
550 )
551 .map(({ threadId }) => threadId);
552 const threadTitles = useEnvironmentQuery(
553 unlistedThreadIds.length === 0
554 ? null
555 : orchestrationEnvironment.threadTitles({
556 environmentId: props.environmentId,
557 input: { threadIds: unlistedThreadIds },
558 }),
559 );
560 const resolvedThreadTitles = threadTitles.data ?? {};
561 // Omitted and archived threads remain reachable; only a settled lookup proves one is gone.
562 const visibleRelationships = relationshipRows.filter(
563 ({ threadId, edge }) =>
564 edge.kind === "subagent" ||
565 !unlistedThreadIds.includes(threadId) ||
566 !threadTitles.isSuccess ||
567 threadTitles.isPending ||
568 Object.hasOwn(resolvedThreadTitles, threadId),
569 );
570 const canDetach = projection ? canDetachThreadProviderSession(projection) : false;
571​
572 const {
573 related = [],
574 active = [],
575 previous = [],
576 sideChats = [],
577 } = groupBy(visibleRelationships, ({ threadId, edge }) => {
578 const isParent = isParentThreadRelationship(edge, props.threadId);
579 if (!isParent && graph.nodes.get(threadId)?.thread?.lineage.sideChat === true)
580 return "sideChats";
581 if (edge.kind !== "subagent" || isParent) return "related";
582 return isFinishedAgentStatus(edge.status) ? "previous" : "active";
583 });
584 const { remoteActive = [], remotePrevious = [] } = groupBy(
585 (projection?.subagents ?? [])
586 .filter((agent): agent is RemoteSubagent => agent.remoteChild !== undefined)
587 .map((remote) => ({ remote })),
588 ({ remote }) => (isFinishedAgentStatus(remote.status) ? "remotePrevious" : "remoteActive"),
589 );
590 const groups: ReadonlyArray<{
591 id: string;
592 label: string | null;
593 rows: ReadonlyArray<LineageRow>;
594 expanded: boolean;
595 }> = [
596 { id: "related", label: null, rows: related, expanded: true },
597 { id: "active", label: null, rows: [...active, ...remoteActive], expanded: true },
598 {
599 id: "previous",
600 label: "Previous agents",
601 rows: [...previous, ...remotePrevious],
602 expanded: false,
603 },
604 ];
605 const runningCount =
606 (projection?.subagents.filter(
607 (agent) => agent.childThreadId === null && agent.status === "running",
608 ).length ?? 0) + active.filter(({ edge }) => edge.status === "running").length;
609​
610 // A linked environment's agent may have delegated this thread from there.
611 const delegatedFrom = currentThread?.delegatedFrom;
612​
613 const lineageVisible =
614 props.showLineage &&
615 (related.length > 0 ||
616 active.length > 0 ||
617 previous.length > 0 ||
618 runningCount > 0 ||
619 remoteActive.length > 0 ||
620 remotePrevious.length > 0 ||
621 delegatedFrom !== undefined ||
622 // Disconnect lives in this heading, so a thread with only side chats still shows it.
623 (canDetach && sideChats.length > 0));
624 const sideChatsVisible = props.showSideChats && sideChats.length > 0;
625 const runningSideChats = sideChats.filter(({ edge }) => edge.status === "running").length;
626​
627 if (!lineageVisible && !sideChatsVisible) {
628 return null;
629 }
630​
631 // Idle children can be omitted from the shell; opening one loads its own detail.
632 const openThread = (threadId: ThreadId) => {
633 if (openSideChatTab(ref, graph.nodes.get(threadId)?.thread ?? null)) return;
634 void navigate({
635 to: "/$environmentId/$threadId",
636 params: buildThreadRouteParams(scopeThreadRef(props.environmentId, threadId)),
637 });
638 };
639​
640 const merge = async () => {
641 if (!latestMergeBackRun || mergeTargetThreadId === null || busyAction !== null) return;
642 if (mergeErrorToastId.current !== null) {
643 toastManager.close(mergeErrorToastId.current);
644 mergeErrorToastId.current = null;
645 }
646 setBusyAction("merge");
647 const result = await mergeBack({
648 environmentId: props.environmentId,
649 input: {
650 sourceThreadId: props.threadId,
651 targetThreadId: mergeTargetThreadId,
652 runId: latestMergeBackRun.id,
653 },
654 });
655 setBusyAction(null);
656 if (result._tag === "Failure") {
657 mergeErrorToastId.current = toastManager.add({
658 type: "error",
659 title: "Could not merge back",
660 });
661 return;
662 }
663 openThread(mergeTargetThreadId);
664 };
665​
666 const detach = async () => {
667 if (!canDetach || busyAction !== null) return;
668 if (detachErrorToastId.current !== null) {
669 toastManager.close(detachErrorToastId.current);
670 detachErrorToastId.current = null;
671 }
672 setBusyAction("detach");
673 const result = await stopSession({
674 environmentId: props.environmentId,
675 input: { threadId: props.threadId },
676 });
677 setBusyAction(null);
678 if (result._tag === "Failure") {
679 detachErrorToastId.current = toastManager.add({
680 type: "error",
681 title: "Could not disconnect agent session",
682 });
683 }
684 };
685​
686 const stopSubagent = async (
687 childThreadId: ThreadId,
688 agent: Pick<OrchestrationV2Subagent, "id" | "threadId">,
689 ) => {
690 if (stoppingId !== null) return;
691 setStoppingId(childThreadId);
692 const result = await interruptTurn({
693 environmentId: props.environmentId,
694 input: { threadId: agent.threadId, subagentId: agent.id },
695 });
696 setStoppingId(null);
697 if (result._tag === "Failure") {
698 toastManager.add({ type: "error", title: "Could not stop subagent" });
699 }
700 };
701​
702 const stopWorkflow = async (subagentId: string) => {
703 if (stoppingId !== null) return;
704 setStoppingId(subagentId);
705 const result = await stopWorkflowCommand({
706 environmentId: props.environmentId,
707 input: { threadId: props.threadId, subagentId: NodeId.make(subagentId) },
708 });
709 setStoppingId(null);
710 if (result._tag === "Failure") {
711 toastManager.add({ type: "error", title: "Could not stop workflow" });
712 }
713 };
714​
715 const parentTitle =
716 mergeTargetThreadId === null
717 ? null
718 : isHiddenRelation(mergeTargetThreadId)
719 ? null
720 : (graph.nodes.get(mergeTargetThreadId)?.thread?.title ?? null);
721​
722 const renderRelationshipRow = ({ threadId, edge }: ThreadRelationshipWalkRow) => {
723 const node = graph.nodes.get(threadId);
724 const isSubagent = edge.kind === "subagent";
725 const isMergeTarget = threadId === mergeTargetThreadId;
726 const isParent = isParentThreadRelationship(edge, props.threadId);
727 const rowStatus =
728 (isParent && threadId === parentRef?.threadId
729 ? (node?.thread?.activityRunStatus ?? parentNativeStatus)
730 : undefined) ?? threadRelationshipRowStatus(graph, { threadId, edge });
731 const RelationshipIcon = isParent
732 ? CornerLeftUpIcon
733 : isSubagent
734 ? BotIcon
735 : node?.thread?.lineage.sideChat === true
736 ? MessagesSquareIcon
737 : GitForkIcon;
738 const relationship =
739 node?.thread?.lineage.sideChat === true && !isParent
740 ? "Side chat"
741 : relationshipLabel(edge, props.threadId);
742 const projectedAgent = isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined;
743 const workflowGroup =
744 projectedAgent === undefined ? undefined : workflowGroupsById.get(projectedAgent.id);
745 const agent = workflowGroup ? projectedAgent : currentSubagent(projectedAgent, node?.thread);
746 const failed = rowStatus === "failed" || rowStatus === "error";
747 const canStop =
748 agent &&
749 (agent.origin === "app_owned" ||
750 (agent.origin === "provider_native" &&
751 agent.driver === "claudeAgent" &&
752 agent.runId != null &&
753 agent.nativeTaskRef?.strength === "strong" &&
754 agent.nativeTaskRef.nativeId !== null &&
755 ["pending", "running", "waiting"].includes(agent.nativeStatus))) &&
756 agent.startedAt &&
757 ["pending", "running", "waiting"].includes(agent.status);
758 const threadTitle = relationshipThreadTitle({
759 title:
760 workflowGroup?.workflow.workflowName ??
761 // Fresher than an archived snapshot's copy.
762 (Object.hasOwn(resolvedThreadTitles, threadId)
763 ? resolvedThreadTitles[threadId]
764 : undefined) ??
765 node?.thread?.title ??
766 agent?.title ??
767 threadId,
768 isSubagent,
769 });
770 const provider = providers?.find(
771 (entry) =>
772 entry.instanceId === (agent?.providerInstanceId ?? node?.thread?.providerInstanceId),
773 );
774 const providerDriver = agent?.driver ?? provider?.driver;
775 const canStopWorkflow =
776 canOperateWorkflow &&
777 workflowGroup?.workflow.status === "running" &&
778 agent?.origin === "provider_native" &&
779 providerDriver === "claudeAgent";
780 const trailingVisibilityClass = (workflowGroup ? canStopWorkflow : canStop)
781 ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0"
782 : "";
783 const project = projects.find((project) => project.id === node?.thread?.projectId);
784 const relationshipHint = `Open ${relationship.toLowerCase()} in this chat`;
785 const RelationshipPopup = agent ? ThreadHoverCardPopup : TooltipPopup;
786 const relationshipTooltip = agent ? (
787 <SubagentTooltipContent
788 title={threadTitle}
789 model={agent.model}
790 providerInstanceId={agent.providerInstanceId}
791 origin={agent.origin}
792 modelSelection={agent.modelSelection}
793 provider={provider}
794 providers={providers}
795 driver={providerDriver}
796 elapsed={<AgentElapsed agent={agent} />}
797 status={agent.status}
798 result={agent.result}
799 progress={agent.progress}
800 parentThread={currentThread ?? undefined}
801 childThread={node?.thread ?? undefined}
802 parentProject={currentProject}
803 childProject={project}
804 />
805 ) : (
806 <LineageTooltipContent title={threadTitle} hint={relationshipHint} />
807 );
808 const relationshipContent = (
809 <>
810 <ThreadRelationshipIcon
811 driver={isSubagent && !isParent ? providerDriver : undefined}
812 provider={provider}
813 providerEntries={providerEntries}
814 fallbackIcon={RelationshipIcon}
815 status={rowStatus}
816 />
817 <span className="min-w-0 flex-1">
818 <span className="block truncate text-left text-sm font-medium leading-4 text-foreground/85">
819 {threadTitle}
820 </span>
821 </span>
822 {agent ? null : (
823 <ArrowRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
824 )}
825 {agent && !failed && (deriveSubagentElapsedMs(agent, 0) !== null || canStopWorkflow) ? (
826 <span
827 className={`shrink-0 text-2xs font-normal tabular-nums text-muted-foreground ${canStopWorkflow ? "min-w-6 text-end" : ""} ${trailingVisibilityClass}`}
828 >
829 <AgentElapsed agent={agent} compact />
830 </span>
831 ) : !isMergeTarget ? (
832 <span
833 className={
834 workflowGroup
835 ? "sr-only"
836 : `shrink-0 text-2xs ${failed ? "text-destructive" : "text-muted-foreground"} ${trailingVisibilityClass}`
837 }
838 >
839 {threadRelationshipStatusLabel(rowStatus)}
840 </span>
841 ) : null}
842 </>
843 );
844 const relationshipLink = (
845 <Tooltip>
846 <TooltipTrigger
847 delay={200}
848 render={
849 <ThreadDetailsControl
850 size="sm"
851 variant="ghost"
852 part={workflowGroup ? "primary" : "row"}
853 onClick={() => openThread(threadId)}
854 />
855 }
856 >
857 {relationshipContent}
858 </TooltipTrigger>
859 <RelationshipPopup side="left">{relationshipTooltip}</RelationshipPopup>
860 </Tooltip>
861 );
862 if (workflowGroup && agent) {
863 return (
864 <ThreadLineageWorkflowRow
865 key={threadId}
866 group={workflowGroup}
867 providerInstanceId={agent.providerInstanceId}
868 provider={provider}
869 providers={providers}
870 providerEntries={providerEntries}
871 driver={providerDriver}
872 onOpenThread={(memberThreadId) => openThread(memberThreadId as ThreadId)}
873 onStop={canStopWorkflow ? () => void stopWorkflow(agent.id) : undefined}
874 stopping={stoppingId === agent.id}
875 stopDisabled={stoppingId !== null}
876 header={relationshipLink}
877 />
878 );
879 }
880 return (
881 <li key={threadId} className="group relative flex h-8 items-center rounded-lg">
882 {isMergeTarget ? (
883 <div className={THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS}>
884 <Tooltip>
885 <TooltipTrigger
886 delay={200}
887 render={
888 <ThreadDetailsControl
889 size="sm"
890 variant="ghost"
891 part="primary"
892 aria-label={`${threadTitle} ${threadRelationshipStatusLabel(rowStatus)}`}
893 onClick={() => openThread(threadId)}
894 />
895 }
896 >
897 {relationshipContent}
898 </TooltipTrigger>
899 <RelationshipPopup side="left">{relationshipTooltip}</RelationshipPopup>
900 </Tooltip>
901 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
902 <Tooltip>
903 <TooltipTrigger
904 render={
905 <ThreadDetailsControl
906 size="sm"
907 variant="ghost"
908 part="secondary"
909 aria-label={
910 parentTitle
911 ? `Merge back to ${parentTitle}`
912 : "Merge back to source conversation"
913 }
914 disabled={!canMerge || busyAction !== null}
915 onClick={() => void merge()}
916 >
917 {busyAction === "merge" ? (
918 <Spinner size="xs" />
919 ) : (
920 <PullRequestGlyph.merged className="size-3" />
921 )}
922 </ThreadDetailsControl>
923 }
924 />
925 <TooltipPopup side="left">
926 {latestMergeBackRun === null
927 ? "Complete a run in this fork before merging it back"
928 : parentTitle
929 ? `Merge this conversation back into ${parentTitle}`
930 : "Merge this conversation back into its source"}
931 </TooltipPopup>
932 </Tooltip>
933 <span className="shrink-0 border border-transparent ps-1 pe-2.5 text-2xs font-medium text-muted-foreground">
934 {threadRelationshipStatusLabel(rowStatus)}
935 </span>
936 </div>
937 ) : (
938 relationshipLink
939 )}
940 {canStop && agent ? (
941 <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">
942 <Tooltip>
943 <TooltipTrigger
944 render={
945 <ThreadDetailsControl
946 size="icon-xs"
947 variant="ghost"
948 part="icon"
949 tone="destructive"
950 aria-label={`Stop subagent ${threadTitle}`}
951 disabled={stoppingId !== null}
952 onClick={() => void stopSubagent(threadId, agent)}
953 />
954 }
955 >
956 {stoppingId === threadId ? (
957 <Spinner aria-hidden size="xs" />
958 ) : (
959 <SquareIcon aria-hidden className="size-3 fill-current" />
960 )}
961 </TooltipTrigger>
962 <TooltipPopup side="left">Stop subagent</TooltipPopup>
963 </Tooltip>
964 </div>
965 ) : null}
966 </li>
967 );
968 };
969​
970 return (
971 <>
972 {lineageVisible ? (
973 <ThreadDetailsSection
974 headingId="thread-details-lineage-heading"
975 title={runningCount > 0 ? `Lineage · ${runningCount} running` : "Lineage"}
976 // A lone "Previous agents" row names itself; the heading returns for status or actions.
977 showHeading={
978 runningCount > 0 ||
979 canDetach ||
980 related.length > 0 ||
981 active.length > 0 ||
982 remoteActive.length > 0 ||
983 delegatedFrom !== undefined
984 }
985 data-thread-relationships-panel
986 actions={
987 canDetach ? (
988 <Menu>
989 <MenuTrigger
990 render={
991 <ThreadDetailsControl
992 size="icon-xs"
993 variant="ghost"
994 part="icon"
995 aria-label="More thread actions"
996 disabled={busyAction !== null}
997 />
998 }
999 >
1000 <MoreHorizontalIcon className="size-3.5" />
1001 </MenuTrigger>
1002 <MenuPopup align="end" className="min-w-60 max-w-(--available-width)">
1003 <MenuItem onClick={() => void detach()}>
1004 <UnplugIcon className="size-3.5" />
1005 Disconnect agent session
1006 </MenuItem>
1007 </MenuPopup>
1008 </Menu>
1009 ) : null
1012 {delegatedFrom === undefined ? null : (
1013 <ThreadLineageRowList hiddenCount={0} onShowMore={() => {}}>
1014 <DelegatedFromRow
1015 delegatedFrom={delegatedFrom}
1016 fallbackLabel={currentThread?.linkOrigin?.label}
1017 />
1018 </ThreadLineageRowList>
1019 )}
1020 {groups.map((group) => (
1021 <ThreadLineageGroup key={`${scopedThreadKey(ref)}:${group.id}`} {...group}>
1022 {(visibleRows) =>
1023 visibleRows.map((row) =>
1024 "remote" in row ? (
1025 <RemoteSubagentRow
1026 key={row.remote.id}
1027 agent={row.remote}
1028 // Its provider instance belongs to the environment it runs in.
1029 provider={providersByEnvironment
1030 .get(row.remote.remoteChild.environmentId)
1031 ?.find((entry) => entry.instanceId === row.remote.providerInstanceId)}
1032 />
1033 ) : (
1034 renderRelationshipRow(row)
1035 ),
1038 </ThreadLineageGroup>
1039 ))}
1040 </ThreadDetailsSection>
1041 ) : null}
1042 {sideChatsVisible ? (
1043 <ThreadDetailsSection
1044 headingId="thread-details-side-chats-heading"
1045 title={runningSideChats > 0 ? `Side chats · ${runningSideChats} running` : "Side chats"}
1046 data-thread-side-chats-panel
1048 <ThreadLineageGroup
1049 key={`${scopedThreadKey(ref)}:side-chats`}
1050 label={null}
1051 rows={sideChats}
1052 expanded
1054 {(visibleRows) => visibleRows.map(renderRelationshipRow)}
1055 </ThreadLineageGroup>
1056 </ThreadDetailsSection>
1057 ) : null}
1058 </>
1059 );