authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 02:19:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 02:19:17-07:00
log4cf8243f855da1abc79f5b09c58065c0fb328c0e
treebd9f75c057203bcb7e78480445490e1274d357f4
parentcd45be3b57dc34796c21e7bbad9ae55fef5a583b
signature Signed by PGP key B5690EEEBB952194

fix(clients): host images say when the connection lacks file access (#19337)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

8 files changed, 92 insertions(+), 31 deletions(-)

apps/mobile/src/features/threads/ThreadMarkdownImage.tsx+13-1
......@@ -1,3 +1,4 @@
1import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets";
12import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
23import { Image } from "expo-image";
34import { createContext, useContext, useEffect, useEffectEvent, useId, useState } from "react";
......@@ -35,6 +36,8 @@ export function ThreadMarkdownImageView(props: {
3536 readonly uri: string | null;
3637 readonly sourceKey: string;
3738 readonly unavailable: boolean;
39 /** Why the image is unavailable, when the cause is known. */
40 readonly unavailableDetail?: string | undefined;
3841 readonly alt: string | null;
3942 /** Pixel size from the server, when it could read the header; the frame is final from the first render. */
4043 readonly knownSize?: { readonly width: number; readonly height: number } | undefined;
......@@ -95,7 +98,11 @@ export function ThreadMarkdownImageView(props: {
9598 style={frameStyle}
9699 >
97100 {failed ? (
98 <Text className="text-xs text-foreground-muted">Image unavailable</Text>
101 <Text className="px-3 text-center text-xs text-foreground-muted">
102 {props.unavailableDetail
103 ? `Image unavailable. ${props.unavailableDetail}`
104 : "Image unavailable"}
105 </Text>
99106 ) : (
100107 <AppActivityIndicator />
101108 )}
......@@ -261,6 +268,11 @@ export function ThreadMarkdownImage(props: {
261268 : `workspace:${props.resource.path}`
262269 }
263270 unavailable={assetUrl._tag === "Failure"}
271 unavailableDetail={
272 assetUrl._tag === "Failure" && assetUrl.reason === "missing-file-access"
273 ? MISSING_FILE_ACCESS_MESSAGE
274 : undefined
275 }
264276 knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined}
265277 format={
266278 (props.resource._tag === "media-file" || props.resource._tag === "draft-workspace-file") &&
apps/mobile/src/state/asset-url-state.ts+2-2
......@@ -1,7 +1,7 @@
11import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
22import type { AssetUrlState as SharedAssetUrlState } from "@t3tools/client-runtime/state/assets";
33
4export type AssetUrlFailureReason = "disconnected" | "failed";
4export type AssetUrlFailureReason = "disconnected" | "failed" | "missing-file-access";
55
66/** The shared state plus a reason on failure, so previews can offer the right retry. */
77export type AssetUrlState =
......@@ -42,7 +42,7 @@ export function deriveAssetUrlState(input: {
4242 return { _tag: "Loading" };
4343 case "connected":
4444 return input.shared._tag === "Failure"
45 ? { _tag: "Failure", reason: "failed" }
45 ? { _tag: "Failure", reason: input.shared.reason ?? "failed" }
4646 : { _tag: "Loading" };
4747 }
4848}
apps/mobile/src/state/assets.test.ts+4-1
......@@ -77,7 +77,10 @@ it.each(["workspace-file", "media-file"] as const)(
7777
7878it("hides host assets with a denied grant while preserving attachments", () => {
7979 state.session = { authenticated: true, scopes: [] };
80 expect(useAssetUrlState(environmentId, resource)).toEqual({ _tag: "Failure", reason: "failed" });
80 expect(useAssetUrlState(environmentId, resource)).toEqual({
81 _tag: "Failure",
82 reason: "missing-file-access",
83 });
8184 expect(state.assetQuery).not.toHaveBeenCalled();
8285 expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({
8386 _tag: "Success",
apps/mobile/src/state/assets.ts+9-14
......@@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
22import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
33import {
44 assetUrlStateFromResult,
5 blockedAssetUrlState,
56 createAssetEnvironmentAtoms,
67 createProjectFaviconUrlAtomFamily,
78 EMPTY_ASSET_URL_ATOM,
......@@ -43,26 +44,20 @@ export function useAssetUrlState(
4344 session: fileAccessSession.data,
4445 sessionError: fileAccessSession.error,
4546 });
46 const canReadResource =
47 fileAccess.canReadFiles ||
48 (resource?._tag !== "workspace-file" &&
49 resource?._tag !== "media-file" &&
50 resource?._tag !== "draft-workspace-file");
47 const blocked = blockedAssetUrlState(resource, fileAccess);
5148 const preparedConnection = usePreparedConnection(environmentId);
5249 const connectionPhase = fileEnvironment.presentation?.connection.phase ?? "available";
5350 const result = useAtomValue(
54 !canReadResource || environmentId === null || resource === null
51 blocked !== null || environmentId === null || resource === null
5552 ? EMPTY_ASSET_URL_ATOM
5653 : assetEnvironment.createUrl({ environmentId, input: { resource } }),
5754 );
58 const shared = !canReadResource
59 ? fileAccess.isPending
60 ? { _tag: "Loading" as const }
61 : { _tag: "Failure" as const }
62 : assetUrlStateFromResult(
63 result,
64 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null,
65 );
55 const shared =
56 blocked ??
57 assetUrlStateFromResult(
58 result,
59 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null,
60 );
6661 return deriveAssetUrlState({
6762 connectionPhase,
6863 // A failure left over from an outage is re-queried as soon as the
apps/web/src/assets/assetUrls.test.ts+4-1
......@@ -76,7 +76,10 @@ it.each(["workspace-file", "media-file"] as const)(
7676
7777it("hides host assets with a denied grant while preserving attachments", () => {
7878 state.session = { authenticated: true, scopes: [] };
79 expect(useAssetUrlState(environmentId, resource)).toEqual({ _tag: "Failure" });
79 expect(useAssetUrlState(environmentId, resource)).toEqual({
80 _tag: "Failure",
81 reason: "missing-file-access",
82 });
8083 expect(state.assetQuery).not.toHaveBeenCalled();
8184 expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({
8285 _tag: "Success",
apps/web/src/assets/assetUrls.ts+4-8
......@@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
22import {
33 type AssetUrlState,
44 assetUrlStateFromResult,
5 blockedAssetUrlState,
56 EMPTY_ASSET_URL_ATOM,
67 resolveAssetUrl,
78} from "@t3tools/client-runtime/state/assets";
......@@ -21,19 +22,14 @@ export function useAssetUrlState(
2122 environmentId: EnvironmentId | null,
2223 resource: AssetResource | null,
2324): AssetUrlState {
24 const fileAccess = useFilesystemReadAccess(environmentId);
25 const canReadResource =
26 fileAccess.canReadFiles ||
27 (resource?._tag !== "workspace-file" &&
28 resource?._tag !== "media-file" &&
29 resource?._tag !== "draft-workspace-file");
25 const blocked = blockedAssetUrlState(resource, useFilesystemReadAccess(environmentId));
3026 const preparedConnection = usePreparedConnection(environmentId);
3127 const result = useAtomValue(
32 !canReadResource || environmentId === null || resource === null
28 blocked !== null || environmentId === null || resource === null
3329 ? EMPTY_ASSET_URL_ATOM
3430 : assetEnvironment.createUrl({ environmentId, input: { resource } }),
3531 );
36 if (!canReadResource) return { _tag: fileAccess.isPending ? "Loading" : "Failure" };
32 if (blocked !== null) return blocked;
3733 return assetUrlStateFromResult(
3834 result,
3935 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null,
apps/web/src/components/ChatMarkdown.tsx+17-3
......@@ -71,6 +71,7 @@ import {
7171 markdownImageSourceFragment,
7272} from "@t3tools/client-runtime/markdown-images";
7373import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";
74import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets";
7475import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
7576import * as Cause from "effect/Cause";
7677import { AsyncResult } from "effect/reactivity";
......@@ -1349,13 +1350,15 @@ function expandableMarkdownImageProps(
13491350function ChatMarkdownMediaUnavailableLabel(props: {
13501351 readonly alt: string;
13511352 readonly kind?: "image" | "video" | undefined;
1353 /** Why the media is unavailable, when the cause is known. */
1354 readonly detail?: string | undefined;
13521355}) {
13531356 const label = props.kind === "video" ? "Video unavailable" : "Image unavailable";
13541357 return (
13551358 // Find indexes no image text, so this fallback must not highlight either.
13561359 <span data-thread-find-ignore className="inline-flex items-center gap-1.5">
13571360 <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />
1358 {props.alt.length > 0 ? `${label} · ${props.alt}` : label}
1361 {[label, props.alt, props.detail].filter(Boolean).join(" · ")}
13591362 </span>
13601363 );
13611364}
......@@ -1366,6 +1369,7 @@ function ChatMarkdownImageFallback(props: {
13661369 readonly copyMarkdown?: string | undefined;
13671370 readonly kind?: "image" | "video";
13681371 readonly actionsSource?: MediaActionSource | undefined;
1372 readonly detail?: string | undefined;
13691373}) {
13701374 const content = (
13711375 <span
......@@ -1375,7 +1379,7 @@ function ChatMarkdownImageFallback(props: {
13751379 "rounded-md border border-border/40 bg-muted/40 px-2 py-1 text-xs text-muted-foreground",
13761380 )}
13771381 >
1378 <ChatMarkdownMediaUnavailableLabel alt={props.alt} kind={props.kind} />
1382 <ChatMarkdownMediaUnavailableLabel alt={props.alt} kind={props.kind} detail={props.detail} />
13791383 </span>
13801384 );
13811385 return props.actionsSource ? (
......@@ -1408,6 +1412,8 @@ function ChatMarkdownImage(props: {
14081412 /** Null while the URL is being resolved; the last decoded image stays up. */
14091413 readonly src: string | null;
14101414 readonly sourceFailed?: boolean | undefined;
1415 /** Why the source failed, when the cause is known. */
1416 readonly unavailableDetail?: string | undefined;
14111417 readonly alt: string;
14121418 readonly copyMarkdown: string | undefined;
14131419 readonly standalone: boolean;
......@@ -1481,6 +1487,7 @@ function ChatMarkdownImage(props: {
14811487 alt={props.alt}
14821488 copyMarkdown={props.copyMarkdown}
14831489 actionsSource={props.actionsSource}
1490 detail={props.unavailableDetail}
14841491 />
14851492 ) : (
14861493 <span
......@@ -1509,7 +1516,7 @@ function ChatMarkdownImage(props: {
15091516 >
15101517 {failed ? (
15111518 <span className="flex size-full items-center justify-center p-2 text-center text-xs text-muted-foreground">
1512 <ChatMarkdownMediaUnavailableLabel alt={props.alt} />
1519 <ChatMarkdownMediaUnavailableLabel alt={props.alt} detail={props.unavailableDetail} />
15131520 </span>
15141521 ) : src !== null ? (
15151522 <img
......@@ -1676,6 +1683,13 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
16761683 key={JSON.stringify([props.environmentId, props.resource, props.srcFragment])}
16771684 src={src}
16781685 sourceFailed={assetUrl._tag === "Failure" && fallbackSrc === undefined}
1686 unavailableDetail={
1687 assetUrl._tag === "Failure" &&
1688 assetUrl.reason === "missing-file-access" &&
1689 fallbackSrc === undefined
1690 ? MISSING_FILE_ACCESS_MESSAGE
1691 : undefined
1692 }
16791693 alt={props.alt}
16801694 copyMarkdown={props.copyMarkdown}
16811695 standalone={props.standalone ?? true}
packages/client-runtime/src/state/assets.ts+39-1
......@@ -69,7 +69,11 @@ export const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial<never, never>(
6969
7070export type AssetUrlState =
7171 | { readonly _tag: "Loading" }
72 | { readonly _tag: "Failure" }
72 | {
73 readonly _tag: "Failure";
74 /** Set when the session lacks `filesystem:read`; the file itself may be fine. */
75 readonly reason?: "missing-file-access";
76 }
7377 | {
7478 readonly _tag: "Success";
7579 readonly url: string;
......@@ -81,6 +85,40 @@ export type AssetUrlState =
8185 readonly imageDimensions?: AssetImageDimensions;
8286 };
8387
88/** Shown in place of a host file the session lacks `filesystem:read` for. */
89export const MISSING_FILE_ACCESS_MESSAGE =
90 "This connection cannot read host files. Pair again with a new link to view it.";
91
92/**
93 * The state for a host file this session cannot read yet, or null when the client
94 * should ask the host. The host requires `filesystem:read` for these resources, so
95 * clients skip the request. A connected session without the scope gets its own
96 * reason, so the UI can say to pair again instead of implying the file is gone.
97 */
98export function blockedAssetUrlState(
99 resource: AssetResource | null,
100 fileAccess: {
101 readonly canReadFiles: boolean;
102 readonly isPending: boolean;
103 readonly error: string | null;
104 },
105): AssetUrlState | null {
106 if (
107 resource === null ||
108 fileAccess.canReadFiles ||
109 (resource._tag !== "workspace-file" &&
110 resource._tag !== "media-file" &&
111 resource._tag !== "draft-workspace-file")
112 ) {
113 return null;
114 }
115 if (fileAccess.isPending) return { _tag: "Loading" };
116 // An error means the session check itself failed, such as when offline.
117 return fileAccess.error === null
118 ? { _tag: "Failure", reason: "missing-file-access" }
119 : { _tag: "Failure" };
120}
121
84122export function assetUrlStateFromResult(
85123 result: AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>,
86124 httpBaseUrl: string | null,