| 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,6 +25,7 @@ import { |
| 25 | import { Alert, Platform, ScrollView, View } from "react-native"; | 25 | import { Alert, Platform, ScrollView, View } from "react-native"; |
| 26 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 26 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 27 | import { useWorkspaceState } from "../../state/workspace"; | 27 | import { useWorkspaceState } from "../../state/workspace"; |
| 28 | import { useEnvironmentShellState } from "../../state/shell"; | ||
| 28 | import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key"; | 29 | import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key"; |
| 29 | import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation"; | 30 | import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation"; |
| 30 | import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draft"; | 31 | import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draft"; |
| ... | @@ -88,6 +89,7 @@ import { | ... | @@ -88,6 +89,7 @@ import { |
| 88 | ThreadInspectorContentStack, | 89 | ThreadInspectorContentStack, |
| 89 | type ThreadInspectorMode, | 90 | type ThreadInspectorMode, |
| 90 | } from "./thread-inspector-content-stack"; | 91 | } from "./thread-inspector-content-stack"; |
| 92 | import { threadRouteIsHydrating } from "./thread-route-hydration"; | ||
| 91 | 93 | ||
| 92 | interface ThreadInspectorSelection { | 94 | interface ThreadInspectorSelection { |
| 93 | readonly routeThreadIdentity: string | null; | 95 | readonly routeThreadIdentity: string | null; |
| ... | @@ -123,7 +125,11 @@ interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps { | ... | @@ -123,7 +125,11 @@ interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps { |
| 123 | readonly renderInspector?: (headerInset: number) => ReactNode; | 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 | return ( | 133 | return ( |
| 128 | <ScrollView | 134 | <ScrollView |
| 129 | contentInsetAdjustmentBehavior="automatic" | 135 | contentInsetAdjustmentBehavior="automatic" |
| ... | @@ -138,6 +144,8 @@ function ThreadUnavailableScreen() { | ... | @@ -138,6 +144,8 @@ function ThreadUnavailableScreen() { |
| 138 | <EmptyState | 144 | <EmptyState |
| 139 | title="Thread unavailable" | 145 | title="Thread unavailable" |
| 140 | detail="This thread is not available in the current mobile snapshot." | 146 | detail="This thread is not available in the current mobile snapshot." |
| 147 | actionLabel={props.actionLabel} | ||
| 148 | onAction={props.onAction} | ||
| 141 | /> | 149 | /> |
| 142 | </ScrollView> | 150 | </ScrollView> |
| 143 | ); | 151 | ); |
| ... | @@ -152,6 +160,9 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { | ... | @@ -152,6 +160,9 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { |
| 152 | const threadIdRaw = firstRouteParam(params.threadId); | 160 | const threadIdRaw = firstRouteParam(params.threadId); |
| 153 | const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; | 161 | const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; |
| 154 | const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); | 162 | const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); |
| 163 | const routeEnvironmentShellState = useEnvironmentShellState(environmentId); | ||
| 164 | const { onReconnectEnvironment } = useRemoteConnections(); | ||
| 165 | const navigation = useNavigation(); | ||
| 155 | const routeConnectionState = | 166 | const routeConnectionState = |
| 156 | routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); | 167 | routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); |
| 157 | const routeThreadKey = | 168 | const routeThreadKey = |
| ... | @@ -177,16 +188,36 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { | ... | @@ -177,16 +188,36 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { |
| 177 | return <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />; | 188 | return <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />; |
| 178 | } | 189 | } |
| 179 | 190 | ||
| 180 | const stillHydrating = | 191 | const stillHydrating = threadRouteIsHydrating({ |
| 181 | workspaceState.isLoadingConnections || | 192 | isLoadingConnections: workspaceState.isLoadingConnections, |
| 182 | routeConnectionState === "connecting" || | 193 | connectionState: routeConnectionState, |
| 183 | routeConnectionState === "reconnecting"; | 194 | shellStatus: routeEnvironmentShellState.status, |
| 195 | shellHasError: Option.isSome(routeEnvironmentShellState.error), | ||
| 196 | detailStatus: selectedThreadDetailState.status, | ||
| 197 | detailHasError: Option.isSome(selectedThreadDetailState.error), | ||
| 198 | }); | ||
| 184 | 199 | ||
| 185 | if (stillHydrating) { | 200 | if (stillHydrating) { |
| 186 | return <OpeningThreadLoadingScreen />; | 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 | function ThreadRouteContent( | 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,7 +3,12 @@ import { |
| 3 | createEnvironmentShellSummaryAtom, | 3 | createEnvironmentShellSummaryAtom, |
| 4 | createEnvironmentSnapshotAtom, | 4 | createEnvironmentSnapshotAtom, |
| 5 | createShellEnvironmentAtoms, | 5 | createShellEnvironmentAtoms, |
| 6 | type EnvironmentShellState, | ||
| 6 | } from "@t3tools/client-runtime/state/shell"; | 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 | import { environmentCatalog } from "../connection/catalog"; | 13 | import { environmentCatalog } from "../connection/catalog"; |
| 9 | import { connectionAtomRuntime } from "../connection/runtime"; | 14 | import { connectionAtomRuntime } from "../connection/runtime"; |
| ... | @@ -15,3 +20,25 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ | ... | @@ -15,3 +20,25 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ |
| 15 | catalogValueAtom: environmentCatalog.catalogValueAtom, | 20 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
| 16 | shellStateValueAtom: environmentShell.stateValueAtom, | 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 | } |