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<OrchestrationV2ThreadShell> = []; 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<ReadonlyArray<OrchestrationV2ThreadShell>>(() => { 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 ( <> <Pressable accessibilityRole="button" accessibilityLabel={`${summary}. Show thread relationships`} onPress={() => { 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" > <SymbolView name={primaryParent ? relationshipSymbol(primaryParent.edge) : "link"} size={14} tintColor={iconColor} type="monochrome" /> <Text className="min-w-0 flex-1 font-t3-medium text-xs text-foreground" numberOfLines={1}> {summary} </Text> {rows.length > 1 ? ( <Text className="text-2xs tabular-nums text-foreground-muted">+{rows.length - 1}</Text> ) : null} <SymbolView name="chevron.right" size={12} tintColor={iconColor} type="monochrome" /> </Pressable> <Modal transparent animationType="fade" visible={visible} statusBarTranslucent onRequestClose={() => setVisible(false)} > <View className="flex-1 justify-end"> <View pointerEvents="none" className="absolute inset-0" style={{ backgroundColor: backdropColor }} /> <Pressable className="absolute inset-0" onPress={() => setVisible(false)} /> <View className="max-h-[78%] rounded-t-[28px] px-4 pt-3" style={{ backgroundColor: sheetColor, paddingBottom: Math.max(insets.bottom, 16) }} > <View className="mb-4 h-1 w-10 self-center rounded-full bg-neutral-400/40" /> <View className="mb-3 flex-row items-center justify-between px-1"> <View> <Text className="font-t3-bold text-lg text-foreground">Thread lineage</Text> <Text className="text-xs text-foreground-muted"> {rows.length} related {rows.length === 1 ? "thread" : "threads"} </Text> </View> <Pressable accessibilityRole="button" accessibilityLabel="Close thread lineage" onPress={() => setVisible(false)} className="h-10 w-10 items-center justify-center rounded-full bg-adaptive-neutral-200-a70-white-a8" > <SymbolView name="xmark" size={14} tintColor={iconColor} type="monochrome" /> </Pressable> </View> <ScrollView contentContainerStyle={{ gap: 8 }} showsVerticalScrollIndicator={false}> {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 ( <Pressable key={`${edge.kind}:${threadId}`} accessibilityRole={disabled ? undefined : "link"} accessibilityState={{ disabled }} disabled={disabled} onPress={() => 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" > <View className="h-8 w-8 items-center justify-center rounded-full bg-adaptive-neutral-200-a70-white-a8"> <SymbolView name={relationshipSymbol(edge)} size={14} tintColor={iconColor} type="monochrome" /> </View> <View className="min-w-0 flex-1"> <Text className="text-3xs uppercase tracking-wide text-foreground-muted"> {relationshipLabel(edge, props.threadId)} </Text> <Text className="font-t3-medium text-sm text-foreground" numberOfLines={1}> {node?.thread?.title ?? threadId} </Text> </View> {availability ? ( <Text className="text-2xs text-foreground-muted">{availability}</Text> ) : ( <SymbolView name="chevron.right" size={12} tintColor={iconColor} type="monochrome" /> )} </Pressable> ); })} </ScrollView> {canMerge || canDetach ? ( <View className="mt-4 gap-2 border-t border-adaptive-neutral-300-a60-white-a12 pt-4"> {canMerge ? ( <Pressable accessibilityRole="button" disabled={busyAction !== null} onPress={() => void merge()} className="min-h-11 flex-row items-center justify-center gap-2 rounded-xl bg-primary px-3" > {busyAction === "merge" ? ( <ActivityIndicator color="white" /> ) : ( <SymbolView name="arrow.triangle.merge" size={14} tintColor="white" /> )} <Text className="font-t3-medium text-sm text-primary-foreground"> Merge back to source </Text> </Pressable> ) : null} {canDetach ? ( <Pressable accessibilityRole="button" disabled={busyAction !== null} onPress={() => 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" ? <ActivityIndicator /> : null} <Text className="font-t3-medium text-sm text-foreground"> Disconnect agent session </Text> </Pressable> ) : null} </View> ) : null} </View> </View> </Modal> </> );}
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<OrchestrationV2ThreadShell> = [];
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<ReadonlyArray<OrchestrationV2ThreadShell>>(() => {
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 (
<>
<Pressable
accessibilityRole="button"
accessibilityLabel={`${summary}. Show thread relationships`}
onPress={() => {
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"
>
<SymbolView
name={primaryParent ? relationshipSymbol(primaryParent.edge) : "link"}
size={14}
tintColor={iconColor}
type="monochrome"
/>
<Text className="min-w-0 flex-1 font-t3-medium text-xs text-foreground" numberOfLines={1}>
{summary}
</Text>
{rows.length > 1 ? (
<Text className="text-2xs tabular-nums text-foreground-muted">+{rows.length - 1}</Text>
) : null}
<SymbolView name="chevron.right" size={12} tintColor={iconColor} type="monochrome" />
</Pressable>
<Modal
transparent
animationType="fade"
visible={visible}
statusBarTranslucent
onRequestClose={() => setVisible(false)}
>
<View className="flex-1 justify-end">
<View
pointerEvents="none"
className="absolute inset-0"
style={{ backgroundColor: backdropColor }}
/>
<Pressable className="absolute inset-0" onPress={() => setVisible(false)} />
<View
className="max-h-[78%] rounded-t-[28px] px-4 pt-3"
style={{ backgroundColor: sheetColor, paddingBottom: Math.max(insets.bottom, 16) }}
>
<View className="mb-4 h-1 w-10 self-center rounded-full bg-neutral-400/40" />
<View className="mb-3 flex-row items-center justify-between px-1">
<View>
<Text className="font-t3-bold text-lg text-foreground">Thread lineage</Text>
<Text className="text-xs text-foreground-muted">
{rows.length} related {rows.length === 1 ? "thread" : "threads"}
</Text>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel="Close thread lineage"
onPress={() => setVisible(false)}
className="h-10 w-10 items-center justify-center rounded-full bg-adaptive-neutral-200-a70-white-a8"
>
<SymbolView name="xmark" size={14} tintColor={iconColor} type="monochrome" />
</Pressable>
</View>
<ScrollView contentContainerStyle={{ gap: 8 }} showsVerticalScrollIndicator={false}>
{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 (
<Pressable
key={`${edge.kind}:${threadId}`}
accessibilityRole={disabled ? undefined : "link"}
accessibilityState={{ disabled }}
disabled={disabled}
onPress={() => 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"
>
<View className="h-8 w-8 items-center justify-center rounded-full bg-adaptive-neutral-200-a70-white-a8">
<SymbolView
name={relationshipSymbol(edge)}
size={14}
tintColor={iconColor}
type="monochrome"
/>
</View>
<View className="min-w-0 flex-1">
<Text className="text-3xs uppercase tracking-wide text-foreground-muted">
{relationshipLabel(edge, props.threadId)}
</Text>
<Text className="font-t3-medium text-sm text-foreground" numberOfLines={1}>
{node?.thread?.title ?? threadId}
</Text>
</View>
{availability ? (
<Text className="text-2xs text-foreground-muted">{availability}</Text>
) : (
<SymbolView
name="chevron.right"
size={12}
tintColor={iconColor}
type="monochrome"
/>
)}
</Pressable>
);
})}
</ScrollView>
{canMerge || canDetach ? (
<View className="mt-4 gap-2 border-t border-adaptive-neutral-300-a60-white-a12 pt-4">
{canMerge ? (
<Pressable
accessibilityRole="button"
disabled={busyAction !== null}
onPress={() => void merge()}
className="min-h-11 flex-row items-center justify-center gap-2 rounded-xl bg-primary px-3"
>
{busyAction === "merge" ? (
<ActivityIndicator color="white" />
) : (
<SymbolView name="arrow.triangle.merge" size={14} tintColor="white" />
)}
<Text className="font-t3-medium text-sm text-primary-foreground">
Merge back to source
</Text>
</Pressable>
) : null}
{canDetach ? (
<Pressable
accessibilityRole="button"
disabled={busyAction !== null}
onPress={() => 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" ? <ActivityIndicator /> : null}
<Text className="font-t3-medium text-sm text-foreground">
Disconnect agent session
</Text>
</Pressable>
) : null}
</View>
) : null}
</View>
</View>
</Modal>
</>
);
}