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