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";...@@ -6,6 +6,7 @@ import type { DraftComposerAttachment } from "../lib/composerImages";
6import { composerAttachmentInlineUri, isFileBackedComposerAttachment } from "../lib/composerImages";6import { composerAttachmentInlineUri, isFileBackedComposerAttachment } from "../lib/composerImages";
7import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";7import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
8import { downloadAndShareAttachment } from "../lib/attachmentDownload";8import { downloadAndShareAttachment } from "../lib/attachmentDownload";
9import { assetImageSource } from "../lib/assetImageSource";
9import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets";10import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets";
10import { AppText as Text } from "./AppText";11import { AppText as Text } from "./AppText";
11import { FilePreviewModal } from "./FilePreviewModal";12import { FilePreviewModal } from "./FilePreviewModal";
...@@ -98,7 +99,11 @@ export function ComposerContextAttachment(props: {...@@ -98,7 +99,11 @@ export function ComposerContextAttachment(props: {
98 onPress={() => setPreviewOpen(true)}99 onPress={() => setPreviewOpen(true)}
99 >100 >
100 <Image101 <Image
101 source={{ uri }}102 source={
103 remoteEnvironmentId === null
104 ? { uri }
105 : assetImageSource(remoteEnvironmentId, resource, uri)
106 }
102 style={{ width: "100%", height: 280 }}107 style={{ width: "100%", height: 280 }}
103 contentFit="contain"108 contentFit="contain"
104 accessibilityLabel={record.name}109 accessibilityLabel={record.name}
apps/mobile/src/features/threads/ComposerQueuedEdit.tsx+11-13
...@@ -5,6 +5,7 @@ import { Pressable, ScrollView, View } from "react-native";...@@ -5,6 +5,7 @@ import { Pressable, ScrollView, View } from "react-native";
5import { AppText as Text } from "../../components/AppText";5import { AppText as Text } from "../../components/AppText";
6import { SymbolView } from "../../components/AppSymbol";6import { SymbolView } from "../../components/AppSymbol";
7import { useAssetUrl } from "../../state/assets";7import { useAssetUrl } from "../../state/assets";
8import { assetImageSource } from "../../lib/assetImageSource";
89
9/**10/**
10 * The composer is shared, so editing a queued message needs a visible way back11 * The composer is shared, so editing a queued message needs a visible way back
...@@ -75,24 +76,21 @@ function QueuedEditAttachmentChip(props: {...@@ -75,24 +76,21 @@ function QueuedEditAttachmentChip(props: {
75}) {76}) {
76 const { attachment } = props;77 const { attachment } = props;
77 const isImage = attachment.mimeType.startsWith("image/");78 const isImage = attachment.mimeType.startsWith("image/");
78 const url = useAssetUrl(79 const resource = {
79 props.environmentId,80 _tag: "attachment",
80 isImage81 attachmentId: attachment.id,
81 ? {82 fileName: attachment.name,
82 _tag: "attachment",83 mimeType: attachment.mimeType,
83 attachmentId: attachment.id,84 disposition: "inline",
84 fileName: attachment.name,85 } as const;
85 mimeType: attachment.mimeType,86 const url = useAssetUrl(props.environmentId, isImage ? resource : null);
86 disposition: "inline",
87 }
88 : null,
89 );
9087
91 return (88 return (
92 <View className="max-w-[180px] flex-row items-center gap-2 rounded-xl border border-border bg-card py-1 pl-1 pr-2">89 <View className="max-w-[180px] flex-row items-center gap-2 rounded-xl border border-border bg-card py-1 pl-1 pr-2">
93 {isImage && url !== null ? (90 {isImage && url !== null ? (
94 <Image91 <Image
95 source={{ uri: url }}92 source={assetImageSource(props.environmentId, resource, url)}
93 cachePolicy="memory-disk"
96 contentFit="cover"94 contentFit="cover"
97 style={{ width: 24, height: 24, borderRadius: 6 }}95 style={{ width: 24, height: 24, borderRadius: 6 }}
98 accessibilityIgnoresInvertColors96 accessibilityIgnoresInvertColors
apps/mobile/src/features/threads/QuestionAnswerHistory.test.tsx+1-1
...@@ -6,9 +6,9 @@ import { describe, expect, it, vi } from "vite-plus/test";...@@ -6,9 +6,9 @@ import { describe, expect, it, vi } from "vite-plus/test";
6vi.mock("react-native", () => ({6vi.mock("react-native", () => ({
7 View: "div",7 View: "div",
8 Pressable: ({ children }: { children: ReactNode }) => <button>{children}</button>,8 Pressable: ({ children }: { children: ReactNode }) => <button>{children}</button>,
9 Image: "img",
10 Linking: { openURL: vi.fn() },9 Linking: { openURL: vi.fn() },
11}));10}));
11vi.mock("expo-image", () => ({ Image: "img" }));
12vi.mock("../../components/AppText", () => ({ AppText: "span" }));12vi.mock("../../components/AppText", () => ({ AppText: "span" }));
13vi.mock("../../state/assets", () => ({ useAssetUrl: () => null }));13vi.mock("../../state/assets", () => ({ useAssetUrl: () => null }));
1414
apps/mobile/src/features/threads/QuestionAnswerHistory.tsx+11-6
...@@ -3,19 +3,19 @@ import type {...@@ -3,19 +3,19 @@ import type {
3 UserInputAttachmentAnswerPayload,3 UserInputAttachmentAnswerPayload,
4 UserInputAttachments,4 UserInputAttachments,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { Image, Linking, Pressable, View } from "react-native";6import { Image } from "expo-image";
7import { Linking, Pressable, View } from "react-native";
7import { getQuestionAnswerText } from "@t3tools/client-runtime/work-log/user-input";8import { getQuestionAnswerText } from "@t3tools/client-runtime/work-log/user-input";
8import { AppText as Text } from "../../components/AppText";9import { AppText as Text } from "../../components/AppText";
10import { assetImageSource } from "../../lib/assetImageSource";
9import { useAssetUrl } from "../../state/assets";11import { useAssetUrl } from "../../state/assets";
1012
11function AnswerFile(props: {13function AnswerFile(props: {
12 environmentId: EnvironmentId;14 environmentId: EnvironmentId;
13 attachment: UserInputAttachments[string][number];15 attachment: UserInputAttachments[string][number];
14}) {16}) {
15 const url = useAssetUrl(props.environmentId, {17 const resource = { _tag: "attachment", attachmentId: props.attachment.id } as const;
16 _tag: "attachment",18 const url = useAssetUrl(props.environmentId, resource);
17 attachmentId: props.attachment.id,
18 });
19 return (19 return (
20 <Pressable20 <Pressable
21 accessibilityRole="link"21 accessibilityRole="link"
...@@ -27,7 +27,12 @@ function AnswerFile(props: {...@@ -27,7 +27,12 @@ function AnswerFile(props: {
27 className="gap-1"27 className="gap-1"
28 >28 >
29 {props.attachment.type === "image" && url ? (29 {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 />
31 ) : null}36 ) : null}
32 <Text className="text-sm text-foreground underline">{props.attachment.name}</Text>37 <Text className="text-sm text-foreground underline">{props.attachment.name}</Text>
33 </Pressable>38 </Pressable>
apps/mobile/src/features/threads/ThreadFeed.tsx+9-4
...@@ -94,7 +94,10 @@ import {...@@ -94,7 +94,10 @@ import {
94 useWindowDimensions,94 useWindowDimensions,
95 View,95 View,
96} from "react-native";96} from "react-native";
97import { Image as ExpoImage } from "expo-image";
98import { useResolveClassNames } from "uniwind";
97import { useFilePreviewGallery, FilePreviewGallery } from "../../components/FilePreviewGallery";99import { useFilePreviewGallery, FilePreviewGallery } from "../../components/FilePreviewGallery";
100import { assetImageSource } from "../../lib/assetImageSource";
98import { composerImageGallery } from "../../lib/composerImageGallery";101import { composerImageGallery } from "../../lib/composerImageGallery";
99import { messageImageGallery } from "./messageImageGallery";102import { messageImageGallery } from "./messageImageGallery";
100import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";103import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
...@@ -457,6 +460,7 @@ function MessageAttachmentImage(props: {...@@ -457,6 +460,7 @@ function MessageAttachmentImage(props: {
457 const uri = useAssetUrl(props.environmentId, resource);460 const uri = useAssetUrl(props.environmentId, resource);
458 const refreshAssetUrl = useRefreshAssetUrl(props.environmentId, resource);461 const refreshAssetUrl = useRefreshAssetUrl(props.environmentId, resource);
459 const retriedImage = useRef(false);462 const retriedImage = useRef(false);
463 const imageStyle = useResolveClassNames(props.className);
460464
461 if (uri === null) {465 if (uri === null) {
462 return (466 return (
...@@ -490,10 +494,11 @@ function MessageAttachmentImage(props: {...@@ -490,10 +494,11 @@ function MessageAttachmentImage(props: {
490 )494 )
491 }495 }
492 >496 >
493 <Image497 <ExpoImage
494 source={{ uri }}498 source={assetImageSource(props.environmentId, resource, uri)}
495 className={props.className}499 cachePolicy="memory-disk"
496 resizeMode="cover"500 style={imageStyle}
501 contentFit="cover"
497 onLoad={() => {502 onLoad={() => {
498 retriedImage.current = false;503 retriedImage.current = false;
499 }}504 }}
apps/mobile/src/features/threads/ThreadQueueControl.tsx+6-3
...@@ -23,6 +23,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities";...@@ -23,6 +23,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities";
23import { useUniwindTheme } from "../../lib/useUniwindTheme";23import { useUniwindTheme } from "../../lib/useUniwindTheme";
24import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";24import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
25import { useAssetUrl } from "../../state/assets";25import { useAssetUrl } from "../../state/assets";
26import { assetImageSource } from "../../lib/assetImageSource";
26import { beginQueuedRunEdit, useQueuedRunEdit } from "../../state/queued-run-edit";27import { beginQueuedRunEdit, useQueuedRunEdit } from "../../state/queued-run-edit";
27import { environmentThreadDetails, threadEnvironment } from "../../state/threads";28import { environmentThreadDetails, threadEnvironment } from "../../state/threads";
28import { useAtomCommand } from "../../state/use-atom-command";29import { useAtomCommand } from "../../state/use-atom-command";
...@@ -563,19 +564,21 @@ function QueueAttachmentThumbnail(props: {...@@ -563,19 +564,21 @@ function QueueAttachmentThumbnail(props: {
563 readonly environmentId: EnvironmentId;564 readonly environmentId: EnvironmentId;
564 readonly attachment: ChatAttachment;565 readonly attachment: ChatAttachment;
565}) {566}) {
566 const url = useAssetUrl(props.environmentId, {567 const resource = {
567 _tag: "attachment",568 _tag: "attachment",
568 attachmentId: props.attachment.id,569 attachmentId: props.attachment.id,
569 fileName: props.attachment.name,570 fileName: props.attachment.name,
570 mimeType: props.attachment.mimeType,571 mimeType: props.attachment.mimeType,
571 disposition: "inline",572 disposition: "inline",
572 });573 } as const;
574 const url = useAssetUrl(props.environmentId, resource);
573 if (url === null) {575 if (url === null) {
574 return <View className="h-6 w-6 rounded bg-subtle" />;576 return <View className="h-6 w-6 rounded bg-subtle" />;
575 }577 }
576 return (578 return (
577 <Image579 <Image
578 source={{ uri: url }}580 source={assetImageSource(props.environmentId, resource, url)}
581 cachePolicy="memory-disk"
579 contentFit="cover"582 contentFit="cover"
580 style={{ width: 24, height: 24, borderRadius: 4 }}583 style={{ width: 24, height: 24, borderRadius: 4 }}
581 accessibilityIgnoresInvertColors584 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";...@@ -7,6 +7,7 @@ import { describe, expect, it } from "vite-plus/test";
77
8import {8import {
9 attachmentFileExtension,9 attachmentFileExtension,
10 chatAttachmentStableKey,
10 createAttachmentId,11 createAttachmentId,
11 createDeterministicAttachmentId,12 createDeterministicAttachmentId,
12 createPendingAttachmentId,13 createPendingAttachmentId,
...@@ -30,6 +31,27 @@ describe("attachmentStore", () => {...@@ -30,6 +31,27 @@ describe("attachmentStore", () => {
30 expect(first && parseThreadSegmentFromAttachmentId(first)).toBe("thread-1");31 expect(first && parseThreadSegmentFromAttachmentId(first)).toBe("thread-1");
31 });32 });
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
33 it("keeps deterministic ids distinct when sanitized thread segments collide", () => {55 it("keeps deterministic ids distinct when sanitized thread segments collide", () => {
34 const dotted = createDeterministicAttachmentId("thread.a", "message-1:0");56 const dotted = createDeterministicAttachmentId("thread.a", "message-1:0");
35 const dashed = createDeterministicAttachmentId("thread-a", "message-1:0");57 const dashed = createDeterministicAttachmentId("thread-a", "message-1:0");
apps/server/src/attachmentStore.ts+15
...@@ -103,6 +103,21 @@ export function createDeterministicAttachmentId(...@@ -103,6 +103,21 @@ export function createDeterministicAttachmentId(
103 return `${threadSegment}-${uuid}`;103 return `${threadSegment}-${uuid}`;
104}104}
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
106export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null {121export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null {
107 const normalizedId = normalizeAttachmentRelativePath(attachmentId);122 const normalizedId = normalizeAttachmentRelativePath(attachmentId);
108 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {123 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
apps/server/src/ws.ts+9-2
...@@ -185,7 +185,11 @@ import * as DeviceService from "./device/DeviceService.ts";...@@ -185,7 +185,11 @@ import * as DeviceService from "./device/DeviceService.ts";
185import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts";185import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts";
186import * as PreviewManager from "./preview/Manager.ts";186import * as PreviewManager from "./preview/Manager.ts";
187import { issueAssetUrl } from "./assets/AssetAccess.ts";187import { issueAssetUrl } from "./assets/AssetAccess.ts";
188import { attachmentRelativePath, createDeterministicAttachmentId } from "./attachmentStore.ts";188import {
189 attachmentRelativePath,
190 chatAttachmentStableKey,
191 createDeterministicAttachmentId,
192} from "./attachmentStore.ts";
189import { parseBase64DataUrl } from "./imageMime.ts";193import { parseBase64DataUrl } from "./imageMime.ts";
190import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts";194import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts";
191import * as PortScanner from "./preview/PortScanner.ts";195import * as PortScanner from "./preview/PortScanner.ts";
...@@ -398,7 +402,10 @@ const persistChatAttachments = Effect.fn("ws.assets.persistChatAttachments")(fun...@@ -398,7 +402,10 @@ const persistChatAttachments = Effect.fn("ws.assets.persistChatAttachments")(fun
398 message: `Attachment ${attachment.name} size does not match its payload.`,402 message: `Attachment ${attachment.name} size does not match its payload.`,
399 });403 });
400 }404 }
401 const rawId = createDeterministicAttachmentId(input.threadId, `${input.messageId}:${index}`);405 const rawId = createDeterministicAttachmentId(
406 input.threadId,
407 chatAttachmentStableKey(input.messageId, index, bytes),
408 );
402 if (rawId === null) {409 if (rawId === null) {
403 return yield* new PersistChatAttachmentsError({410 return yield* new PersistChatAttachmentsError({
404 message: "Could not allocate an attachment identifier.",411 message: "Could not allocate an attachment identifier.",