diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index bc5bd51888d9..09bb94bfb5de 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -115,6 +115,7 @@ interface MediaVideoPlayerProps { readonly thumbnailKey: string; readonly thumbnailVisible?: boolean; readonly unavailable?: boolean; + readonly unavailableDetail?: string; readonly expanded?: boolean; readonly autoPlay?: boolean; readonly paused?: boolean; @@ -126,6 +127,9 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { const [playbackUri, setPlaybackUri] = useState(props.expanded ? props.uri : null); // Keep an opened player mounted while signing or reconnecting temporarily has no usable URL. if (playbackUri === null && props.expanded && props.uri !== null) setPlaybackUri(props.uri); + const unavailableText = props.unavailableDetail + ? `Video unavailable. ${props.unavailableDetail}` + : "Video unavailable"; return ( 0 ? "Touch and hold for media actions" : undefined } @@ -163,7 +169,7 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { /> ) : null} {props.unavailable ? ( - Video unavailable + {unavailableText} ) : props.uri === null ? ( ) : ( diff --git a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx index e01f6ae3b2aa..7e03a69dbeb5 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx @@ -1,3 +1,4 @@ +import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; import { createContext, useContext } from "react"; import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; @@ -39,6 +40,11 @@ export function ThreadMarkdownVideo(props: { thumbnailKey={thumbnailKey} thumbnailVisible={visible} unavailable={"resource" in source && asset._tag === "Failure"} + unavailableDetail={ + "resource" in source && asset._tag === "Failure" && asset.reason === "missing-file-access" + ? MISSING_FILE_ACCESS_MESSAGE + : undefined + } actionsSource={source.actionsSource} /> ); diff --git a/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx b/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx index 6e6d5fc368f4..daf0d97aab9a 100644 --- a/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx @@ -1,3 +1,4 @@ +import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; import { act } from "react"; @@ -5,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; const mint = vi.hoisted(() => vi.fn()); +const deniedEnvironmentId = vi.hoisted(() => "denied-environment"); // Atoms derived from the server config run in the real registry over a missing config. vi.mock(import("../state/server"), async (original) => { const actual = await original(); @@ -25,6 +27,16 @@ vi.mock(import("../state/session"), async (original) => ({ useEnvironmentScope: () => false, readPreparedConnection: (() => ({ httpBaseUrl: "https://host.test" })) as never, })); +vi.mock(import("../state/filesystem"), async (original) => { + const actual = await original(); + return { + ...actual, + useFilesystemReadAccess: ((environmentId: EnvironmentId | null) => + environmentId === deniedEnvironmentId + ? { canReadFiles: false, isPending: false, error: null } + : actual.useFilesystemReadAccess(environmentId)) as never, + }; +}); import ChatMarkdown from "./ChatMarkdown"; @@ -68,3 +80,23 @@ it("opens host media through server authorization before the client grant loads" ), ); }); + +it("explains a host video the connection cannot read instead of offering a retry", async () => { + const container = document.body.appendChild(document.createElement("div")); + const root = createRoot(container); + roots.push(root); + await act(async () => + root.render( + , + ), + ); + await vi.waitFor(() => expect(container.textContent).toContain(MISSING_FILE_ACCESS_MESSAGE)); + expect(container.textContent).not.toContain("Retry video"); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a608ecb0298b..abee35edfbb2 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1517,6 +1517,7 @@ function ChatMarkdownVideo(props: { readonly copyMarkdown: string | undefined; readonly originalUrl?: string | undefined; readonly sourceFailed?: boolean | undefined; + readonly unavailableDetail?: string | undefined; readonly style?: CSSProperties | undefined; readonly mediaIdentity?: string | undefined; readonly actionsSource?: MediaActionSource | undefined; @@ -1527,6 +1528,7 @@ function ChatMarkdownVideo(props: { key={props.mediaIdentity ?? props.copyMarkdown ?? props.src} src={props.src} sourceFailed={props.sourceFailed} + unavailableDetail={props.unavailableDetail} label={props.alt} originalUrl={props.originalUrl} style={props.style} @@ -1592,6 +1594,10 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props : undefined; const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; const fallbackSrc = assetUrl._tag === "Failure" ? props.fallbackSrc : undefined; + const missingFileAccess = + assetUrl._tag === "Failure" && + assetUrl.reason === "missing-file-access" && + fallbackSrc === undefined; const src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") @@ -1633,12 +1639,14 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props ); @@ -1649,13 +1657,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props key={JSON.stringify([props.environmentId, props.resource, props.srcFragment])} src={src} sourceFailed={assetUrl._tag === "Failure" && fallbackSrc === undefined} - unavailableDetail={ - assetUrl._tag === "Failure" && - assetUrl.reason === "missing-file-access" && - fallbackSrc === undefined - ? MISSING_FILE_ACCESS_MESSAGE - : undefined - } + unavailableDetail={missingFileAccess ? MISSING_FILE_ACCESS_MESSAGE : undefined} alt={props.alt} copyMarkdown={props.copyMarkdown} standalone={props.standalone ?? true} diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index 1392b995c536..1e7863858ddd 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -13,6 +13,7 @@ interface MediaVideoPlayerProps { readonly src: string | null; readonly label: string; readonly sourceFailed?: boolean | undefined; + readonly unavailableDetail?: string | undefined; readonly originalUrl?: string | undefined; readonly revision?: string | null | undefined; readonly preload?: "visible" | "metadata" | undefined; @@ -31,6 +32,7 @@ export function MediaVideoPlayer({ src: latestSrc, label, sourceFailed = false, + unavailableDetail, originalUrl, revision = null, preload = "visible", @@ -137,9 +139,9 @@ export function MediaVideoPlayer({ stateClassName, )} > - + - Video unavailable{label ? ` · ${label}` : ""} + {["Video unavailable", label, unavailableDetail].filter(Boolean).join(" · ")} {latestSrc !== null || onRetry ? (