Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 101 additions & 5 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -78,6 +78,11 @@ interface ThreadInspectorSelection {

type NativeHeaderItems = ReadonlyArray<Record<string, unknown>>;

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;
Expand Down Expand Up @@ -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<string, number>());
const refreshSelectedThreadDetailRef = useRef(selectedThreadDetailQuery.refresh);
const [stalledDetailKey, setStalledDetailKey] = useState<string | null>(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<typeof setTimeout> | 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();
Comment thread
juliusmarminge marked this conversation as resolved.

return () => {
cancelled = true;
if (timer !== null) {
clearTimeout(timer);
}
};
}, [
routeThreadKey,
routeConnectionState,
selectedThreadKey,
selectedThreadDetail,
selectedThreadDetailState.status,
stalledDetailKey,
]);

if (environmentId === null || threadIdRaw === null) {
return <OpeningThreadLoadingScreen />;
Expand All @@ -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 <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />;
return (
<ThreadRouteContent
{...props}
detailLoadError={stalledDetailKey === routeThreadKey ? THREAD_DETAIL_STALL_ERROR : null}
selectedThreadDetailState={selectedThreadDetailState}
/>
);
}

const stillHydrating =
Expand All @@ -172,7 +267,8 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) {

function ThreadRouteContent(
props: ThreadRouteScreenProps & {
readonly selectedThreadDetailState: ReturnType<typeof useSelectedThreadDetailState>;
readonly detailLoadError: string | null;
readonly selectedThreadDetailState: ReturnType<typeof useSelectedThreadDetailQuery>["state"];
},
) {
const {
Expand Down Expand Up @@ -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,
});
Expand Down
8 changes: 3 additions & 5 deletions apps/mobile/src/state/query.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -15,10 +16,7 @@ export interface EnvironmentQueryView<A> {
}

function formatError(cause: Cause.Cause<unknown>): 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<A, E>(
Expand Down
26 changes: 26 additions & 0 deletions apps/mobile/src/state/threadQueryState.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>): string {
return causeFailureMessage(cause, "Could not load conversation.");
}

export function environmentThreadStateFromAsyncResult(
result: AsyncResult.AsyncResult<EnvironmentThreadState, unknown>,
): 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;
}
40 changes: 40 additions & 0 deletions apps/mobile/src/state/threads.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
38 changes: 27 additions & 11 deletions apps/mobile/src/state/threads.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useAtomValue } from "@effect/atom-react";
import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
import {
createEnvironmentThreadDetailAtoms,
createEnvironmentThreadShellAtoms,
Expand All @@ -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);
Expand All @@ -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;
}
12 changes: 10 additions & 2 deletions apps/mobile/src/state/use-thread-detail.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
});
Expand Down
45 changes: 45 additions & 0 deletions packages/client-runtime/src/errors/causeMessage.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
19 changes: 19 additions & 0 deletions packages/client-runtime/src/errors/causeMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as Cause from "effect/Cause";

export function causeFailureMessage(cause: Cause.Cause<unknown>, 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;
}
1 change: 1 addition & 0 deletions packages/client-runtime/src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./causeMessage.ts";
export * from "./errorTrace.ts";
export * from "./safeLog.ts";
export * from "./transport.ts";
Loading
Loading