# Files changed · PR \#23212 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23212/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(web,mobile\): new replies show overwritten local images](https://git.cafe/pingdotgg/t3code/pull/23212.md)

Version: 9

Head commit: f7082ecebb44b72cff641b6ff381b83fad5d7111

Base commit: 89fe509232e787b51d0cf695861afe73316ce85b

Comparison base: ed9b7ca9c53843ea1e6b466bf7d52928d10e7db0

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/features/threads/ThreadFeed.tsx

Status: modified; +12; −9

```diff
@@ -258,6 +258,7 @@
 import { useFileChipShare } from "./useFileChipShare";
 import {
   MarkdownImageAvailableWidthContext,
+  MarkdownMediaRevisionContext,
   ThreadMarkdownImage,
   ThreadMarkdownImageUnavailable,
   ThreadMarkdownImageView,
@@ -1554,15 +1555,17 @@
       >
         {renderedText.trim().length > 0 ? (
           <MarkdownImageAvailableWidthContext value={props.markdownContentWidth}>
+            <MarkdownMediaRevisionContext value={message.id}>
+              <AssistantMarkdownContent
+                markdown={renderedText}
+                environmentId={props.environmentId}
+                markdownStyles={styles}
+                linkHandlers={props.markdownLinkHandlers}
+                onUseArtifactTemplate={props.onUseArtifactTemplate}
+                renderImage={props.renderMarkdownImage}
+                skills={props.skills}
+              />
-            <AssistantMarkdownContent
-              markdown={renderedText}
-              environmentId={props.environmentId}
-              markdownStyles={styles}
-              linkHandlers={props.markdownLinkHandlers}
-              onUseArtifactTemplate={props.onUseArtifactTemplate}
+            </MarkdownMediaRevisionContext>
-              renderImage={props.renderMarkdownImage}
-              skills={props.skills}
-            />
           </MarkdownImageAvailableWidthContext>
         ) : null}
         {attachments.map((attachment) => {
```

## apps/mobile/src/features/threads/ThreadMarkdownImage.tsx

Status: modified; +9; −1

```diff
@@ -30,6 +30,9 @@
  */
 export const MarkdownImageAvailableWidthContext = createContext(0);
 
+/** The reply an image belongs to, so a file overwritten at the same path is fetched again. */
+export const MarkdownMediaRevisionContext = createContext<string | undefined>(undefined);
+
 export function ThreadMarkdownImageView(props: {
   readonly uri: string | null;
   readonly sourceKey: string;
@@ -248,7 +251,12 @@
   readonly onPressPreview: (source: FilePreviewSource) => void;
 }) {
   const withGallery = useFilePreviewGallery();
-  const assetUrl = useAssetUrlState(props.environmentId, props.resource);
+  const revision = useContext(MarkdownMediaRevisionContext);
+  const assetUrl = useAssetUrlState(
+    props.environmentId,
+    props.resource,
+    props.resource._tag === "media-file" ? revision : undefined,
+  );
 
   return (
     <ThreadMarkdownImageView
```

## apps/mobile/src/state/assets.ts

Status: modified; +5; −1

```diff
@@ -33,6 +33,7 @@
 export function useAssetUrlState(
   environmentId: EnvironmentId | null,
   resource: AssetResource | null,
+  cacheRevision?: string,
 ): AssetUrlState {
   const fileAccessSession = useEnvironmentQuery(
     environmentId === null ? null : environmentSession.sessionStateAtom(environmentId),
@@ -50,7 +51,10 @@
   const result = useAtomValue(
     blocked !== null || environmentId === null || resource === null
       ? EMPTY_ASSET_URL_ATOM
+      : assetEnvironment.createUrl({
-      : assetEnvironment.createUrl({ environmentId, input: { resource } }),
+          environmentId,
+          input: cacheRevision === undefined ? { resource } : { resource, cacheRevision },
+        }),
   );
   const shared =
     blocked ??
```

## apps/web/src/assets/assetUrls.ts

Status: modified; +11; −2

