1import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
2import { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts";
3import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
4import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail";
5import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput";
6import { useCallback, useEffect, useMemo, useState } from "react";
7import { Pressable, View } from "react-native";
8import { useSafeAreaInsets } from "react-native-safe-area-context";
9
10import { AppText as Text } from "../../components/AppText";
11import { SymbolView } from "../../components/AppSymbol";
12import { orchestrationEnvironment } from "../../state/orchestration";
13import { scopeThreadRef } from "@t3tools/client-runtime/environment";
14import { useThreadShell, useThreadShells } from "../../state/entities";
15import { useEnvironmentQuery } from "../../state/query";
16import { ThreadMcpApp } from "./McpAppWebView";
17
18type McpAppFullscreenScreenProps = StaticScreenProps<{
19 readonly environmentId: string;
20 readonly threadId: string;
21 readonly conversationThreadId?: string;
22 readonly itemId: string;
23 readonly revision?: string;
24}>;
25
26/**
27 * The thread an app's messages go to. A link only names it, so it counts only
28 * when it is the app's own thread or a fork of it; anything else falls back to
29 * the app's thread.
30 */
31function useConversationThreadId(
32 environmentId: EnvironmentId,
33 threadId: ThreadId,
34 requested: string | undefined,
35): ThreadId {
36 const shells = useThreadShells();
37 return useMemo(
38 () =>
39 requested !== undefined && descendsFrom(shells, environmentId, requested, threadId)
40 ? ThreadId.make(requested)
41 : threadId,
42 [shells, environmentId, threadId, requested],
43 );
44}
45
46/** True when `threadId` is `ancestorId` or one of its forks, at any depth. */
47function descendsFrom(
48 shells: ReadonlyArray<EnvironmentThreadShell>,
49 environmentId: EnvironmentId,
50 threadId: string,
51 ancestorId: ThreadId,
52): boolean {
53 const parents = new Map(
54 shells
55 .filter((shell) => shell.environmentId === environmentId)
56 // Only forks continue a conversation; a subagent is its own.
57 .filter((shell) => shell.lineage.relationshipToParent === "fork")
58 .map((shell) => [shell.id as string, shell.lineage.parentThreadId]),
59 );
60 let current: string | null | undefined = threadId;
61 for (let depth = 0; depth < 64 && current != null; depth += 1) {
62 if (current === ancestorId) return true;
63 current = parents.get(current);
64 }
65 return false;
66}
67
68/**
69 * An MCP App full screen (`ui/request-display-mode`), in a modal over the
70 * thread. It is its own view of the app; closing it returns to the row. The
71 * app is read from its stored tool call, never from the route, so a link can
72 * only open an app that call really produced.
73 */
74export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) {
75 const navigation = useNavigation();
76 const insets = useSafeAreaInsets();
77 // A plain goBack pops whatever is on top, so it only runs while this modal
78 // is: an exit that lands while another screen covers it (after a teardown
79 // wait) waits for the modal to be on top again, rather than popping that
80 // screen or being lost.
81 const focused = useIsFocused();
82 const [exitRequested, setExitRequested] = useState(false);
83 const onClose = useCallback(() => {
84 if (navigation.isFocused()) navigation.goBack();
85 else setExitRequested(true);
86 }, [navigation]);
87 const [size, setSize] = useState({ width: 0, height: 0 });
88 const params = route.params;
89 const environmentId = EnvironmentId.make(params.environmentId);
90 const threadId = ThreadId.make(params.threadId);
91 const itemId = TurnItemId.make(params.itemId);
92 const conversationThreadId = useConversationThreadId(
93 environmentId,
94 threadId,
95 params.conversationThreadId,
96 );
97 const detail = useEnvironmentQuery(
98 orchestrationEnvironment.turnItem({
99 environmentId,
100 input: {
101 threadId,
102 itemId,
103 ...(params.revision === undefined ? {} : { revision: params.revision }),
104 },
105 }),
106 );
107 const item = detail.data?.item;
108 // One reference per app: the item is a new object on every refetch (a
109 // reconnect, for one), and a new reference would rebuild the app's host.
110 const derived = item?.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined;
111 const [app, setApp] = useState(derived);
112 if (derived?.attachmentId !== app?.attachmentId) setApp(derived);
113 // The approval or question the agent waits on renders on the thread this
114 // modal covers, so the app steps aside for it.
115 const conversation = useThreadShell(scopeThreadRef(environmentId, conversationThreadId));
116 const awaitingUser =
117 conversation?.hasPendingApprovals === true || conversation?.hasPendingUserInput === true;
118 useEffect(() => {
119 if ((awaitingUser || exitRequested) && focused) navigation.goBack();
120 }, [awaitingUser, exitRequested, focused, navigation]);
121
122 return (
123 <View className="flex-1 bg-screen" style={{ paddingTop: insets.top }}>
124 <View className="h-11 flex-row items-center justify-between px-3">
125 <Text className="text-base font-semibold text-foreground" numberOfLines={1}>
126 {app?.server ?? "App"}
127 </Text>
128 <Pressable
129 accessibilityRole="button"
130 accessibilityLabel="Exit full screen"
131 hitSlop={8}
132 onPress={onClose}
133 >
134 <SymbolView name="xmark" size={18} tintColor="gray" />
135 </Pressable>
136 </View>
137 <View
138 className="flex-1"
139 style={{ paddingBottom: insets.bottom }}
140 onLayout={(event) =>
141 setSize({
142 width: Math.round(event.nativeEvent.layout.width),
143 height: Math.round(event.nativeEvent.layout.height - insets.bottom),
144 })
145 }
146 >
147 {app === undefined ? (
148 detail.data === undefined ? null : (
149 <Text className="m-6 text-sm text-foreground-muted">This app cannot be shown.</Text>
150 )
151 ) : size.width > 0 ? (
152 <ThreadMcpApp
153 environmentId={environmentId}
154 threadId={threadId}
155 conversationThreadId={conversationThreadId}
156 itemId={itemId}
157 revision={params.revision ?? (item == null ? "" : turnItemDetailRevision(item))}
158 app={app}
159 width={size.width}
160 height={size.height}
161 displayMode="fullscreen"
162 onExitFullscreen={onClose}
163 />
164 ) : null}
165 </View>
166 </View>
167 );
168}