| 1 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 2 | import { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; |
| 3 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 4 | import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; |
| 5 | import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; |
| 6 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 7 | import { Pressable, View } from "react-native"; |
| 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 9 | |
| 10 | import { AppText as Text } from "../../components/AppText"; |
| 11 | import { SymbolView } from "../../components/AppSymbol"; |
| 12 | import { orchestrationEnvironment } from "../../state/orchestration"; |
| 13 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 14 | import { useThreadShell, useThreadShells } from "../../state/entities"; |
| 15 | import { useEnvironmentQuery } from "../../state/query"; |
| 16 | import { ThreadMcpApp } from "./McpAppWebView"; |
| 17 | |
| 18 | type 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 | */ |
| 31 | function 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. */ |
| 47 | function 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 | */ |
| 74 | export 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 | } |