import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { deriveThreadRelationshipGraph, immediateThreadRelationships, isParentThreadRelationship, orderWebThreadLineageRows, 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, PlusIcon, UnplugIcon, } from "lucide-react"; import { useCallback, useMemo, useRef, useState, type ReactNode } 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"; // Lineage paging: a busy thread can accumulate dozens of forks and subagents, // and the panel it lives in already scrolls. Show a workable window, keep the // rest behind Show more, and bound what is shown so the sections below Lineage // stay reachable. const THREAD_LINEAGE_INITIAL_COUNT = 6; const THREAD_LINEAGE_PAGE_COUNT = 12; export function resolveThreadLineageWindow( rows: ReadonlyArray, visibleCount: number, ): { readonly visibleRows: ReadonlyArray; readonly hiddenCount: number } { const visibleRows = rows.slice(0, visibleCount); return { visibleRows, hiddenCount: rows.length - visibleRows.length }; } export function ThreadLineageRowList(props: { readonly hiddenCount: number; readonly onShowMore: () => void; readonly children: ReactNode; }) { return ( <> {/* Bounded rather than free-growing so Lineage cannot push the rest of the thread details panel out of view. Plain overflow, not a ScrollArea component: this sits inside an already scrolling panel, where a max-height-only virtual viewport measures badly. Every row is a focusable button, so keyboard users reach and scroll the region through the rows themselves and the container needs no extra tab stop of its own. */}
    {props.children}
{props.hiddenCount > 0 ? ( ) : null} ); } 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 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 archived = useArchivedThreadSnapshots([props.environmentId]); const archivedShells = archived.snapshots.find( (entry) => entry.environmentId === props.environmentId, )?.snapshot.threads; const graph = useMemo(() => { const shells: ReadonlyArray = [ ...threadShells .filter((thread) => thread.environmentId === props.environmentId) .map((thread) => thread.source), ...(archivedShells ?? []), ]; return deriveThreadRelationshipGraph({ threads: shells, projection }); }, [archivedShells, projection, props.environmentId, threadShells]); 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 = useMemo( () => orderWebThreadLineageRows({ graph, rows: immediateThreadRelationships(graph, props.threadId), currentThreadId: props.threadId, mergeTargetThreadId, }), [graph, mergeTargetThreadId, props.threadId], ); const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null; const canDetach = projection ? canDetachThreadProviderSession(projection) : false; const [visibleCount, setVisibleCount] = useState(THREAD_LINEAGE_INITIAL_COUNT); const lineageResetKey = scopedThreadKey(ref); const lastLineageResetKeyRef = useRef(lineageResetKey); if (lastLineageResetKeyRef.current !== lineageResetKey) { lastLineageResetKeyRef.current = lineageResetKey; setVisibleCount(THREAD_LINEAGE_INITIAL_COUNT); } const showMore = useCallback( () => setVisibleCount((count) => count + THREAD_LINEAGE_PAGE_COUNT), [], ); const { visibleRows, hiddenCount } = resolveThreadLineageWindow(relationshipRows, visibleCount); 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}
{visibleRows.map(({ threadId, edge }) => { const node = graph.nodes.get(threadId); const isSubagent = edge.kind === "subagent"; const isMergeTarget = threadId === mergeTargetThreadId; const isParent = isParentThreadRelationship(edge, props.threadId); const RelationshipIcon = isParent ? CornerLeftUpIcon : isSubagent ? BotIcon : GitForkIcon; const relationship = relationshipLabel(edge, props.threadId); const threadTitle = relationshipThreadTitle({ title: node?.thread?.title ?? threadId, isSubagent, }); const relationshipContent = ( <> {threadTitle} ); return (
  • {isMergeTarget ? (
    openThread(threadId)} /> } > {relationshipContent} {node?.missing ? "This related thread is unavailable" : `Open ${relationship.toLowerCase()} in this chat`}
    ) : ( openThread(threadId)} className={THREAD_DETAILS_PANEL_LINK_ROW_CLASS} /> } > {relationshipContent} {node?.missing ? "This related thread is unavailable" : `Open ${relationship.toLowerCase()} in this chat`} )}
  • ); })}
    ); }