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 @@...@@ -1,3 +1,4 @@
1import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets";
1import type { AssetResource, EnvironmentId } from "@t3tools/contracts";2import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
2import { Image } from "expo-image";3import { Image } from "expo-image";
3import { createContext, useContext, useEffect, useEffectEvent, useId, useState } from "react";4import { createContext, useContext, useEffect, useEffectEvent, useId, useState } from "react";
...@@ -35,6 +36,8 @@ export function ThreadMarkdownImageView(props: {...@@ -35,6 +36,8 @@ export function ThreadMarkdownImageView(props: {
35 readonly uri: string | null;36 readonly uri: string | null;
36 readonly sourceKey: string;37 readonly sourceKey: string;
37 readonly unavailable: boolean;38 readonly unavailable: boolean;
39 /** Why the image is unavailable, when the cause is known. */
40 readonly unavailableDetail?: string | undefined;
38 readonly alt: string | null;41 readonly alt: string | null;
39 /** Pixel size from the server, when it could read the header; the frame is final from the first render. */42 /** Pixel size from the server, when it could read the header; the frame is final from the first render. */
40 readonly knownSize?: { readonly width: number; readonly height: number } | undefined;43 readonly knownSize?: { readonly width: number; readonly height: number } | undefined;
...@@ -95,7 +98,11 @@ export function ThreadMarkdownImageView(props: {...@@ -95,7 +98,11 @@ export function ThreadMarkdownImageView(props: {
95 style={frameStyle}98 style={frameStyle}
96 >99 >
97 {failed ? (100 {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>
99 ) : (106 ) : (
100 <AppActivityIndicator />107 <AppActivityIndicator />
101 )}108 )}
...@@ -261,6 +268,11 @@ export function ThreadMarkdownImage(props: {...@@ -261,6 +268,11 @@ export function ThreadMarkdownImage(props: {
261 : `workspace:${props.resource.path}`268 : `workspace:${props.resource.path}`
262 }269 }
263 unavailable={assetUrl._tag === "Failure"}270 unavailable={assetUrl._tag === "Failure"}
271 unavailableDetail={
272 assetUrl._tag === "Failure" && assetUrl.reason === "missing-file-access"
273 ? MISSING_FILE_ACCESS_MESSAGE
274 : undefined
275 }
264 knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined}276 knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined}
265 format={277 format={
266 (props.resource._tag === "media-file" || props.resource._tag === "draft-workspace-file") &&278 (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 @@...@@ -1,7 +1,7 @@
1import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";1import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2import type { AssetUrlState as SharedAssetUrlState } from "@t3tools/client-runtime/state/assets";2import 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
6/** The shared state plus a reason on failure, so previews can offer the right retry. */6/** The shared state plus a reason on failure, so previews can offer the right retry. */
7export type AssetUrlState =7export type AssetUrlState =
...@@ -42,7 +42,7 @@ export function deriveAssetUrlState(input: {...@@ -42,7 +42,7 @@ export function deriveAssetUrlState(input: {
42 return { _tag: "Loading" };42 return { _tag: "Loading" };
43 case "connected":43 case "connected":
44 return input.shared._tag === "Failure"44 return input.shared._tag === "Failure"
45 ? { _tag: "Failure", reason: "failed" }45 ? { _tag: "Failure", reason: input.shared.reason ?? "failed" }
46 : { _tag: "Loading" };46 : { _tag: "Loading" };
47 }47 }
48}48}
apps/mobile/src/state/assets.test.ts+4-1
...@@ -77,7 +77,10 @@ it.each(["workspace-file", "media-file"] as const)(...@@ -77,7 +77,10 @@ it.each(["workspace-file", "media-file"] as const)(
7777
78it("hides host assets with a denied grant while preserving attachments", () => {78it("hides host assets with a denied grant while preserving attachments", () => {
79 state.session = { authenticated: true, scopes: [] };79 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 });
81 expect(state.assetQuery).not.toHaveBeenCalled();84 expect(state.assetQuery).not.toHaveBeenCalled();
82 expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({85 expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({
83 _tag: "Success",86 _tag: "Success",
apps/mobile/src/state/assets.ts+9-14
...@@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";...@@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
2import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";2import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
3import {3import {
4 assetUrlStateFromResult,4 assetUrlStateFromResult,
5 blockedAssetUrlState,
5 createAssetEnvironmentAtoms,6 createAssetEnvironmentAtoms,
6 createProjectFaviconUrlAtomFamily,7 createProjectFaviconUrlAtomFamily,
7 EMPTY_ASSET_URL_ATOM,8 EMPTY_ASSET_URL_ATOM,
...@@ -43,26 +44,20 @@ export function useAssetUrlState(...@@ -43,26 +44,20 @@ export function useAssetUrlState(
43 session: fileAccessSession.data,44 session: fileAccessSession.data,
44 sessionError: fileAccessSession.error,45 sessionError: fileAccessSession.error,
45 });46 });
46 const canReadResource =47 const blocked = blockedAssetUrlState(resource, fileAccess);
47 fileAccess.canReadFiles ||
48 (resource?._tag !== "workspace-file" &&
49 resource?._tag !== "media-file" &&
50 resource?._tag !== "draft-workspace-file");
51 const preparedConnection = usePreparedConnection(environmentId);48 const preparedConnection = usePreparedConnection(environmentId);
52 const connectionPhase = fileEnvironment.presentation?.connection.phase ?? "available";49 const connectionPhase = fileEnvironment.presentation?.connection.phase ?? "available";
53 const result = useAtomValue(50 const result = useAtomValue(
54 !canReadResource || environmentId === null || resource === null51 blocked !== null || environmentId === null || resource === null
55 ? EMPTY_ASSET_URL_ATOM52 ? EMPTY_ASSET_URL_ATOM
56 : assetEnvironment.createUrl({ environmentId, input: { resource } }),53 : assetEnvironment.createUrl({ environmentId, input: { resource } }),
57 );54 );
58 const shared = !canReadResource55 const shared =
59 ? fileAccess.isPending56 blocked ??
60 ? { _tag: "Loading" as const }57 assetUrlStateFromResult(
61 : { _tag: "Failure" as const }58 result,
62 : assetUrlStateFromResult(59 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null,
63 result,60 );
64 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null,
65 );
66 return deriveAssetUrlState({61 return deriveAssetUrlState({
67 connectionPhase,62 connectionPhase,
68 // A failure left over from an outage is re-queried as soon as the63 // 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)(...@@ -76,7 +76,10 @@ it.each(["workspace-file", "media-file"] as const)(
7676
77it("hides host assets with a denied grant while preserving attachments", () => {77it("hides host assets with a denied grant while preserving attachments", () => {
78 state.session = { authenticated: true, scopes: [] };78 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 });
80 expect(state.assetQuery).not.toHaveBeenCalled();83 expect(state.assetQuery).not.toHaveBeenCalled();
81 expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({84 expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({
82 _tag: "Success",85 _tag: "Success",
apps/web/src/assets/assetUrls.ts+4-8
...@@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";...@@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
2import {2import {
3 type AssetUrlState,3 type AssetUrlState,
4 assetUrlStateFromResult,4 assetUrlStateFromResult,
5 blockedAssetUrlState,
5 EMPTY_ASSET_URL_ATOM,6 EMPTY_ASSET_URL_ATOM,
6 resolveAssetUrl,7 resolveAssetUrl,
7} from "@t3tools/client-runtime/state/assets";8} from "@t3tools/client-runtime/state/assets";
...@@ -21,19 +22,14 @@ export function useAssetUrlState(...@@ -21,19 +22,14 @@ export function useAssetUrlState(
21 environmentId: EnvironmentId | null,22 environmentId: EnvironmentId | null,
22 resource: AssetResource | null,23 resource: AssetResource | null,
23): AssetUrlState {24): AssetUrlState {
24 const fileAccess = useFilesystemReadAccess(environmentId);25 const blocked = blockedAssetUrlState(resource, useFilesystemReadAccess(environmentId));
25 const canReadResource =
26 fileAccess.canReadFiles ||
27 (resource?._tag !== "workspace-file" &&
28 resource?._tag !== "media-file" &&
29 resource?._tag !== "draft-workspace-file");
30 const preparedConnection = usePreparedConnection(environmentId);26 const preparedConnection = usePreparedConnection(environmentId);
31 const result = useAtomValue(27 const result = useAtomValue(
32 !canReadResource || environmentId === null || resource === null28 blocked !== null || environmentId === null || resource === null
33 ? EMPTY_ASSET_URL_ATOM29 ? EMPTY_ASSET_URL_ATOM
34 : assetEnvironment.createUrl({ environmentId, input: { resource } }),30 : assetEnvironment.createUrl({ environmentId, input: { resource } }),
35 );31 );
36 if (!canReadResource) return { _tag: fileAccess.isPending ? "Loading" : "Failure" };32 if (blocked !== null) return blocked;
37 return assetUrlStateFromResult(33 return assetUrlStateFromResult(
38 result,34 result,
39 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null,35 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null,
apps/web/src/components/ChatMarkdown.tsx+17-3
...@@ -71,6 +71,7 @@ import {...@@ -71,6 +71,7 @@ import {
71 markdownImageSourceFragment,71 markdownImageSourceFragment,
72} from "@t3tools/client-runtime/markdown-images";72} from "@t3tools/client-runtime/markdown-images";
73import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";73import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";
74import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets";
74import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";75import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
75import * as Cause from "effect/Cause";76import * as Cause from "effect/Cause";
76import { AsyncResult } from "effect/reactivity";77import { AsyncResult } from "effect/reactivity";
...@@ -1349,13 +1350,15 @@ function expandableMarkdownImageProps(...@@ -1349,13 +1350,15 @@ function expandableMarkdownImageProps(
1349function ChatMarkdownMediaUnavailableLabel(props: {1350function ChatMarkdownMediaUnavailableLabel(props: {
1350 readonly alt: string;1351 readonly alt: string;
1351 readonly kind?: "image" | "video" | undefined;1352 readonly kind?: "image" | "video" | undefined;
1353 /** Why the media is unavailable, when the cause is known. */
1354 readonly detail?: string | undefined;
1352}) {1355}) {
1353 const label = props.kind === "video" ? "Video unavailable" : "Image unavailable";1356 const label = props.kind === "video" ? "Video unavailable" : "Image unavailable";
1354 return (1357 return (
1355 // Find indexes no image text, so this fallback must not highlight either.1358 // Find indexes no image text, so this fallback must not highlight either.
1356 <span data-thread-find-ignore className="inline-flex items-center gap-1.5">1359 <span data-thread-find-ignore className="inline-flex items-center gap-1.5">
1357 <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />1360 <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(" · ")}
1359 </span>1362 </span>
1360 );1363 );
1361}1364}
...@@ -1366,6 +1369,7 @@ function ChatMarkdownImageFallback(props: {...@@ -1366,6 +1369,7 @@ function ChatMarkdownImageFallback(props: {
1366 readonly copyMarkdown?: string | undefined;1369 readonly copyMarkdown?: string | undefined;
1367 readonly kind?: "image" | "video";1370 readonly kind?: "image" | "video";
1368 readonly actionsSource?: MediaActionSource | undefined;1371 readonly actionsSource?: MediaActionSource | undefined;
1372 readonly detail?: string | undefined;
1369}) {1373}) {
1370 const content = (1374 const content = (
1371 <span1375 <span
...@@ -1375,7 +1379,7 @@ function ChatMarkdownImageFallback(props: {...@@ -1375,7 +1379,7 @@ function ChatMarkdownImageFallback(props: {
1375 "rounded-md border border-border/40 bg-muted/40 px-2 py-1 text-xs text-muted-foreground",1379 "rounded-md border border-border/40 bg-muted/40 px-2 py-1 text-xs text-muted-foreground",
1376 )}1380 )}
1377 >1381 >
1378 <ChatMarkdownMediaUnavailableLabel alt={props.alt} kind={props.kind} />1382 <ChatMarkdownMediaUnavailableLabel alt={props.alt} kind={props.kind} detail={props.detail} />
1379 </span>1383 </span>
1380 );1384 );
1381 return props.actionsSource ? (1385 return props.actionsSource ? (
...@@ -1408,6 +1412,8 @@ function ChatMarkdownImage(props: {...@@ -1408,6 +1412,8 @@ function ChatMarkdownImage(props: {
1408 /** Null while the URL is being resolved; the last decoded image stays up. */1412 /** Null while the URL is being resolved; the last decoded image stays up. */
1409 readonly src: string | null;1413 readonly src: string | null;
1410 readonly sourceFailed?: boolean | undefined;1414 readonly sourceFailed?: boolean | undefined;
1415 /** Why the source failed, when the cause is known. */
1416 readonly unavailableDetail?: string | undefined;
1411 readonly alt: string;1417 readonly alt: string;
1412 readonly copyMarkdown: string | undefined;1418 readonly copyMarkdown: string | undefined;
1413 readonly standalone: boolean;1419 readonly standalone: boolean;
...@@ -1481,6 +1487,7 @@ function ChatMarkdownImage(props: {...@@ -1481,6 +1487,7 @@ function ChatMarkdownImage(props: {
1481 alt={props.alt}1487 alt={props.alt}
1482 copyMarkdown={props.copyMarkdown}1488 copyMarkdown={props.copyMarkdown}
1483 actionsSource={props.actionsSource}1489 actionsSource={props.actionsSource}
1490 detail={props.unavailableDetail}
1484 />1491 />
1485 ) : (1492 ) : (
1486 <span1493 <span
...@@ -1509,7 +1516,7 @@ function ChatMarkdownImage(props: {...@@ -1509,7 +1516,7 @@ function ChatMarkdownImage(props: {
1509 >1516 >
1510 {failed ? (1517 {failed ? (
1511 <span className="flex size-full items-center justify-center p-2 text-center text-xs text-muted-foreground">1518 <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} />
1513 </span>1520 </span>
1514 ) : src !== null ? (1521 ) : src !== null ? (
1515 <img1522 <img
...@@ -1676,6 +1683,13 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props...@@ -1676,6 +1683,13 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
1676 key={JSON.stringify([props.environmentId, props.resource, props.srcFragment])}1683 key={JSON.stringify([props.environmentId, props.resource, props.srcFragment])}
1677 src={src}1684 src={src}
1678 sourceFailed={assetUrl._tag === "Failure" && fallbackSrc === undefined}1685 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 }
1679 alt={props.alt}1693 alt={props.alt}
1680 copyMarkdown={props.copyMarkdown}1694 copyMarkdown={props.copyMarkdown}
1681 standalone={props.standalone ?? true}1695 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>(...@@ -69,7 +69,11 @@ export const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial<never, never>(
6969
70export type AssetUrlState =70export type AssetUrlState =
71 | { readonly _tag: "Loading" }71 | { 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 }
73 | {77 | {
74 readonly _tag: "Success";78 readonly _tag: "Success";
75 readonly url: string;79 readonly url: string;
...@@ -81,6 +85,40 @@ export type AssetUrlState =...@@ -81,6 +85,40 @@ export type AssetUrlState =
81 readonly imageDimensions?: AssetImageDimensions;85 readonly imageDimensions?: AssetImageDimensions;
82 };86 };
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
84export function assetUrlStateFromResult(122export function assetUrlStateFromResult(
85 result: AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>,123 result: AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>,
86 httpBaseUrl: string | null,124 httpBaseUrl: string | null,