```diff
@@ -22,13 +22,17 @@
 export function useAssetUrlState(
   environmentId: EnvironmentId | null,
   resource: AssetResource | null,
+  cacheRevision?: string,
 ): AssetUrlState {
   const blocked = blockedAssetUrlState(resource, useFilesystemReadAccess(environmentId));
   const preparedConnection = usePreparedConnection(environmentId);
   const result = useAtomValue(
     blocked !== null || environmentId === null || resource === null
       ? EMPTY_ASSET_URL_ATOM
+      : assetEnvironment.createUrl({
+          environmentId,
+          input: assetUrlInput(resource, cacheRevision),
-      : assetEnvironment.createUrl({ environmentId, input: { resource } }),
+        }),
   );
   if (blocked !== null) return blocked;
   return {
@@ -43,6 +47,7 @@
 export function useAssetUrlRefresh(
   environmentId: EnvironmentId | null,
   resource: AssetResource | null,
+  cacheRevision?: string,
 ): () => Promise<string | null> {
   const connection = usePreparedConnection(environmentId);
   const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null;
@@ -52,7 +57,7 @@
   });
   return async () => {
     if (environmentId === null || resource === null || httpBaseUrl === null) return null;
-    const result = await refresh({ environmentId, input: { resource } });
+    const result = await refresh({ environmentId, input: assetUrlInput(resource, cacheRevision) });
     if (result._tag === "Failure") throw squashAtomCommandFailure(result);
     return resolveAssetUrl(httpBaseUrl, result.value.relativeUrl);
   };
@@ -92,6 +97,10 @@
   return urls;
 }
 
+function assetUrlInput(resource: AssetResource, cacheRevision: string | undefined) {
+  return cacheRevision === undefined ? { resource } : { resource, cacheRevision };
+}
+
 function isFileResource(resource: AssetResource): boolean {
   return (
     resource._tag === "workspace-file" ||
```

## apps/web/src/components/ChatMarkdown.tsx

Status: modified; +6; −0

```diff
@@ -233,6 +233,8 @@
   onRunShellCommand?: ((command: string) => void) | undefined;
   imageBaseDir?: string | undefined;
   workspaceMutationId?: string | null | undefined;
+  /** Mints file media URLs once per revision, such as a reply, instead of reusing a cached one. */
+  mediaRevision?: string | undefined;
   onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
   extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
   renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined;
@@ -445,6 +447,7 @@
   onRunShellCommand,
   imageBaseDir,
   workspaceMutationId,
+  mediaRevision,
   onImageExpand,
   renderContextReference,
   headingLevelOffset = 0,
@@ -890,6 +893,7 @@
     headingLevelOffset,
     imageBaseDir,
     workspaceMutationId,
+    mediaRevision,
     inlineCodeFileLinkMetaByText,
     inline,
     mathEnabled,
@@ -1350,6 +1354,7 @@
       imageBaseDir,
       threadRef,
       workspaceMutationId,
+      mediaRevision,
       renderContextReference,
     } = use(ChatMarkdownRendererContext);
     const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia;
@@ -1462,6 +1467,7 @@
           style={authoredSizeStyle}
           workspaceRoot={cwd}
           workspaceMutationId={workspaceMutationId}
+          mediaRevision={kind === "image" ? mediaRevision : undefined}
           onImageExpand={imageExpand}
         />
       );
```

## apps/web/src/components/chat/ChatMarkdownMedia.tsx

Status: modified; +7; −2

```diff
@@ -332,10 +332,15 @@
   readonly fallbackSrc?: string | undefined;
   readonly workspaceRoot?: string | undefined;
   readonly workspaceMutationId?: string | null | undefined;
+  readonly mediaRevision?: string | undefined;
   readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
 }) {
+  const assetUrl = useAssetUrlState(props.environmentId, props.resource, props.mediaRevision);
+  const refreshAssetUrl = useAssetUrlRefresh(
+    props.environmentId,
+    props.resource,
-  const assetUrl = useAssetUrlState(props.environmentId, props.resource);
-  const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource);
+    props.mediaRevision,
+  );
   const connectionReady = usePreparedConnection(props.environmentId)._tag === "Some";
   const resource = props.resource;
   const refreshToken = JSON.stringify([props.environmentId, resource, props.workspaceMutationId]);
```

## apps/web/src/components/chat/TimelineAssistantRows.tsx

Status: modified; +1; −0

```diff
@@ -43,6 +43,7 @@
               text={messageText}
               cwd={ctx.markdownCwd}
               threadRef={ctx.threadRef ?? undefined}
+              mediaRevision={row.message.id}
               isStreaming={Boolean(row.message.streaming)}
               lineBreaks={shouldPreserveAssistantLineBreaks(messageText)}
               skills={ctx.skills}
```

