import * as Haptics from "expo-haptics"; import { SymbolView, type SFSymbol } from "expo-symbols"; 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 { copySorted } from "@t3tools/shared/Array"; import { useNavigation } from "@react-navigation/native"; import { useMemo, useState } from "react"; import { ActivityIndicator, Modal, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useThreadShells } from "../../state/entities"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { useThreadProjection } from "../../state/use-thread-detail"; import { useArchivedThreadSnapshots } from "../archive/useArchivedThreadSnapshots"; function relationshipLabel(edge: ThreadRelationshipEdge, currentThreadId: ThreadId): string { if (edge.kind === "transfer") { return edge.sourceThreadId === currentThreadId ? "Context sent to" : "Context received from"; } if (edge.kind === "subagent") { return edge.sourceThreadId === currentThreadId ? "Subagent" : "Parent agent"; } return edge.sourceThreadId === currentThreadId ? "Fork" : "Forked from"; } function relationshipSymbol(edge: ThreadRelationshipEdge): SFSymbol { if (edge.kind === "subagent") return "person.2"; if (edge.kind === "transfer") return "arrow.left.arrow.right"; return "arrow.triangle.branch"; } function threadAvailability( thread: OrchestrationV2ThreadShell | null, missing: boolean, ): string | null { if (missing) return "Unavailable"; if (thread?.deletedAt !== null && thread?.deletedAt !== undefined) return "Deleted"; if (thread?.archivedAt !== null && thread?.archivedAt !== undefined) return "Archived"; return null; } const EMPTY_THREAD_SHELLS: ReadonlyArray = []; export function ThreadRelationshipsBanner(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; }) { const scopedProjection = useThreadProjection(props); const projection = scopedProjection?.projection ?? null; const threadShells = useThreadShells(); const environmentIds = useMemo(() => [props.environmentId], [props.environmentId]); const archived = useArchivedThreadSnapshots(environmentIds); const archivedShells = archived.snapshots.find((entry) => entry.environmentId === props.environmentId)?.snapshot .threads ?? EMPTY_THREAD_SHELLS; const shells = useMemo>(() => { const environmentShells: OrchestrationV2ThreadShell[] = []; for (const thread of threadShells) { if (thread.environmentId === props.environmentId) { environmentShells.push(thread.source); } } environmentShells.push(...archivedShells); return environmentShells; }, [archivedShells, props.environmentId, threadShells]); const graph = useMemo( () => deriveThreadRelationshipGraph({ threads: shells, projection }), [projection, shells], ); const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection); const rows = useMemo( () => copySorted( immediateThreadRelationships(graph, props.threadId), (left, right) => Number(right.threadId === mergeTargetThreadId) - Number(left.threadId === mergeTargetThreadId), ), [graph, mergeTargetThreadId, props.threadId], ); const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection); const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null; const canDetach = projection ? canDetachThreadProviderSession(projection) : false; const [visible, setVisible] = useState(false); const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null); const navigation = useNavigation(); const mergeBack = useAtomCommand(threadEnvironment.mergeBack, "merge thread back"); const stopSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); const insets = useSafeAreaInsets(); const theme = useUniwindTheme(); const backdropColor = theme["--color-backdrop"]; const sheetColor = theme["--color-sheet"]; const iconColor = theme["--color-icon-subtle"]; if (rows.length === 0 && !canDetach) return null; const primaryParent = rows.find(({ edge }) => edge.targetThreadId === props.threadId) ?? rows[0]; const primaryNode = primaryParent ? graph.nodes.get(primaryParent.threadId) : null; const summary = primaryParent ? `${relationshipLabel(primaryParent.edge, props.threadId)}: ${primaryNode?.thread?.title ?? "related thread"}` : "Agent session connected"; const openThread = (threadId: ThreadId, archivedThread: boolean) => { setVisible(false); void Haptics.selectionAsync(); if (archivedThread) { navigation.navigate("SettingsSheet", { screen: "SettingsContent", params: { screen: "SettingsArchive" }, }); return; } navigation.navigate("Thread", { environmentId: props.environmentId, threadId, }); }; const merge = async () => { if (!canMerge || mergeTargetThreadId === null || latestMergeBackRun === null) return; setBusyAction("merge"); const result = await mergeBack({ environmentId: props.environmentId, input: { sourceThreadId: props.threadId, targetThreadId: mergeTargetThreadId, runId: latestMergeBackRun.id, creationSource: "mobile", }, }); setBusyAction(null); if (result._tag === "Success") openThread(mergeTargetThreadId, false); }; const detach = async () => { if (!canDetach) return; setBusyAction("detach"); await stopSession({ environmentId: props.environmentId, input: { threadId: props.threadId }, }); setBusyAction(null); }; return ( <> { void Haptics.selectionAsync(); setVisible(true); }} className="mb-4 min-h-11 flex-row items-center gap-2 rounded-xl border border-adaptive-neutral-300-a60-white-a12 bg-card px-3 py-2.5" > {summary} {rows.length > 1 ? ( +{rows.length - 1} ) : null} setVisible(false)} > setVisible(false)} /> Thread lineage {rows.length} related {rows.length === 1 ? "thread" : "threads"} setVisible(false)} className="h-10 w-10 items-center justify-center rounded-full bg-adaptive-neutral-200-a70-white-a8" > {rows.map(({ threadId, edge }) => { const node = graph.nodes.get(threadId); const availability = threadAvailability( node?.thread ?? null, node?.missing ?? true, ); const archivedThread = availability === "Archived"; const disabled = availability === "Unavailable" || availability === "Deleted"; return ( openThread(threadId, archivedThread)} className="min-h-14 flex-row items-center gap-3 rounded-2xl border border-adaptive-neutral-300-a60-white-a12 bg-card px-3 py-2.5" > {relationshipLabel(edge, props.threadId)} {node?.thread?.title ?? threadId} {availability ? ( {availability} ) : ( )} ); })} {canMerge || canDetach ? ( {canMerge ? ( void merge()} className="min-h-11 flex-row items-center justify-center gap-2 rounded-xl bg-primary px-3" > {busyAction === "merge" ? ( ) : ( )} Merge back to source ) : null} {canDetach ? ( void detach()} className="min-h-11 flex-row items-center justify-center gap-2 rounded-xl border border-adaptive-neutral-300-a60-white-a12 px-3" > {busyAction === "detach" ? : null} Disconnect agent session ) : null} ) : null} ); }