authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-10-13 08:12:47+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-13 08:12:47+05:30
log1af138668b8bcf5c289382dde1ccaecdd79614b8
treeb4b68209e57751d4ff61d4f8da71b8277c4ec489
parent346247bf1a461d200209b3f33f2bb7f148987b1f
signature Signed by PGP key B5690EEEBB952194

fix(mobile): attachment images no longer re-download on every reconnect (#18824)

Co-authored-by: PR Batch Tester <agent@local.test> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

11 files changed, 168 insertions(+), 30 deletions(-)

apps/mobile/src/components/ComposerContextAttachment.tsx+6-1
......@@ -6,6 +6,7 @@ import type { DraftComposerAttachment } from "../lib/composerImages";
66import { composerAttachmentInlineUri, isFileBackedComposerAttachment } from "../lib/composerImages";
77import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
88import { downloadAndShareAttachment } from "../lib/attachmentDownload";
9import { assetImageSource } from "../lib/assetImageSource";
910import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets";
1011import { AppText as Text } from "./AppText";
1112import { FilePreviewModal } from "./FilePreviewModal";
......@@ -98,7 +99,11 @@ export function ComposerContextAttachment(props: {
9899 onPress={() => setPreviewOpen(true)}
99100 >
100101 <Image
101 source={{ uri }}
102 source={
103 remoteEnvironmentId === null
104 ? { uri }
105 : assetImageSource(remoteEnvironmentId, resource, uri)
106 }
102107 style={{ width: "100%", height: 280 }}
103108 contentFit="contain"
104109 accessibilityLabel={record.name}
apps/mobile/src/features/threads/ComposerQueuedEdit.tsx+11-13
......@@ -5,6 +5,7 @@ import { Pressable, ScrollView, View } from "react-native";
55import { AppText as Text } from "../../components/AppText";
66import { SymbolView } from "../../components/AppSymbol";
77import { useAssetUrl } from "../../state/assets";
8import { assetImageSource } from "../../lib/assetImageSource";
89
910/**
1011 * The composer is shared, so editing a queued message needs a visible way back
......@@ -75,24 +76,21 @@ function QueuedEditAttachmentChip(props: {
7576}) {
7677 const { attachment } = props;
7778 const isImage = attachment.mimeType.startsWith("image/");
78 const url = useAssetUrl(
79 props.environmentId,
80 isImage
81 ? {
82 _tag: "attachment",
83 attachmentId: attachment.id,
84 fileName: attachment.name,
85 mimeType: attachment.mimeType,
86 disposition: "inline",
87 }
88 : null,
89 );
79 const resource = {
80 _tag: "attachment",
81 attachmentId: attachment.id,
82 fileName: attachment.name,
83 mimeType: attachment.mimeType,
84 disposition: "inline",
85 } as const;
86 const url = useAssetUrl(props.environmentId, isImage ? resource : null);
9087
9188 return (
9289 <View className="max-w-[180px] flex-row items-center gap-2 rounded-xl border border-border bg-card py-1 pl-1 pr-2">
9390 {isImage && url !== null ? (
9491 <Image
95 source={{ uri: url }}
92 source={assetImageSource(props.environmentId, resource, url)}
93 cachePolicy="memory-disk"
9694 contentFit="cover"
9795 style={{ width: 24, height: 24, borderRadius: 6 }}
9896 accessibilityIgnoresInvertColors
apps/mobile/src/features/threads/QuestionAnswerHistory.test.tsx+1-1
......@@ -6,9 +6,9 @@ import { describe, expect, it, vi } from "vite-plus/test";
66vi.mock("react-native", () => ({
77 View: "div",
88 Pressable: ({ children }: { children: ReactNode }) => <button>{children}</button>,
9 Image: "img",
109 Linking: { openURL: vi.fn() },
1110}));
11vi.mock("expo-image", () => ({ Image: "img" }));
1212vi.mock("../../components/AppText", () => ({ AppText: "span" }));
1313vi.mock("../../state/assets", () => ({ useAssetUrl: () => null }));
1414
apps/mobile/src/features/threads/QuestionAnswerHistory.tsx+11-6
......@@ -3,19 +3,19 @@ import type {
33 UserInputAttachmentAnswerPayload,
44 UserInputAttachments,
55} from "@t3tools/contracts";
6import { Image, Linking, Pressable, View } from "react-native";
6import { Image } from "expo-image";
7import { Linking, Pressable, View } from "react-native";
78import { getQuestionAnswerText } from "@t3tools/client-runtime/work-log/user-input";
89import { AppText as Text } from "../../components/AppText";
10import { assetImageSource } from "../../lib/assetImageSource";
911import { useAssetUrl } from "../../state/assets";
1012
1113function AnswerFile(props: {
1214 environmentId: EnvironmentId;
1315 attachment: UserInputAttachments[string][number];
1416}) {
15 const url = useAssetUrl(props.environmentId, {
16 _tag: "attachment",
17 attachmentId: props.attachment.id,
18 });
17 const resource = { _tag: "attachment", attachmentId: props.attachment.id } as const;
18 const url = useAssetUrl(props.environmentId, resource);
1919 return (
2020 <Pressable
2121 accessibilityRole="link"
......@@ -27,7 +27,12 @@ function AnswerFile(props: {
2727 className="gap-1"
2828 >
2929 {props.attachment.type === "image" && url ? (
30 <Image source={{ uri: url }} style={{ width: 160, height: 96 }} resizeMode="contain" />
30 <Image
31 source={assetImageSource(props.environmentId, resource, url)}
32 cachePolicy="memory-disk"
33 style={{ width: 160, height: 96 }}
34 contentFit="contain"
35 />
3136 ) : null}
3237 <Text className="text-sm text-foreground underline">{props.attachment.name}</Text>
3338 </Pressable>
apps/mobile/src/features/threads/ThreadFeed.tsx+9-4
......@@ -94,7 +94,10 @@ import {
9494 useWindowDimensions,
9595 View,
9696} from "react-native";
97import { Image as ExpoImage } from "expo-image";
98import { useResolveClassNames } from "uniwind";
9799import { useFilePreviewGallery, FilePreviewGallery } from "../../components/FilePreviewGallery";
100import { assetImageSource } from "../../lib/assetImageSource";
98101import { composerImageGallery } from "../../lib/composerImageGallery";
99102import { messageImageGallery } from "./messageImageGallery";
100103import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
......@@ -457,6 +460,7 @@ function MessageAttachmentImage(props: {
457460 const uri = useAssetUrl(props.environmentId, resource);
458461 const refreshAssetUrl = useRefreshAssetUrl(props.environmentId, resource);
459462 const retriedImage = useRef(false);
463 const imageStyle = useResolveClassNames(props.className);
460464
461465 if (uri === null) {
462466 return (
......@@ -490,10 +494,11 @@ function MessageAttachmentImage(props: {
490494 )
491495 }
492496 >
493 <Image
494 source={{ uri }}
495 className={props.className}
496 resizeMode="cover"
497 <ExpoImage
498 source={assetImageSource(props.environmentId, resource, uri)}
499 cachePolicy="memory-disk"
500 style={imageStyle}
501 contentFit="cover"
497502 onLoad={() => {
498503 retriedImage.current = false;
499504 }}
apps/mobile/src/features/threads/ThreadQueueControl.tsx+6-3
......@@ -23,6 +23,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities";
2323import { useUniwindTheme } from "../../lib/useUniwindTheme";
2424import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
2525import { useAssetUrl } from "../../state/assets";
26import { assetImageSource } from "../../lib/assetImageSource";
2627import { beginQueuedRunEdit, useQueuedRunEdit } from "../../state/queued-run-edit";
2728import { environmentThreadDetails, threadEnvironment } from "../../state/threads";
2829import { useAtomCommand } from "../../state/use-atom-command";
......@@ -563,19 +564,21 @@ function QueueAttachmentThumbnail(props: {
563564 readonly environmentId: EnvironmentId;
564565 readonly attachment: ChatAttachment;
565566}) {
566 const url = useAssetUrl(props.environmentId, {
567 const resource = {
567568 _tag: "attachment",
568569 attachmentId: props.attachment.id,
569570 fileName: props.attachment.name,
570571 mimeType: props.attachment.mimeType,
571572 disposition: "inline",
572 });
573 } as const;
574 const url = useAssetUrl(props.environmentId, resource);
573575 if (url === null) {
574576 return <View className="h-6 w-6 rounded bg-subtle" />;
575577 }
576578 return (
577579 <Image
578 source={{ uri: url }}
580 source={assetImageSource(props.environmentId, resource, url)}
581 cachePolicy="memory-disk"
579582 contentFit="cover"
580583 style={{ width: 24, height: 24, borderRadius: 4 }}
581584 accessibilityIgnoresInvertColors
apps/mobile/src/lib/assetImageSource.test.ts created+58
......@@ -0,0 +1,58 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { assetImageSource } from "./assetImageSource";
5
6const environmentId = EnvironmentId.make("environment-1");
7
8describe("assetImageSource", () => {
9 it("caches an attachment under one key across re-signed URLs", () => {
10 const resource = { _tag: "attachment", attachmentId: "thread-1-photo.jpg" } as const;
11 const first = assetImageSource(
12 environmentId,
13 resource,
14 "https://host/api/assets/token-a/p.jpg",
15 );
16 const second = assetImageSource(
17 environmentId,
18 resource,
19 "https://host/api/assets/token-b/p.jpg",
20 );
21
22 expect(first.cacheKey).toBeDefined();
23 expect(second).toStrictEqual({
24 uri: "https://host/api/assets/token-b/p.jpg",
25 cacheKey: first.cacheKey,
26 });
27 expect(
28 assetImageSource(EnvironmentId.make("environment-2"), resource, "https://other/p.jpg")
29 .cacheKey,
30 ).not.toBe(first.cacheKey);
31 });
32
33 it("keeps ids that contain colons from sharing a key", () => {
34 const uri = "https://host/api/assets/token/p.jpg";
35 const left = assetImageSource(
36 EnvironmentId.make("a"),
37 { _tag: "attachment", attachmentId: "b:c" },
38 uri,
39 );
40 const right = assetImageSource(
41 EnvironmentId.make("a:b"),
42 { _tag: "attachment", attachmentId: "c" },
43 uri,
44 );
45
46 expect(left.cacheKey).not.toBe(right.cacheKey);
47 });
48
49 it("keeps workspace files keyed by URL because they can change on disk", () => {
50 const source = assetImageSource(
51 environmentId,
52 { _tag: "draft-workspace-file", cwd: "/repo", path: "logo.png" },
53 "https://host/api/assets/token-a/logo.png",
54 );
55
56 expect(source).toStrictEqual({ uri: "https://host/api/assets/token-a/logo.png" });
57 });
58});
apps/mobile/src/lib/assetImageSource.ts created+20
......@@ -0,0 +1,20 @@
1import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
2import type { ImageSource } from "expo-image";
3
4/**
5 * expo-image source for a signed asset URL. Every mint signs a new URL, and a
6 * reconnect mints again, so a URL-keyed cache misses each time. Attachments
7 * never change once stored, so they cache under their id instead.
8 */
9export function assetImageSource(
10 environmentId: EnvironmentId,
11 resource: AssetResource,
12 uri: string,
13): ImageSource {
14 return resource._tag === "attachment"
15 ? {
16 uri,
17 cacheKey: `t3-attachment:${JSON.stringify([environmentId, resource.attachmentId])}`,
18 }
19 : { uri };
20}
apps/server/src/attachmentStore.test.ts+22
......@@ -7,6 +7,7 @@ import { describe, expect, it } from "vite-plus/test";
77
88import {
99 attachmentFileExtension,
10 chatAttachmentStableKey,
1011 createAttachmentId,
1112 createDeterministicAttachmentId,
1213 createPendingAttachmentId,
......@@ -30,6 +31,27 @@ describe("attachmentStore", () => {
3031 expect(first && parseThreadSegmentFromAttachmentId(first)).toBe("thread-1");
3132 });
3233
34 it("gives a queued edit's new image its own id instead of an earlier image's", () => {
35 const imageA = new Uint8Array([1, 2, 3]);
36 const imageB = new Uint8Array([4, 5, 6]);
37 const sent = createDeterministicAttachmentId(
38 "thread-1",
39 chatAttachmentStableKey("message-1", 0, imageA),
40 );
41 const retried = createDeterministicAttachmentId(
42 "thread-1",
43 chatAttachmentStableKey("message-1", 0, imageA),
44 );
45 // Editing the queued message keeps its id, and the new upload is first in its batch.
46 const edited = createDeterministicAttachmentId(
47 "thread-1",
48 chatAttachmentStableKey("message-1", 0, imageB),
49 );
50
51 expect(retried).toBe(sent);
52 expect(edited).not.toBe(sent);
53 });
54
3355 it("keeps deterministic ids distinct when sanitized thread segments collide", () => {
3456 const dotted = createDeterministicAttachmentId("thread.a", "message-1:0");
3557 const dashed = createDeterministicAttachmentId("thread-a", "message-1:0");
apps/server/src/attachmentStore.ts+15
......@@ -103,6 +103,21 @@ export function createDeterministicAttachmentId(
103103 return `${threadSegment}-${uuid}`;
104104}
105105
106/**
107 * Stable key for an inline chat attachment. A retry resends the same bytes and
108 * keeps its id. Editing a queued message reuses the message id, so the bytes
109 * also stop a new image from taking an earlier image's id and file. Clients
110 * cache attachment images by id, which relies on ids never changing bytes.
111 */
112export function chatAttachmentStableKey(
113 messageId: string,
114 index: number,
115 bytes: Uint8Array,
116): string {
117 const digest = NodeCrypto.createHash("sha256").update(bytes).digest("hex");
118 return `${messageId}:${index}:${digest}`;
119}
120
106121export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null {
107122 const normalizedId = normalizeAttachmentRelativePath(attachmentId);
108123 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
apps/server/src/ws.ts+9-2
......@@ -185,7 +185,11 @@ import * as DeviceService from "./device/DeviceService.ts";
185185import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts";
186186import * as PreviewManager from "./preview/Manager.ts";
187187import { issueAssetUrl } from "./assets/AssetAccess.ts";
188import { attachmentRelativePath, createDeterministicAttachmentId } from "./attachmentStore.ts";
188import {
189 attachmentRelativePath,
190 chatAttachmentStableKey,
191 createDeterministicAttachmentId,
192} from "./attachmentStore.ts";
189193import { parseBase64DataUrl } from "./imageMime.ts";
190194import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts";
191195import * as PortScanner from "./preview/PortScanner.ts";
......@@ -398,7 +402,10 @@ const persistChatAttachments = Effect.fn("ws.assets.persistChatAttachments")(fun
398402 message: `Attachment ${attachment.name} size does not match its payload.`,
399403 });
400404 }
401 const rawId = createDeterministicAttachmentId(input.threadId, `${input.messageId}:${index}`);
405 const rawId = createDeterministicAttachmentId(
406 input.threadId,
407 chatAttachmentStableKey(input.messageId, index, bytes),
408 );
402409 if (rawId === null) {
403410 return yield* new PersistChatAttachmentsError({
404411 message: "Could not allocate an attachment identifier.",