authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-03 02:41:56-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 02:41:56-07:00
log2aa907b1969237efdaced29612fb46ba51be7041
tree27e014622a2df6a4ff85b03f5401977a06b1a0da
parent06336460c9988f29c71e839c4c9c840c4552e077
signature Signed by PGP key B5690EEEBB952194

fix(mobile): show an error instead of an endless preview spinner (#9123)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

6 files changed, 219 insertions(+), 20 deletions(-)

apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+27-4
......@@ -37,6 +37,7 @@ import { useThreadSelection } from "../../state/use-thread-selection";
3737import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
3838import { useEnvironmentQuery } from "../../state/query";
3939import { projectEnvironment } from "../../state/projects";
40import type { AssetUrlFailureReason } from "../../state/asset-url-state";
4041import {
4142 useAdaptiveWorkspaceLayout,
4243 useAdaptiveWorkspacePaneRole,
......@@ -56,6 +57,7 @@ import { preloadWorkspaceFileContents } from "./preload-workspace-file";
5657import { SourceFileSurface } from "./SourceFileSurface";
5758import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
5859import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
60import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError";
5961import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview";
6062import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
6163import {
......@@ -104,8 +106,10 @@ function defaultViewMode(path: string | null): FileViewMode {
104106
105107function FileContent(props: {
106108 readonly activeMode: FileViewMode;
109 readonly environmentId: EnvironmentId | null;
107110 readonly previewUri: string | null;
108 readonly previewUnavailable: boolean;
111 readonly previewFailure: AssetUrlFailureReason | null;
112 readonly onRetryPreview: () => void;
109113 readonly videoSource: MediaVideoPreviewSource | null;
110114 readonly mediaSource?: MediaActionsSource;
111115 readonly resolveVideoUri: () => Promise<string | null>;
......@@ -121,8 +125,22 @@ function FileContent(props: {
121125 const isMarkdown = isMarkdownPreviewFile(props.relativePath);
122126 const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath);
123127 const isImageFile = isWorkspaceImagePreviewPath(props.relativePath);
128 const isVideoFile = isVideoPreviewFile(props.relativePath);
129 // Only the surfaces that wait on a signed asset URL can be blocked by one.
130 const needsAssetUrl =
131 isVideoFile || (props.activeMode === "preview" && (isImageFile || isBrowserFile));
124132
125 if (isVideoPreviewFile(props.relativePath)) {
133 if (needsAssetUrl && props.previewFailure !== null) {
134 return (
135 <WorkspaceFilePreviewError
136 environmentId={props.environmentId}
137 reason={props.previewFailure}
138 onRetry={props.onRetryPreview}
139 />
140 );
141 }
142
143 if (isVideoFile) {
126144 return (
127145 <WorkspaceFileVideoPreview
128146 name={basename(props.relativePath)}
......@@ -130,7 +148,6 @@ function FileContent(props: {
130148 uri={props.previewUri}
131149 source={props.videoSource}
132150 resolvePlaybackUri={props.resolveVideoUri}
133 unavailable={props.previewUnavailable}
134151 />
135152 );
136153 }
......@@ -588,6 +605,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
588605 assetPreviewUri === null || previewRevision === 0
589606 ? assetPreviewUri
590607 : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`;
608 // Remounting the preview after a re-mint is what makes a failed asset URL retryable.
609 const handleRetryPreview = () => {
610 void assetPreview.refresh().finally(() => setPreviewRevision((current) => current + 1));
611 };
591612 const needsFileContents =
592613 relativePath !== null &&
593614 !isVideoFile &&
......@@ -889,8 +910,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
889910 <FileContent
890911 key={previewKey}
891912 activeMode={resolvedActiveMode}
913 environmentId={environmentId}
892914 previewUri={previewUri}
893 previewUnavailable={assetPreview._tag === "Failure"}
915 previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null}
916 onRetryPreview={handleRetryPreview}
894917 videoSource={videoSource}
895918 mediaSource={mediaSource}
896919 resolveVideoUri={assetPreview.refresh}
apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx created+59
......@@ -0,0 +1,59 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import { useCallback } from "react";
3import { View } from "react-native";
4
5import { EmptyState } from "../../components/EmptyState";
6import { environmentCatalog } from "../../connection/catalog";
7import type { AssetUrlFailureReason } from "../../state/asset-url-state";
8import { useAtomCommand } from "../../state/use-atom-command";
9import { useEnvironmentPresentation } from "../../state/presentation";
10import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
11
12/**
13 * Terminal state for a preview whose signed asset URL will not arrive. A dead
14 * environment reuses the same notice the terminal and review sheets show, so the
15 * user gets one recognizable way back online.
16 */
17export function WorkspaceFilePreviewError(props: {
18 readonly environmentId: EnvironmentId | null;
19 readonly reason: AssetUrlFailureReason;
20 readonly onRetry: () => void;
21}) {
22 const { environmentId, onRetry } = props;
23 const environment = useEnvironmentPresentation(environmentId);
24 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
25 const retryConnection = useCallback(() => {
26 if (environmentId !== null) void retryEnvironment(environmentId);
27 onRetry();
28 }, [environmentId, onRetry, retryEnvironment]);
29
30 if (props.reason === "disconnected") {
31 return (
32 <View className="flex-1 bg-sheet">
33 <EnvironmentConnectionNotice
34 environmentLabel={environment.presentation?.entry.target.label ?? "Environment"}
35 connection={
36 environment.presentation?.connection ?? {
37 phase: "available",
38 error: null,
39 traceId: null,
40 }
41 }
42 resourceName="preview"
43 onRetry={retryConnection}
44 />
45 </View>
46 );
47 }
48
49 return (
50 <View className="flex-1 items-center justify-center bg-sheet px-6">
51 <EmptyState
52 title="Preview unavailable"
53 detail="This file may be missing, unsupported, or unavailable on this environment."
54 actionLabel="Try again"
55 onAction={props.onRetry}
56 />
57 </View>
58 );
59}
apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx-13
......@@ -1,6 +1,5 @@
11import { View } from "react-native";
22
3import { EmptyState } from "../../components/EmptyState";
43import { MediaVideoPlayer } from "../../components/MediaVideoPlayer";
54import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource";
65
......@@ -11,21 +10,9 @@ export function WorkspaceFileVideoPreview(props: {
1110 readonly uri: string | null;
1211 readonly source: MediaVideoPreviewSource | null;
1312 readonly resolvePlaybackUri: () => Promise<string | null>;
14 readonly unavailable: boolean;
1513}) {
1614 const uri = props.uri;
1715
18 if (props.unavailable) {
19 return (
20 <View className="flex-1 items-center justify-center bg-sheet px-6">
21 <EmptyState
22 title="Video unavailable"
23 detail="This file may be missing, unsupported, or unavailable on this environment."
24 />
25 </View>
26 );
27 }
28
2916 return (
3017 <View className="flex-1 items-center justify-center bg-sheet p-4">
3118 <MediaVideoPlayer
apps/mobile/src/state/asset-url-state.test.ts created+54
......@@ -0,0 +1,54 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { deriveAssetUrlState } from "./asset-url-state";
4
5const SUCCESS = { _tag: "Success" as const, url: "https://environment.example/api/assets/abc" };
6
7describe("deriveAssetUrlState", () => {
8 it("passes a resolved URL through on a live environment", () => {
9 expect(deriveAssetUrlState({ connectionPhase: "connected", shared: SUCCESS })).toEqual(SUCCESS);
10 });
11
12 it("waits while the query is pending and the environment is still reachable", () => {
13 for (const connectionPhase of ["available", "connecting", "connected"] as const) {
14 expect(deriveAssetUrlState({ connectionPhase, shared: { _tag: "Loading" } })).toEqual({
15 _tag: "Loading",
16 });
17 }
18 });
19
20 it("stops waiting once the environment is offline, retrying, or in error", () => {
21 for (const connectionPhase of ["offline", "reconnecting", "error"] as const) {
22 expect(deriveAssetUrlState({ connectionPhase, shared: { _tag: "Loading" } })).toEqual({
23 _tag: "Failure",
24 reason: "disconnected",
25 });
26 }
27 });
28
29 // A dead environment fails the URL query itself, so the query outcome alone
30 // cannot tell a missing file from a missing connection.
31 it("reports disconnected when the query failed while the environment is down", () => {
32 for (const connectionPhase of ["available", "offline", "reconnecting", "error"] as const) {
33 expect(deriveAssetUrlState({ connectionPhase, shared: { _tag: "Failure" } })).toEqual({
34 _tag: "Failure",
35 reason: "disconnected",
36 });
37 }
38 });
39
40 it("does not hand out a resolved URL for a disconnected environment", () => {
41 for (const connectionPhase of ["offline", "reconnecting", "error"] as const) {
42 expect(deriveAssetUrlState({ connectionPhase, shared: SUCCESS })).toEqual({
43 _tag: "Failure",
44 reason: "disconnected",
45 });
46 }
47 });
48
49 it("reports a failed query on a connected environment", () => {
50 expect(
51 deriveAssetUrlState({ connectionPhase: "connected", shared: { _tag: "Failure" } }),
52 ).toEqual({ _tag: "Failure", reason: "failed" });
53 });
54});
apps/mobile/src/state/asset-url-state.ts created+47
......@@ -0,0 +1,47 @@
1import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2import type { AssetUrlState as SharedAssetUrlState } from "@t3tools/client-runtime/state/assets";
3
4export type AssetUrlFailureReason = "disconnected" | "failed";
5
6/** The shared state plus a reason on failure, so previews can offer the right retry. */
7export type AssetUrlState =
8 | { readonly _tag: "Loading" }
9 | { readonly _tag: "Failure"; readonly reason: AssetUrlFailureReason }
10 | Extract<SharedAssetUrlState, { readonly _tag: "Success" }>;
11
12/**
13 * Folds the shared asset URL state with the environment connection phase. A
14 * dead environment wins over everything else: even a resolved URL is
15 * unreachable there, and a failed query is caused by the outage, not the file.
16 */
17export function deriveAssetUrlState(input: {
18 readonly connectionPhase: EnvironmentConnectionPhase;
19 readonly shared: SharedAssetUrlState;
20}): AssetUrlState {
21 if (
22 input.connectionPhase === "offline" ||
23 input.connectionPhase === "reconnecting" ||
24 input.connectionPhase === "error"
25 ) {
26 return { _tag: "Failure", reason: "disconnected" };
27 }
28 if (input.shared._tag === "Success") {
29 return input.shared;
30 }
31 switch (input.connectionPhase) {
32 // "available" is the idle, not yet dialled state. A pending query there is
33 // still on its way, but the query atom fails at once while idle, so a
34 // failure means the environment is not connected rather than the file is
35 // missing.
36 case "available":
37 return input.shared._tag === "Failure"
38 ? { _tag: "Failure", reason: "disconnected" }
39 : { _tag: "Loading" };
40 case "connecting":
41 return { _tag: "Loading" };
42 case "connected":
43 return input.shared._tag === "Failure"
44 ? { _tag: "Failure", reason: "failed" }
45 : { _tag: "Loading" };
46 }
47}
apps/mobile/src/state/assets.ts+32-3
......@@ -1,35 +1,64 @@
11import { useAtomValue } from "@effect/atom-react";
22import {
3 type AssetUrlState,
3 type EnvironmentConnectionPhase,
4 presentConnectionState,
5} from "@t3tools/client-runtime/connection";
6import {
47 assetUrlStateFromResult,
58 createAssetEnvironmentAtoms,
69 EMPTY_ASSET_URL_ATOM,
710} from "@t3tools/client-runtime/state/assets";
811import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
12import * as Option from "effect/Option";
13import { AsyncResult, Atom } from "effect/unstable/reactivity";
914import { useCallback } from "react";
1015
16import { environmentCatalog } from "../connection/catalog";
1117import { connectionAtomRuntime } from "../connection/runtime";
18import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";
1219import { usePreparedConnection } from "./session";
1320import { useAtomQueryRunner } from "./use-atom-query-runner";
1421
15export type { AssetUrlState } from "@t3tools/client-runtime/state/assets";
22export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state";
1623
1724export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);
1825
26const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe(
27 Atom.withLabel("mobile-asset-connection-state:empty"),
28);
29
30function useConnectionPhase(environmentId: EnvironmentId | null): EnvironmentConnectionPhase {
31 const state = useAtomValue(
32 environmentId === null
33 ? EMPTY_CONNECTION_STATE_ATOM
34 : environmentCatalog.stateAtom(environmentId),
35 );
36 const value = Option.getOrNull(AsyncResult.value(state));
37 return value === null ? "available" : presentConnectionState(value).phase;
38}
39
1940export function useAssetUrlState(
2041 environmentId: EnvironmentId | null,
2142 resource: AssetResource | null,
2243): AssetUrlState {
2344 const preparedConnection = usePreparedConnection(environmentId);
45 const connectionPhase = useConnectionPhase(environmentId);
2446 const result = useAtomValue(
2547 environmentId === null || resource === null
2648 ? EMPTY_ASSET_URL_ATOM
2749 : assetEnvironment.createUrl({ environmentId, input: { resource } }),
2850 );
29 return assetUrlStateFromResult(
51 const shared = assetUrlStateFromResult(
3052 result,
3153 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null,
3254 );
55 return deriveAssetUrlState({
56 connectionPhase,
57 // A failure left over from an outage is re-queried as soon as the
58 // connection returns. While that re-query is in flight it is not a verdict
59 // on the file, so it reads as loading rather than a false "unavailable".
60 shared: shared._tag === "Failure" && result.waiting ? { _tag: "Loading" } : shared,
61 });
3362}
3463
3564export function useAssetUrl(