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 ? (
(
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,