| author | |
| committer | |
| log | 2aa907b1969237efdaced29612fb46ba51be7041 |
| tree | 27e014622a2df6a4ff85b03f5401977a06b1a0da |
| parent | 06336460c9988f29c71e839c4c9c840c4552e077 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 37 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; |
| 38 | 38 | import { useEnvironmentQuery } from "../../state/query"; |
| 39 | 39 | import { projectEnvironment } from "../../state/projects"; |
| 40 | import type { AssetUrlFailureReason } from "../../state/asset-url-state"; | |
| 40 | 41 | import { |
| 41 | 42 | useAdaptiveWorkspaceLayout, |
| 42 | 43 | useAdaptiveWorkspacePaneRole, |
| ... | ... | @@ -56,6 +57,7 @@ import { preloadWorkspaceFileContents } from "./preload-workspace-file"; |
| 56 | 57 | import { SourceFileSurface } from "./SourceFileSurface"; |
| 57 | 58 | import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; |
| 58 | 59 | import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; |
| 60 | import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError"; | |
| 59 | 61 | import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; |
| 60 | 62 | import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; |
| 61 | 63 | import { |
| ... | ... | @@ -104,8 +106,10 @@ function defaultViewMode(path: string | null): FileViewMode { |
| 104 | 106 | |
| 105 | 107 | function FileContent(props: { |
| 106 | 108 | readonly activeMode: FileViewMode; |
| 109 | readonly environmentId: EnvironmentId | null; | |
| 107 | 110 | readonly previewUri: string | null; |
| 108 | readonly previewUnavailable: boolean; | |
| 111 | readonly previewFailure: AssetUrlFailureReason | null; | |
| 112 | readonly onRetryPreview: () => void; | |
| 109 | 113 | readonly videoSource: MediaVideoPreviewSource | null; |
| 110 | 114 | readonly mediaSource?: MediaActionsSource; |
| 111 | 115 | readonly resolveVideoUri: () => Promise<string | null>; |
| ... | ... | @@ -121,8 +125,22 @@ function FileContent(props: { |
| 121 | 125 | const isMarkdown = isMarkdownPreviewFile(props.relativePath); |
| 122 | 126 | const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath); |
| 123 | 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)); | |
| 124 | 132 | |
| 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 | 144 | return ( |
| 127 | 145 | <WorkspaceFileVideoPreview |
| 128 | 146 | name={basename(props.relativePath)} |
| ... | ... | @@ -130,7 +148,6 @@ function FileContent(props: { |
| 130 | 148 | uri={props.previewUri} |
| 131 | 149 | source={props.videoSource} |
| 132 | 150 | resolvePlaybackUri={props.resolveVideoUri} |
| 133 | unavailable={props.previewUnavailable} | |
| 134 | 151 | /> |
| 135 | 152 | ); |
| 136 | 153 | } |
| ... | ... | @@ -588,6 +605,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 588 | 605 | assetPreviewUri === null || previewRevision === 0 |
| 589 | 606 | ? assetPreviewUri |
| 590 | 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 | 612 | const needsFileContents = |
| 592 | 613 | relativePath !== null && |
| 593 | 614 | !isVideoFile && |
| ... | ... | @@ -889,8 +910,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 889 | 910 | <FileContent |
| 890 | 911 | key={previewKey} |
| 891 | 912 | activeMode={resolvedActiveMode} |
| 913 | environmentId={environmentId} | |
| 892 | 914 | previewUri={previewUri} |
| 893 | previewUnavailable={assetPreview._tag === "Failure"} | |
| 915 | previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null} | |
| 916 | onRetryPreview={handleRetryPreview} | |
| 894 | 917 | videoSource={videoSource} |
| 895 | 918 | mediaSource={mediaSource} |
| 896 | 919 | resolveVideoUri={assetPreview.refresh} |
apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx created+59| ... | ... | @@ -0,0 +1,59 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { useCallback } from "react"; | |
| 3 | import { View } from "react-native"; | |
| 4 | ||
| 5 | import { EmptyState } from "../../components/EmptyState"; | |
| 6 | import { environmentCatalog } from "../../connection/catalog"; | |
| 7 | import type { AssetUrlFailureReason } from "../../state/asset-url-state"; | |
| 8 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 9 | import { useEnvironmentPresentation } from "../../state/presentation"; | |
| 10 | import { 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 | */ | |
| 17 | export 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 | 1 | import { View } from "react-native"; |
| 2 | 2 | |
| 3 | import { EmptyState } from "../../components/EmptyState"; | |
| 4 | 3 | import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; |
| 5 | 4 | import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; |
| 6 | 5 | |
| ... | ... | @@ -11,21 +10,9 @@ export function WorkspaceFileVideoPreview(props: { |
| 11 | 10 | readonly uri: string | null; |
| 12 | 11 | readonly source: MediaVideoPreviewSource | null; |
| 13 | 12 | readonly resolvePlaybackUri: () => Promise<string | null>; |
| 14 | readonly unavailable: boolean; | |
| 15 | 13 | }) { |
| 16 | 14 | const uri = props.uri; |
| 17 | 15 | |
| 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 | 16 | return ( |
| 30 | 17 | <View className="flex-1 items-center justify-center bg-sheet p-4"> |
| 31 | 18 | <MediaVideoPlayer |
apps/mobile/src/state/asset-url-state.test.ts created+54| ... | ... | @@ -0,0 +1,54 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { deriveAssetUrlState } from "./asset-url-state"; | |
| 4 | ||
| 5 | const SUCCESS = { _tag: "Success" as const, url: "https://environment.example/api/assets/abc" }; | |
| 6 | ||
| 7 | describe("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 @@ |
| 1 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | |
| 2 | import type { AssetUrlState as SharedAssetUrlState } from "@t3tools/client-runtime/state/assets"; | |
| 3 | ||
| 4 | export type AssetUrlFailureReason = "disconnected" | "failed"; | |
| 5 | ||
| 6 | /** The shared state plus a reason on failure, so previews can offer the right retry. */ | |
| 7 | export 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 | */ | |
| 17 | export 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 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { |
| 3 | type AssetUrlState, | |
| 3 | type EnvironmentConnectionPhase, | |
| 4 | presentConnectionState, | |
| 5 | } from "@t3tools/client-runtime/connection"; | |
| 6 | import { | |
| 4 | 7 | assetUrlStateFromResult, |
| 5 | 8 | createAssetEnvironmentAtoms, |
| 6 | 9 | EMPTY_ASSET_URL_ATOM, |
| 7 | 10 | } from "@t3tools/client-runtime/state/assets"; |
| 8 | 11 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 12 | import * as Option from "effect/Option"; | |
| 13 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | |
| 9 | 14 | import { useCallback } from "react"; |
| 10 | 15 | |
| 16 | import { environmentCatalog } from "../connection/catalog"; | |
| 11 | 17 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 18 | import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; | |
| 12 | 19 | import { usePreparedConnection } from "./session"; |
| 13 | 20 | import { useAtomQueryRunner } from "./use-atom-query-runner"; |
| 14 | 21 | |
| 15 | export type { AssetUrlState } from "@t3tools/client-runtime/state/assets"; | |
| 22 | export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; | |
| 16 | 23 | |
| 17 | 24 | export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); |
| 18 | 25 | |
| 26 | const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe( | |
| 27 | Atom.withLabel("mobile-asset-connection-state:empty"), | |
| 28 | ); | |
| 29 | ||
| 30 | function 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 | ||
| 19 | 40 | export function useAssetUrlState( |
| 20 | 41 | environmentId: EnvironmentId | null, |
| 21 | 42 | resource: AssetResource | null, |
| 22 | 43 | ): AssetUrlState { |
| 23 | 44 | const preparedConnection = usePreparedConnection(environmentId); |
| 45 | const connectionPhase = useConnectionPhase(environmentId); | |
| 24 | 46 | const result = useAtomValue( |
| 25 | 47 | environmentId === null || resource === null |
| 26 | 48 | ? EMPTY_ASSET_URL_ATOM |
| 27 | 49 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), |
| 28 | 50 | ); |
| 29 | return assetUrlStateFromResult( | |
| 51 | const shared = assetUrlStateFromResult( | |
| 30 | 52 | result, |
| 31 | 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 | } |
| 34 | 63 | |
| 35 | 64 | export function useAssetUrl( |