import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { deriveThreadRelationshipGraph, immediateThreadRelationships, resolveMergeBackTargetThreadId, type ThreadRelationshipEdge, } from "@t3tools/client-runtime/state/thread-relationships"; import { canDetachThreadProviderSession, resolveLatestMergeBackRun, } from "@t3tools/client-runtime/state/thread-workflows"; import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; import { ArrowRightIcon, BotIcon, CornerLeftUpIcon, GitForkIcon, GitMergeIcon, LoaderCircleIcon, MoreHorizontalIcon, UnplugIcon, } from "lucide-react"; import { useState } from "react"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { buildThreadRouteParams } from "../../threadRoutes"; import { useThreadProjection, useThreadShells } from "../../state/entities"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { THREAD_DETAILS_PANEL_ICON_ACTION_CLASS, THREAD_DETAILS_PANEL_LINK_ROW_CLASS, THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS, THREAD_DETAILS_PANEL_LINK_SPLIT_SECONDARY_CLASS, THREAD_DETAILS_PANEL_MENU_POPUP_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, } from "./threadDetailsPanelStyles"; const THREAD_RELATIONSHIP_ICON_CLASS = "size-4 shrink-0 text-muted-foreground"; function relationshipLabel(edge: ThreadRelationshipEdge, currentThreadId: ThreadId) { if (edge.kind === "transfer") return "Context transfer"; if (edge.kind === "subagent") { return edge.sourceThreadId === currentThreadId ? "Subagent" : "Parent agent"; } return edge.sourceThreadId === currentThreadId ? "Fork" : "Parent thread"; } function isParentRelationship(edge: ThreadRelationshipEdge, currentThreadId: ThreadId): boolean { return edge.kind !== "transfer" && edge.targetThreadId === currentThreadId; } function relationshipSortKey(input: { readonly edge: ThreadRelationshipEdge; readonly threadId: ThreadId; readonly currentThreadId: ThreadId; readonly mergeTargetThreadId: ThreadId | null; }): number { if (isParentRelationship(input.edge, input.currentThreadId)) return 0; if (input.threadId === input.mergeTargetThreadId) return 1; return 2; } function statusDotClass(status: string | null): string { if (status === "running" || status === "in_progress") return "bg-info"; if (status === "failed" || status === "error") return "bg-destructive"; if (status === "completed") return "bg-success"; return "bg-muted-foreground/45"; } function relationshipThreadTitle(input: { readonly title: string; readonly isSubagent: boolean; }): string { if (!input.isSubagent) return input.title; return input.title.replace(/^Subagent:\s*/i, ""); } export function ThreadRelationshipsPanel(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; }) { const ref = scopeThreadRef(props.environmentId, props.threadId); const projection = useThreadProjection(ref)?.projection ?? null; const threadShells = useThreadShells(); const activeShells = threadShells.filter( (thread) => thread.environmentId === props.environmentId, ); const archived = useArchivedThreadSnapshots([props.environmentId]); const archivedShells = archived.snapshots.find( (entry) => entry.environmentId === props.environmentId, )?.snapshot.threads; const shells: ReadonlyArray = [ ...activeShells.map((thread) => thread.source), ...(archivedShells ?? []), ]; const graph = deriveThreadRelationshipGraph({ threads: shells, projection }); const navigate = useNavigate(); const mergeBack = useAtomCommand(threadEnvironment.mergeBack); const stopSession = useAtomCommand(threadEnvironment.stopSession); const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null); const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection); const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection); const relationshipRows = immediateThreadRelationships(graph, props.threadId).toSorted( (left, right) => relationshipSortKey({ edge: left.edge, threadId: left.threadId, currentThreadId: props.threadId, mergeTargetThreadId, }) - relationshipSortKey({ edge: right.edge, threadId: right.threadId, currentThreadId: props.threadId, mergeTargetThreadId, }), ); const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null; const canDetach = projection ? canDetachThreadProviderSession(projection) : false; if (relationshipRows.length === 0) { return null; } const openThread = (threadId: ThreadId) => { void navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(scopeThreadRef(props.environmentId, threadId)), }); }; const merge = async () => { if (!latestMergeBackRun || mergeTargetThreadId === null || busyAction !== null) return; setBusyAction("merge"); const result = await mergeBack({ environmentId: props.environmentId, input: { sourceThreadId: props.threadId, targetThreadId: mergeTargetThreadId, runId: latestMergeBackRun.id, }, }); setBusyAction(null); if (result._tag === "Success") openThread(mergeTargetThreadId); }; const detach = async () => { if (!canDetach || busyAction !== null) return; setBusyAction("detach"); await stopSession({ environmentId: props.environmentId, input: { threadId: props.threadId }, }); setBusyAction(null); }; const parentTitle = mergeTargetThreadId === null ? null : (graph.nodes.get(mergeTargetThreadId)?.thread?.title ?? null); return (

Lineage

{canDetach ? ( } > void detach()}> Disconnect agent session ) : null}
{relationshipRows.length > 0 ? ( ) : null}
); }