## packages/client-runtime/src/state/assets.ts

Status: modified; +13; −1

```diff
@@ -127,6 +127,15 @@
   };
 }
 
+/**
+ * `cacheRevision` stays on the client: it keys a separate cached URL, so a file overwritten
+ * at the same path is minted again for a new revision (such as a new reply) instead of
+ * reusing a URL the browser already holds the previous bytes for.
+ */
+export type AssetCreateUrlCacheInput = AssetCreateUrlInput & {
+  readonly cacheRevision?: string;
+};
+
 export function createAssetEnvironmentAtoms<R, E>(
   runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, E>,
   localMediaEnvironment?: Atom.Atom<{
@@ -134,7 +143,10 @@
     readonly httpBaseUrl: string;
   } | null>,
 ) {
+  const execute = Effect.fn("assets.createUrl")(function* ({
+    cacheRevision: _cacheRevision,
+    ...input
+  }: AssetCreateUrlCacheInput) {
-  const execute = Effect.fn("assets.createUrl")(function* (input: AssetCreateUrlInput) {
     const result = yield* request(WS_METHODS.assetsCreateUrl, input).pipe(Effect.result);
     if (Result.isSuccess(result)) return result.success;
     const error = result.failure;
```

## scripts/fixtures/image-cache-proof.json

Status: added; +8; −0

```diff
@@ -1,0 +1,8 @@
+{
+  "timestamp": "2026-10-21T12:00:00.000Z",
+  "imagePath": "/tmp/t3-15676-chart.png",
+  "initialReplies": ["R1", "R2", "R3", "R4"],
+  "nextReply": "R5 \u2014 source overwritten to YELLOW v2\n\n![chart](/tmp/t3-15676-chart.png)",
+  "board": "PNG landscape: ![landscape](./fixtures/chart.png)\n\nInline PNG ![inline](/tmp/t3-15676-chart.png) follows prose.\n\nFile URL: ![file](file:///tmp/t3-15676-chart.png)\n\nTall PNG: ![tall](./fixtures/tall.png)\n\nSVG: ![vector](./fixtures/chart.svg)\n\nSVG fragment: ![fragment](./fixtures/chart.svg#viewBox(0,0,100,100))\n\nMissing: ![missing](./fixtures/nonexistent.png)\n\nBlocked: ![blocked](javascript:invalid)\n\nLinked: [![linked](./fixtures/chart.png)](https://example.invalid/)\n\nDirect data: ![direct](data:image/png;base64,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)",
+  "supplement": "Authored size: <img src=\"./fixtures/chart.png\" alt=\"sized\" width=\"90\" height=\"25\">\n\nSpaced path: ![space](<./fixtures/chart space.png>)\n\nEncoded path: ![encoded](./fixtures/chart%20space.png)"
+}
```

## scripts/fixtures/image-cache-proof.py

Status: added; +80; −0

