diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx index 72a4a2be8e4b07222dc09c51b8a85ff309926293..f08b9343d630a5a9ddd15b00e486929ac19b6238 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx @@ -1,3 +1,4 @@ +import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { Image } from "expo-image"; import { createContext, useContext, useEffect, useEffectEvent, useId, useState } from "react"; @@ -35,6 +36,8 @@ export function ThreadMarkdownImageView(props: { readonly uri: string | null; readonly sourceKey: string; readonly unavailable: boolean; + /** Why the image is unavailable, when the cause is known. */ + readonly unavailableDetail?: string | undefined; readonly alt: string | null; /** Pixel size from the server, when it could read the header; the frame is final from the first render. */ readonly knownSize?: { readonly width: number; readonly height: number } | undefined; @@ -95,7 +98,11 @@ export function ThreadMarkdownImageView(props: { style={frameStyle} > {failed ? ( - Image unavailable + + {props.unavailableDetail + ? `Image unavailable. ${props.unavailableDetail}` + : "Image unavailable"} + ) : ( )} @@ -261,6 +268,11 @@ export function ThreadMarkdownImage(props: { : `workspace:${props.resource.path}` } unavailable={assetUrl._tag === "Failure"} + unavailableDetail={ + assetUrl._tag === "Failure" && assetUrl.reason === "missing-file-access" + ? MISSING_FILE_ACCESS_MESSAGE + : undefined + } knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined} format={ (props.resource._tag === "media-file" || props.resource._tag === "draft-workspace-file") && diff --git a/apps/mobile/src/state/asset-url-state.ts b/apps/mobile/src/state/asset-url-state.ts index e0b9d433c8a37330a830c80f6d7409d5f5498303..9c13cb879de83c37133e55014facf49cfcc2b087 100644 --- a/apps/mobile/src/state/asset-url-state.ts +++ b/apps/mobile/src/state/asset-url-state.ts @@ -1,7 +1,7 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import type { AssetUrlState as SharedAssetUrlState } from "@t3tools/client-runtime/state/assets"; -export type AssetUrlFailureReason = "disconnected" | "failed"; +export type AssetUrlFailureReason = "disconnected" | "failed" | "missing-file-access"; /** The shared state plus a reason on failure, so previews can offer the right retry. */ export type AssetUrlState = @@ -42,7 +42,7 @@ export function deriveAssetUrlState(input: { return { _tag: "Loading" }; case "connected": return input.shared._tag === "Failure" - ? { _tag: "Failure", reason: "failed" } + ? { _tag: "Failure", reason: input.shared.reason ?? "failed" } : { _tag: "Loading" }; } } diff --git a/apps/mobile/src/state/assets.test.ts b/apps/mobile/src/state/assets.test.ts index 428c8222f4b89b44b2d879b924593cdc2eae4f51..3c4dd9251bc8880ff620863cecebf1d45a427d7b 100644 --- a/apps/mobile/src/state/assets.test.ts +++ b/apps/mobile/src/state/assets.test.ts @@ -77,7 +77,10 @@ it.each(["workspace-file", "media-file"] as const)( it("hides host assets with a denied grant while preserving attachments", () => { state.session = { authenticated: true, scopes: [] }; - expect(useAssetUrlState(environmentId, resource)).toEqual({ _tag: "Failure", reason: "failed" }); + expect(useAssetUrlState(environmentId, resource)).toEqual({ + _tag: "Failure", + reason: "missing-file-access", + }); expect(state.assetQuery).not.toHaveBeenCalled(); expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({ _tag: "Success", diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index c853d61ceac578b5811c67e686d2f653f002b101..7aa9a78039de4567d256e60ef6409065ba74b10e 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; import { assetUrlStateFromResult, + blockedAssetUrlState, createAssetEnvironmentAtoms, createProjectFaviconUrlAtomFamily, EMPTY_ASSET_URL_ATOM, @@ -43,26 +44,20 @@ export function useAssetUrlState( session: fileAccessSession.data, sessionError: fileAccessSession.error, }); - const canReadResource = - fileAccess.canReadFiles || - (resource?._tag !== "workspace-file" && - resource?._tag !== "media-file" && - resource?._tag !== "draft-workspace-file"); + const blocked = blockedAssetUrlState(resource, fileAccess); const preparedConnection = usePreparedConnection(environmentId); const connectionPhase = fileEnvironment.presentation?.connection.phase ?? "available"; const result = useAtomValue( - !canReadResource || environmentId === null || resource === null + blocked !== null || environmentId === null || resource === null ? EMPTY_ASSET_URL_ATOM : assetEnvironment.createUrl({ environmentId, input: { resource } }), ); - const shared = !canReadResource - ? fileAccess.isPending - ? { _tag: "Loading" as const } - : { _tag: "Failure" as const } - : assetUrlStateFromResult( - result, - preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, - ); + const shared = + blocked ?? + assetUrlStateFromResult( + result, + preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, + ); return deriveAssetUrlState({ connectionPhase, // A failure left over from an outage is re-queried as soon as the diff --git a/apps/web/src/assets/assetUrls.test.ts b/apps/web/src/assets/assetUrls.test.ts index b8faa0c9c938d21490054f0a47a9c2fef9bcf72c..13ff3ea8e579786a4ddcbf15ac44e6d49625efae 100644 --- a/apps/web/src/assets/assetUrls.test.ts +++ b/apps/web/src/assets/assetUrls.test.ts @@ -76,7 +76,10 @@ it.each(["workspace-file", "media-file"] as const)( it("hides host assets with a denied grant while preserving attachments", () => { state.session = { authenticated: true, scopes: [] }; - expect(useAssetUrlState(environmentId, resource)).toEqual({ _tag: "Failure" }); + expect(useAssetUrlState(environmentId, resource)).toEqual({ + _tag: "Failure", + reason: "missing-file-access", + }); expect(state.assetQuery).not.toHaveBeenCalled(); expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({ _tag: "Success", diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index e401135d21d5acde0dd7821518576fa3adab3326..d09809836a85b7b90df52c569cab9ac8f1c20b26 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { type AssetUrlState, assetUrlStateFromResult, + blockedAssetUrlState, EMPTY_ASSET_URL_ATOM, resolveAssetUrl, } from "@t3tools/client-runtime/state/assets"; @@ -21,19 +22,14 @@ export function useAssetUrlState( environmentId: EnvironmentId | null, resource: AssetResource | null, ): AssetUrlState { - const fileAccess = useFilesystemReadAccess(environmentId); - const canReadResource = - fileAccess.canReadFiles || - (resource?._tag !== "workspace-file" && - resource?._tag !== "media-file" && - resource?._tag !== "draft-workspace-file"); + const blocked = blockedAssetUrlState(resource, useFilesystemReadAccess(environmentId)); const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( - !canReadResource || environmentId === null || resource === null + blocked !== null || environmentId === null || resource === null ? EMPTY_ASSET_URL_ATOM : assetEnvironment.createUrl({ environmentId, input: { resource } }), ); - if (!canReadResource) return { _tag: fileAccess.isPending ? "Loading" : "Failure" }; + if (blocked !== null) return blocked; return assetUrlStateFromResult( result, preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 34dc23858dcdd1367e1b87c4223ce812c032ca3e..e959f76a2593e6e3f8702ad94043e5ac02fe85de 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -71,6 +71,7 @@ import { markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; +import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; @@ -1349,13 +1350,15 @@ function expandableMarkdownImageProps( function ChatMarkdownMediaUnavailableLabel(props: { readonly alt: string; readonly kind?: "image" | "video" | undefined; + /** Why the media is unavailable, when the cause is known. */ + readonly detail?: string | undefined; }) { const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; return ( // Find indexes no image text, so this fallback must not highlight either. - {props.alt.length > 0 ? `${label} · ${props.alt}` : label} + {[label, props.alt, props.detail].filter(Boolean).join(" · ")} ); } @@ -1366,6 +1369,7 @@ function ChatMarkdownImageFallback(props: { readonly copyMarkdown?: string | undefined; readonly kind?: "image" | "video"; readonly actionsSource?: MediaActionSource | undefined; + readonly detail?: string | undefined; }) { const content = ( - + ); return props.actionsSource ? ( @@ -1408,6 +1412,8 @@ function ChatMarkdownImage(props: { /** Null while the URL is being resolved; the last decoded image stays up. */ readonly src: string | null; readonly sourceFailed?: boolean | undefined; + /** Why the source failed, when the cause is known. */ + readonly unavailableDetail?: string | undefined; readonly alt: string; readonly copyMarkdown: string | undefined; readonly standalone: boolean; @@ -1481,6 +1487,7 @@ function ChatMarkdownImage(props: { alt={props.alt} copyMarkdown={props.copyMarkdown} actionsSource={props.actionsSource} + detail={props.unavailableDetail} /> ) : ( {failed ? ( - + ) : src !== null ? ( {props.alt}( export type AssetUrlState = | { readonly _tag: "Loading" } - | { readonly _tag: "Failure" } + | { + readonly _tag: "Failure"; + /** Set when the session lacks `filesystem:read`; the file itself may be fine. */ + readonly reason?: "missing-file-access"; + } | { readonly _tag: "Success"; readonly url: string; @@ -81,6 +85,40 @@ export type AssetUrlState = readonly imageDimensions?: AssetImageDimensions; }; +/** Shown in place of a host file the session lacks `filesystem:read` for. */ +export const MISSING_FILE_ACCESS_MESSAGE = + "This connection cannot read host files. Pair again with a new link to view it."; + +/** + * The state for a host file this session cannot read yet, or null when the client + * should ask the host. The host requires `filesystem:read` for these resources, so + * clients skip the request. A connected session without the scope gets its own + * reason, so the UI can say to pair again instead of implying the file is gone. + */ +export function blockedAssetUrlState( + resource: AssetResource | null, + fileAccess: { + readonly canReadFiles: boolean; + readonly isPending: boolean; + readonly error: string | null; + }, +): AssetUrlState | null { + if ( + resource === null || + fileAccess.canReadFiles || + (resource._tag !== "workspace-file" && + resource._tag !== "media-file" && + resource._tag !== "draft-workspace-file") + ) { + return null; + } + if (fileAccess.isPending) return { _tag: "Loading" }; + // An error means the session check itself failed, such as when offline. + return fileAccess.error === null + ? { _tag: "Failure", reason: "missing-file-access" } + : { _tag: "Failure" }; +} + export function assetUrlStateFromResult( result: AsyncResult.AsyncResult, httpBaseUrl: string | null,