From caf8b5d798909780a7b197cb264e782888b919c1 Mon Sep 17 00:00:00 2001 From: Michel Liao <107891771+Michel-Liao@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:28:18 -0400 Subject: [PATCH] fix(mobile): wait for thread deep link hydration (#11502) --- .../features/threads/ThreadRouteScreen.tsx | 43 +++++++++-- .../threads/thread-route-hydration.test.ts | 74 +++++++++++++++++++ .../threads/thread-route-hydration.ts | 38 ++++++++++ apps/mobile/src/state/shell.ts | 27 +++++++ 4 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-route-hydration.test.ts create mode 100644 apps/mobile/src/features/threads/thread-route-hydration.ts diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 0942647c7a71f0c971520a98dd8025bcc1c8a4b0..f78553dbb7e989286ff99eee20c00b6f9da91958 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -25,6 +25,7 @@ import { import { Alert, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; +import { useEnvironmentShellState } from "../../state/shell"; import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key"; import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation"; import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draft"; @@ -88,6 +89,7 @@ import { ThreadInspectorContentStack, type ThreadInspectorMode, } from "./thread-inspector-content-stack"; +import { threadRouteIsHydrating } from "./thread-route-hydration"; interface ThreadInspectorSelection { readonly routeThreadIdentity: string | null; @@ -123,7 +125,11 @@ interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps { readonly renderInspector?: (headerInset: number) => ReactNode; } -function ThreadUnavailableScreen() { +/** Shows recovery only after the target route has reached a terminal unavailable state. */ +function ThreadUnavailableScreen(props: { + readonly actionLabel: string; + readonly onAction: () => void; +}) { return ( ); @@ -152,6 +160,9 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { const threadIdRaw = firstRouteParam(params.threadId); const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); + const routeEnvironmentShellState = useEnvironmentShellState(environmentId); + const { onReconnectEnvironment } = useRemoteConnections(); + const navigation = useNavigation(); const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); const routeThreadKey = @@ -177,16 +188,36 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { return ; } - const stillHydrating = - workspaceState.isLoadingConnections || - routeConnectionState === "connecting" || - routeConnectionState === "reconnecting"; + const stillHydrating = threadRouteIsHydrating({ + isLoadingConnections: workspaceState.isLoadingConnections, + connectionState: routeConnectionState, + shellStatus: routeEnvironmentShellState.status, + shellHasError: Option.isSome(routeEnvironmentShellState.error), + detailStatus: selectedThreadDetailState.status, + detailHasError: Option.isSome(selectedThreadDetailState.error), + }); if (stillHydrating) { return ; } - return ; + return ( + { + if (routeEnvironmentRuntime !== null) { + onReconnectEnvironment(environmentId); + return; + } + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironments" }, + }); + }} + /> + ); } function ThreadRouteContent( diff --git a/apps/mobile/src/features/threads/thread-route-hydration.test.ts b/apps/mobile/src/features/threads/thread-route-hydration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfea7a57840cba76fb55e601f124219ee68584d5 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-route-hydration.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { threadRouteIsHydrating } from "./thread-route-hydration"; + +const settled = { + isLoadingConnections: false, + connectionState: "connected" as const, + shellStatus: "live" as const, + shellHasError: false, + detailStatus: "live" as const, + detailHasError: false, +}; + +describe("threadRouteIsHydrating", () => { + it("waits for shell and thread detail hydration", () => { + expect(threadRouteIsHydrating({ ...settled, shellStatus: "synchronizing" })).toBe(true); + expect(threadRouteIsHydrating({ ...settled, shellStatus: "empty" })).toBe(true); + expect(threadRouteIsHydrating({ ...settled, detailStatus: "synchronizing" })).toBe(true); + expect(threadRouteIsHydrating({ ...settled, detailStatus: "empty" })).toBe(true); + }); + + it("stops waiting once an empty detail has an actionable outcome", () => { + expect( + threadRouteIsHydrating({ + ...settled, + shellStatus: "empty", + shellHasError: true, + detailStatus: "deleted", + }), + ).toBe(false); + expect( + threadRouteIsHydrating({ + ...settled, + detailStatus: "empty", + detailHasError: true, + }), + ).toBe(false); + expect( + threadRouteIsHydrating({ + ...settled, + connectionState: "available", + detailStatus: "empty", + }), + ).toBe(false); + expect(threadRouteIsHydrating({ ...settled, detailStatus: "deleted" })).toBe(false); + }); + + it("prioritizes terminal outcomes over unrelated hydration", () => { + expect( + threadRouteIsHydrating({ + ...settled, + connectionState: "reconnecting", + shellStatus: "synchronizing", + detailStatus: "deleted", + }), + ).toBe(false); + expect( + threadRouteIsHydrating({ + ...settled, + connectionState: "reconnecting", + shellStatus: "synchronizing", + detailHasError: true, + }), + ).toBe(false); + expect( + threadRouteIsHydrating({ + ...settled, + connectionState: "offline", + shellStatus: "synchronizing", + detailStatus: "synchronizing", + }), + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-route-hydration.ts b/apps/mobile/src/features/threads/thread-route-hydration.ts new file mode 100644 index 0000000000000000000000000000000000000000..95a86b7bcb4c7720a2f889cf148f326b428129e8 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-route-hydration.ts @@ -0,0 +1,38 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; + +/** + * Reports whether the route-local projections can still yield the requested + * thread. Explicit terminal outcomes win over unrelated synchronization. + */ +export function threadRouteIsHydrating(input: { + readonly isLoadingConnections: boolean; + readonly connectionState: EnvironmentConnectionPhase; + readonly shellStatus: EnvironmentShellStatus; + readonly shellHasError: boolean; + readonly detailStatus: EnvironmentThreadStatus; + readonly detailHasError: boolean; +}): boolean { + if (input.detailStatus === "deleted" || input.shellHasError || input.detailHasError) { + return false; + } + if (input.isLoadingConnections) { + return true; + } + if ( + input.connectionState === "available" || + input.connectionState === "offline" || + input.connectionState === "error" + ) { + return false; + } + return ( + input.connectionState === "connecting" || + input.connectionState === "reconnecting" || + input.shellStatus === "synchronizing" || + (input.connectionState === "connected" && input.shellStatus === "empty") || + input.detailStatus === "synchronizing" || + (input.connectionState === "connected" && input.detailStatus === "empty") + ); +} diff --git a/apps/mobile/src/state/shell.ts b/apps/mobile/src/state/shell.ts index e879dd25e2927178ff73ac7fb1bfe1e754ac50ea..e20209c7cd8154c16db039d7278a309f70e3c9ef 100644 --- a/apps/mobile/src/state/shell.ts +++ b/apps/mobile/src/state/shell.ts @@ -3,7 +3,12 @@ import { createEnvironmentShellSummaryAtom, createEnvironmentSnapshotAtom, createShellEnvironmentAtoms, + type EnvironmentShellState, } from "@t3tools/client-runtime/state/shell"; +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; @@ -15,3 +20,25 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, shellStateValueAtom: environmentShell.stateValueAtom, }); + +const EMPTY_ENVIRONMENT_SHELL_STATE_ATOM = Atom.make( + AsyncResult.success({ + snapshot: Option.none(), + status: "empty", + error: Option.none(), + }), +).pipe(Atom.withLabel("mobile-environment-shell:empty")); + +/** Reads one environment's shell projection without waiting on other environments. */ +export function useEnvironmentShellState(environmentId: EnvironmentId | null) { + const result = useAtomValue( + environmentId === null + ? EMPTY_ENVIRONMENT_SHELL_STATE_ATOM + : environmentShell.stateAtom(environmentId), + ); + return Option.getOrElse(AsyncResult.value(result), () => ({ + snapshot: Option.none(), + status: "empty" as const, + error: Option.none(), + })); +} -- 2.54.0