diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 994a04e677b24..a4314e41d164e 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -1,13 +1,22 @@ import { SheetCloseButton } from "../../components/ui/SheetHeader"; import { Button } from "../../components/ui/Button"; import * as Dialog from "../../components/ui/Dialog"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { deviceToolVersionLabels, deviceToolUpdateOwnership, deviceToolUpdatePolicy, + otherThreadDeviceSessions, } from "@t3tools/client-runtime/state/device"; -import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; +import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; +import { + StackActions, + useIsFocused, + useNavigation, + type StaticScreenProps, +} from "@react-navigation/native"; +import { EnvironmentId, ThreadId, type DeviceSession } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { useEffect, useRef, useState, type RefObject } from "react"; import * as Haptics from "expo-haptics"; @@ -34,6 +43,7 @@ import { deriveBottomControlInsets } from "../../lib/reserved-regions"; import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { uuidv4 } from "../../lib/uuid"; import { deviceEnvironment, useDeviceHubAccess } from "../../state/device"; +import { useThreadShell } from "../../state/entities"; import { useEnvironmentQuery } from "../../state/query"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -88,6 +98,7 @@ export function DevicePreviewScreen({ readonly initialDeviceKey?: string; }) { const embedded = onMaximize !== undefined; + const navigation = useNavigation(); const insets = useSafeAreaInsets(); // In a pane the stream sits below the pane's bar, which content otherwise slides under. const barOverlap = useBarOverlap(); @@ -103,10 +114,14 @@ export function DevicePreviewScreen({ const [controlsVisible, setControlsVisible] = useState(true); const retryHost = useAtomCommand(deviceEnvironment.list); const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false }); + const close = useAtomCommand(deviceEnvironment.close, { reportFailure: false }); const streamRef = useRef(null); const state = useEnvironmentQuery(deviceEnvironment.state({ environmentId, input: {} })); const previews = threadDevicePreviews(state.data, threadId); const preview = selectedThreadDevicePreview(previews, selectedKey); + const holders = preview + ? otherThreadDeviceSessions(state.data?.sessions ?? [], preview.session) + : []; const onInputConnected = async (connected: boolean) => { setInputConnected(connected); if (!connected) setControlsVisible(true); @@ -148,6 +163,29 @@ export function DevicePreviewScreen({ } }; + const takeOver = async (holder: DeviceSession) => { + const result = await close({ + environmentId, + input: { threadId: holder.threadId, hostId: holder.hostId, deviceId: holder.deviceId }, + }); + if (result._tag === "Failure") { + Dialog.alert("Could not take over device", String(Cause.squash(result.cause))); + } + }; + const holderBanners = holders.map((holder) => ( + + navigation.dispatch( + StackActions.replace("Thread", { environmentId, threadId: holder.threadId }), + ) + } + onTakeOver={() => takeOver(holder)} + /> + )); + const controls: AppBarMenuItem[] = [ ...(state.data?.hosts .filter( @@ -280,6 +318,9 @@ export function DevicePreviewScreen({ ]} /> + {holderBanners.length > 0 ? ( + {holderBanners} + ) : null} ) : null} {preview && focused && foreground ? ( @@ -347,6 +388,7 @@ export function DevicePreviewScreen({ /> + {holderBanners.length > 0 ? {holderBanners} : null} ) : ( @@ -372,6 +414,46 @@ export function DevicePreviewScreen({ ); } +function DeviceHolderBanner(props: { + readonly environmentId: EnvironmentId; + readonly holder: DeviceSession; + readonly onOpenThread: () => void; + readonly onTakeOver: () => Promise; +}) { + const shell = useThreadShell(scopeThreadRef(props.environmentId, props.holder.threadId)); + const running = + threadRuntimeIsActive(shell?.runtime) || + backgroundWorkHoldsCompletion(shell?.pendingBackgroundTasks ?? []); + const [takingOver, setTakingOver] = useState(false); + const title = shell?.title.trim() || "another thread"; + return ( + + + + Busy in {title} + {running ? ", which is running." : shell ? ", which isn't running." : "."} + + + + + ); +} + function DeviceNotice(props: { readonly onDismiss: () => void; readonly children: ReactNode }) { return (
, + session: Pick, +) { + return sessions.filter( + (other) => + other.threadId !== session.threadId && + other.hostId === session.hostId && + other.deviceId === session.deviceId, + ); +} + export const deviceToolUpdateOwnership = "This environment's T3 server chooses device tool versions for itself and its SSH hosts. Update that server to receive newer tool versions; updating only your browser or mobile app does not update a remote server.";