```diff
@@ -1,0 +1,80 @@
+#!/usr/bin/env python3
+"""Seed static image replies by cloning app-written V2 rows; stop the sandbox server first.
+
+Example: python3 scripts/fixtures/image-cache-proof.py --db .t3/userdata/statev2.sqlite
+--thread <app-created-thread-id> --workspace /tmp/issue-15676/workspace
+Requires Pillow for labelled PNG fixtures. This changes projections only, not event history.
+"""
+import argparse, json, pathlib, sqlite3
+from PIL import Image, ImageDraw, ImageFont
+
+p = argparse.ArgumentParser()
+p.add_argument('--db', type=pathlib.Path)
+p.add_argument('--thread')
+p.add_argument('--template-thread')
+p.add_argument('--workspace', type=pathlib.Path, required=True)
+p.add_argument('--color', choices=['blue', 'yellow'], default='blue')
+p.add_argument('--images-only', action='store_true')
+p.add_argument('--board', action='store_true')
+p.add_argument('--supplement', action='store_true')
+p.add_argument('--font', type=pathlib.Path, help='TrueType font for PNG labels; defaults to Liberation Sans Bold, else Pillow default')
+a = p.parse_args()
+f = json.loads(pathlib.Path(__file__).with_suffix('.json').read_text())
+default_font = pathlib.Path('/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf')
+if a.font and not a.font.is_file():
+    p.error(f'--font not found: {a.font}')
+font_path = a.font or (default_font if default_font.is_file() else None)
+font = ImageFont.truetype(str(font_path), 28) if font_path else ImageFont.load_default(28)
+label, color, ink = ('BLUE v1', '#1261e8', 'white') if a.color == 'blue' else ('YELLOW v2', '#ffe100', 'black')
+def png(path, size):
+    image = Image.new('RGB', size, color)
+    ImageDraw.Draw(image).text((12, 24), label, font=font, fill=ink)
+    image.save(path)
+assets = a.workspace / 'fixtures'
+assets.mkdir(parents=True, exist_ok=True)
+png(f['imagePath'], (360, 100))
+png(assets / 'chart.png', (360, 100))
+png(assets / 'chart space.png', (360, 100))
+png(assets / 'tall.png', (180, 280))
+(assets / 'chart.svg').write_text('<svg xmlns="http://www.w3.org/2000/svg" width="360" height="100" viewBox="0 0 360 100"><rect width="360" height="100" fill="'+color+'"/><text x="12" y="55" font-size="28" fill="'+ink+'">'+label+' SVG</text></svg>')
+if a.images_only:
+    raise SystemExit()
+if not a.db or not a.thread:
+    p.error('--db and --thread are required for projection seeding')
+resolved = a.db.resolve()
+if '.t3' not in resolved.parts or resolved.is_relative_to(pathlib.Path.home() / '.t3'):
+    p.error('Only an isolated worktree .t3 database is supported')
+c = sqlite3.connect(resolved)
+c.row_factory = sqlite3.Row
+# Keep the first backup: a repeat seed must not replace the pre-seed recovery copy.
+backup_path = pathlib.Path(str(resolved)+'.image-proof-backup')
+if not backup_path.exists():
+    with sqlite3.connect(backup_path) as backup:
+        c.backup(backup)
+msg = c.execute("SELECT * FROM orchestration_v2_projection_messages WHERE thread_id=? AND role='assistant' LIMIT 1", (a.template_thread or a.thread,)).fetchone()
+item = c.execute("SELECT * FROM orchestration_v2_projection_turn_items WHERE thread_id=? AND type='assistant_message' LIMIT 1", (a.template_thread or a.thread,)).fetchone()
+if not msg or not item:
+    p.error('Create this thread and complete a real provider reply before seeding')
+texts = [f['supplement']] if a.supplement else [f['board']] if a.board else [r+' — source BLUE v1\n\n![chart]('+f['imagePath']+')' for r in f['initialReplies']]
+with c:
+    c.execute('DELETE FROM orchestration_v2_projection_messages WHERE thread_id=?', (a.thread,))
+    c.execute('DELETE FROM orchestration_v2_projection_turn_items WHERE thread_id=?', (a.thread,))
+    for i, text in enumerate(texts, 1):
+        mid, iid = a.thread+'-message-'+str(i), a.thread+'-item-'+str(i)
+        for table, template, rowid in [('orchestration_v2_projection_messages',msg,mid), ('orchestration_v2_projection_turn_items',item,iid)]:
+            row = dict(template)
+            payload = json.loads(row['payload_json'])
+            payload.update(id=rowid, text=text, updatedAt=f['timestamp'], threadId=a.thread, nodeId=None, runId='image-proof-static-run-'+str(i))
+            row['thread_id'] = a.thread
+            row['run_id'] = payload['runId']
+            row['node_id'] = payload['nodeId']
+            row['updated_at'] = f['timestamp']
+            if table.endswith('_messages'):
+                row.update(message_id=mid, created_at=f['timestamp'])
+                payload['createdAt'] = f['timestamp']
+            else:
+                row.update(turn_item_id=iid, ordinal=i)
+                payload.update(messageId=mid, nativeItemRef=None, providerTurnId=None, ordinal=i, startedAt=f['timestamp'], completedAt=f['timestamp'])
+            row['payload_json'] = json.dumps(payload)
+            c.execute('INSERT INTO '+table+' ('+','.join(row)+') VALUES ('+','.join('?' for _ in row)+')',tuple(row.values()))
+print('Seeded static projection fixture:',len(texts),'assistant replies; event history unchanged')
```
