diff --git a/apps/mobile/src/components/ComposerContextAttachment.tsx b/apps/mobile/src/components/ComposerContextAttachment.tsx index 6c7b6e010f5cb411e584d06a2a4b6893f9bf178e..ab14dfb957b46a974d3db1d99c9be21996529ecd 100644 --- a/apps/mobile/src/components/ComposerContextAttachment.tsx +++ b/apps/mobile/src/components/ComposerContextAttachment.tsx @@ -6,6 +6,7 @@ import type { DraftComposerAttachment } from "../lib/composerImages"; import { composerAttachmentInlineUri, isFileBackedComposerAttachment } from "../lib/composerImages"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; import { downloadAndShareAttachment } from "../lib/attachmentDownload"; +import { assetImageSource } from "../lib/assetImageSource"; import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; import { AppText as Text } from "./AppText"; import { FilePreviewModal } from "./FilePreviewModal"; @@ -98,7 +99,11 @@ export function ComposerContextAttachment(props: { onPress={() => setPreviewOpen(true)} > {isImage && url !== null ? ( ({ View: "div", Pressable: ({ children }: { children: ReactNode }) => , - Image: "img", Linking: { openURL: vi.fn() }, })); +vi.mock("expo-image", () => ({ Image: "img" })); vi.mock("../../components/AppText", () => ({ AppText: "span" })); vi.mock("../../state/assets", () => ({ useAssetUrl: () => null })); diff --git a/apps/mobile/src/features/threads/QuestionAnswerHistory.tsx b/apps/mobile/src/features/threads/QuestionAnswerHistory.tsx index 2a08ee65bb08248d96a29df93d8e3ba6c471f298..dcfca2fbca19474cfb5bb237cd6d0bbd844f4313 100644 --- a/apps/mobile/src/features/threads/QuestionAnswerHistory.tsx +++ b/apps/mobile/src/features/threads/QuestionAnswerHistory.tsx @@ -3,19 +3,19 @@ import type { UserInputAttachmentAnswerPayload, UserInputAttachments, } from "@t3tools/contracts"; -import { Image, Linking, Pressable, View } from "react-native"; +import { Image } from "expo-image"; +import { Linking, Pressable, View } from "react-native"; import { getQuestionAnswerText } from "@t3tools/client-runtime/work-log/user-input"; import { AppText as Text } from "../../components/AppText"; +import { assetImageSource } from "../../lib/assetImageSource"; import { useAssetUrl } from "../../state/assets"; function AnswerFile(props: { environmentId: EnvironmentId; attachment: UserInputAttachments[string][number]; }) { - const url = useAssetUrl(props.environmentId, { - _tag: "attachment", - attachmentId: props.attachment.id, - }); + const resource = { _tag: "attachment", attachmentId: props.attachment.id } as const; + const url = useAssetUrl(props.environmentId, resource); return ( {props.attachment.type === "image" && url ? ( - + ) : null} {props.attachment.name} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 6198174ce8f0f18ab67b07d55615bf027354af23..61d6da783bf0a724711a09cd3e1d2566f2c5619a 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -94,7 +94,10 @@ import { useWindowDimensions, View, } from "react-native"; +import { Image as ExpoImage } from "expo-image"; +import { useResolveClassNames } from "uniwind"; import { useFilePreviewGallery, FilePreviewGallery } from "../../components/FilePreviewGallery"; +import { assetImageSource } from "../../lib/assetImageSource"; import { composerImageGallery } from "../../lib/composerImageGallery"; import { messageImageGallery } from "./messageImageGallery"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; @@ -457,6 +460,7 @@ function MessageAttachmentImage(props: { const uri = useAssetUrl(props.environmentId, resource); const refreshAssetUrl = useRefreshAssetUrl(props.environmentId, resource); const retriedImage = useRef(false); + const imageStyle = useResolveClassNames(props.className); if (uri === null) { return ( @@ -490,10 +494,11 @@ function MessageAttachmentImage(props: { ) } > - { retriedImage.current = false; }} diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index 19191e2fe836fe05b3f750b02787211909300e5a..e8d81a20a8bc5de6f94971e85acc9d75c37ae439 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -23,6 +23,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { useAssetUrl } from "../../state/assets"; +import { assetImageSource } from "../../lib/assetImageSource"; import { beginQueuedRunEdit, useQueuedRunEdit } from "../../state/queued-run-edit"; import { environmentThreadDetails, threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -563,19 +564,21 @@ function QueueAttachmentThumbnail(props: { readonly environmentId: EnvironmentId; readonly attachment: ChatAttachment; }) { - const url = useAssetUrl(props.environmentId, { + const resource = { _tag: "attachment", attachmentId: props.attachment.id, fileName: props.attachment.name, mimeType: props.attachment.mimeType, disposition: "inline", - }); + } as const; + const url = useAssetUrl(props.environmentId, resource); if (url === null) { return ; } return ( { + it("caches an attachment under one key across re-signed URLs", () => { + const resource = { _tag: "attachment", attachmentId: "thread-1-photo.jpg" } as const; + const first = assetImageSource( + environmentId, + resource, + "https://host/api/assets/token-a/p.jpg", + ); + const second = assetImageSource( + environmentId, + resource, + "https://host/api/assets/token-b/p.jpg", + ); + + expect(first.cacheKey).toBeDefined(); + expect(second).toStrictEqual({ + uri: "https://host/api/assets/token-b/p.jpg", + cacheKey: first.cacheKey, + }); + expect( + assetImageSource(EnvironmentId.make("environment-2"), resource, "https://other/p.jpg") + .cacheKey, + ).not.toBe(first.cacheKey); + }); + + it("keeps ids that contain colons from sharing a key", () => { + const uri = "https://host/api/assets/token/p.jpg"; + const left = assetImageSource( + EnvironmentId.make("a"), + { _tag: "attachment", attachmentId: "b:c" }, + uri, + ); + const right = assetImageSource( + EnvironmentId.make("a:b"), + { _tag: "attachment", attachmentId: "c" }, + uri, + ); + + expect(left.cacheKey).not.toBe(right.cacheKey); + }); + + it("keeps workspace files keyed by URL because they can change on disk", () => { + const source = assetImageSource( + environmentId, + { _tag: "draft-workspace-file", cwd: "/repo", path: "logo.png" }, + "https://host/api/assets/token-a/logo.png", + ); + + expect(source).toStrictEqual({ uri: "https://host/api/assets/token-a/logo.png" }); + }); +}); diff --git a/apps/mobile/src/lib/assetImageSource.ts b/apps/mobile/src/lib/assetImageSource.ts new file mode 100644 index 0000000000000000000000000000000000000000..83f0a8fd917f2a13836bb499733cd829ae558f4c --- /dev/null +++ b/apps/mobile/src/lib/assetImageSource.ts @@ -0,0 +1,20 @@ +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import type { ImageSource } from "expo-image"; + +/** + * expo-image source for a signed asset URL. Every mint signs a new URL, and a + * reconnect mints again, so a URL-keyed cache misses each time. Attachments + * never change once stored, so they cache under their id instead. + */ +export function assetImageSource( + environmentId: EnvironmentId, + resource: AssetResource, + uri: string, +): ImageSource { + return resource._tag === "attachment" + ? { + uri, + cacheKey: `t3-attachment:${JSON.stringify([environmentId, resource.attachmentId])}`, + } + : { uri }; +} diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index a8b7927b624dfdb1a3cf9f828c79caedc85e3dd3..5ca6312abc276f820af3c54074d2717a2890c3b3 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vite-plus/test"; import { attachmentFileExtension, + chatAttachmentStableKey, createAttachmentId, createDeterministicAttachmentId, createPendingAttachmentId, @@ -30,6 +31,27 @@ describe("attachmentStore", () => { expect(first && parseThreadSegmentFromAttachmentId(first)).toBe("thread-1"); }); + it("gives a queued edit's new image its own id instead of an earlier image's", () => { + const imageA = new Uint8Array([1, 2, 3]); + const imageB = new Uint8Array([4, 5, 6]); + const sent = createDeterministicAttachmentId( + "thread-1", + chatAttachmentStableKey("message-1", 0, imageA), + ); + const retried = createDeterministicAttachmentId( + "thread-1", + chatAttachmentStableKey("message-1", 0, imageA), + ); + // Editing the queued message keeps its id, and the new upload is first in its batch. + const edited = createDeterministicAttachmentId( + "thread-1", + chatAttachmentStableKey("message-1", 0, imageB), + ); + + expect(retried).toBe(sent); + expect(edited).not.toBe(sent); + }); + it("keeps deterministic ids distinct when sanitized thread segments collide", () => { const dotted = createDeterministicAttachmentId("thread.a", "message-1:0"); const dashed = createDeterministicAttachmentId("thread-a", "message-1:0"); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 0927b03761f72bee3cfbd98ef783e1bde4ee505c..29275dc55d76b2d6ffb2bffd5b3ace35e5afab8e 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -103,6 +103,21 @@ export function createDeterministicAttachmentId( return `${threadSegment}-${uuid}`; } +/** + * Stable key for an inline chat attachment. A retry resends the same bytes and + * keeps its id. Editing a queued message reuses the message id, so the bytes + * also stop a new image from taking an earlier image's id and file. Clients + * cache attachment images by id, which relies on ids never changing bytes. + */ +export function chatAttachmentStableKey( + messageId: string, + index: number, + bytes: Uint8Array, +): string { + const digest = NodeCrypto.createHash("sha256").update(bytes).digest("hex"); + return `${messageId}:${index}:${digest}`; +} + export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null { const normalizedId = normalizeAttachmentRelativePath(attachmentId); if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 375c2ac78aa1fef56feedcc80d6767b2a33a0ef7..8ee055e2a1e44ac57e06f7aa97efbf1e4b47e17b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -185,7 +185,11 @@ import * as DeviceService from "./device/DeviceService.ts"; import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; -import { attachmentRelativePath, createDeterministicAttachmentId } from "./attachmentStore.ts"; +import { + attachmentRelativePath, + chatAttachmentStableKey, + createDeterministicAttachmentId, +} from "./attachmentStore.ts"; import { parseBase64DataUrl } from "./imageMime.ts"; import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; import * as PortScanner from "./preview/PortScanner.ts"; @@ -398,7 +402,10 @@ const persistChatAttachments = Effect.fn("ws.assets.persistChatAttachments")(fun message: `Attachment ${attachment.name} size does not match its payload.`, }); } - const rawId = createDeterministicAttachmentId(input.threadId, `${input.messageId}:${index}`); + const rawId = createDeterministicAttachmentId( + input.threadId, + chatAttachmentStableKey(input.messageId, index, bytes), + ); if (rawId === null) { return yield* new PersistChatAttachmentsError({ message: "Could not allocate an attachment identifier.",