diff --git a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx index de7ecb1b0e690..4440ed712fbf4 100644 --- a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx +++ b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx @@ -12,7 +12,6 @@ import { } from "@t3tools/client-runtime/preview/server-browser-stream"; import type { EnvironmentId, PreviewStreamHostSetup } from "@t3tools/contracts"; import { - useEffect, useEffectEvent, useImperativeHandle, useLayoutEffect, @@ -52,11 +51,13 @@ export interface PreviewPictureInPictureState { type NativeStreamBridge = { readonly ref?: Ref; readonly onUnauthorized: () => void; + /** Asks for fresh access after a refusal, once `delayMs` has passed. */ + readonly onRefused: (delayMs: number) => void; readonly onGone?: () => void; readonly onViewport?: (viewport: { readonly width: number; readonly height: number }) => void; readonly onControl?: (control: PreviewStreamControl | null) => void; readonly onPictureInPicture?: (state: PreviewPictureInPictureState, detail?: string) => void; - /** True while frames show, so commands reach the page. Pass a stable function. */ + /** True while frames show, so commands reach the page. */ readonly onStreamingChange?: (streaming: boolean) => void; readonly compact?: boolean; readonly pdfUri?: string | null; @@ -113,14 +114,21 @@ const MAX_REFUSALS = 3; export function PreviewStreamWebView( props: Omit & - Omit & { + Omit & { readonly environmentId: EnvironmentId; readonly paused?: boolean; }, ) { - const { access, error, refresh } = usePreviewStreamAccess(props.environmentId); + const { access, error, refresh, retryAfter } = usePreviewStreamAccess(props.environmentId); if (access && !props.paused) { - return ; + return ( + + ); } return ( | null>(null); - useEffect( - () => () => { - if (refreshTimer.current !== null) clearTimeout(refreshTimer.current); - }, - [], - ); const configuration = JSON.stringify({ access: props.access, threadId: props.threadId, @@ -188,14 +189,7 @@ function AuthorizedPreviewStream({ onUnauthorized={() => { const refusals = ++unauthorized.current; if (refusals >= MAX_REFUSALS) return false; - if (refreshTimer.current !== null) clearTimeout(refreshTimer.current); - refreshTimer.current = setTimeout( - () => { - refreshTimer.current = null; - props.onUnauthorized(); - }, - refusals === 1 ? 0 : 1_000 * 2 ** (refusals - 1), - ); + props.onRefused(refusals === 1 ? 0 : 1_000 * 2 ** (refusals - 1)); return true; }} onRetry={() => { @@ -232,7 +226,7 @@ function PreviewStreamDocumentView({ onRetry, onStreaming, onRecoverProcess, -}: Omit & { +}: Omit & { readonly configuration: string; readonly background: string; readonly onUnauthorized: () => boolean; @@ -241,13 +235,13 @@ function PreviewStreamDocumentView({ readonly onRecoverProcess: () => boolean; }) { const webView = useRef>(null); - const active = useRef(true); + const active = useRef(false); const failed = useRef(false); + const bootstrapTimer = useRef | null>(null); const [status, setStatus] = useState<"connecting" | "streaming" | "error">("connecting"); const [error, setError] = useState(null); const [gone, setGone] = useState(false); const [hostSetup, setHostSetup] = useState(null); - const [started, setStarted] = useState(false); const [control, setControl] = useState(null); const dialogRef = useRef(null); const [promptText, setPromptText] = useState(""); @@ -263,7 +257,6 @@ function PreviewStreamDocumentView({ ), ); }; - const controlChanged = useEffectEvent((next: PreviewStreamControl | null) => onControl?.(next)); const command = (input: PreviewStreamInput) => webView.current?.injectJavaScript( `window.T3PreviewStream?.command(${JSON.stringify(input)}); true;`, @@ -277,9 +270,15 @@ function PreviewStreamDocumentView({ }, () => Dialog.alert("Could not read the clipboard"), ); + const clearBootstrapTimer = () => { + if (bootstrapTimer.current === null) return; + clearTimeout(bootstrapTimer.current); + bootstrapTimer.current = null; + }; const fail = (message: string) => { if (!active.current || failed.current) return; failed.current = true; + clearBootstrapTimer(); webView.current?.injectJavaScript("window.T3PreviewStream?.stop(); true;"); onStreamingChange?.(false); setControl(null); @@ -291,11 +290,23 @@ function PreviewStreamDocumentView({ const bootstrapTimedOut = useEffectEvent(() => fail("Browser viewer could not start. Reconnect to try again."), ); - useEffect(() => { - if (started) return; - const timer = setTimeout(bootstrapTimedOut, 15_000); - return () => clearTimeout(timer); - }, [started]); + const streamEnded = useEffectEvent(() => { + onStreamingChange?.(false); + onControl?.(null); + }); + // The stream lives exactly as long as this view. + useLayoutEffect(() => { + active.current = true; + const view = webView.current; + bootstrapTimer.current = setTimeout(bootstrapTimedOut, 15_000); + return () => { + if (bootstrapTimer.current !== null) clearTimeout(bootstrapTimer.current); + bootstrapTimer.current = null; + active.current = false; + view?.injectJavaScript("window.T3PreviewStream?.stop(); true;"); + streamEnded(); + }; + }, []); const source = useMemo( () => ({ html: previewStreamDocument(configuration, previewStreamScript), @@ -309,16 +320,6 @@ function PreviewStreamDocumentView({ togglePictureInPicture: () => webView.current?.injectJavaScript("window.T3PreviewStream?.pictureInPicture(); true;"), })); - useLayoutEffect(() => { - active.current = true; - const view = webView.current; - return () => { - active.current = false; - view?.injectJavaScript("window.T3PreviewStream?.stop(); true;"); - }; - }, []); - useEffect(() => () => onStreamingChange?.(false), [onStreamingChange]); - useEffect(() => () => controlChanged(null), []); const processTerminated = () => { if (!active.current || failed.current) return; if (!onRecoverProcess()) fail("Browser viewer stopped. Reconnect to try again."); @@ -407,7 +408,7 @@ function PreviewStreamDocumentView({ onPictureInPicture?.(message, message.detail); return; case "status": - setStarted(true); + clearBootstrapTimer(); if (message.status === "error") { fail(message.detail ?? "Browser stream failed."); return; diff --git a/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx b/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx index 5f6a876b0d597..ea5f0d9c8c0ca 100644 --- a/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx +++ b/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx @@ -2,7 +2,7 @@ import { useIsFocused } from "@react-navigation/native"; import type { EnvironmentId, PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; import { previewPdfAssetUrl } from "@t3tools/shared/preview"; import * as Option from "effect/Option"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useState } from "react"; import { Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"; @@ -26,21 +26,30 @@ export function ThreadBrowserFloat(props: { readonly onOpen: (tabId: string) => void; }) { const [tabId, setTabId] = useState(null); - const known = useRef | null>(null); - useEffect(() => { - if (!props.loaded) return; - const previous = known.current; - known.current = new Map(props.tabs.map((tab) => [tab.tabId, tab.revealRequest?.id])); - if (previous === null) return; - const opened = props.tabs.findLast( - (tab) => - tab.reveal === true && - (tab.revealRequest - ? previous.get(tab.tabId) !== tab.revealRequest.id - : !previous.has(tab.tabId)), - ); + // The reveal requests already seen, so only a tab revealed after the first + // list pops the player. + const [seen, setSeen] = useState<{ + readonly tabs: ReadonlyArray; + readonly reveals: ReadonlyMap; + } | null>(null); + if (props.loaded && seen?.tabs !== props.tabs) { + setSeen({ + tabs: props.tabs, + reveals: new Map(props.tabs.map((tab) => [tab.tabId, tab.revealRequest?.id])), + }); + const previous = seen?.reveals; + const opened = + previous === undefined + ? undefined + : props.tabs.findLast( + (tab) => + tab.reveal === true && + (tab.revealRequest + ? previous.get(tab.tabId) !== tab.revealRequest.id + : !previous.has(tab.tabId)), + ); if (opened !== undefined) setTabId(opened.tabId); - }, [props.loaded, props.tabs]); + } const focused = useIsFocused(); const foreground = useAppForeground(); const tab = props.tabs.find((entry) => entry.tabId === tabId) ?? null; diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index f42eb1c67027f..d5c633793a99c 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -9,7 +9,7 @@ import { import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import { useEffect, useRef, useState, type RefObject } from "react"; import * as Haptics from "expo-haptics"; import { Accelerometer } from "expo-sensors"; import { Pressable, StatusBar, View } from "react-native"; @@ -31,7 +31,8 @@ import { useAppForeground } from "../../lib/appState"; import { useHardwareBack } from "../../lib/backHandler"; import { deriveBottomControlInsets } from "../../lib/reserved-regions"; import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; -import { deviceEnvironment, refreshDeviceHubAccess, useDeviceHubAccess } from "../../state/device"; +import { uuidv4 } from "../../lib/uuid"; +import { deviceEnvironment, useDeviceHubAccess } from "../../state/device"; import { useEnvironmentQuery } from "../../state/query"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -102,13 +103,10 @@ export function DevicePreviewScreen({ const state = useEnvironmentQuery(deviceEnvironment.state({ environmentId, input: {} })); const previews = threadDevicePreviews(state.data, threadId); const preview = selectedThreadDevicePreview(previews, selectedKey); - const onInputConnected = useCallback( - async (connected: boolean) => { - setInputConnected(connected); - if (connected && !embedded) setControlsVisible(false); - }, - [embedded], - ); + const onInputConnected = async (connected: boolean) => { + setInputConnected(connected); + if (connected && !embedded) setControlsVisible(false); + }; useEffect(() => { if (focused && state.data !== null && previews.length === 0) onClose(); }, [focused, state.data, previews.length, onClose]); @@ -401,12 +399,9 @@ function OpenDevicePreview({ }) { const { session } = preview; const { themeVariables } = useAppearancePreferences(); - const { access, error, refresh } = useDeviceHubAccess(environmentId, session.hostId); - const onUnauthorized = async () => refreshDeviceHubAccess(environmentId); - useEffect(() => { - refreshDeviceHubAccess(environmentId); - return () => void onInputConnected(false); - }, [environmentId, onInputConnected]); + const [viewer] = useState(uuidv4); + const { access, error, refresh } = useDeviceHubAccess(environmentId, session.hostId, viewer); + const onUnauthorized = async () => refresh(); return access ? ( boolean; }) { const webView = useRef>(null); - const active = useRef(true); + const active = useRef(false); const failed = useRef(false); + const bootstrapTimer = useRef | null>(null); const [status, setStatus] = useState("connecting"); const [error, setError] = useState(null); - const [started, setStarted] = useState(false); + const clearBootstrapTimer = () => { + if (bootstrapTimer.current === null) return; + clearTimeout(bootstrapTimer.current); + bootstrapTimer.current = null; + }; const fail = (message: string) => { if (!active.current || failed.current) return; failed.current = true; + clearBootstrapTimer(); void onInputConnected(false); webView.current?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); setError(message); @@ -111,11 +116,21 @@ function DeviceStreamDocumentView({ const bootstrapTimedOut = useEffectEvent(() => fail("Device viewer could not start. Reconnect to try again."), ); - useEffect(() => { - if (started) return; - const timer = setTimeout(bootstrapTimedOut, 15_000); - return () => clearTimeout(timer); - }, [started]); + const resetInput = useEffectEvent(() => void onInputConnected(false)); + // The stream lives exactly as long as this view. + useLayoutEffect(() => { + active.current = true; + resetInput(); + const view = webView.current; + bootstrapTimer.current = setTimeout(bootstrapTimedOut, 15_000); + return () => { + if (bootstrapTimer.current !== null) clearTimeout(bootstrapTimer.current); + bootstrapTimer.current = null; + active.current = false; + view?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); + resetInput(); + }; + }, []); const source = { html: deviceStreamDocument(configuration, deviceStreamScript), baseUrl, @@ -131,16 +146,6 @@ function DeviceStreamDocumentView({ appSwitcher: () => command("appSwitcher"), rotate: () => command("rotate"), })); - const resetInput = useEffectEvent(() => void onInputConnected(false)); - useLayoutEffect(() => { - active.current = true; - resetInput(); - const view = webView.current; - return () => { - active.current = false; - view?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); - }; - }, []); const processTerminated = () => { if (!active.current || failed.current) return; void onInputConnected(false); @@ -173,7 +178,7 @@ function DeviceStreamDocumentView({ else if (message?.type === "input") void onInputConnected(message.connected); else if (message?.type === "retry") onRetry(); else if (message?.type === "status") { - setStarted(true); + clearBootstrapTimer(); if (message.status === "error") fail(message.detail ?? "Device stream failed."); else { setStatus(message.status); diff --git a/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx index ccfd2ffd15df8..aa6e4cce537d3 100644 --- a/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx +++ b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx @@ -3,7 +3,7 @@ import { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -64,11 +64,19 @@ export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) { const insets = useSafeAreaInsets(); // Wait for focus before goBack; an exit under another screen must not pop that screen. const focused = useIsFocused(); - const [exitRequested, setExitRequested] = useState(false); - const onClose = useCallback(() => { - if (navigation.isFocused()) navigation.goBack(); - else setExitRequested(true); - }, [navigation]); + const exitPending = useRef(false); + const onClose = () => { + if (navigation.isFocused()) { + navigation.goBack(); + return; + } + if (exitPending.current) return; + exitPending.current = true; + const unsubscribe = navigation.addListener("focus", () => { + unsubscribe(); + navigation.goBack(); + }); + }; const [size, setSize] = useState({ width: 0, height: 0 }); const params = route.params; const environmentId = EnvironmentId.make(params.environmentId); @@ -97,9 +105,10 @@ export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) { const conversation = useThreadShell(scopeThreadRef(environmentId, conversationThreadId)); const awaitingUser = conversation?.hasPendingApprovals === true || conversation?.hasPendingUserInput === true; + // A request for the user is server state with no local handler to react in. useEffect(() => { - if ((awaitingUser || exitRequested) && focused) navigation.goBack(); - }, [awaitingUser, exitRequested, focused, navigation]); + if (awaitingUser && focused && !exitPending.current) navigation.goBack(); + }, [awaitingUser, focused, navigation]); return ( diff --git a/apps/mobile/src/features/threads/McpAppWebView.tsx b/apps/mobile/src/features/threads/McpAppWebView.tsx index 4a64b5b4b1516..1c0c2e742e70a 100644 --- a/apps/mobile/src/features/threads/McpAppWebView.tsx +++ b/apps/mobile/src/features/threads/McpAppWebView.tsx @@ -21,9 +21,10 @@ import { type McpAppReference, } from "@t3tools/shared/mcpApp"; import * as Predicate from "effect/Predicate"; +import { AsyncResult } from "effect/reactivity"; import Constants from "expo-constants"; import { useNavigation } from "@react-navigation/native"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { WebView, type WebViewMessageEvent } from "react-native-webview"; @@ -34,6 +35,7 @@ import { mobileHtmlRenderTheme } from "../../lib/htmlRenderTheme"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { uuidv4 } from "../../lib/uuid"; import { useAssetUrlState } from "../../state/assets"; +import { appAtomRegistry } from "../../state/atom-registry"; import { useThreadShell } from "../../state/entities"; import { mcpAppEnvironment } from "../../state/mcpApps"; import { orchestrationEnvironment } from "../../state/orchestration"; @@ -88,6 +90,18 @@ const confirm = (title: string, message: string, action: string) => const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; +// The feed omits tool input and output; the app needs both. +function storedToolCall( + item: { readonly type: string; readonly input?: unknown; readonly output?: unknown } | null, +) { + if (item?.type !== "dynamic_tool") return undefined; + const output = item.output as { readonly result?: unknown } | undefined; + return { + arguments: item.input, + result: output?.result as McpAppCallToolResult | undefined, + }; +} + /** WebViews reload when moved; tear down inline before opening fullscreen and recreate it on return. */ export function ThreadMcpApp(props: { readonly environmentId: EnvironmentId; @@ -101,19 +115,29 @@ export function ThreadMcpApp(props: { readonly onExitFullscreen?: () => void; readonly height?: number; }) { - // Equal app content must retain its reference to preserve the live document host. const [height, setHeight] = useState(MCP_APP_INLINE_MAX_HEIGHT); + const [documentKey, setDocumentKeyState] = useState(0); + // The view keeps its first URL: a re-minted one would reload the app. + const [uri, setUri] = useState(null); + const [loaded, setLoaded] = useState(false); + const [navigatedAway, setNavigatedAway] = useState(false); + // Equal app content must retain its reference to preserve the live document host. + // A different app needs a new document even when its asset URL is unchanged, + // so the WebView key moves on with the document key, not only the URL. const [app, setApp] = useState(props.app); if (!mcpAppReferencesEqual(app, props.app)) { setApp(props.app); setHeight(MCP_APP_INLINE_MAX_HEIGHT); + setUri(null); + setLoaded(false); + setNavigatedAway(false); + setDocumentKeyState((value) => value + 1); } const fullscreen = props.displayMode === "fullscreen"; const navigation = useNavigation(); const insets = useSafeAreaInsets(); const [closed, setClosed] = useState(false); const [presentedFullscreen, setPresentedFullscreen] = useState(false); - const [documentKey, setDocumentKeyState] = useState(0); const { themeId, themeAppearance, themeVariables, systemColorsActive } = useAppearancePreferences(); @@ -140,11 +164,7 @@ export function ThreadMcpApp(props: { [app], ); const asset = useAssetUrlState(props.environmentId, resource); - // The view keeps its first URL: a re-minted one would reload the app. - const [uri, setUri] = useState(null); if (uri === null && asset._tag === "Success") setUri(asset.url); - const [loaded, setLoaded] = useState(false); - const [navigatedAway, setNavigatedAway] = useState(false); const [generation, setGeneration] = useState(0); const [crashed, setCrashed] = useState(false); const restart = () => { @@ -159,49 +179,41 @@ export function ThreadMcpApp(props: { setGeneration(1); }; const [secret] = useState(uuidv4); - const setDocumentKey = (next: (value: number) => number) => { + const reopenDocument = () => { setLoaded(false); setHeight(MCP_APP_INLINE_MAX_HEIGHT); setNavigatedAway(false); if (asset._tag === "Success") setUri(asset.url); - setDocumentKeyState(next); + setDocumentKeyState((value) => value + 1); }; - const openNewDocument = useRef(setDocumentKey); - useEffect(() => { - openNewDocument.current = setDocumentKey; + const returnedFromFullscreen = useEffectEvent(() => { + reopenDocument(); + setPresentedFullscreen(false); }); + // oxlint-disable-next-line t3code/react-effect-budget -- Navigation focus is outside React; listen only while the app is presented full screen, and stop on unmount. useEffect(() => { if (!presentedFullscreen) return; - return navigation.addListener("focus", () => { - openNewDocument.current((value) => value + 1); - setPresentedFullscreen(false); - }); + return navigation.addListener("focus", () => returnedFromFullscreen()); }, [navigation, presentedFullscreen]); - // The feed omits tool input and output; the app needs both. - const detail = useEnvironmentQuery( - orchestrationEnvironment.turnItem({ + const detailAtom = orchestrationEnvironment.turnItem({ + environmentId: props.environmentId, + input: { threadId: props.threadId, itemId: props.itemId, revision: props.revision }, + }); + const toolInfo = useEnvironmentQuery( + mcpAppEnvironment.toolInfoQuery({ environmentId: props.environmentId, - input: { threadId: props.threadId, itemId: props.itemId, revision: props.revision }, + input: { threadId: props.threadId, itemId: props.itemId, name: app.tool }, }), ); - const storedItem = detail.data?.item; - const toolCall = useMemo(() => { - if (storedItem?.type !== "dynamic_tool") return undefined; - const output = storedItem.output as { readonly result?: unknown } | undefined; - return { - arguments: storedItem.input, - result: output?.result as McpAppCallToolResult | undefined, - }; - }, [storedItem]); + const toolDefinition = toolInfo.data?.tool; const callTool = useAtomCommand(mcpAppEnvironment.callTool, { reportFailure: false }); - const toolInfo = useAtomCommand(mcpAppEnvironment.toolInfo, { reportFailure: false }); + const toolInfoCommand = useAtomCommand(mcpAppEnvironment.toolInfo, { reportFailure: false }); const readResource = useAtomCommand(mcpAppEnvironment.readResource, { reportFailure: false }); const updateModelContext = useAtomCommand(mcpAppEnvironment.updateModelContext, { reportFailure: false, }); - const [toolDefinition, setToolDefinition] = useState(undefined); // Read current context per message without rebuilding the host and losing its session. const conversation = useThreadShell( scopeThreadRef(props.environmentId, props.conversationThreadId), @@ -212,8 +224,9 @@ export function ThreadMcpApp(props: { awaitingUser, theme, props, + asset, callTool, - toolInfo, + toolInfo: toolInfoCommand, readResource, updateModelContext, insets, @@ -221,237 +234,255 @@ export function ThreadMcpApp(props: { navigation, }; const latest = useRef(live); + // The attached WebView owns its host; state lets the tool-call effect pair + // a host only with an atom from a render after that host existed. + const [host, setHost] = useState(null); useEffect(() => { + const previous = latest.current; latest.current = live; + // Building the context is not free, so resend only when one of its inputs changed. + if ( + previous.theme !== live.theme || + previous.insets !== live.insets || + previous.toolDefinition !== live.toolDefinition || + previous.props.width !== live.props.width || + previous.props.height !== live.props.height || + previous.props.displayMode !== live.props.displayMode + ) { + host?.updateHostContext(); + } }); + // The host sends the call once the app initializes, so a stored item that + // lands late still reaches it. A completed call can arrive under a new + // revision, which is a new atom, so the host follows the current one. + // oxlint-disable-next-line t3code/react-effect-budget -- subscribes the MCP host to the current tool call useEffect(() => { - let cancelled = false; - void latest.current - .toolInfo({ - environmentId: props.environmentId, - input: { threadId: props.threadId, itemId: props.itemId, name: app.tool }, - }) - .then((info) => { - if (!cancelled && info._tag === "Success" && info.value.tool !== undefined) { - setToolDefinition(info.value.tool); - } - }); - return () => { - cancelled = true; - }; - }, [props.environmentId, props.threadId, props.itemId, app.tool]); - const webView = useRef>(null); - const hostRef = useRef(null); + if (host === null) return; + return appAtomRegistry.subscribe( + detailAtom, + (result) => { + const call = AsyncResult.isSuccess(result) ? storedToolCall(result.value.item) : undefined; + if (call !== undefined) host.setToolCall(call); + }, + { immediate: true }, + ); + }, [host, detailAtom]); const userActive = useRef(false); - useEffect(() => { - if (uri === null) return; - const scope = () => { - const { environmentId, threadId, itemId } = latest.current.props; - return { environmentId, input: { threadId, itemId } }; - }; - const hostContext = (): McpAppHostContext => { - const current = latest.current; - const isFullscreen = current.props.displayMode === "fullscreen"; - return { - theme: current.theme.appearance, - styles: { variables: mcpAppStyleVariables(current.theme.variables) }, - displayMode: isFullscreen ? "fullscreen" : "inline", - availableDisplayModes: ["inline", "fullscreen"], - containerDimensions: isFullscreen - ? { - width: current.props.width, - height: current.props.height ?? MCP_APP_INLINE_MAX_HEIGHT, - } - : { width: current.props.width, maxHeight: MCP_APP_INLINE_MAX_HEIGHT }, - platform: "mobile", - locale: Intl.DateTimeFormat().resolvedOptions().locale, - timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, - userAgent: `t3-code/${Constants.expoConfig?.version ?? "0.0.0"}`, - deviceCapabilities: { touch: true, hover: false }, - safeAreaInsets: isFullscreen - ? { top: 0, right: current.insets.right, bottom: 0, left: current.insets.left } - : { top: 0, right: 0, bottom: 0, left: 0 }, - ...(current.toolDefinition === undefined - ? {} - : { toolInfo: { tool: current.toolDefinition } }), + // Each WebView document gets its own host: the view is keyed by document, + // restart generation and URL, and a changed app re-attaches this callback. + // Any other identity change would tear the host down, so per-render values + // are read through `latest`, never captured. + const attachWebView = useCallback( + (view: WebView | null) => { + if (view === null) return; + const reloadInline = () => { + setLoaded(false); + setHeight(MCP_APP_INLINE_MAX_HEIGHT); + setNavigatedAway(false); + const current = latest.current.asset; + if (current._tag === "Success") setUri(current.url); + setDocumentKeyState((value) => value + 1); }; - }; - const next = makeMcpAppHost({ - app, - hostVersion: Constants.expoConfig?.version ?? "0.0.0", - post: (message) => - webView.current?.injectJavaScript( - `window.__t3McpAppReceive&&window.__t3McpAppReceive(${JSON.stringify(message)});true;`, - ), - hostContext, - callTool: async ({ name, arguments: args }) => { - const { environmentId, input } = scope(); - const info = await latest.current.toolInfo({ environmentId, input: { ...input, name } }); - if (info._tag !== "Success") throw commandFailure(info); - if (!info.value.callable) throw new McpAppHostRefusal("This app cannot call that tool."); - if ( - !info.value.readOnly && - !(await confirm( - `Allow ${app.server} to run ${info.value.title ?? name}?`, - JSON.stringify(args, null, 2), - "Allow", - )) - ) { - throw new McpAppHostRefusal("Declined by the user."); - } - const result = await latest.current.callTool({ - environmentId, - input: { ...input, name, arguments: args }, - }); - if (result._tag !== "Success") throw commandFailure(result); - return result.value; - }, - readResource: async ({ uri: resourceUri }) => { - const { environmentId, input } = scope(); - const result = await latest.current.readResource({ - environmentId, - input: { ...input, uri: resourceUri }, - }); - if (result._tag !== "Success") throw commandFailure(result); - return result.value; - }, - openLink: async (url) => { - // WebViews cannot prove user activation; confirm links to prevent timer-driven navigation out of T3. - if (!(await confirm(`Open a link from ${app.server}?`, url, "Open"))) { - throw new McpAppHostRefusal("Declined by the user."); - } - if (!(await tryOpenExternalUrl(url, "mcp-app"))) { - throw new McpAppHostRefusal("The link could not be opened."); - } - }, - sendMessage: async (text) => { - if (!(await confirm(`Send this message from ${app.server}?`, text, "Send"))) { - throw new McpAppHostRefusal("Declined by the user."); - } - await enqueueThreadOutboxMessage({ - environmentId: latest.current.props.environmentId, - threadId: latest.current.props.conversationThreadId, - messageId: MessageId.make(uuidv4()), - commandId: CommandId.make(uuidv4()), - text, - attachments: [], - dispatchMode: "queue", - createdAt: new Date().toISOString(), - }); - }, - updateModelContext: async (context) => { - const { environmentId, input } = scope(); - const result = await latest.current.updateModelContext({ - environmentId, - input: { - ...input, - ...context, - conversationThreadId: latest.current.props.conversationThreadId, - }, - }); - if (result._tag !== "Success") throw commandFailure(result); - }, - requestDisplayMode: async (mode) => { - const current = latest.current.props; - // receive dispatches synchronously; read activation before awaiting teardown. - if ( - mode === "fullscreen" && - (!userActive.current || - latest.current.awaitingUser || - !latest.current.navigation.isFocused()) - ) { - return current.displayMode ?? "inline"; - } - if (mode === "fullscreen" && current.displayMode !== "fullscreen") { - await hostRef.current?.teardown(); - // Approval arrival or navigation during the wait requires reloading inline. - if (latest.current.awaitingUser || !latest.current.navigation.isFocused()) { - openNewDocument.current((value) => value + 1); - return "inline"; + const scope = () => { + const { environmentId, threadId, itemId } = latest.current.props; + return { environmentId, input: { threadId, itemId } }; + }; + const hostContext = (): McpAppHostContext => { + const current = latest.current; + const isFullscreen = current.props.displayMode === "fullscreen"; + return { + theme: current.theme.appearance, + styles: { variables: mcpAppStyleVariables(current.theme.variables) }, + displayMode: isFullscreen ? "fullscreen" : "inline", + availableDisplayModes: ["inline", "fullscreen"], + containerDimensions: isFullscreen + ? { + width: current.props.width, + height: current.props.height ?? MCP_APP_INLINE_MAX_HEIGHT, + } + : { width: current.props.width, maxHeight: MCP_APP_INLINE_MAX_HEIGHT }, + platform: "mobile", + locale: Intl.DateTimeFormat().resolvedOptions().locale, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + userAgent: `t3-code/${Constants.expoConfig?.version ?? "0.0.0"}`, + deviceCapabilities: { touch: true, hover: false }, + safeAreaInsets: isFullscreen + ? { top: 0, right: current.insets.right, bottom: 0, left: current.insets.left } + : { top: 0, right: 0, bottom: 0, left: 0 }, + ...(current.toolDefinition === undefined + ? {} + : { toolInfo: { tool: current.toolDefinition } }), + }; + }; + const documentHost: McpAppHost = makeMcpAppHost({ + app, + hostVersion: Constants.expoConfig?.version ?? "0.0.0", + post: (message) => + view.injectJavaScript( + `window.__t3McpAppReceive&&window.__t3McpAppReceive(${JSON.stringify(message)});true;`, + ), + hostContext, + callTool: async ({ name, arguments: args }) => { + const { environmentId, input } = scope(); + const info = await latest.current.toolInfo({ environmentId, input: { ...input, name } }); + if (info._tag !== "Success") throw commandFailure(info); + if (!info.value.callable) throw new McpAppHostRefusal("This app cannot call that tool."); + if ( + !info.value.readOnly && + !(await confirm( + `Allow ${app.server} to run ${info.value.title ?? name}?`, + JSON.stringify(args, null, 2), + "Allow", + )) + ) { + throw new McpAppHostRefusal("Declined by the user."); } - setPresentedFullscreen(true); - latest.current.navigation.navigate("ThreadMcpApp", { - environmentId: String(current.environmentId), - threadId: String(current.threadId), - conversationThreadId: String(current.conversationThreadId), - itemId: String(current.itemId), - revision: current.revision, + const result = await latest.current.callTool({ + environmentId, + input: { ...input, name, arguments: args }, }); - return "fullscreen"; - } - if (mode === "inline" && current.displayMode === "fullscreen") { - await hostRef.current?.teardown(); - current.onExitFullscreen?.(); - return "inline"; - } - return current.displayMode ?? "inline"; - }, - downloadFile: async (files) => { - const names = files.map((file) => file.name).join(", "); - if (!(await confirm(`Save a file from ${app.server}?`, names, "Save"))) { - throw new McpAppHostRefusal("Declined by the user."); - } - for (const file of files) { - let bytes: Uint8Array | undefined; - let mimeType = file.mimeType ?? "application/octet-stream"; - if (file._tag === "embedded") { - bytes = file.bytes; - } else { - const { environmentId, input } = scope(); - const read = await latest.current.readResource({ - environmentId, - input: { ...input, uri: file.uri }, - }); - if (read._tag !== "Success") throw commandFailure(read); - const content = read.value.contents[0]; - bytes = mcpResourceBytes(content); - const declared = (content as { readonly mimeType?: unknown } | undefined)?.mimeType; - if (typeof declared === "string") mimeType = declared; + if (result._tag !== "Success") throw commandFailure(result); + return result.value; + }, + readResource: async ({ uri: resourceUri }) => { + const { environmentId, input } = scope(); + const result = await latest.current.readResource({ + environmentId, + input: { ...input, uri: resourceUri }, + }); + if (result._tag !== "Success") throw commandFailure(result); + return result.value; + }, + openLink: async (url) => { + // WebViews cannot prove user activation; confirm links to prevent timer-driven navigation out of T3. + if (!(await confirm(`Open a link from ${app.server}?`, url, "Open"))) { + throw new McpAppHostRefusal("Declined by the user."); } - if (bytes === undefined) throw new McpAppHostRefusal(`${file.name} has no contents.`); - if (bytes.byteLength > MAX_DOWNLOAD_BYTES) { - throw new McpAppHostRefusal(`${file.name} is too large to save.`); + if (!(await tryOpenExternalUrl(url, "mcp-app"))) { + throw new McpAppHostRefusal("The link could not be opened."); } - const shared = await shareGeneratedAttachment({ - bytes, - attachment: { name: file.name, mimeType }, - signal: new AbortController().signal, + }, + sendMessage: async (text) => { + if (!(await confirm(`Send this message from ${app.server}?`, text, "Send"))) { + throw new McpAppHostRefusal("Declined by the user."); + } + await enqueueThreadOutboxMessage({ + environmentId: latest.current.props.environmentId, + threadId: latest.current.props.conversationThreadId, + messageId: MessageId.make(uuidv4()), + commandId: CommandId.make(uuidv4()), + text, + attachments: [], + dispatchMode: "queue", + createdAt: new Date().toISOString(), }); - if (!shared) throw new McpAppHostRefusal("Sharing is not available on this device."); - } - }, - onRequestTeardown: () => { - const current = latest.current.props; - if (current.displayMode === "fullscreen") { - void hostRef.current?.teardown().then(() => current.onExitFullscreen?.()); - } else { - void hostRef.current?.teardown().then(() => setClosed(true)); - } - }, - onSizeChanged: (size) => { - if (latest.current.props.displayMode === "fullscreen") return; - const nextHeight = mcpAppInlineHeight(size.height); - if (nextHeight !== undefined) setHeight(nextHeight); - }, - }); - hostRef.current = next; - return () => { - // An unmount cannot wait: the request goes out before the view does. - void next.teardown(); - hostRef.current = null; - }; - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A restarted view needs a new host. - }, [uri, app, generation, documentKey]); - - useEffect(() => { - hostRef.current?.updateHostContext(); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Context changes trigger a resend. - }, [theme, props.width, props.height, insets, toolDefinition]); - useEffect(() => { - if (toolCall !== undefined) hostRef.current?.setToolCall(toolCall); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each new document needs the call. - }, [toolCall, uri, generation, documentKey]); + }, + updateModelContext: async (context) => { + const { environmentId, input } = scope(); + const result = await latest.current.updateModelContext({ + environmentId, + input: { + ...input, + ...context, + conversationThreadId: latest.current.props.conversationThreadId, + }, + }); + if (result._tag !== "Success") throw commandFailure(result); + }, + requestDisplayMode: async (mode) => { + const current = latest.current.props; + // receive dispatches synchronously; read activation before awaiting teardown. + if ( + mode === "fullscreen" && + (!userActive.current || + latest.current.awaitingUser || + !latest.current.navigation.isFocused()) + ) { + return current.displayMode ?? "inline"; + } + if (mode === "fullscreen" && current.displayMode !== "fullscreen") { + await documentHost.teardown(); + // Approval arrival or navigation during the wait requires reloading inline. + const navigation = latest.current.navigation; + if (latest.current.awaitingUser || !navigation.isFocused()) { + reloadInline(); + return "inline"; + } + setPresentedFullscreen(true); + navigation.navigate("ThreadMcpApp", { + environmentId: String(current.environmentId), + threadId: String(current.threadId), + conversationThreadId: String(current.conversationThreadId), + itemId: String(current.itemId), + revision: current.revision, + }); + return "fullscreen"; + } + if (mode === "inline" && current.displayMode === "fullscreen") { + await documentHost.teardown(); + current.onExitFullscreen?.(); + return "inline"; + } + return current.displayMode ?? "inline"; + }, + downloadFile: async (files) => { + const names = files.map((file) => file.name).join(", "); + if (!(await confirm(`Save a file from ${app.server}?`, names, "Save"))) { + throw new McpAppHostRefusal("Declined by the user."); + } + for (const file of files) { + let bytes: Uint8Array | undefined; + let mimeType = file.mimeType ?? "application/octet-stream"; + if (file._tag === "embedded") { + bytes = file.bytes; + } else { + const { environmentId, input } = scope(); + const read = await latest.current.readResource({ + environmentId, + input: { ...input, uri: file.uri }, + }); + if (read._tag !== "Success") throw commandFailure(read); + const content = read.value.contents[0]; + bytes = mcpResourceBytes(content); + const declared = (content as { readonly mimeType?: unknown } | undefined)?.mimeType; + if (typeof declared === "string") mimeType = declared; + } + if (bytes === undefined) throw new McpAppHostRefusal(`${file.name} has no contents.`); + if (bytes.byteLength > MAX_DOWNLOAD_BYTES) { + throw new McpAppHostRefusal(`${file.name} is too large to save.`); + } + const shared = await shareGeneratedAttachment({ + bytes, + attachment: { name: file.name, mimeType }, + signal: new AbortController().signal, + }); + if (!shared) throw new McpAppHostRefusal("Sharing is not available on this device."); + } + }, + onRequestTeardown: () => { + const current = latest.current.props; + if (current.displayMode === "fullscreen") { + void documentHost.teardown().then(() => current.onExitFullscreen?.()); + } else { + void documentHost.teardown().then(() => setClosed(true)); + } + }, + onSizeChanged: (size) => { + if (latest.current.props.displayMode === "fullscreen") return; + const nextHeight = mcpAppInlineHeight(size.height); + if (nextHeight !== undefined) setHeight(nextHeight); + }, + }); + setHost(documentHost); + return () => { + // An unmount cannot wait: the request goes out before the view does. + void documentHost.teardown(); + setHost((current) => (current === documentHost ? null : current)); + }; + }, + [app], + ); const source = useMemo( () => @@ -486,7 +517,7 @@ export function ThreadMcpApp(props: { label="Show app" tone="secondary" onPress={() => { - setDocumentKey((value) => value + 1); + reopenDocument(); setClosed(false); }} /> @@ -505,8 +536,8 @@ export function ThreadMcpApp(props: { ) : uri !== null && !crashed ? ( - key={`${documentKey}:${generation}`} - ref={webView} + key={`${documentKey}:${generation}:${uri}`} + ref={attachWebView} onContentProcessDidTerminate={restart} onRenderProcessGone={restart} source={source!} @@ -534,12 +565,12 @@ export function ThreadMcpApp(props: { if (!Predicate.isObject(envelope) || envelope.secret !== secret) return; const message = envelope.message; if (Predicate.isObject(message) && message.t3 === "navigated") { - hostRef.current?.dispose(); + host?.dispose(); setNavigatedAway(true); return; } userActive.current = envelope.active === true; - hostRef.current?.receive(message); + host?.receive(message); userActive.current = false; }} /> diff --git a/apps/mobile/src/state/device.ts b/apps/mobile/src/state/device.ts index 9183f8effdfb4..4364053207e4f 100644 --- a/apps/mobile/src/state/device.ts +++ b/apps/mobile/src/state/device.ts @@ -7,32 +7,32 @@ import { Atom } from "effect/reactivity"; import { useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; -import { appAtomRegistry } from "./atom-registry"; import { useEnvironmentQuery } from "./query"; import { environmentSession, usePreparedConnection } from "./session"; export const deviceEnvironment = createDeviceEnvironmentAtoms(connectionAtomRuntime); +// Hub tickets are single use, so each viewer resolves its own access: a new +// viewer (a reload, a device switch, a return to the screen) never reuses a +// ticket an earlier connection already spent. const deviceHubAccessAtom = Atom.family((environmentId: EnvironmentId) => - connectionAtomRuntime - .atom((get) => { - const prepared = Option.getOrNull( - get(environmentSession.preparedConnectionValueAtom(environmentId)), - ); - return prepared === null - ? Effect.never - : resolveDeviceHubAccess({ prepared, hubBasePath: "/api/device-hub" }); - }) - .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`mobile-device-hub-access:${environmentId}`)), + Atom.family((_viewer: string) => + connectionAtomRuntime + .atom((get) => { + const prepared = Option.getOrNull( + get(environmentSession.preparedConnectionValueAtom(environmentId)), + ); + return prepared === null + ? Effect.never + : resolveDeviceHubAccess({ prepared, hubBasePath: "/api/device-hub" }); + }) + .pipe(Atom.withLabel(`mobile-device-hub-access:${environmentId}`)), + ), ); -export function refreshDeviceHubAccess(environmentId: EnvironmentId) { - appAtomRegistry.refresh(deviceHubAccessAtom(environmentId)); -} - -export function useDeviceHubAccess(environmentId: EnvironmentId, hostId: string) { +export function useDeviceHubAccess(environmentId: EnvironmentId, hostId: string, viewer: string) { const prepared = usePreparedConnection(environmentId); - const query = useEnvironmentQuery(deviceHubAccessAtom(environmentId)); + const query = useEnvironmentQuery(deviceHubAccessAtom(environmentId)(viewer)); const access = useMemo( () => query.data && query.error === null && Option.isSome(prepared) diff --git a/apps/mobile/src/state/preview.ts b/apps/mobile/src/state/preview.ts index f1a6f77298cd6..c3afad5f34c2c 100644 --- a/apps/mobile/src/state/preview.ts +++ b/apps/mobile/src/state/preview.ts @@ -16,6 +16,7 @@ import { AsyncResult, Atom } from "effect/reactivity"; import { useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "./atom-registry"; import { useEnvironmentQuery } from "./query"; import { environmentSession, usePreparedConnection } from "./session"; @@ -142,22 +143,40 @@ export function useThreadServerBrowserTabs(input: { return { tabs: sessions, loaded: tabs.listed }; } +// The earliest time the next access request may go out. +const previewStreamNotBeforeAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make(0).pipe(Atom.withLabel(`mobile-preview-stream-retry:${environmentId}`)), +); + const previewStreamAccessAtom = Atom.family((environmentId: EnvironmentId) => connectionAtomRuntime .atom((get) => { const prepared = Option.getOrNull( get(environmentSession.preparedConnectionValueAtom(environmentId)), ); - return prepared === null - ? Effect.never - : resolveDeviceHubAccess({ prepared, hubBasePath: PREVIEW_STREAM_BASE_PATH }); + const notBefore = get(previewStreamNotBeforeAtom(environmentId)); + if (prepared === null) return Effect.never; + const resolve = resolveDeviceHubAccess({ prepared, hubBasePath: PREVIEW_STREAM_BASE_PATH }); + // A recompute interrupts the wait, so only the newest retry resolves. + const wait = notBefore - Date.now(); + return wait > 0 ? Effect.andThen(Effect.sleep(wait), resolve) : resolve; }) .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`mobile-preview-stream-access:${environmentId}`)), ); +/** Resolves fresh stream access once `delayMs` has passed. */ +function retryPreviewStreamAccess(environmentId: EnvironmentId, delayMs: number) { + appAtomRegistry.set(previewStreamNotBeforeAtom(environmentId), Date.now() + delayMs); +} + export function usePreviewStreamAccess(environmentId: EnvironmentId) { const prepared = usePreparedConnection(environmentId); const query = useEnvironmentQuery(previewStreamAccessAtom(environmentId)); const access = query.data && query.error === null && Option.isSome(prepared) ? query.data : null; - return { access, error: query.error, refresh: query.refresh }; + return { + access, + error: query.error, + refresh: query.refresh, + retryAfter: (delayMs: number) => retryPreviewStreamAccess(environmentId, delayMs), + }; } diff --git a/packages/client-runtime/src/mcpApps/host.test.ts b/packages/client-runtime/src/mcpApps/host.test.ts index 8fb9ce2b80c9f..8588770715814 100644 --- a/packages/client-runtime/src/mcpApps/host.test.ts +++ b/packages/client-runtime/src/mcpApps/host.test.ts @@ -130,6 +130,33 @@ describe("makeMcpAppHost", () => { expect(sent.find((m) => m.id === 2)?.result).toEqual({ content: [] }); }); + it("sends a later result for a call that had none, without repeating its input", () => { + const { host, sent } = setup(); + initialize(host); + sent.length = 0; + host.setToolCall({ arguments: { city: "Oslo" }, result: undefined }); + host.setToolCall({ + arguments: { city: "Oslo" }, + result: { content: [{ type: "text", text: "Sunny" }] }, + }); + host.setToolCall({ + arguments: { city: "Bergen" }, + result: { content: [{ type: "text", text: "Rain" }] }, + }); + expect(sent).toEqual([ + { + jsonrpc: "2.0", + method: "ui/notifications/tool-input", + params: { arguments: { city: "Oslo" } }, + }, + { + jsonrpc: "2.0", + method: "ui/notifications/tool-result", + params: { content: [{ type: "text", text: "Sunny" }] }, + }, + ]); + }); + it("accepts ui/message as one text block or the SDK's block array", async () => { const messages: Array = []; const { host, sent } = setup({ sendMessage: async (text) => void messages.push(text) }); diff --git a/packages/client-runtime/src/mcpApps/host.ts b/packages/client-runtime/src/mcpApps/host.ts index ed59c9770d7bc..5eee10af4338a 100644 --- a/packages/client-runtime/src/mcpApps/host.ts +++ b/packages/client-runtime/src/mcpApps/host.ts @@ -405,8 +405,16 @@ export function makeMcpAppHost(options: McpAppHostOptions): McpAppHost { else handleNotification(method, params); }, setToolCall: (call) => { - toolCall ??= call; - sendToolCall(); + // A running call has no result yet; only its completion may replace it. + if (toolCall === undefined || !toolCallSent) { + if (toolCall?.result === undefined) toolCall = call; + sendToolCall(); + return; + } + if (toolCall.result !== undefined || call.result === undefined) return; + // The app already has this call's input; it only needs the result. + toolCall = { arguments: toolCall.arguments, result: call.result }; + notify("ui/notifications/tool-result", normalizeMcpAppToolResult(call.result)); }, updateHostContext: () => { // The spec forbids messages before the app finishes initializing; it