authorgravatar for 107891771+Michel-Liao@users.noreply.github.comMichel Liao <107891771+Michel-Liao@users.noreply.github.com> 2026-09-15 13:28:18-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 10:28:18-07:00
logcaf8b5d798909780a7b197cb264e782888b919c1
tree2955595699c6b622b32b61e77944c261bac9d3f1
parentd1790aa14639a0361c02fbc5c326d0aa76f95a83
signature Signed by PGP key B5690EEEBB952194

fix(mobile): wait for thread deep link hydration (#11502)


4 files changed, 176 insertions(+), 6 deletions(-)

apps/mobile/src/features/threads/ThreadRouteScreen.tsx+37-6
......@@ -25,6 +25,7 @@ import {
2525import { Alert, Platform, ScrollView, View } from "react-native";
2626import { useSafeAreaInsets } from "react-native-safe-area-context";
2727import { useWorkspaceState } from "../../state/workspace";
28import { useEnvironmentShellState } from "../../state/shell";
2829import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key";
2930import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation";
3031import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draft";
......@@ -88,6 +89,7 @@ import {
8889 ThreadInspectorContentStack,
8990 type ThreadInspectorMode,
9091} from "./thread-inspector-content-stack";
92import { threadRouteIsHydrating } from "./thread-route-hydration";
9193
9294interface ThreadInspectorSelection {
9395 readonly routeThreadIdentity: string | null;
......@@ -123,7 +125,11 @@ interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps {
123125 readonly renderInspector?: (headerInset: number) => ReactNode;
124126}
125127
126function ThreadUnavailableScreen() {
128/** Shows recovery only after the target route has reached a terminal unavailable state. */
129function ThreadUnavailableScreen(props: {
130 readonly actionLabel: string;
131 readonly onAction: () => void;
132}) {
127133 return (
128134 <ScrollView
129135 contentInsetAdjustmentBehavior="automatic"
......@@ -138,6 +144,8 @@ function ThreadUnavailableScreen() {
138144 <EmptyState
139145 title="Thread unavailable"
140146 detail="This thread is not available in the current mobile snapshot."
147 actionLabel={props.actionLabel}
148 onAction={props.onAction}
141149 />
142150 </ScrollView>
143151 );
......@@ -152,6 +160,9 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
152160 const threadIdRaw = firstRouteParam(params.threadId);
153161 const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
154162 const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);
163 const routeEnvironmentShellState = useEnvironmentShellState(environmentId);
164 const { onReconnectEnvironment } = useRemoteConnections();
165 const navigation = useNavigation();
155166 const routeConnectionState =
156167 routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);
157168 const routeThreadKey =
......@@ -177,16 +188,36 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
177188 return <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />;
178189 }
179190
180 const stillHydrating =
181 workspaceState.isLoadingConnections ||
182 routeConnectionState === "connecting" ||
183 routeConnectionState === "reconnecting";
191 const stillHydrating = threadRouteIsHydrating({
192 isLoadingConnections: workspaceState.isLoadingConnections,
193 connectionState: routeConnectionState,
194 shellStatus: routeEnvironmentShellState.status,
195 shellHasError: Option.isSome(routeEnvironmentShellState.error),
196 detailStatus: selectedThreadDetailState.status,
197 detailHasError: Option.isSome(selectedThreadDetailState.error),
198 });
184199
185200 if (stillHydrating) {
186201 return <OpeningThreadLoadingScreen />;
187202 }
188203
189 return <ThreadUnavailableScreen />;
204 return (
205 <ThreadUnavailableScreen
206 actionLabel={
207 routeEnvironmentRuntime === null ? "Manage environments" : "Reconnect environment"
208 }
209 onAction={() => {
210 if (routeEnvironmentRuntime !== null) {
211 onReconnectEnvironment(environmentId);
212 return;
213 }
214 navigation.navigate("SettingsSheet", {
215 screen: "SettingsContent",
216 params: { screen: "SettingsEnvironments" },
217 });
218 }}
219 />
220 );
190221}
191222
192223function ThreadRouteContent(
apps/mobile/src/features/threads/thread-route-hydration.test.ts created+74
......@@ -0,0 +1,74 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { threadRouteIsHydrating } from "./thread-route-hydration";
4
5const settled = {
6 isLoadingConnections: false,
7 connectionState: "connected" as const,
8 shellStatus: "live" as const,
9 shellHasError: false,
10 detailStatus: "live" as const,
11 detailHasError: false,
12};
13
14describe("threadRouteIsHydrating", () => {
15 it("waits for shell and thread detail hydration", () => {
16 expect(threadRouteIsHydrating({ ...settled, shellStatus: "synchronizing" })).toBe(true);
17 expect(threadRouteIsHydrating({ ...settled, shellStatus: "empty" })).toBe(true);
18 expect(threadRouteIsHydrating({ ...settled, detailStatus: "synchronizing" })).toBe(true);
19 expect(threadRouteIsHydrating({ ...settled, detailStatus: "empty" })).toBe(true);
20 });
21
22 it("stops waiting once an empty detail has an actionable outcome", () => {
23 expect(
24 threadRouteIsHydrating({
25 ...settled,
26 shellStatus: "empty",
27 shellHasError: true,
28 detailStatus: "deleted",
29 }),
30 ).toBe(false);
31 expect(
32 threadRouteIsHydrating({
33 ...settled,
34 detailStatus: "empty",
35 detailHasError: true,
36 }),
37 ).toBe(false);
38 expect(
39 threadRouteIsHydrating({
40 ...settled,
41 connectionState: "available",
42 detailStatus: "empty",
43 }),
44 ).toBe(false);
45 expect(threadRouteIsHydrating({ ...settled, detailStatus: "deleted" })).toBe(false);
46 });
47
48 it("prioritizes terminal outcomes over unrelated hydration", () => {
49 expect(
50 threadRouteIsHydrating({
51 ...settled,
52 connectionState: "reconnecting",
53 shellStatus: "synchronizing",
54 detailStatus: "deleted",
55 }),
56 ).toBe(false);
57 expect(
58 threadRouteIsHydrating({
59 ...settled,
60 connectionState: "reconnecting",
61 shellStatus: "synchronizing",
62 detailHasError: true,
63 }),
64 ).toBe(false);
65 expect(
66 threadRouteIsHydrating({
67 ...settled,
68 connectionState: "offline",
69 shellStatus: "synchronizing",
70 detailStatus: "synchronizing",
71 }),
72 ).toBe(false);
73 });
74});
apps/mobile/src/features/threads/thread-route-hydration.ts created+38
......@@ -0,0 +1,38 @@
1import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";
3import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
4
5/**
6 * Reports whether the route-local projections can still yield the requested
7 * thread. Explicit terminal outcomes win over unrelated synchronization.
8 */
9export function threadRouteIsHydrating(input: {
10 readonly isLoadingConnections: boolean;
11 readonly connectionState: EnvironmentConnectionPhase;
12 readonly shellStatus: EnvironmentShellStatus;
13 readonly shellHasError: boolean;
14 readonly detailStatus: EnvironmentThreadStatus;
15 readonly detailHasError: boolean;
16}): boolean {
17 if (input.detailStatus === "deleted" || input.shellHasError || input.detailHasError) {
18 return false;
19 }
20 if (input.isLoadingConnections) {
21 return true;
22 }
23 if (
24 input.connectionState === "available" ||
25 input.connectionState === "offline" ||
26 input.connectionState === "error"
27 ) {
28 return false;
29 }
30 return (
31 input.connectionState === "connecting" ||
32 input.connectionState === "reconnecting" ||
33 input.shellStatus === "synchronizing" ||
34 (input.connectionState === "connected" && input.shellStatus === "empty") ||
35 input.detailStatus === "synchronizing" ||
36 (input.connectionState === "connected" && input.detailStatus === "empty")
37 );
38}
apps/mobile/src/state/shell.ts+27
......@@ -3,7 +3,12 @@ import {
33 createEnvironmentShellSummaryAtom,
44 createEnvironmentSnapshotAtom,
55 createShellEnvironmentAtoms,
6 type EnvironmentShellState,
67} from "@t3tools/client-runtime/state/shell";
8import { useAtomValue } from "@effect/atom-react";
9import type { EnvironmentId } from "@t3tools/contracts";
10import * as Option from "effect/Option";
11import { AsyncResult, Atom } from "effect/unstable/reactivity";
712
813import { environmentCatalog } from "../connection/catalog";
914import { connectionAtomRuntime } from "../connection/runtime";
......@@ -15,3 +20,25 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({
1520 catalogValueAtom: environmentCatalog.catalogValueAtom,
1621 shellStateValueAtom: environmentShell.stateValueAtom,
1722});
23
24const EMPTY_ENVIRONMENT_SHELL_STATE_ATOM = Atom.make(
25 AsyncResult.success<EnvironmentShellState>({
26 snapshot: Option.none(),
27 status: "empty",
28 error: Option.none(),
29 }),
30).pipe(Atom.withLabel("mobile-environment-shell:empty"));
31
32/** Reads one environment's shell projection without waiting on other environments. */
33export function useEnvironmentShellState(environmentId: EnvironmentId | null) {
34 const result = useAtomValue(
35 environmentId === null
36 ? EMPTY_ENVIRONMENT_SHELL_STATE_ATOM
37 : environmentShell.stateAtom(environmentId),
38 );
39 return Option.getOrElse(AsyncResult.value(result), () => ({
40 snapshot: Option.none(),
41 status: "empty" as const,
42 error: Option.none(),
43 }));
44}