diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index e60f03bb8c3..51e71bc6105 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(() => { + setStalledDetailKey(null); + return () => { + 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/threads.ts b/packages/client-runtime/src/state/threads.ts index 5a55e6dafe5..4c73ef892da 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -15,6 +15,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"; @@ -36,10 +37,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."); } function shouldPersistThread(thread: OrchestrationThread): boolean {