| author | |
| committer | |
| log | caf8b5d798909780a7b197cb264e782888b919c1 |
| tree | 2955595699c6b622b32b61e77944c261bac9d3f1 |
| parent | d1790aa14639a0361c02fbc5c326d0aa76f95a83 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 176 insertions(+), 6 deletions(-)
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+37-6| ... | ... | @@ -25,6 +25,7 @@ import { |
| 25 | 25 | import { Alert, Platform, ScrollView, View } from "react-native"; |
| 26 | 26 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 27 | 27 | import { useWorkspaceState } from "../../state/workspace"; |
| 28 | import { useEnvironmentShellState } from "../../state/shell"; | |
| 28 | 29 | import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key"; |
| 29 | 30 | import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation"; |
| 30 | 31 | import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draft"; |
| ... | ... | @@ -88,6 +89,7 @@ import { |
| 88 | 89 | ThreadInspectorContentStack, |
| 89 | 90 | type ThreadInspectorMode, |
| 90 | 91 | } from "./thread-inspector-content-stack"; |
| 92 | import { threadRouteIsHydrating } from "./thread-route-hydration"; | |
| 91 | 93 | |
| 92 | 94 | interface ThreadInspectorSelection { |
| 93 | 95 | readonly routeThreadIdentity: string | null; |
| ... | ... | @@ -123,7 +125,11 @@ interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps { |
| 123 | 125 | readonly renderInspector?: (headerInset: number) => ReactNode; |
| 124 | 126 | } |
| 125 | 127 | |
| 126 | function ThreadUnavailableScreen() { | |
| 128 | /** Shows recovery only after the target route has reached a terminal unavailable state. */ | |
| 129 | function ThreadUnavailableScreen(props: { | |
| 130 | readonly actionLabel: string; | |
| 131 | readonly onAction: () => void; | |
| 132 | }) { | |
| 127 | 133 | return ( |
| 128 | 134 | <ScrollView |
| 129 | 135 | contentInsetAdjustmentBehavior="automatic" |
| ... | ... | @@ -138,6 +144,8 @@ function ThreadUnavailableScreen() { |
| 138 | 144 | <EmptyState |
| 139 | 145 | title="Thread unavailable" |
| 140 | 146 | detail="This thread is not available in the current mobile snapshot." |
| 147 | actionLabel={props.actionLabel} | |
| 148 | onAction={props.onAction} | |
| 141 | 149 | /> |
| 142 | 150 | </ScrollView> |
| 143 | 151 | ); |
| ... | ... | @@ -152,6 +160,9 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { |
| 152 | 160 | const threadIdRaw = firstRouteParam(params.threadId); |
| 153 | 161 | const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; |
| 154 | 162 | const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); |
| 163 | const routeEnvironmentShellState = useEnvironmentShellState(environmentId); | |
| 164 | const { onReconnectEnvironment } = useRemoteConnections(); | |
| 165 | const navigation = useNavigation(); | |
| 155 | 166 | const routeConnectionState = |
| 156 | 167 | routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); |
| 157 | 168 | const routeThreadKey = |
| ... | ... | @@ -177,16 +188,36 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { |
| 177 | 188 | return <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />; |
| 178 | 189 | } |
| 179 | 190 | |
| 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 | }); | |
| 184 | 199 | |
| 185 | 200 | if (stillHydrating) { |
| 186 | 201 | return <OpeningThreadLoadingScreen />; |
| 187 | 202 | } |
| 188 | 203 | |
| 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 | ); | |
| 190 | 221 | } |
| 191 | 222 | |
| 192 | 223 | function ThreadRouteContent( |
apps/mobile/src/features/threads/thread-route-hydration.test.ts created+74| ... | ... | @@ -0,0 +1,74 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { threadRouteIsHydrating } from "./thread-route-hydration"; | |
| 4 | ||
| 5 | const 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 | ||
| 14 | describe("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 @@ |
| 1 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | |
| 2 | import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; | |
| 3 | import 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 | */ | |
| 9 | export 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 { |
| 3 | 3 | createEnvironmentShellSummaryAtom, |
| 4 | 4 | createEnvironmentSnapshotAtom, |
| 5 | 5 | createShellEnvironmentAtoms, |
| 6 | type EnvironmentShellState, | |
| 6 | 7 | } from "@t3tools/client-runtime/state/shell"; |
| 8 | import { useAtomValue } from "@effect/atom-react"; | |
| 9 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | |
| 7 | 12 | |
| 8 | 13 | import { environmentCatalog } from "../connection/catalog"; |
| 9 | 14 | import { connectionAtomRuntime } from "../connection/runtime"; |
| ... | ... | @@ -15,3 +20,25 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ |
| 15 | 20 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
| 16 | 21 | shellStateValueAtom: environmentShell.stateValueAtom, |
| 17 | 22 | }); |
| 23 | ||
| 24 | const 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. */ | |
| 33 | export 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 | } |