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";...@@ -37,6 +37,7 @@ import { useThreadSelection } from "../../state/use-thread-selection";
37import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";37import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
38import { useEnvironmentQuery } from "../../state/query";38import { useEnvironmentQuery } from "../../state/query";
39import { projectEnvironment } from "../../state/projects";39import { projectEnvironment } from "../../state/projects";
40import type { AssetUrlFailureReason } from "../../state/asset-url-state";
40import {41import {
41 useAdaptiveWorkspaceLayout,42 useAdaptiveWorkspaceLayout,
42 useAdaptiveWorkspacePaneRole,43 useAdaptiveWorkspacePaneRole,
...@@ -56,6 +57,7 @@ import { preloadWorkspaceFileContents } from "./preload-workspace-file";...@@ -56,6 +57,7 @@ import { preloadWorkspaceFileContents } from "./preload-workspace-file";
56import { SourceFileSurface } from "./SourceFileSurface";57import { SourceFileSurface } from "./SourceFileSurface";
57import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";58import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
58import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";59import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
60import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError";
59import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview";61import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview";
60import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";62import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
61import {63import {
...@@ -104,8 +106,10 @@ function defaultViewMode(path: string | null): FileViewMode {...@@ -104,8 +106,10 @@ function defaultViewMode(path: string | null): FileViewMode {
104106
105function FileContent(props: {107function FileContent(props: {
106 readonly activeMode: FileViewMode;108 readonly activeMode: FileViewMode;
109 readonly environmentId: EnvironmentId | null;
107 readonly previewUri: string | null;110 readonly previewUri: string | null;
108 readonly previewUnavailable: boolean;111 readonly previewFailure: AssetUrlFailureReason | null;
112 readonly onRetryPreview: () => void;
109 readonly videoSource: MediaVideoPreviewSource | null;113 readonly videoSource: MediaVideoPreviewSource | null;
110 readonly mediaSource?: MediaActionsSource;114 readonly mediaSource?: MediaActionsSource;
111 readonly resolveVideoUri: () => Promise<string | null>;115 readonly resolveVideoUri: () => Promise<string | null>;
...@@ -121,8 +125,22 @@ function FileContent(props: {...@@ -121,8 +125,22 @@ function FileContent(props: {
121 const isMarkdown = isMarkdownPreviewFile(props.relativePath);125 const isMarkdown = isMarkdownPreviewFile(props.relativePath);
122 const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath);126 const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath);
123 const isImageFile = isWorkspaceImagePreviewPath(props.relativePath);127 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) {
126 return (144 return (
127 <WorkspaceFileVideoPreview145 <WorkspaceFileVideoPreview
128 name={basename(props.relativePath)}146 name={basename(props.relativePath)}
...@@ -130,7 +148,6 @@ function FileContent(props: {...@@ -130,7 +148,6 @@ function FileContent(props: {
130 uri={props.previewUri}148 uri={props.previewUri}
131 source={props.videoSource}149 source={props.videoSource}
132 resolvePlaybackUri={props.resolveVideoUri}150 resolvePlaybackUri={props.resolveVideoUri}
133 unavailable={props.previewUnavailable}
134 />151 />
135 );152 );
136 }153 }
...@@ -588,6 +605,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -588,6 +605,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
588 assetPreviewUri === null || previewRevision === 0605 assetPreviewUri === null || previewRevision === 0
589 ? assetPreviewUri606 ? assetPreviewUri
590 : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`;607 : `${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 };
591 const needsFileContents =612 const needsFileContents =
592 relativePath !== null &&613 relativePath !== null &&
593 !isVideoFile &&614 !isVideoFile &&
...@@ -889,8 +910,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -889,8 +910,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
889 <FileContent910 <FileContent
890 key={previewKey}911 key={previewKey}
891 activeMode={resolvedActiveMode}912 activeMode={resolvedActiveMode}
913 environmentId={environmentId}
892 previewUri={previewUri}914 previewUri={previewUri}
893 previewUnavailable={assetPreview._tag === "Failure"}915 previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null}
916 onRetryPreview={handleRetryPreview}
894 videoSource={videoSource}917 videoSource={videoSource}
895 mediaSource={mediaSource}918 mediaSource={mediaSource}
896 resolveVideoUri={assetPreview.refresh}919 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 @@...@@ -1,6 +1,5 @@
1import { View } from "react-native";1import { View } from "react-native";
22
3import { EmptyState } from "../../components/EmptyState";
4import { MediaVideoPlayer } from "../../components/MediaVideoPlayer";3import { MediaVideoPlayer } from "../../components/MediaVideoPlayer";
5import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource";4import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource";
65
...@@ -11,21 +10,9 @@ export function WorkspaceFileVideoPreview(props: {...@@ -11,21 +10,9 @@ export function WorkspaceFileVideoPreview(props: {
11 readonly uri: string | null;10 readonly uri: string | null;
12 readonly source: MediaVideoPreviewSource | null;11 readonly source: MediaVideoPreviewSource | null;
13 readonly resolvePlaybackUri: () => Promise<string | null>;12 readonly resolvePlaybackUri: () => Promise<string | null>;
14 readonly unavailable: boolean;
15}) {13}) {
16 const uri = props.uri;14 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
29 return (16 return (
30 <View className="flex-1 items-center justify-center bg-sheet p-4">17 <View className="flex-1 items-center justify-center bg-sheet p-4">
31 <MediaVideoPlayer18 <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 @@...@@ -1,35 +1,64 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import {2import {
3 type AssetUrlState,3 type EnvironmentConnectionPhase,
4 presentConnectionState,
5} from "@t3tools/client-runtime/connection";
6import {
4 assetUrlStateFromResult,7 assetUrlStateFromResult,
5 createAssetEnvironmentAtoms,8 createAssetEnvironmentAtoms,
6 EMPTY_ASSET_URL_ATOM,9 EMPTY_ASSET_URL_ATOM,
7} from "@t3tools/client-runtime/state/assets";10} from "@t3tools/client-runtime/state/assets";
8import type { AssetResource, EnvironmentId } from "@t3tools/contracts";11import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
12import * as Option from "effect/Option";
13import { AsyncResult, Atom } from "effect/unstable/reactivity";
9import { useCallback } from "react";14import { useCallback } from "react";
1015
16import { environmentCatalog } from "../connection/catalog";
11import { connectionAtomRuntime } from "../connection/runtime";17import { connectionAtomRuntime } from "../connection/runtime";
18import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";
12import { usePreparedConnection } from "./session";19import { usePreparedConnection } from "./session";
13import { useAtomQueryRunner } from "./use-atom-query-runner";20import { 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
17export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);24export 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
19export function useAssetUrlState(40export function useAssetUrlState(
20 environmentId: EnvironmentId | null,41 environmentId: EnvironmentId | null,
21 resource: AssetResource | null,42 resource: AssetResource | null,
22): AssetUrlState {43): AssetUrlState {
23 const preparedConnection = usePreparedConnection(environmentId);44 const preparedConnection = usePreparedConnection(environmentId);
45 const connectionPhase = useConnectionPhase(environmentId);
24 const result = useAtomValue(46 const result = useAtomValue(
25 environmentId === null || resource === null47 environmentId === null || resource === null
26 ? EMPTY_ASSET_URL_ATOM48 ? EMPTY_ASSET_URL_ATOM
27 : assetEnvironment.createUrl({ environmentId, input: { resource } }),49 : assetEnvironment.createUrl({ environmentId, input: { resource } }),
28 );50 );
29 return assetUrlStateFromResult(51 const shared = assetUrlStateFromResult(
30 result,52 result,
31 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null,53 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null,
32 );54 );
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 });
33}62}
3463
35export function useAssetUrl(64export function useAssetUrl(