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<Row>( rows: ReadonlyArray<Row>, visibleCount: number,): { readonly visibleRows: ReadonlyArray<Row>; 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. */} <ul aria-label="Related threads" className="m-0 max-h-[13.5rem] list-none overflow-y-auto overscroll-contain p-0" > {props.children} </ul> {props.hiddenCount > 0 ? ( <button type="button" onClick={props.onShowMore} className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 text-left text-[13px] font-medium text-muted-foreground/70 hover:bg-black/[0.055] hover:text-foreground/80 dark:hover:bg-white/[0.075]" > <PlusIcon aria-hidden className="-mx-0.5 size-4 shrink-0" /> Show {Math.min(props.hiddenCount, THREAD_LINEAGE_PAGE_COUNT)} more </button> ) : 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<OrchestrationV2ThreadShell> = [ ...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 ( <section aria-labelledby="thread-details-lineage-heading" className="border-t border-border/65 px-2 pb-2.5 pt-2" data-thread-relationships-panel > <div className="mb-1 flex min-h-8 items-center justify-between gap-2 px-2"> <h3 id="thread-details-lineage-heading" className="text-[11px] font-medium text-muted-foreground" > Lineage </h3> <div className="flex shrink-0 items-center gap-1"> {canDetach ? ( <Menu> <MenuTrigger render={ <Button size="icon-xs" variant="ghost" className={THREAD_DETAILS_PANEL_ICON_ACTION_CLASS} aria-label="More thread actions" disabled={busyAction !== null} /> } > <MoreHorizontalIcon className="size-3.5" /> </MenuTrigger> <MenuPopup align="end" className={THREAD_DETAILS_PANEL_MENU_POPUP_CLASS}> <MenuItem onClick={() => void detach()}> <UnplugIcon className="size-3.5" /> Disconnect agent session </MenuItem> </MenuPopup> </Menu> ) : null} </div> </div> <ThreadLineageRowList hiddenCount={hiddenCount} onShowMore={showMore}> {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 = ( <> <span className="relative -mx-0.5 grid size-4 shrink-0 place-items-center"> <RelationshipIcon className={THREAD_RELATIONSHIP_ICON_CLASS} /> <span className={cn( "absolute -bottom-1 -right-1 size-2 rounded-full border-2 border-card", statusDotClass(edge.status), )} aria-hidden="true" /> </span> <span className="min-w-0 flex-1"> <span className="block truncate text-[13px] font-medium leading-4 text-foreground/85"> {threadTitle} </span> </span> <ArrowRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" /> </> ); return ( <li key={threadId} className="group flex h-9 items-center rounded-lg"> {isMergeTarget ? ( <div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}> <Tooltip> <TooltipTrigger render={ <Button size="sm" variant="ghost" className={THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS} disabled={node?.missing === true} onClick={() => openThread(threadId)} /> } > {relationshipContent} </TooltipTrigger> <TooltipPopup side="left"> {node?.missing ? "This related thread is unavailable" : `Open ${relationship.toLowerCase()} in this chat`} </TooltipPopup> </Tooltip> <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} /> <Tooltip> <TooltipTrigger render={ <Button size="sm" variant="ghost" className={THREAD_DETAILS_PANEL_LINK_SPLIT_SECONDARY_CLASS} aria-label={ parentTitle ? `Merge back to ${parentTitle}` : "Merge back to source conversation" } disabled={!canMerge || busyAction !== null} onClick={() => void merge()} > {busyAction === "merge" ? ( <LoaderCircleIcon className="size-3 animate-spin" /> ) : ( <GitMergeIcon className="size-3" /> )} </Button> } /> <TooltipPopup side="left"> {latestMergeBackRun === null ? "Complete a run in this fork before merging it back" : parentTitle ? `Merge this conversation back into ${parentTitle}` : "Merge this conversation back into its source"} </TooltipPopup> </Tooltip> </div> ) : ( <Tooltip> <TooltipTrigger render={ <Button size="sm" variant="ghost" disabled={node?.missing === true} onClick={() => openThread(threadId)} className={THREAD_DETAILS_PANEL_LINK_ROW_CLASS} /> } > {relationshipContent} </TooltipTrigger> <TooltipPopup side="left"> {node?.missing ? "This related thread is unavailable" : `Open ${relationship.toLowerCase()} in this chat`} </TooltipPopup> </Tooltip> )} </li> ); })} </ThreadLineageRowList> </section> );}
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<Row>(
rows: ReadonlyArray<Row>,
visibleCount: number,
): { readonly visibleRows: ReadonlyArray<Row>; 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.
*/}
<ul
aria-label="Related threads"
className="m-0 max-h-[13.5rem] list-none overflow-y-auto overscroll-contain p-0"
>
{props.children}
</ul>
{props.hiddenCount > 0 ? (
<button
type="button"
onClick={props.onShowMore}
className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 text-left text-[13px] font-medium text-muted-foreground/70 hover:bg-black/[0.055] hover:text-foreground/80 dark:hover:bg-white/[0.075]"
>
<PlusIcon aria-hidden className="-mx-0.5 size-4 shrink-0" />
Show {Math.min(props.hiddenCount, THREAD_LINEAGE_PAGE_COUNT)} more
</button>
) : 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<OrchestrationV2ThreadShell> = [
...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 (
<section
aria-labelledby="thread-details-lineage-heading"
className="border-t border-border/65 px-2 pb-2.5 pt-2"
data-thread-relationships-panel
>
<div className="mb-1 flex min-h-8 items-center justify-between gap-2 px-2">
<h3
id="thread-details-lineage-heading"
className="text-[11px] font-medium text-muted-foreground"
>
Lineage
</h3>
<div className="flex shrink-0 items-center gap-1">
{canDetach ? (
<Menu>
<MenuTrigger
render={
<Button
size="icon-xs"
variant="ghost"
className={THREAD_DETAILS_PANEL_ICON_ACTION_CLASS}
aria-label="More thread actions"
disabled={busyAction !== null}
/>
}
>
<MoreHorizontalIcon className="size-3.5" />
</MenuTrigger>
<MenuPopup align="end" className={THREAD_DETAILS_PANEL_MENU_POPUP_CLASS}>
<MenuItem onClick={() => void detach()}>
<UnplugIcon className="size-3.5" />
Disconnect agent session
</MenuItem>
</MenuPopup>
</Menu>
) : null}
</div>
</div>
<ThreadLineageRowList hiddenCount={hiddenCount} onShowMore={showMore}>
{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 = (
<>
<span className="relative -mx-0.5 grid size-4 shrink-0 place-items-center">
<RelationshipIcon className={THREAD_RELATIONSHIP_ICON_CLASS} />
<span
className={cn(
"absolute -bottom-1 -right-1 size-2 rounded-full border-2 border-card",
statusDotClass(edge.status),
)}
aria-hidden="true"
/>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium leading-4 text-foreground/85">
{threadTitle}
</span>
</span>
<ArrowRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</>
);
return (
<li key={threadId} className="group flex h-9 items-center rounded-lg">
{isMergeTarget ? (
<div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}>
<Tooltip>
<TooltipTrigger
render={
<Button
size="sm"
variant="ghost"
className={THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS}
disabled={node?.missing === true}
onClick={() => openThread(threadId)}
/>
}
>
{relationshipContent}
</TooltipTrigger>
<TooltipPopup side="left">
{node?.missing
? "This related thread is unavailable"
: `Open ${relationship.toLowerCase()} in this chat`}
</TooltipPopup>
</Tooltip>
<span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
<Tooltip>
<TooltipTrigger
render={
<Button
size="sm"
variant="ghost"
className={THREAD_DETAILS_PANEL_LINK_SPLIT_SECONDARY_CLASS}
aria-label={
parentTitle
? `Merge back to ${parentTitle}`
: "Merge back to source conversation"
}
disabled={!canMerge || busyAction !== null}
onClick={() => void merge()}
>
{busyAction === "merge" ? (
<LoaderCircleIcon className="size-3 animate-spin" />
) : (
<GitMergeIcon className="size-3" />
)}
</Button>
}
/>
<TooltipPopup side="left">
{latestMergeBackRun === null
? "Complete a run in this fork before merging it back"
: parentTitle
? `Merge this conversation back into ${parentTitle}`
: "Merge this conversation back into its source"}
</TooltipPopup>
</Tooltip>
</div>
) : (
<Tooltip>
<TooltipTrigger
render={
<Button
size="sm"
variant="ghost"
disabled={node?.missing === true}
onClick={() => openThread(threadId)}
className={THREAD_DETAILS_PANEL_LINK_ROW_CLASS}
/>
}
>
{relationshipContent}
</TooltipTrigger>
<TooltipPopup side="left">
{node?.missing
? "This related thread is unavailable"
: `Open ${relationship.toLowerCase()} in this chat`}
</TooltipPopup>
</Tooltip>
)}
</li>
);
})}
</ThreadLineageRowList>
</section>
);
}