| author | |
| committer | |
| log | 4cf8243f855da1abc79f5b09c58065c0fb328c0e |
| tree | bd9f75c057203bcb7e78480445490e1274d357f4 |
| parent | cd45be3b57dc34796c21e7bbad9ae55fef5a583b |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; | |
| 1 | 2 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 2 | 3 | import { Image } from "expo-image"; |
| 3 | 4 | import { createContext, useContext, useEffect, useEffectEvent, useId, useState } from "react"; |
| ... | ... | @@ -35,6 +36,8 @@ export function ThreadMarkdownImageView(props: { |
| 35 | 36 | readonly uri: string | null; |
| 36 | 37 | readonly sourceKey: string; |
| 37 | 38 | readonly unavailable: boolean; |
| 39 | /** Why the image is unavailable, when the cause is known. */ | |
| 40 | readonly unavailableDetail?: string | undefined; | |
| 38 | 41 | readonly alt: string | null; |
| 39 | 42 | /** Pixel size from the server, when it could read the header; the frame is final from the first render. */ |
| 40 | 43 | readonly knownSize?: { readonly width: number; readonly height: number } | undefined; |
| ... | ... | @@ -95,7 +98,11 @@ export function ThreadMarkdownImageView(props: { |
| 95 | 98 | style={frameStyle} |
| 96 | 99 | > |
| 97 | 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 | 107 | <AppActivityIndicator /> |
| 101 | 108 | )} |
| ... | ... | @@ -261,6 +268,11 @@ export function ThreadMarkdownImage(props: { |
| 261 | 268 | : `workspace:${props.resource.path}` |
| 262 | 269 | } |
| 263 | 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 | 276 | knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined} |
| 265 | 277 | format={ |
| 266 | 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 | 1 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 2 | 2 | import type { AssetUrlState as SharedAssetUrlState } from "@t3tools/client-runtime/state/assets"; |
| 3 | 3 | |
| 4 | export type AssetUrlFailureReason = "disconnected" | "failed"; | |
| 4 | export type AssetUrlFailureReason = "disconnected" | "failed" | "missing-file-access"; | |
| 5 | 5 | |
| 6 | 6 | /** The shared state plus a reason on failure, so previews can offer the right retry. */ |
| 7 | 7 | export type AssetUrlState = |
| ... | ... | @@ -42,7 +42,7 @@ export function deriveAssetUrlState(input: { |
| 42 | 42 | return { _tag: "Loading" }; |
| 43 | 43 | case "connected": |
| 44 | 44 | return input.shared._tag === "Failure" |
| 45 | ? { _tag: "Failure", reason: "failed" } | |
| 45 | ? { _tag: "Failure", reason: input.shared.reason ?? "failed" } | |
| 46 | 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 | 77 | |
| 78 | 78 | it("hides host assets with a denied grant while preserving attachments", () => { |
| 79 | 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 | 84 | expect(state.assetQuery).not.toHaveBeenCalled(); |
| 82 | 85 | expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({ |
| 83 | 86 | _tag: "Success", |
apps/mobile/src/state/assets.ts+9-14| ... | ... | @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; |
| 3 | 3 | import { |
| 4 | 4 | assetUrlStateFromResult, |
| 5 | blockedAssetUrlState, | |
| 5 | 6 | createAssetEnvironmentAtoms, |
| 6 | 7 | createProjectFaviconUrlAtomFamily, |
| 7 | 8 | EMPTY_ASSET_URL_ATOM, |
| ... | ... | @@ -43,26 +44,20 @@ export function useAssetUrlState( |
| 43 | 44 | session: fileAccessSession.data, |
| 44 | 45 | sessionError: fileAccessSession.error, |
| 45 | 46 | }); |
| 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); | |
| 51 | 48 | const preparedConnection = usePreparedConnection(environmentId); |
| 52 | 49 | const connectionPhase = fileEnvironment.presentation?.connection.phase ?? "available"; |
| 53 | 50 | const result = useAtomValue( |
| 54 | !canReadResource || environmentId === null || resource === null | |
| 51 | blocked !== null || environmentId === null || resource === null | |
| 55 | 52 | ? EMPTY_ASSET_URL_ATOM |
| 56 | 53 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), |
| 57 | 54 | ); |
| 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 | ); | |
| 66 | 61 | return deriveAssetUrlState({ |
| 67 | 62 | connectionPhase, |
| 68 | 63 | // 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 | 76 | |
| 77 | 77 | it("hides host assets with a denied grant while preserving attachments", () => { |
| 78 | 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 | 83 | expect(state.assetQuery).not.toHaveBeenCalled(); |
| 81 | 84 | expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({ |
| 82 | 85 | _tag: "Success", |
apps/web/src/assets/assetUrls.ts+4-8| ... | ... | @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { |
| 3 | 3 | type AssetUrlState, |
| 4 | 4 | assetUrlStateFromResult, |
| 5 | blockedAssetUrlState, | |
| 5 | 6 | EMPTY_ASSET_URL_ATOM, |
| 6 | 7 | resolveAssetUrl, |
| 7 | 8 | } from "@t3tools/client-runtime/state/assets"; |
| ... | ... | @@ -21,19 +22,14 @@ export function useAssetUrlState( |
| 21 | 22 | environmentId: EnvironmentId | null, |
| 22 | 23 | resource: AssetResource | null, |
| 23 | 24 | ): 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)); | |
| 30 | 26 | const preparedConnection = usePreparedConnection(environmentId); |
| 31 | 27 | const result = useAtomValue( |
| 32 | !canReadResource || environmentId === null || resource === null | |
| 28 | blocked !== null || environmentId === null || resource === null | |
| 33 | 29 | ? EMPTY_ASSET_URL_ATOM |
| 34 | 30 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), |
| 35 | 31 | ); |
| 36 | if (!canReadResource) return { _tag: fileAccess.isPending ? "Loading" : "Failure" }; | |
| 32 | if (blocked !== null) return blocked; | |
| 37 | 33 | return assetUrlStateFromResult( |
| 38 | 34 | result, |
| 39 | 35 | preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, |
apps/web/src/components/ChatMarkdown.tsx+17-3| ... | ... | @@ -71,6 +71,7 @@ import { |
| 71 | 71 | markdownImageSourceFragment, |
| 72 | 72 | } from "@t3tools/client-runtime/markdown-images"; |
| 73 | 73 | import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; |
| 74 | import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; | |
| 74 | 75 | import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; |
| 75 | 76 | import * as Cause from "effect/Cause"; |
| 76 | 77 | import { AsyncResult } from "effect/reactivity"; |
| ... | ... | @@ -1349,13 +1350,15 @@ function expandableMarkdownImageProps( |
| 1349 | 1350 | function ChatMarkdownMediaUnavailableLabel(props: { |
| 1350 | 1351 | readonly alt: string; |
| 1351 | 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 | 1356 | const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; |
| 1354 | 1357 | return ( |
| 1355 | 1358 | // Find indexes no image text, so this fallback must not highlight either. |
| 1356 | 1359 | <span data-thread-find-ignore className="inline-flex items-center gap-1.5"> |
| 1357 | 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 | 1362 | </span> |
| 1360 | 1363 | ); |
| 1361 | 1364 | } |
| ... | ... | @@ -1366,6 +1369,7 @@ function ChatMarkdownImageFallback(props: { |
| 1366 | 1369 | readonly copyMarkdown?: string | undefined; |
| 1367 | 1370 | readonly kind?: "image" | "video"; |
| 1368 | 1371 | readonly actionsSource?: MediaActionSource | undefined; |
| 1372 | readonly detail?: string | undefined; | |
| 1369 | 1373 | }) { |
| 1370 | 1374 | const content = ( |
| 1371 | 1375 | <span |
| ... | ... | @@ -1375,7 +1379,7 @@ function ChatMarkdownImageFallback(props: { |
| 1375 | 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 | 1383 | </span> |
| 1380 | 1384 | ); |
| 1381 | 1385 | return props.actionsSource ? ( |
| ... | ... | @@ -1408,6 +1412,8 @@ function ChatMarkdownImage(props: { |
| 1408 | 1412 | /** Null while the URL is being resolved; the last decoded image stays up. */ |
| 1409 | 1413 | readonly src: string | null; |
| 1410 | 1414 | readonly sourceFailed?: boolean | undefined; |
| 1415 | /** Why the source failed, when the cause is known. */ | |
| 1416 | readonly unavailableDetail?: string | undefined; | |
| 1411 | 1417 | readonly alt: string; |
| 1412 | 1418 | readonly copyMarkdown: string | undefined; |
| 1413 | 1419 | readonly standalone: boolean; |
| ... | ... | @@ -1481,6 +1487,7 @@ function ChatMarkdownImage(props: { |
| 1481 | 1487 | alt={props.alt} |
| 1482 | 1488 | copyMarkdown={props.copyMarkdown} |
| 1483 | 1489 | actionsSource={props.actionsSource} |
| 1490 | detail={props.unavailableDetail} | |
| 1484 | 1491 | /> |
| 1485 | 1492 | ) : ( |
| 1486 | 1493 | <span |
| ... | ... | @@ -1509,7 +1516,7 @@ function ChatMarkdownImage(props: { |
| 1509 | 1516 | > |
| 1510 | 1517 | {failed ? ( |
| 1511 | 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 | 1520 | </span> |
| 1514 | 1521 | ) : src !== null ? ( |
| 1515 | 1522 | <img |
| ... | ... | @@ -1676,6 +1683,13 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props |
| 1676 | 1683 | key={JSON.stringify([props.environmentId, props.resource, props.srcFragment])} |
| 1677 | 1684 | src={src} |
| 1678 | 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 | 1693 | alt={props.alt} |
| 1680 | 1694 | copyMarkdown={props.copyMarkdown} |
| 1681 | 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 | 69 | |
| 70 | 70 | export type AssetUrlState = |
| 71 | 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 | 78 | readonly _tag: "Success"; |
| 75 | 79 | readonly url: string; |
| ... | ... | @@ -81,6 +85,40 @@ export type AssetUrlState = |
| 81 | 85 | readonly imageDimensions?: AssetImageDimensions; |
| 82 | 86 | }; |
| 83 | 87 | |
| 88 | /** Shown in place of a host file the session lacks `filesystem:read` for. */ | |
| 89 | export 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 | */ | |
| 98 | export 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 | ||
| 84 | 122 | export function assetUrlStateFromResult( |
| 85 | 123 | result: AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>, |
| 86 | 124 | httpBaseUrl: string | null, |