From 6a776e5851291241e61b79a9dd71b7bfcc014dd3 Mon Sep 17 00:00:00 2001 From: Iliya Lyan <68940374+12ya@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:11:21 +0900 Subject: [PATCH 1/6] feat(web,mobile): take over a device an idle thread still holds A thread keeps its device session after its run ends, so its agent-device claim refuses other threads' agents with DEVICE_IN_USE and names only a session hash. Nothing showed which thread held the device or offered a way to release it. The Device panel and the mobile device viewer now name other threads that have the same device open, linked to the thread. When that thread isn't running, Take over closes the device there through the existing device.close request, which releases its agent-device claim. --- .../devices/DevicePreviewRouteScreen.tsx | 84 ++++++++++++++++++- .../device/DevicePanel.browser.test.tsx | 64 ++++++++++++++ .../web/src/components/device/DevicePanel.tsx | 66 +++++++++++++++ docs/user/devices.md | 5 ++ packages/client-runtime/src/state/device.ts | 20 ++++- 5 files changed, 236 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 1a380949e14fb..5f0d9ed8b41b2 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -1,13 +1,21 @@ 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 { + 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"; @@ -35,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"; @@ -89,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(); @@ -104,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); @@ -149,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( @@ -281,6 +318,9 @@ export function DevicePreviewScreen({ ]} /> + {holderBanners.length > 0 ? ( + {holderBanners} + ) : null} ) : null} {preview && focused && foreground ? ( @@ -348,6 +388,7 @@ export function DevicePreviewScreen({ /> + {holderBanners.length > 0 ? {holderBanners} : null} ) : ( @@ -373,6 +414,45 @@ 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); + const [takingOver, setTakingOver] = useState(false); + const title = shell?.title.trim() || "another thread"; + return ( + + + + Busy in {title} + {running ? ", which is running." : ", which isn't running."} + + + {running ? null : ( + + )} + + ); +} + 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."; From 1b15169ccdebdf02c4045d00a7158428ed35dca2 Mon Sep 17 00:00:00 2001 From: Iliya Lyan <68940374+12ya@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:44:44 +0900 Subject: [PATCH 2/6] fix(server): only take a device over from a thread with no work in flight Take over relied on the client's view of the holding thread. Clients omit older settled threads from their shells, and a thread can start a run between render and click, so a stale or missing shell could release a session a working agent was using. `device.close` now accepts `takeOver: { openedAt }`. Under the device lifecycle lock the server closes only the session opened at that time, and only when the holder has no active run or pending background task; otherwise it refuses with DeviceTakeoverRefusedError. A takeover never powers the device off. Web and mobile send the session they showed and no longer claim "isn't running" for a thread they cannot see. --- .../devices/DevicePreviewRouteScreen.tsx | 9 ++- apps/server/src/device/DeviceService.test.ts | 75 +++++++++++++++++++ apps/server/src/device/DeviceService.ts | 31 ++++++-- .../server/src/device/DeviceThreadActivity.ts | 33 ++++++++ apps/server/src/server.ts | 2 + .../device/DevicePanel.browser.test.tsx | 7 +- .../web/src/components/device/DevicePanel.tsx | 9 ++- docs/internals/devices.md | 2 + packages/contracts/src/device.ts | 21 ++++++ 9 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/device/DeviceThreadActivity.ts diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 5f0d9ed8b41b2..1a6e6955d6cd0 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -166,7 +166,12 @@ export function DevicePreviewScreen({ const takeOver = async (holder: DeviceSession) => { const result = await close({ environmentId, - input: { threadId: holder.threadId, hostId: holder.hostId, deviceId: holder.deviceId }, + input: { + threadId: holder.threadId, + hostId: holder.hostId, + deviceId: holder.deviceId, + takeOver: { openedAt: holder.openedAt }, + }, }); if (result._tag === "Failure") { Dialog.alert("Could not take over device", String(Cause.squash(result.cause))); @@ -434,7 +439,7 @@ function DeviceHolderBanner(props: { > Busy in {title} - {running ? ", which is running." : ", which isn't running."} + {running ? ", which is running." : shell ? ", which isn't running." : "."} {running ? null : ( diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 64054630f0d9c..755e5aee40dc4 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -40,6 +40,7 @@ import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; import * as DeviceService from "./DeviceService.ts"; +import * as DeviceThreadActivity from "./DeviceThreadActivity.ts"; import * as SshDeviceHost from "./SshDeviceHost.ts"; import { agentDeviceSession, releaseAgentDeviceSession } from "./AgentDeviceTarget.ts"; @@ -1792,6 +1793,80 @@ describe("agent session release on close", () => { }), ); + const withActivity = (active: ReadonlySet) => + Effect.provideService( + DeviceThreadActivity.DeviceThreadActivity, + DeviceThreadActivity.DeviceThreadActivity.of({ + isActive: (threadId) => Effect.succeed(active.has(threadId)), + }), + ); + + it.effect("a takeover leaves a busy, unconfirmed, or replaced session claimed", () => + Effect.gen(function* () { + const holder = ThreadId.make("holder"); + const takeOver = ( + service: DeviceService.DeviceService["Service"], + session: { readonly deviceId: string; readonly openedAt: string }, + ) => + service.close({ + threadId: holder, + deviceId: session.deviceId, + shutdown: true, + takeOver: { openedAt: session.openedAt }, + }); + + const busy = yield* fixture().pipe(withActivity(new Set([holder]))); + yield* busy.service.configure({ enabled: true, agentAccessEnabled: true }); + const busySession = yield* openClaim(busy.service, holder); + expect(yield* takeOver(busy.service, busySession).pipe(Effect.flip)).toMatchObject({ + _tag: "DeviceTakeoverRefusedError", + reason: "thread_active", + }); + yield* takeOver(busy.service, { ...busySession, openedAt: "2000-01-01T00:00:00.000Z" }); + + // Without a thread activity source the server cannot vouch that the holder is idle. + const unknown = yield* fixture(); + yield* unknown.service.configure({ enabled: true, agentAccessEnabled: true }); + const unknownSession = yield* openClaim(unknown.service, holder); + expect(yield* takeOver(unknown.service, unknownSession).pipe(Effect.flip)).toMatchObject({ + _tag: "DeviceTakeoverRefusedError", + reason: "activity_unknown", + }); + + for (const { service, closedSessions, requests } of [busy, unknown]) { + expect(closedSessions).toEqual([]); + expect((yield* service.state).sessions.map((entry) => entry.threadId)).toEqual([holder]); + expect(requests.some((url) => url.endsWith("/shutdown"))).toBe(false); + } + }), + ); + + it.effect("a takeover releases an idle holder's exact session without powering off", () => + Effect.gen(function* () { + const { service, closedSessions, requests } = yield* fixture().pipe(withActivity(new Set())); + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + const holder = ThreadId.make("holder"); + const taker = ThreadId.make("taker"); + const session = yield* openClaim(service, holder); + yield* openClaim(service, taker, session.deviceId); + + yield* service.close({ + threadId: holder, + deviceId: session.deviceId, + shutdown: true, + takeOver: { openedAt: session.openedAt }, + }); + + const name = yield* agentDeviceSession(holder, session.hostId, session.deviceId).pipe( + Effect.provide(NodeCrypto.layer), + ); + expect(closedSessions).toMatchObject([{ params: { command: "close", session: name } }]); + const state = yield* service.state; + expect(state.sessions.map((entry) => entry.threadId)).toEqual([taker]); + expect(requests.some((url) => url.endsWith("/shutdown"))).toBe(false); + }), + ); + it.effect.each(["reject", "http_error", "malformed"] as const)( "retains retryable state on %s", (failure) => diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 790daabe7a445..bd5607edc78b1 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -23,6 +23,7 @@ import { type DeviceSimlockIdentity, DeviceSimlockIdentityError, type DeviceSimlockIdentityInput, + DeviceTakeoverRefusedError, type DeviceHostConfig, type EasDeviceHostConfig, type SimlockDeviceHostConfig, @@ -72,6 +73,7 @@ import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretReso import * as SecretResolverLayer from "../secrets/SecretResolver.ts"; import { requireSettingsSecret } from "../secrets/settingsSecretValue.ts"; import * as DeviceHost from "./DeviceHost.ts"; +import * as DeviceThreadActivity from "./DeviceThreadActivity.ts"; import * as SshDeviceHost from "./SshDeviceHost.ts"; import * as EasDeviceHost from "./EasDeviceHost.ts"; import * as SimlockDeviceHost from "./SimlockDeviceHost.ts"; @@ -279,6 +281,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ) { const settings = yield* ServerSettings.ServerSettingsService; const digest = yield* Digest.Digest; + const threadActivity = yield* Effect.serviceOption(DeviceThreadActivity.DeviceThreadActivity); const lifecycleLock = yield* Semaphore.make(1); const readDeviceSettings = settings.getSettings.pipe( Effect.map((value) => ({ @@ -896,8 +899,22 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* })); }); + const requireIdleHolder = (threadId: ThreadId) => { + const refuse = (reason: DeviceTakeoverRefusedError["reason"]) => + new DeviceTakeoverRefusedError({ threadId, reason }); + return Option.match(threadActivity, { + onNone: () => Effect.fail(refuse("activity_unknown")), + onSome: (activity) => + activity.isActive(threadId).pipe( + Effect.mapError(() => refuse("activity_unknown")), + Effect.flatMap((active) => (active ? Effect.fail(refuse("thread_active")) : Effect.void)), + ), + }); + }; + const close: DeviceService["Service"]["close"] = Effect.fn("DeviceService.close")( function* (input) { + const poweringOff = input.shutdown === true && input.takeOver === undefined; // Open publishes under the same lock; an old close must not release a reopened claim. const closing = yield* lifecycleLock.withPermit( Effect.gen(function* () { @@ -906,9 +923,13 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (session) => session.threadId === input.threadId && (input.hostId === undefined || session.hostId === input.hostId) && - (input.deviceId === undefined || session.deviceId === input.deviceId), + (input.deviceId === undefined || session.deviceId === input.deviceId) && + (input.takeOver === undefined || session.openedAt === input.takeOver.openedAt), ); - const ending = input.shutdown + if (input.takeOver !== undefined && closing.length > 0) { + yield* requireIdleHolder(input.threadId); + } + const ending = poweringOff ? state.sessions.filter((session) => closing.some( (closed) => @@ -935,8 +956,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* yield* ready.releaseAgentSession(name, session.deviceId); } } - if (input.shutdown) yield* releaseLaunchers(ending); - if (input.shutdown) { + if (poweringOff) yield* releaseLaunchers(ending); + if (poweringOff) { for (const session of closing) { const ready = yield* currentReadiness(session.hostId); if (!ready) @@ -954,7 +975,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return closing; }), ); - if (input.shutdown) + if (poweringOff) yield* Effect.forEach( closing, (session) => diff --git a/apps/server/src/device/DeviceThreadActivity.ts b/apps/server/src/device/DeviceThreadActivity.ts new file mode 100644 index 0000000000000..6fac1ea0d822f --- /dev/null +++ b/apps/server/src/device/DeviceThreadActivity.ts @@ -0,0 +1,33 @@ +import type { ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; + +/** Whether a thread may still be driving its devices, so a takeover must leave them alone. */ +export class DeviceThreadActivity extends Context.Service< + DeviceThreadActivity, + { + readonly isActive: ( + threadId: ThreadId, + ) => Effect.Effect; + } +>()("t3/device/DeviceThreadActivity") {} + +export const layer = Layer.effect( + DeviceThreadActivity, + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + return DeviceThreadActivity.of({ + // Background commands and subagents outlive their run and can still run agent-device. + isActive: Effect.fn("DeviceThreadActivity.isActive")(function* (threadId: ThreadId) { + const shell = yield* projections.getThreadShell(threadId); + return ( + shell !== null && + (shell.activeRunId !== null || (shell.pendingBackgroundTasks?.length ?? 0) > 0) + ); + }), + }); + }), +); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index fe2740a8c7a2d..4c3695a41fe93 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -86,6 +86,7 @@ import * as PeerMcpClient from "./peer/PeerMcpClient.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as DeviceService from "./device/DeviceService.ts"; +import * as DeviceThreadActivity from "./device/DeviceThreadActivity.ts"; import * as DeviceHubProxy from "./device/DeviceHubProxy.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; @@ -501,6 +502,7 @@ const layerPreview = Layer.empty.pipe( ); const layerDevice = DeviceService.layer.pipe( + Layer.provide(DeviceThreadActivity.layer.pipe(Layer.provide(ProjectionStoreV2.layer))), Layer.provide(layerServerSettings), Layer.provide(ProcessRunner.layer), Layer.provide(NetService.layer), diff --git a/apps/web/src/components/device/DevicePanel.browser.test.tsx b/apps/web/src/components/device/DevicePanel.browser.test.tsx index 130361e538a8e..322e7aa06754b 100644 --- a/apps/web/src/components/device/DevicePanel.browser.test.tsx +++ b/apps/web/src/components/device/DevicePanel.browser.test.tsx @@ -244,7 +244,12 @@ describe("DevicePanel takeover", () => { [ { environmentId: threadA.environmentId, - input: { threadId: holder, hostId: "local", deviceId: "phone" }, + input: { + threadId: holder, + hostId: "local", + deviceId: "phone", + takeOver: { openedAt: session.openedAt }, + }, }, ], ]); diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index b465ff2aa797f..f2444cd1b657b 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -186,7 +186,12 @@ export function DevicePanel(props: { const settleOperation = startOperation(null); return close({ environmentId, - input: { threadId: holder.threadId, hostId: holder.hostId, deviceId: holder.deviceId }, + input: { + threadId: holder.threadId, + hostId: holder.hostId, + deviceId: holder.deviceId, + takeOver: { openedAt: holder.openedAt }, + }, }).then((result) => settleOperation(result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null), ); @@ -434,7 +439,7 @@ function DeviceHolderNotice(props: { title: "another thread", }} /> - {running ? ", which is running." : ", which isn't running."} + {running ? ", which is running." : shell ? ", which isn't running." : "."} {running ? null : ( - )} +
); } diff --git a/docs/user/devices.md b/docs/user/devices.md index 9dc02435dbf7e..ba0a479c02b56 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -80,9 +80,10 @@ viewer stops streaming and leaves the device available to the agent. Device activity also appears in the thread timeline. Only one thread's agent can drive a device at a time. When another thread also -has the device open, the Device panel and the mobile viewer name that thread; -select the name to go there. If that thread isn't running, choose **Take over** -to close the device there so this thread's agent can use it. +has the device open, the Device panel and the mobile viewer name that thread +and say whether it is still running; select the name to go there. Choose +**Take over** to close the device there so this thread's agent can use it. An +agent still working in that thread loses the device. Agents drive the device through the `agent-device` command line. T3 Code installs and starts it only after **Agents can use devices** is on. iOS From 813814496c812149d469d04a7cadd87c0d2dc000 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 04:21:01 +0000 Subject: [PATCH 5/6] fix(web,mobile): holder with pending background work reads as running --- .../mobile/src/features/devices/DevicePreviewRouteScreen.tsx | 5 ++++- apps/web/src/components/device/DevicePanel.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index cb7c80eac026a..2a4957460719e 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -9,6 +9,7 @@ import { otherThreadDeviceSessions, } from "@t3tools/client-runtime/state/device"; import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; +import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { StackActions, useIsFocused, @@ -420,7 +421,9 @@ function DeviceHolderBanner(props: { readonly onTakeOver: () => Promise; }) { const shell = useThreadShell(scopeThreadRef(props.environmentId, props.holder.threadId)); - const running = threadRuntimeIsActive(shell?.runtime); + const running = + threadRuntimeIsActive(shell?.runtime) || + backgroundWorkHoldsCompletion(shell?.pendingBackgroundTasks ?? []); const [takingOver, setTakingOver] = useState(false); const title = shell?.title.trim() || "another thread"; return ( diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 2696b47aa80a3..a44da04dc2670 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -2,6 +2,7 @@ import { DeviceHostUpdates } from "./DeviceHostUpdates"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { otherThreadDeviceSessions } from "@t3tools/client-runtime/state/device"; import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; +import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import type { DevicePlatform, DeviceServiceState, @@ -418,7 +419,9 @@ function DeviceHolderNotice(props: { readonly onTakeOver: () => Promise; }) { const shell = useThreadShell(scopeThreadRef(props.environmentId, props.holder.threadId)); - const running = threadRuntimeIsActive(shell?.runtime); + const running = + threadRuntimeIsActive(shell?.runtime) || + backgroundWorkHoldsCompletion(shell?.pendingBackgroundTasks ?? []); const [takingOver, setTakingOver] = useState(false); return (
Date: Tue, 10 Nov 2026 04:36:49 +0000 Subject: [PATCH 6/6] fix(web,mobile): name the holder thread in each Take over button --- apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx | 1 + apps/web/src/components/device/DevicePanel.browser.test.tsx | 4 ++-- apps/web/src/components/device/DevicePanel.tsx | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 2a4957460719e..a4314e41d164e 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -441,6 +441,7 @@ function DeviceHolderBanner(props: {