| 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,3 +1,4 @@ |
| 1 | import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; | ||
| 1 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | 2 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { Image } from "expo-image"; | 3 | import { Image } from "expo-image"; |
| 3 | import { createContext, useContext, useEffect, useEffectEvent, useId, useState } from "react"; | 4 | import { 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 @@ |
| 1 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | 1 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 2 | import type { AssetUrlState as SharedAssetUrlState } from "@t3tools/client-runtime/state/assets"; | 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 | /** 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. */ |
| 7 | export type AssetUrlState = | 7 | export 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)( |
| 77 | 77 | ||
| 78 | it("hides host assets with a denied grant while preserving attachments", () => { | 78 | it("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"; |
| 2 | import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; | 2 | import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; |
| 3 | import { | 3 | import { |
| 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 === null | 51 | blocked !== null || environmentId === null || resource === null |
| 55 | ? EMPTY_ASSET_URL_ATOM | 52 | ? EMPTY_ASSET_URL_ATOM |
| 56 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), | 53 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), |
| 57 | ); | 54 | ); |
| 58 | const shared = !canReadResource | 55 | const shared = |
| 59 | ? fileAccess.isPending | 56 | 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 the | 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,7 +76,10 @@ it.each(["workspace-file", "media-file"] as const)( |
| 76 | 76 | ||
| 77 | it("hides host assets with a denied grant while preserving attachments", () => { | 77 | it("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"; |
| 2 | import { | 2 | import { |
| 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 === null | 28 | blocked !== null || environmentId === null || resource === null |
| 33 | ? EMPTY_ASSET_URL_ATOM | 29 | ? 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"; |
| 73 | import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; | 73 | import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; |
| 74 | import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; | ||
| 74 | import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; | 75 | import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; |
| 75 | import * as Cause from "effect/Cause"; | 76 | import * as Cause from "effect/Cause"; |
| 76 | import { AsyncResult } from "effect/reactivity"; | 77 | import { AsyncResult } from "effect/reactivity"; |
| ... | @@ -1349,13 +1350,15 @@ function expandableMarkdownImageProps( | ... | @@ -1349,13 +1350,15 @@ function expandableMarkdownImageProps( |
| 1349 | function ChatMarkdownMediaUnavailableLabel(props: { | 1350 | function 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 | <span | 1375 | <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 | <span | 1493 | <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 | <img | 1522 | <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>( |
| 69 | 69 | ||
| 70 | export type AssetUrlState = | 70 | export 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 | }; |
| 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 | export function assetUrlStateFromResult( | 122 | export function assetUrlStateFromResult( |
| 85 | result: AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>, | 123 | result: AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>, |
| 86 | httpBaseUrl: string | null, | 124 | httpBaseUrl: string | null, |