Skip to content
Source
Sign inSign up
Open History to see the last commit

McpAppFullscreenScreen.tsx

179 lines6.3 KB

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 { useEffect, useRef, useState } from "react";
7import { View } from "react-native";
8import { useSafeAreaInsets } from "react-native-safe-area-context";
9​
10import { AppText as Text } from "../../components/ui/AppText";
11import { AppBar } from "../../components/ui/AppBar";
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​
26function useConversationThreadId(
27 environmentId: EnvironmentId,
28 threadId: ThreadId,
29 requested: string | undefined,
30): ThreadId {
31 const shells = useThreadShells();
32 return requested !== undefined && descendsFrom(shells, environmentId, requested, threadId)
33 ? ThreadId.make(requested)
34 : threadId;
36​
37function descendsFrom(
38 shells: ReadonlyArray<EnvironmentThreadShell>,
39 environmentId: EnvironmentId,
40 threadId: string,
41 ancestorId: ThreadId,
42): boolean {
43 const parents = new Map(
44 shells
45 .filter((shell) => shell.environmentId === environmentId)
46 .filter((shell) => shell.lineage.relationshipToParent === "fork")
47 .map((shell) => [shell.id as string, shell.lineage.parentThreadId]),
48 );
49 let current: string | null | undefined = threadId;
50 for (let depth = 0; depth < 64 && current != null; depth += 1) {
51 if (current === ancestorId) return true;
52 current = parents.get(current);
53 }
54 return false;
56​
57/** Resolve apps from stored tool calls so links cannot supply apps the call never produced. */
58export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) {
59 const navigation = useNavigation();
60 const { environmentId, threadId, itemId } = route.params;
61 if (!environmentId?.trim() || !threadId?.trim() || !itemId?.trim()) {
62 return (
63 <View className="flex-1 bg-screen">
64 <AppBar
65 title="App"
66 onClose={() => navigation.goBack()}
67 closeAccessibilityLabel="Exit full screen"
68 />
69 <Text className="m-6 text-sm text-foreground-muted">This app cannot be shown.</Text>
70 </View>
71 );
72 }
73 return (
74 <McpAppFullscreen
75 params={route.params}
76 environmentId={EnvironmentId.make(environmentId)}
77 threadId={ThreadId.make(threadId)}
78 itemId={TurnItemId.make(itemId)}
79 />
80 );
82​
83function McpAppFullscreen({
84 params,
85 environmentId,
86 threadId,
87 itemId,
88}: {
89 readonly params: McpAppFullscreenScreenProps["route"]["params"];
90 readonly environmentId: EnvironmentId;
91 readonly threadId: ThreadId;
92 readonly itemId: TurnItemId;
93}) {
94 const navigation = useNavigation();
95 const insets = useSafeAreaInsets();
96 // Wait for focus before goBack; an exit under another screen must not pop that screen.
97 const focused = useIsFocused();
98 const exitPending = useRef(false);
99 const onClose = () => {
100 if (navigation.isFocused()) {
101 navigation.goBack();
102 return;
103 }
104 if (exitPending.current) return;
105 exitPending.current = true;
106 const unsubscribe = navigation.addListener("focus", () => {
107 unsubscribe();
108 navigation.goBack();
109 });
110 };
111 const [size, setSize] = useState({ width: 0, height: 0 });
112 const conversationThreadId = useConversationThreadId(
113 environmentId,
114 threadId,
115 params.conversationThreadId,
116 );
117 const detail = useEnvironmentQuery(
118 orchestrationEnvironment.turnItem({
119 environmentId,
120 input: {
121 threadId,
122 itemId,
123 ...(params.revision === undefined ? {} : { revision: params.revision }),
124 },
125 }),
126 );
127 const item = detail.data?.item;
128 // Keep one reference per app; refetches must not rebuild the host.
129 const derived = item?.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined;
130 const [app, setApp] = useState(derived);
131 if (derived?.attachmentId !== app?.attachmentId) setApp(derived);
132 const conversation = useThreadShell(scopeThreadRef(environmentId, conversationThreadId));
133 const awaitingUser =
134 conversation?.hasPendingApprovals === true || conversation?.hasPendingUserInput === true;
135 // A request for the user is server state with no local handler to react in.
136 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- closes the focused screen through the navigator when the server asks the user for input or approval
137 useEffect(() => {
138 if (awaitingUser && focused && !exitPending.current) navigation.goBack();
139 }, [awaitingUser, focused, navigation]);
140​
141 return (
142 <View className="flex-1 bg-screen">
143 <AppBar
144 title={app?.server ?? "App"}
145 onClose={onClose}
146 closeAccessibilityLabel="Exit full screen"
147 />
148 <View
149 className="flex-1"
150 style={{ paddingBottom: insets.bottom }}
151 onLayout={(event) =>
152 setSize({
153 width: Math.round(event.nativeEvent.layout.width),
154 height: Math.round(event.nativeEvent.layout.height - insets.bottom),
155 })
156 }
157 >
158 {app === undefined ? (
159 detail.data === undefined ? null : (
160 <Text className="m-6 text-sm text-foreground-muted">This app cannot be shown.</Text>
161 )
162 ) : size.width > 0 ? (
163 <ThreadMcpApp
164 environmentId={environmentId}
165 threadId={threadId}
166 conversationThreadId={conversationThreadId}
167 itemId={itemId}
168 revision={params.revision ?? (item == null ? "" : turnItemDetailRevision(item))}
169 app={app}
170 width={size.width}
171 height={size.height}
172 displayMode="fullscreen"
173 onExitFullscreen={onClose}
174 />
175 ) : null}
176 </View>
177 </View>
178 );