From 22c58edb724e1232873737bcec8d67f4bd9809ac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 10:53:29 +0200 Subject: [PATCH 1/3] fix(mobile): Recover thread state reliably after reconnects Co-authored-by: codex --- .../features/threads/ThreadRouteScreen.tsx | 106 +++++++++++++++++- apps/mobile/src/state/query.ts | 8 +- apps/mobile/src/state/threadQueryState.ts | 26 +++++ apps/mobile/src/state/threads.test.ts | 40 +++++++ apps/mobile/src/state/threads.ts | 38 +++++-- apps/mobile/src/state/use-thread-detail.ts | 12 +- .../src/errors/causeMessage.test.ts | 45 ++++++++ .../client-runtime/src/errors/causeMessage.ts | 19 ++++ packages/client-runtime/src/errors/index.ts | 1 + .../src/state/shell-sync.test.ts | 80 +++++++++++++ packages/client-runtime/src/state/shell.ts | 14 +-- .../src/state/threads-sync.test.ts | 35 ++++++ packages/client-runtime/src/state/threads.ts | 10 +- 13 files changed, 396 insertions(+), 38 deletions(-) create mode 100644 apps/mobile/src/state/threadQueryState.ts create mode 100644 apps/mobile/src/state/threads.test.ts create mode 100644 packages/client-runtime/src/errors/causeMessage.test.ts create mode 100644 packages/client-runtime/src/errors/causeMessage.ts diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index e60f03bb8c3..d2c2df6411a 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -31,7 +31,7 @@ import { useRemoteEnvironmentRuntime, } from "../../state/use-remote-environment-registry"; import { useKnownTerminalSessions } from "../../state/use-terminal-session"; -import { useSelectedThreadDetailState } from "../../state/use-thread-detail"; +import { useSelectedThreadDetailQuery } from "../../state/use-thread-detail"; import { useThreadSelection } from "../../state/use-thread-selection"; import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; import { @@ -78,6 +78,11 @@ interface ThreadInspectorSelection { type NativeHeaderItems = ReadonlyArray>; +const THREAD_DETAIL_STALL_RETRY_DELAYS_MS = [8_000, 12_000] as const; +const THREAD_DETAIL_STALL_ERROR_DELAY_MS = 20_000; +const THREAD_DETAIL_STALL_ERROR = + "The conversation did not finish loading. Close and reopen the thread to retry."; + function InspectorPaneRoleActivation() { useAdaptiveWorkspacePaneRole("inspector"); return null; @@ -144,7 +149,91 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { selectedThread === null ? null : scopedThreadKey(selectedThread.environmentId, selectedThread.id); - const selectedThreadDetailState = useSelectedThreadDetailState(); + const selectedThreadDetailQuery = useSelectedThreadDetailQuery(); + const selectedThreadDetailState = selectedThreadDetailQuery.state; + const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); + const detailRefreshAttemptsRef = useRef(new Map()); + const refreshSelectedThreadDetailRef = useRef(selectedThreadDetailQuery.refresh); + const [stalledDetailKey, setStalledDetailKey] = useState(null); + + useEffect(() => { + refreshSelectedThreadDetailRef.current = selectedThreadDetailQuery.refresh; + }, [selectedThreadDetailQuery.refresh]); + + useEffect( + () => () => { + if (routeThreadKey !== null) { + detailRefreshAttemptsRef.current.delete(routeThreadKey); + } + }, + [routeThreadKey], + ); + + useEffect(() => { + if (routeThreadKey === null) { + return; + } + + if (selectedThreadKey !== routeThreadKey) { + return; + } + + if (selectedThreadDetail !== null || selectedThreadDetailState.status === "deleted") { + detailRefreshAttemptsRef.current.delete(routeThreadKey); + setStalledDetailKey((current) => (current === routeThreadKey ? null : current)); + return; + } + + if (routeConnectionState !== "connected") { + detailRefreshAttemptsRef.current.delete(routeThreadKey); + setStalledDetailKey((current) => (current === routeThreadKey ? null : current)); + return; + } + + if (stalledDetailKey === routeThreadKey) { + return; + } + + let cancelled = false; + let timer: ReturnType | null = null; + const scheduleRetry = () => { + const attempts = detailRefreshAttemptsRef.current.get(routeThreadKey) ?? 0; + const delayMs = + THREAD_DETAIL_STALL_RETRY_DELAYS_MS[attempts] ?? THREAD_DETAIL_STALL_ERROR_DELAY_MS; + timer = setTimeout(() => { + if (cancelled) { + return; + } + const currentAttempts = detailRefreshAttemptsRef.current.get(routeThreadKey) ?? 0; + if (currentAttempts !== attempts) { + return; + } + if (attempts >= THREAD_DETAIL_STALL_RETRY_DELAYS_MS.length) { + setStalledDetailKey(routeThreadKey); + return; + } + detailRefreshAttemptsRef.current.set(routeThreadKey, attempts + 1); + refreshSelectedThreadDetailRef.current(); + scheduleRetry(); + }, delayMs); + }; + + scheduleRetry(); + + return () => { + cancelled = true; + if (timer !== null) { + clearTimeout(timer); + } + }; + }, [ + routeThreadKey, + routeConnectionState, + selectedThreadKey, + selectedThreadDetail, + selectedThreadDetailState.status, + stalledDetailKey, + ]); if (environmentId === null || threadIdRaw === null) { return ; @@ -155,7 +244,13 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { // loading placeholder while messages fetch, and the composer's connection // pill reports connecting/reconnecting/syncing status. if (selectedThread !== null && selectedThreadKey === routeThreadKey) { - return ; + return ( + + ); } const stillHydrating = @@ -172,7 +267,8 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { function ThreadRouteContent( props: ThreadRouteScreenProps & { - readonly selectedThreadDetailState: ReturnType; + readonly detailLoadError: string | null; + readonly selectedThreadDetailState: ReturnType["state"]; }, ) { const { @@ -735,7 +831,7 @@ function ThreadRouteContent( const contentPresentation = projectThreadContentPresentation({ hasDetail: selectedThreadDetail !== null, - detailError: Option.getOrNull(selectedThreadDetailState.error), + detailError: Option.getOrNull(selectedThreadDetailState.error) ?? props.detailLoadError, detailDeleted: selectedThreadDetailState.status === "deleted", connectionState: routeConnectionState, }); diff --git a/apps/mobile/src/state/query.ts b/apps/mobile/src/state/query.ts index c29d01d397b..e2adffbdfb7 100644 --- a/apps/mobile/src/state/query.ts +++ b/apps/mobile/src/state/query.ts @@ -1,5 +1,6 @@ import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; -import * as Cause from "effect/Cause"; +import { causeFailureMessage } from "@t3tools/client-runtime/errors"; +import type * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -15,10 +16,7 @@ export interface EnvironmentQueryView { } function formatError(cause: Cause.Cause): string { - const error = Cause.squash(cause); - return error instanceof Error && error.message.trim().length > 0 - ? error.message - : "The environment request failed."; + return causeFailureMessage(cause, "The environment request failed."); } export function useEnvironmentQuery( diff --git a/apps/mobile/src/state/threadQueryState.ts b/apps/mobile/src/state/threadQueryState.ts new file mode 100644 index 00000000000..a919fe0db88 --- /dev/null +++ b/apps/mobile/src/state/threadQueryState.ts @@ -0,0 +1,26 @@ +import { + EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadState, +} from "@t3tools/client-runtime/state/threads"; +import { causeFailureMessage } from "@t3tools/client-runtime/errors"; +import type * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; + +function formatThreadStateFailure(cause: Cause.Cause): string { + return causeFailureMessage(cause, "Could not load conversation."); +} + +export function environmentThreadStateFromAsyncResult( + result: AsyncResult.AsyncResult, +): EnvironmentThreadState { + const value = Option.getOrNull(AsyncResult.value(result)); + if (AsyncResult.isFailure(result)) { + return { + ...(value ?? EMPTY_ENVIRONMENT_THREAD_STATE), + error: Option.some(formatThreadStateFailure(result.cause)), + }; + } + + return value ?? EMPTY_ENVIRONMENT_THREAD_STATE; +} diff --git a/apps/mobile/src/state/threads.test.ts b/apps/mobile/src/state/threads.test.ts new file mode 100644 index 00000000000..7285a9fa2b8 --- /dev/null +++ b/apps/mobile/src/state/threads.test.ts @@ -0,0 +1,40 @@ +import { EMPTY_ENVIRONMENT_THREAD_STATE } from "@t3tools/client-runtime/state/threads"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { environmentThreadStateFromAsyncResult } from "./threadQueryState"; + +describe("environmentThreadStateFromAsyncResult", () => { + it("returns the empty state while the first value is loading", () => { + expect(environmentThreadStateFromAsyncResult(AsyncResult.initial(true))).toEqual( + EMPTY_ENVIRONMENT_THREAD_STATE, + ); + }); + + it("surfaces a failure when no previous value exists", () => { + const state = environmentThreadStateFromAsyncResult( + AsyncResult.failure(Cause.fail("thread sync failed")), + ); + + expect(Option.getOrNull(state.data)).toBeNull(); + expect(Option.getOrNull(state.error)).toBe("thread sync failed"); + }); + + it("preserves previous thread data while surfacing a refresh failure", () => { + const previousState = { + ...EMPTY_ENVIRONMENT_THREAD_STATE, + status: "live" as const, + }; + const previousSuccess = AsyncResult.success(previousState); + const state = environmentThreadStateFromAsyncResult( + AsyncResult.failure(Cause.fail(new Error("refresh failed")), { + previousSuccess: Option.some(previousSuccess), + }), + ); + + expect(state.status).toBe("live"); + expect(Option.getOrNull(state.error)).toBe("refresh failed"); + }); +}); diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 7f247123051..cf6c1c2fe7a 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -1,4 +1,4 @@ -import { useAtomValue } from "@effect/atom-react"; +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { createEnvironmentThreadDetailAtoms, createEnvironmentThreadShellAtoms, @@ -8,12 +8,12 @@ import { createThreadEnvironmentAtoms, } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId, ThreadId } 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"; import { environmentSnapshotAtom } from "./shell"; +import { environmentThreadStateFromAsyncResult } from "./threadQueryState"; export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); @@ -29,17 +29,33 @@ const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_ Atom.withLabel("mobile-environment-thread:empty"), ); -export function useEnvironmentThread( +export interface EnvironmentThreadQuery { + readonly state: EnvironmentThreadState; + readonly isPending: boolean; + readonly refresh: () => void; +} + +export function useEnvironmentThreadQuery( environmentId: EnvironmentId | null, threadId: ThreadId | null, -): EnvironmentThreadState { - const result = useAtomValue( +): EnvironmentThreadQuery { + const atom = environmentId !== null && threadId !== null ? environmentThreads.stateAtom(environmentId, threadId) - : EMPTY_THREAD_STATE_ATOM, - ); - return Option.getOrElse( - AsyncResult.value(result), - () => EMPTY_ENVIRONMENT_THREAD_STATE, - ) as EnvironmentThreadState; + : EMPTY_THREAD_STATE_ATOM; + const result = useAtomValue(atom); + const refresh = useAtomRefresh(atom); + + return { + state: environmentThreadStateFromAsyncResult(result), + isPending: environmentId !== null && threadId !== null && result.waiting, + refresh, + }; +} + +export function useEnvironmentThread( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): EnvironmentThreadState { + return useEnvironmentThreadQuery(environmentId, threadId).state; } diff --git a/apps/mobile/src/state/use-thread-detail.ts b/apps/mobile/src/state/use-thread-detail.ts index 388b4d9afcb..19546f780ba 100644 --- a/apps/mobile/src/state/use-thread-detail.ts +++ b/apps/mobile/src/state/use-thread-detail.ts @@ -1,7 +1,7 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { useEnvironmentThread } from "./threads"; +import { useEnvironmentThread, useEnvironmentThreadQuery } from "./threads"; import { useThreadSelection } from "./use-thread-selection"; export interface ThreadDetailTarget { @@ -13,9 +13,17 @@ export function useThreadDetail(target: ThreadDetailTarget) { return useEnvironmentThread(target.environmentId, target.threadId); } +export function useThreadDetailQuery(target: ThreadDetailTarget) { + return useEnvironmentThreadQuery(target.environmentId, target.threadId); +} + export function useSelectedThreadDetailState() { + return useSelectedThreadDetailQuery().state; +} + +export function useSelectedThreadDetailQuery() { const { selectedThread } = useThreadSelection(); - return useThreadDetail({ + return useThreadDetailQuery({ environmentId: selectedThread?.environmentId ?? null, threadId: selectedThread?.id ?? null, }); diff --git a/packages/client-runtime/src/errors/causeMessage.test.ts b/packages/client-runtime/src/errors/causeMessage.test.ts new file mode 100644 index 00000000000..2900820816e --- /dev/null +++ b/packages/client-runtime/src/errors/causeMessage.test.ts @@ -0,0 +1,45 @@ +import * as Cause from "effect/Cause"; +import { describe, expect, it } from "vite-plus/test"; + +import { causeFailureMessage } from "./causeMessage.ts"; + +describe("causeFailureMessage", () => { + it("returns the message of an Error failure", () => { + expect(causeFailureMessage(Cause.fail(new Error("boom")), "fallback")).toBe("boom"); + }); + + it("returns a string failure verbatim", () => { + expect(causeFailureMessage(Cause.fail("nope"), "fallback")).toBe("nope"); + }); + + it("returns the message of a tagged error object", () => { + expect( + causeFailureMessage( + Cause.fail({ _tag: "RelayInternalError", message: "relay down" }), + "fallback", + ), + ).toBe("relay down"); + }); + + it("falls back for empty Error messages", () => { + expect(causeFailureMessage(Cause.fail(new Error(" ")), "fallback")).toBe("fallback"); + }); + + it("falls back for empty string failures", () => { + expect(causeFailureMessage(Cause.fail(""), "fallback")).toBe("fallback"); + }); + + it("falls back for objects without a string message", () => { + expect(causeFailureMessage(Cause.fail({ code: 500 }), "fallback")).toBe("fallback"); + expect(causeFailureMessage(Cause.fail({ message: 42 }), "fallback")).toBe("fallback"); + }); + + it("falls back for other primitive failures", () => { + expect(causeFailureMessage(Cause.fail(null), "fallback")).toBe("fallback"); + expect(causeFailureMessage(Cause.fail(42), "fallback")).toBe("fallback"); + }); + + it("returns the defect message for die causes", () => { + expect(causeFailureMessage(Cause.die(new Error("defect")), "fallback")).toBe("defect"); + }); +}); diff --git a/packages/client-runtime/src/errors/causeMessage.ts b/packages/client-runtime/src/errors/causeMessage.ts new file mode 100644 index 00000000000..41f8ae42fe5 --- /dev/null +++ b/packages/client-runtime/src/errors/causeMessage.ts @@ -0,0 +1,19 @@ +import * as Cause from "effect/Cause"; + +export function causeFailureMessage(cause: Cause.Cause, fallback: string): string { + const message = failureMessage(Cause.squash(cause)); + return message !== null && message.trim().length > 0 ? message : fallback; +} + +export function failureMessage(error: unknown): string | null { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + if (typeof error === "object" && error !== null && "message" in error) { + return typeof error.message === "string" ? error.message : null; + } + return null; +} diff --git a/packages/client-runtime/src/errors/index.ts b/packages/client-runtime/src/errors/index.ts index 7eb6244e5a7..5931161e10c 100644 --- a/packages/client-runtime/src/errors/index.ts +++ b/packages/client-runtime/src/errors/index.ts @@ -1,3 +1,4 @@ +export * from "./causeMessage.ts"; export * from "./errorTrace.ts"; export * from "./safeLog.ts"; export * from "./transport.ts"; diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index b6eb4e9f710..a058f852184 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -137,6 +137,86 @@ describe("environment shell synchronization", () => { }), ); + it.effect("restores live status after reconnect when no new shell events arrive", () => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), + } as unknown as WsRpcProtocolClient; + const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); + const activeSession = yield* SubscriptionRef.make>( + Option.some(session(client)), + ); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: supervisorState, + session: activeSession, + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.some(LIVE_SHELL_SNAPSHOT)), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const snapshotLoader = ShellSnapshotLoader.of({ + load: () => Effect.succeed(Option.none()), + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader, snapshotLoader), + ); + + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => state.status === "live"), + Stream.runHead, + ); + + yield* SubscriptionRef.set(supervisorState, { + desired: true, + network: "online", + phase: "connecting", + stage: "synchronizing", + attempt: 2, + generation: 1, + lastFailure: null, + retryAt: null, + }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => state.status === "synchronizing"), + Stream.runHead, + ); + + yield* SubscriptionRef.set(supervisorState, { + desired: true, + network: "online", + phase: "connected", + stage: null, + attempt: 2, + generation: 2, + lastFailure: null, + retryAt: null, + }); + const state = yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((value) => value.status === "live"), + Stream.runHead, + ); + + expect(Option.getOrThrow(state).status).toBe("live"); + expect(Option.getOrThrow(Option.getOrThrow(state).snapshot)).toEqual(LIVE_SHELL_SNAPSHOT); + }), + ); + it.effect("resumes a warm shell cache via afterSequence without an HTTP fetch", () => Effect.gen(function* () { const cachedSnapshot: OrchestrationShellSnapshot = { diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index faa70bc4f3a..9036ff5289e 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -99,15 +99,11 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") status: "synchronizing" as const, error: Option.none(), })); - const setReady = SubscriptionRef.update(state, (current) => - current.status === "live" - ? current - : { - ...current, - status: "synchronizing" as const, - error: Option.none(), - }, - ); + const setReady = SubscriptionRef.update(state, (current) => ({ + ...current, + status: Option.isSome(current.snapshot) ? ("live" as const) : ("synchronizing" as const), + error: Option.none(), + })); const setStreamError = (error: unknown) => Effect.logWarning("Could not synchronize the environment shell.").pipe( Effect.annotateLogs({ diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 8e982723d22..1c76f0f171f 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -483,4 +483,39 @@ describe("EnvironmentThreads", () => { expect((yield* Ref.get(harness.latest)).status).toBe("live"); }), ); + + it.effect("restores live status after reconnect when no new thread events arrive", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* awaitThreadState(harness.observed, (value) => value.status === "live"); + + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connecting", + stage: "synchronizing", + attempt: 2, + generation: 1, + lastFailure: null, + retryAt: null, + }); + yield* awaitThreadState(harness.observed, (value) => value.status === "synchronizing"); + + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connected", + stage: null, + attempt: 2, + generation: 2, + lastFailure: null, + retryAt: null, + }); + yield* awaitThreadState(harness.observed, (value) => value.status === "live"); + + const latest = yield* Ref.get(harness.latest); + expect(latest.status).toBe("live"); + expect(Option.getOrThrow(latest.data)).toEqual(BASE_THREAD); + }), + ); }); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index fd5b425fa2a..2acbb58326e 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -14,6 +14,7 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom } from "effect/unstable/reactivity"; +import { causeFailureMessage } from "../errors/causeMessage.ts"; import { EnvironmentRegistry } from "../connection/registry.ts"; import { connectionProjectionPhase } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; @@ -35,10 +36,7 @@ function statusWithoutLiveData(data: Option.Option): Enviro } function formatThreadError(cause: Cause.Cause): string { - const error = Cause.squash(cause); - return error instanceof Error && error.message.trim().length > 0 - ? error.message - : "Could not synchronize the thread."; + return causeFailureMessage(cause, "Could not synchronize the thread."); } export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make")(function* ( @@ -101,11 +99,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make error: Option.none(), })); const setReady = SubscriptionRef.update(state, (current) => - current.status === "live" || current.status === "deleted" + current.status === "deleted" ? current : { ...current, - status: "synchronizing" as const, + status: Option.isSome(current.data) ? ("live" as const) : ("synchronizing" as const), error: Option.none(), }, ); From 2f86672318fc9f146a268106c0f2c8da3b90aee1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 11:41:51 +0200 Subject: [PATCH 2/3] fix(mobile): Wait for thread synchronization before retrying Co-authored-by: codex --- apps/mobile/src/features/threads/ThreadRouteScreen.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index d2c2df6411a..85682366b1d 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -184,6 +184,14 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { return; } + // A warm reconnect legitimately has no detail while the runtime catches + // up. The watchdog should start only after synchronization settles. + if (selectedThreadDetailState.status === "synchronizing") { + detailRefreshAttemptsRef.current.delete(routeThreadKey); + setStalledDetailKey((current) => (current === routeThreadKey ? null : current)); + return; + } + if (routeConnectionState !== "connected") { detailRefreshAttemptsRef.current.delete(routeThreadKey); setStalledDetailKey((current) => (current === routeThreadKey ? null : current)); From 9ff248f04d5d3e73af59e90e57eb5a0ef3f87903 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 11:51:28 +0200 Subject: [PATCH 3/3] fix(mobile): Preserve stalled thread retry attempts Co-authored-by: codex --- apps/mobile/src/features/threads/ThreadRouteScreen.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 85682366b1d..d2c2df6411a 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -184,14 +184,6 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { return; } - // A warm reconnect legitimately has no detail while the runtime catches - // up. The watchdog should start only after synchronization settles. - if (selectedThreadDetailState.status === "synchronizing") { - detailRefreshAttemptsRef.current.delete(routeThreadKey); - setStalledDetailKey((current) => (current === routeThreadKey ? null : current)); - return; - } - if (routeConnectionState !== "connected") { detailRefreshAttemptsRef.current.delete(routeThreadKey); setStalledDetailKey((current) => (current === routeThreadKey ? null : current));