From a0d85f1b2cde271317dedf8842bb007ee9dbf84e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 02:05:50 +0200 Subject: [PATCH 01/10] feat(mobile): capture cloud-agent attachment refs in file-part cache Parse the wrapper's sandbox file:// URL into a messageUuid+filename reference and store it as a ref-only cache entry, so a later slice can presign a fresh R2 download URL on demand. Add overwrite and resolve-failed helpers with store-driven failure state. --- .../components/agents/file-part-cache.test.ts | 172 ++++++++++++++++++ .../src/components/agents/file-part-cache.ts | 65 ++++++- .../agents/file-part-preview.test.ts | 45 +++++ .../components/agents/file-part-preview.ts | 23 +++ 4 files changed, 303 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/agents/file-part-cache.test.ts b/apps/mobile/src/components/agents/file-part-cache.test.ts index 8d27d4feea..6d8ab386b6 100644 --- a/apps/mobile/src/components/agents/file-part-cache.test.ts +++ b/apps/mobile/src/components/agents/file-part-cache.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- cohesive unit suite for the file-part cache: capture, overwrite, resolve-failed, and hook-subscription paths share one mock harness */ /* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React trees under vitest (same pattern as src/components/agents/attachment-preview-strip.mounted.test.tsx) */ import { createElement } from 'react'; import TestRenderer, { act } from 'react-test-renderer'; @@ -6,8 +7,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { __resetFilePartCacheForTests, cacheFilePart, + clearFilePartResolveFailed, getFilePartCacheEntry, isUsableFilePartUrl, + markFilePartResolveFailed, + overwriteFilePartCacheEntry, useFilePartCache, } from '@/components/agents/file-part-cache'; @@ -188,6 +192,150 @@ describe('cacheFilePart', () => { expect(fileInstances).toHaveLength(0); expect(getFilePartCacheEntry('part-1')).toBeUndefined(); }); + + it('stores a ref-only entry for a cloud-agent attachment file:// URL', () => { + const uuid = '11111111-2222-4333-8444-555555555555'; + cacheFilePart('part-attach', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`, + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + + expect(fileInstances).toHaveLength(0); + expect(getFilePartCacheEntry('part-attach')).toEqual({ + mime: 'text/markdown', + filename: `${uuid}.md`, + attachmentRef: { messageUuid: uuid, filename: `${uuid}.md` }, + }); + expect(getFilePartCacheEntry('part-attach')).not.toHaveProperty('url'); + }); +}); + +describe('overwriteFilePartCacheEntry', () => { + const uuid = '11111111-2222-4333-8444-555555555555'; + + it('replaces url, preserves attachmentRef, and emits', async () => { + cacheFilePart('part-overwrite', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`, + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + const renderer = await mountProbe('part-overwrite'); + + await act(async () => { + await Promise.resolve(); + overwriteFilePartCacheEntry('part-overwrite', { + url: 'https://r2.example/signed', + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + }); + + expect(textOf(renderer)).toBe('https://r2.example/signed|ok'); + expect(getFilePartCacheEntry('part-overwrite')).toEqual({ + url: 'https://r2.example/signed', + mime: 'text/markdown', + filename: `${uuid}.md`, + attachmentRef: { messageUuid: uuid, filename: `${uuid}.md` }, + }); + renderer.unmount(); + }); + + it('keeps attachmentRef and clears resolveFailed after a failed resolve', () => { + cacheFilePart('part-overwrite-failed', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`, + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + markFilePartResolveFailed('part-overwrite-failed'); + + overwriteFilePartCacheEntry('part-overwrite-failed', { + url: 'https://example.com/fresh.md', + mime: 'text/markdown', + filename: 'fresh.md', + }); + + expect(getFilePartCacheEntry('part-overwrite-failed')).toEqual({ + url: 'https://example.com/fresh.md', + mime: 'text/markdown', + filename: 'fresh.md', + attachmentRef: { messageUuid: uuid, filename: `${uuid}.md` }, + }); + expect(getFilePartCacheEntry('part-overwrite-failed')).not.toHaveProperty('resolveFailed'); + }); + + it('does not overwrite or emit when the payload URL is unusable', async () => { + cacheFilePart('part-overwrite-unusable', { + url: 'https://example.com/keep.md', + mime: 'text/markdown', + filename: 'keep.md', + }); + const before = getFilePartCacheEntry('part-overwrite-unusable'); + + const onRender = vi.fn<() => void>(); + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + await act(async () => { + await Promise.resolve(); + ref.current = TestRenderer.create( + createElement(CountingProbe, { partId: 'part-overwrite-unusable', onRender }) + ); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + const rendersBefore = onRender.mock.calls.length; + expect(textOf(renderer)).toBe('https://example.com/keep.md'); + + await act(async () => { + await Promise.resolve(); + overwriteFilePartCacheEntry('part-overwrite-unusable', { + url: 'file:///etc/passwd', + mime: 'text/plain', + filename: 'x.txt', + }); + }); + + expect(getFilePartCacheEntry('part-overwrite-unusable')).toBe(before); + expect(textOf(renderer)).toBe('https://example.com/keep.md'); + expect(onRender.mock.calls.length).toBe(rendersBefore); + + renderer.unmount(); + }); +}); + +describe('markFilePartResolveFailed / clearFilePartResolveFailed', () => { + const uuid = '11111111-2222-4333-8444-555555555555'; + + it('marks on a new identity, clears the key, and emits', async () => { + cacheFilePart('part-fail', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/x.md`, + mime: 'text/markdown', + filename: 'x.md', + }); + const before = getFilePartCacheEntry('part-fail'); + const renderer = await mountProbe('part-fail'); + + await act(async () => { + await Promise.resolve(); + markFilePartResolveFailed('part-fail'); + }); + const marked = getFilePartCacheEntry('part-fail'); + expect(marked?.resolveFailed).toBe(true); + expect(marked).not.toBe(before); + expect(textOf(renderer)).toBe('none|failed'); + + await act(async () => { + await Promise.resolve(); + clearFilePartResolveFailed('part-fail'); + }); + const cleared = getFilePartCacheEntry('part-fail'); + expect(cleared).not.toHaveProperty('resolveFailed'); + expect(cleared).not.toBe(marked); + expect(textOf(renderer)).toBe('none|ok'); + + renderer.unmount(); + }); }); describe('isUsableFilePartUrl', () => { @@ -209,6 +357,30 @@ function Probe({ partId }: { partId: string }) { return createElement('Text', null, entry?.url ?? 'none'); } +function EntryProbe({ partId }: { partId: string }) { + const entry = useFilePartCache(partId); + const failed = entry?.resolveFailed === true ? 'failed' : 'ok'; + return createElement('Text', null, `${entry?.url ?? 'none'}|${failed}`); +} + +function CountingProbe({ partId, onRender }: { partId: string; onRender: () => void }) { + const entry = useFilePartCache(partId); + onRender(); + return createElement('Text', null, entry?.url ?? 'none'); +} + +async function mountProbe(partId: string): Promise { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + await act(async () => { + await Promise.resolve(); + ref.current = TestRenderer.create(createElement(EntryProbe, { partId })); + }); + if (!ref.current) { + throw new Error('renderer was not created'); + } + return ref.current; +} + function textOf(renderer: TestRenderer.ReactTestRenderer): string | undefined { const node = renderer.root.find(n => typeof n.type === 'string' && (n.type as string) === 'Text'); return node.props.children as string | undefined; diff --git a/apps/mobile/src/components/agents/file-part-cache.ts b/apps/mobile/src/components/agents/file-part-cache.ts index 6293bc5b83..c8c7d49051 100644 --- a/apps/mobile/src/components/agents/file-part-cache.ts +++ b/apps/mobile/src/components/agents/file-part-cache.ts @@ -3,6 +3,7 @@ import { useSyncExternalStore } from 'react'; import { getSafeCacheFilename } from '@/lib/share-remote-file'; +import { type CloudAgentAttachmentRef, parseCloudAgentAttachmentUrl } from './file-part-preview'; import { stripDataUrlBase64Prefix } from './tool-card-image-cache'; const CACHE_DIR_NAME = 'session-file-parts'; @@ -11,9 +12,13 @@ const CACHE_DIR_NAME = 'session-file-parts'; * small `file://` URI; `http(s)` URLs are stored as-is. No bytes are * downloaded. */ export type FilePartCacheEntry = { - url: string; + // resolved usable URL; ABSENT on ref-only entries + url?: string; mime: string; filename?: string; + attachmentRef?: CloudAgentAttachmentRef; + // last on-demand presign failed; cleared on success/retry + resolveFailed?: boolean; }; /** Reactive map of partId → captured FilePart entry. First write wins. */ @@ -77,7 +82,9 @@ function resolveCacheUrl( /** * Record a captured FilePart URL. A `data:` URL is written to disk and stored * as a `file://` URI; an `http(s)` URL is stored as-is. Never downloads bytes. - * First write wins: a later call for the same `partId` is a no-op. + * A cloud-agent sandbox `file://` attachment URL stores a ref-only entry (no + * `url` key) for later on-demand presigning. First write wins: a later call + * for the same `partId` is a no-op. */ export function cacheFilePart( partId: string, @@ -86,6 +93,16 @@ export function cacheFilePart( if (entriesByPartId.has(partId)) { return; } + const ref = parseCloudAgentAttachmentUrl(payload.url); + if (ref) { + entriesByPartId.set(partId, { + mime: payload.mime, + ...(payload.filename ? { filename: payload.filename } : {}), + attachmentRef: ref, + }); + emitChange(); + return; + } const url = resolveCacheUrl(partId, payload); if (url === undefined) { return; @@ -98,6 +115,50 @@ export function cacheFilePart( emitChange(); } +/** + * Replace a cached entry with a freshly resolved URL (e.g. a re-presigned + * download URL). Preserves any attachment reference and clears the failed + * mark. A `resolveCacheUrl` rejection returns without writing. + */ +export function overwriteFilePartCacheEntry( + partId: string, + payload: Readonly<{ url: string; mime: string; filename?: string }> +): void { + const url = resolveCacheUrl(partId, payload); + if (url === undefined) { + return; + } + entriesByPartId.set(partId, { + url, + mime: payload.mime, + ...(payload.filename ? { filename: payload.filename } : {}), + attachmentRef: entriesByPartId.get(partId)?.attachmentRef, + }); + emitChange(); +} + +/** Mark an existing entry's last on-demand presign as failed. */ +export function markFilePartResolveFailed(partId: string): void { + const entry = entriesByPartId.get(partId); + if (!entry) { + return; + } + entriesByPartId.set(partId, { ...entry, resolveFailed: true }); + emitChange(); +} + +/** Clear the failed mark from an existing entry. */ +export function clearFilePartResolveFailed(partId: string): void { + const entry = entriesByPartId.get(partId); + if (!entry) { + return; + } + const next = { ...entry }; + delete next.resolveFailed; + entriesByPartId.set(partId, next); + emitChange(); +} + /** Synchronous lookup used by tests. */ export function getFilePartCacheEntry(partId: string): FilePartCacheEntry | undefined { return entriesByPartId.get(partId); diff --git a/apps/mobile/src/components/agents/file-part-preview.test.ts b/apps/mobile/src/components/agents/file-part-preview.test.ts index b91a12e7fb..9964978526 100644 --- a/apps/mobile/src/components/agents/file-part-preview.test.ts +++ b/apps/mobile/src/components/agents/file-part-preview.test.ts @@ -4,8 +4,53 @@ import { getFilePartAccessibilityLabel, getFilePartKind, isMarkdownFilePart, + parseCloudAgentAttachmentUrl, } from './file-part-preview'; +describe('parseCloudAgentAttachmentUrl', () => { + const UUID = '11111111-2222-4333-8444-555555555555'; + + it('parses a sandbox URL for .md, .mdx, and .png filenames', () => { + for (const filename of ['notes.md', 'docs.mdx', 'shot.png']) { + expect( + parseCloudAgentAttachmentUrl(`file:///tmp/attachments/agent-1/user-1/${UUID}/${filename}`) + ).toEqual({ messageUuid: UUID, filename }); + } + }); + + it('returns undefined for a non-attachment file:// URL', () => { + expect(parseCloudAgentAttachmentUrl('file:///etc/passwd')).toBeUndefined(); + }); + + it('returns undefined for an empty string', () => { + expect(parseCloudAgentAttachmentUrl('')).toBeUndefined(); + }); + + it('returns undefined for http(s) and data: URLs', () => { + expect(parseCloudAgentAttachmentUrl('http://example.com/a.md')).toBeUndefined(); + expect(parseCloudAgentAttachmentUrl('https://example.com/a.md')).toBeUndefined(); + expect(parseCloudAgentAttachmentUrl('data:text/markdown;base64,QUJD')).toBeUndefined(); + }); + + it('returns undefined for extra path segments after the uuid', () => { + expect( + parseCloudAgentAttachmentUrl(`file:///tmp/attachments/agent-1/user-1/${UUID}/notes.md/extra`) + ).toBeUndefined(); + }); + + it('returns undefined for a trailing slash', () => { + expect( + parseCloudAgentAttachmentUrl(`file:///tmp/attachments/agent-1/user-1/${UUID}/notes.md/`) + ).toBeUndefined(); + }); + + it('returns undefined when the uuid segment is not a uuid', () => { + expect( + parseCloudAgentAttachmentUrl('file:///tmp/attachments/agent-1/user-1/not-a-uuid/notes.md') + ).toBeUndefined(); + }); +}); + describe('isMarkdownFilePart', () => { it('is true for .md and .mdx filenames', () => { expect(isMarkdownFilePart('README.md')).toBe(true); diff --git a/apps/mobile/src/components/agents/file-part-preview.ts b/apps/mobile/src/components/agents/file-part-preview.ts index e4b0da6191..c9cc32b260 100644 --- a/apps/mobile/src/components/agents/file-part-preview.ts +++ b/apps/mobile/src/components/agents/file-part-preview.ts @@ -1,5 +1,28 @@ import { isMarkdownPath } from './read-tool-markdown'; +/** A cloud-agent attachment reference parsed from the wrapper's sandbox URL. */ +export type CloudAgentAttachmentRef = { messageUuid: string; filename: string }; + +// Persisted history and live events carry the wrapper's sandbox URL +// `file:///tmp/attachments////` +// (`services/cloud-agent-next/wrapper/src/session-bootstrap.ts`). +// Removal condition: none — stored messages keep this form permanently. +const CLOUD_AGENT_ATTACHMENT_URL = + /^file:\/\/\/tmp\/attachments\/[^/]+\/[^/]+\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\/([^/]+)$/; + +export function parseCloudAgentAttachmentUrl(url: string): CloudAgentAttachmentRef | undefined { + const match = CLOUD_AGENT_ATTACHMENT_URL.exec(url); + if (!match) { + return undefined; + } + const messageUuid = match[1]; + const filename = match[2]; + if (messageUuid === undefined || filename === undefined) { + return undefined; + } + return { messageUuid, filename }; +} + export type FilePartKind = 'image' | 'markdown' | 'other'; export function isMarkdownFilePart(filename: string | undefined): boolean { From 1078afd93cf42c6f926a1fc9cc11b2591df45be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 02:28:37 +0200 Subject: [PATCH 02/10] feat(mobile): resolve cloud-agent attachment previews via presigned R2 Add an on-demand resolver that presigns a fresh R2 GET for captured attachment refs and feeds the existing preview modal and image viewer. Wire the renderer to show busy, error-with-retry, and unavailable states, and re-presign on retry so 15-minute URL expiry recovers. --- .../file-part-renderer.mounted.test.tsx | 188 ++++++++++++++++++ .../components/agents/file-part-renderer.tsx | 136 +++++++++---- .../agents/file-part-url-resolver.ts | 122 ++++++++++++ 3 files changed, 406 insertions(+), 40 deletions(-) create mode 100644 apps/mobile/src/components/agents/file-part-url-resolver.ts diff --git a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx index 9ef5ef3fed..25f6598049 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FilePartRenderer } from './file-part-renderer'; import { __resetFilePartCacheForTests, cacheFilePart } from './file-part-cache'; +import { __resetFilePartUrlResolverForTests } from './file-part-url-resolver'; type FileInstance = { uri: string; @@ -84,6 +85,16 @@ const toastMock = vi.hoisted(() => ({ vi.mock('sonner-native', () => ({ toast: toastMock })); +const getAttachmentDownloadUrlMutate = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/trpc', () => ({ + trpcClient: { + cloudAgentNext: { + getAttachmentDownloadUrl: { mutate: getAttachmentDownloadUrlMutate }, + }, + }, +})); + vi.mock('react-native', () => ({ ActivityIndicator: 'ActivityIndicator', Modal: 'Modal', @@ -122,6 +133,13 @@ beforeEach(() => { vi.clearAllMocks(); fileInstances.length = 0; __resetFilePartCacheForTests(); + __resetFilePartUrlResolverForTests(); + getAttachmentDownloadUrlMutate.mockReset(); + getAttachmentDownloadUrlMutate.mockResolvedValue({ + signedUrl: 'https://r2.example/signed', + key: 'k', + expiresAt: '2026-01-01T00:00:00Z', + }); expoFileSystemMock.fileText.mockReset(); shareRemoteFileMock.getSafeCacheFilename.mockImplementation( ({ id, filename }: { id: string; filename: string }) => `${id}-${filename}` @@ -594,4 +612,174 @@ describe('FilePartRenderer mounted', () => { await unmount(renderer); }); + + it('presigns a markdown attachment and previews its text', async () => { + expoFileSystemMock.fileText.mockResolvedValue('# Attachment'); + const uuid = '11111111-1111-4111-8111-111111111111'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`, + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: `${uuid}.md`, url: '' }) + ); + const root = renderer.root; + + await flushAsync(); + + expect(getAttachmentDownloadUrlMutate).toHaveBeenCalledWith({ + messageUuid: uuid, + filename: `${uuid}.md`, + }); + + await press(first(pressableByLabel(root, `Preview ${uuid}.md`))); + await flushAsync(); + + const markdown = findByType(root, 'ChatMarkdownText'); + expect(markdown).toHaveLength(1); + expect(markdown[0]?.props.value).toBe('# Attachment'); + expect(toastMock.error).not.toHaveBeenCalledWith('Preview unavailable'); + + await unmount(renderer); + }); + + it('shows a retry toast when the presign fails, then opens after a successful retry', async () => { + expoFileSystemMock.fileText.mockResolvedValue('# Attachment'); + const uuid = '22222222-2222-4222-8222-222222222222'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`, + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + getAttachmentDownloadUrlMutate.mockRejectedValueOnce(new Error('presign failed')); + + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: `${uuid}.md`, url: '' }) + ); + const root = renderer.root; + + await flushAsync(); + + await press(first(pressableByLabel(root, `Preview ${uuid}.md`))); + + expect(toastMock.error).toHaveBeenCalledWith('Could not load this file. Try again.'); + expect(findByType(root, 'Modal')).toHaveLength(0); + + await flushAsync(); + + await press(first(pressableByLabel(root, `Preview ${uuid}.md`))); + await flushAsync(); + + const markdown = findByType(root, 'ChatMarkdownText'); + expect(markdown).toHaveLength(1); + expect(markdown[0]?.props.value).toBe('# Attachment'); + + await unmount(renderer); + }); + + it('presigns an image attachment and renders the inline image', async () => { + const uuid = '33333333-3333-4333-8333-333333333333'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.png`, + mime: 'image/png', + filename: `${uuid}.png`, + }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'image/png', filename: `${uuid}.png`, url: '' }) + ); + const root = renderer.root; + + await flushAsync(); + + const image = findByType(root, 'Image')[0]; + if (!image) { + throw new Error('image not found'); + } + expect(image.props.source).toEqual({ uri: 'https://r2.example/signed' }); + + await press(first(pressableByLabel(root, `Open ${uuid}.png full screen`))); + + const viewers = findByType(root, 'ImageViewerModal'); + expect(viewers).toHaveLength(1); + expect(viewers[0]?.props).toMatchObject({ visible: true, uri: 'https://r2.example/signed' }); + + await unmount(renderer); + }); + + it('toasts "Preview unavailable" for a part with no URL and no cache entry', async () => { + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'application/pdf', filename: 'report.pdf', url: '' }) + ); + const root = renderer.root; + + await flushAsync(); + + await press(first(pressableByLabel(root, 'Open report.pdf'))); + + expect(toastMock.error).toHaveBeenCalledWith('Preview unavailable'); + expect(getAttachmentDownloadUrlMutate).not.toHaveBeenCalled(); + + await unmount(renderer); + }); + + it('re-presigns on modal Retry after a download failure', async () => { + expoFileSystemMock.fileText.mockResolvedValue('# Attachment'); + const uuid = '44444444-4444-4444-8444-444444444444'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`, + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + shareRemoteFileMock.downloadRemoteFile.mockRejectedValueOnce(new Error('R2 404')); + + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: `${uuid}.md`, url: '' }) + ); + const root = renderer.root; + + await flushAsync(); + + await press(first(pressableByLabel(root, `Preview ${uuid}.md`))); + await flushAsync(); + + expect(texts(root)).toContain('Could not load this file.'); + const retryButtons = pressableByLabel(root, 'Retry loading file'); + expect(retryButtons).toHaveLength(1); + + const callsBefore = getAttachmentDownloadUrlMutate.mock.calls.length; + await press(first(retryButtons)); + await flushAsync(); + + expect(getAttachmentDownloadUrlMutate.mock.calls.length).toBe(callsBefore + 1); + + const markdown = findByType(root, 'ChatMarkdownText'); + expect(markdown).toHaveLength(1); + expect(markdown[0]?.props.value).toBe('# Attachment'); + + await unmount(renderer); + }); + + it('shows "This file is empty." for an empty markdown attachment', async () => { + expoFileSystemMock.fileText.mockResolvedValue(''); + const uuid = '55555555-5555-4555-8555-555555555555'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`, + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: `${uuid}.md`, url: '' }) + ); + const root = renderer.root; + + await flushAsync(); + + await press(first(pressableByLabel(root, `Preview ${uuid}.md`))); + await flushAsync(); + + expect(texts(root)).toContain('This file is empty.'); + + await unmount(renderer); + }); }); diff --git a/apps/mobile/src/components/agents/file-part-renderer.tsx b/apps/mobile/src/components/agents/file-part-renderer.tsx index 4f300e2cf8..84dbb52654 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.tsx @@ -22,8 +22,8 @@ import { } from '@/lib/share-remote-file'; import { ChatMarkdownText } from './chat-markdown-text'; -import { isUsableFilePartUrl, useFilePartCache } from './file-part-cache'; import { getFilePartAccessibilityLabel, getFilePartKind } from './file-part-preview'; +import { refreshFilePartUrl, useResolvedFilePartUrl } from './file-part-url-resolver'; import { stripDataUrlBase64Prefix } from './tool-card-image-cache'; const CACHE_DIR_NAME = 'session-file-parts'; @@ -94,24 +94,12 @@ type FilePartRendererProps = { type PreviewMode = 'markdown' | 'text'; -/** Prefer the captured cache URL, falling back to the part's own URL. The - * cached URL is produced by our cache and is always trusted. */ -function resolveUsableUrl(cachedUrl: string | undefined, partUrl: string): string | undefined { - if (cachedUrl) { - return cachedUrl; - } - if (isUsableFilePartUrl(partUrl)) { - return partUrl; - } - return undefined; -} - export function FilePartRenderer({ part }: Readonly) { const colors = useThemeColors(); const { showActionSheetWithOptions } = useActionSheet(); - const cached = useFilePartCache(part.id); - const url = resolveUsableUrl(cached?.url, part.url); + const resolved = useResolvedFilePartUrl(part); + const url = resolved.status === 'ready' ? resolved.url : undefined; const kind = getFilePartKind({ mime: part.mime, filename: part.filename }); const [viewerVisible, setViewerVisible] = useState(false); @@ -141,30 +129,38 @@ export function FilePartRenderer({ part }: Readonly) { } function handleChipTap() { - if (!url) { - toast.error('Preview unavailable'); + if (url) { + if (kind === 'markdown') { + setPreview('markdown'); + return; + } + showActionSheetWithOptions( + { + options: ['Open as text', 'Open in external app', 'Cancel'], + cancelButtonIndex: 2, + }, + index => { + if (index === undefined || index === 2) { + return; + } + if (index === 0) { + setPreview('text'); + } else if (index === 1) { + void handleShare(); + } + } + ); return; } - if (kind === 'markdown') { - setPreview('markdown'); + if (resolved.status === 'resolving') { return; } - showActionSheetWithOptions( - { - options: ['Open as text', 'Open in external app', 'Cancel'], - cancelButtonIndex: 2, - }, - index => { - if (index === undefined || index === 2) { - return; - } - if (index === 0) { - setPreview('text'); - } else if (index === 1) { - void handleShare(); - } - } - ); + if (resolved.status === 'error') { + resolved.retry?.(); + toast.error('Could not load this file. Try again.'); + return; + } + toast.error('Preview unavailable'); } if (kind === 'image') { @@ -173,7 +169,16 @@ export function FilePartRenderer({ part }: Readonly) { return ( { - setImageFailed(false); + if (!resolved.attachmentRef) { + setImageFailed(false); + return; + } + void (async () => { + const ok = await refreshFilePartUrl(part.id); + if (ok) { + setImageFailed(false); + } + })(); }} className="my-1 flex-row items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 dark:bg-neutral-900" accessibilityRole="button" @@ -222,6 +227,32 @@ export function FilePartRenderer({ part }: Readonly) { ); } + if (resolved.status === 'resolving') { + return ( + + + Loading image + + ); + } + if (resolved.status === 'error') { + return ( + { + resolved.retry?.(); + }} + className="my-1 flex-row items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 dark:bg-neutral-900" + accessibilityRole="button" + accessibilityLabel="Image unavailable, retry loading" + > + + Image unavailable + + ); + } return ( @@ -235,12 +266,16 @@ export function FilePartRenderer({ part }: Readonly) { - {sharing ? : } + {sharing || resolved.status === 'resolving' ? ( + + ) : ( + + )} {part.filename ?? 'File'} @@ -250,6 +285,14 @@ export function FilePartRenderer({ part }: Readonly) { mode={preview} url={url} part={part} + onRetry={ + resolved.attachmentRef + ? async () => { + const ok = await refreshFilePartUrl(part.id); + return ok; + } + : undefined + } onClose={() => { setPreview(null); }} @@ -263,10 +306,11 @@ type FilePreviewModalProps = { mode: PreviewMode; url: string; part: FilePart; + onRetry?: () => Promise; onClose: () => void; }; -function FilePreviewModal({ mode, url, part, onClose }: Readonly) { +function FilePreviewModal({ mode, url, part, onRetry, onClose }: Readonly) { const { id, mime, filename } = part; const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading'); const [text, setText] = useState(''); @@ -306,7 +350,19 @@ function FilePreviewModal({ mode, url, part, onClose }: ReadonlyCould not load this file. { - setAttempt(prev => prev + 1); + if (!onRetry) { + setAttempt(prev => prev + 1); + return; + } + setStatus('loading'); + void (async () => { + const ok = await onRetry(); + if (!ok) { + setStatus('error'); + } else { + setAttempt(prev => prev + 1); + } + })(); }} accessibilityRole="button" accessibilityLabel="Retry loading file" diff --git a/apps/mobile/src/components/agents/file-part-url-resolver.ts b/apps/mobile/src/components/agents/file-part-url-resolver.ts new file mode 100644 index 0000000000..c2445ce63c --- /dev/null +++ b/apps/mobile/src/components/agents/file-part-url-resolver.ts @@ -0,0 +1,122 @@ +import { type FilePart } from '@kilocode/cloud-agent-sdk'; +import { useEffect, useState } from 'react'; + +import { trpcClient } from '@/lib/trpc'; + +import { + clearFilePartResolveFailed, + getFilePartCacheEntry, + isUsableFilePartUrl, + markFilePartResolveFailed, + overwriteFilePartCacheEntry, + useFilePartCache, +} from './file-part-cache'; +import { type CloudAgentAttachmentRef, parseCloudAgentAttachmentUrl } from './file-part-preview'; + +/** Part IDs with an on-demand presign in flight. Dedupes a StrictMode + * double-mount and a leave/reopen during the mutate. */ +const inFlight = new Set(); + +export type ResolvedFilePartUrl = { + status: 'ready' | 'resolving' | 'unavailable' | 'error'; + // set only when status === 'ready' + url?: string; + // set whenever a ref is known + attachmentRef?: CloudAgentAttachmentRef; + // set only when status === 'error' + retry?: () => void; +}; + +/** + * Resolve a usable URL for a FilePart. A captured `http(s)`/`data:` URL (or a + * cached one) is used directly. A cloud-agent sandbox `file://` attachment is + * presigned on demand via `getAttachmentDownloadUrl`. Failure state lives in + * the cache store so a remounted instance sees a failed presign instead of a + * stuck `resolving`. + */ +export function useResolvedFilePartUrl(part: FilePart): ResolvedFilePartUrl { + const cached = useFilePartCache(part.id); + const [attempt, setAttempt] = useState(0); + + const url = cached?.url ?? (isUsableFilePartUrl(part.url) ? part.url : undefined); + const ref = cached?.attachmentRef ?? parseCloudAgentAttachmentUrl(part.url); + const failed = cached?.resolveFailed === true; + + useEffect(() => { + const entry = getFilePartCacheEntry(part.id); + if (entry?.url || entry?.resolveFailed) { + return; + } + const attachmentRef = entry?.attachmentRef ?? parseCloudAgentAttachmentUrl(part.url); + if (!attachmentRef || inFlight.has(part.id)) { + return; + } + inFlight.add(part.id); + void (async () => { + try { + const result = await trpcClient.cloudAgentNext.getAttachmentDownloadUrl.mutate({ + messageUuid: attachmentRef.messageUuid, + filename: attachmentRef.filename, + }); + overwriteFilePartCacheEntry(part.id, { + url: result.signedUrl, + mime: part.mime, + filename: part.filename, + }); + } catch { + markFilePartResolveFailed(part.id); + } finally { + inFlight.delete(part.id); + } + })(); + }, [part.id, part.url, part.mime, part.filename, cached, attempt]); + + if (url !== undefined) { + return { status: 'ready', url, ...(ref ? { attachmentRef: ref } : {}) }; + } + if (!ref) { + return { status: 'unavailable' }; + } + if (failed) { + return { + status: 'error', + attachmentRef: ref, + retry: () => { + clearFilePartResolveFailed(part.id); + setAttempt(a => a + 1); + }, + }; + } + return { status: 'resolving', attachmentRef: ref }; +} + +/** + * Re-presign a cached attachment ref and swap the entry's URL. Returns false + * (never throws) when there is no entry, no ref, or the presign fails. + */ +export async function refreshFilePartUrl(partId: string): Promise { + const entry = getFilePartCacheEntry(partId); + const ref = entry?.attachmentRef; + if (!entry || !ref) { + return false; + } + try { + const result = await trpcClient.cloudAgentNext.getAttachmentDownloadUrl.mutate({ + messageUuid: ref.messageUuid, + filename: ref.filename, + }); + overwriteFilePartCacheEntry(partId, { + url: result.signedUrl, + mime: entry.mime, + ...(entry.filename ? { filename: entry.filename } : {}), + }); + return true; + } catch { + return false; + } +} + +/** Test-only: clear the in-flight set between cases. */ +export function __resetFilePartUrlResolverForTests(): void { + inFlight.clear(); +} From c3d161498b6b0724849fa259550dcec8d52ac96f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 03:30:39 +0200 Subject: [PATCH 03/10] fix(mobile): open attachment preview after a tap during presign The cache hook snapshotted a version counter and ignored it, so a post-mount overwrite (the on-demand presign) did not reliably re-render subscribers and the chip stayed busy. Snapshot the entry object instead, and open the markdown modal once the URL lands when the chip is tapped while the presign is in flight. --- .../src/components/agents/file-part-cache.ts | 17 +++---- .../file-part-renderer.mounted.test.tsx | 44 +++++++++++++++++++ .../components/agents/file-part-renderer.tsx | 5 +++ 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/components/agents/file-part-cache.ts b/apps/mobile/src/components/agents/file-part-cache.ts index c8c7d49051..99be6d97fc 100644 --- a/apps/mobile/src/components/agents/file-part-cache.ts +++ b/apps/mobile/src/components/agents/file-part-cache.ts @@ -24,11 +24,8 @@ export type FilePartCacheEntry = { /** Reactive map of partId → captured FilePart entry. First write wins. */ const entriesByPartId = new Map(); const listeners = new Set<() => void>(); -/** Bumped on every map mutation so useSyncExternalStore sees a new snapshot. */ -let entriesVersion = 0; function emitChange(): void { - entriesVersion += 1; for (const listener of listeners) { listener(); } @@ -41,10 +38,6 @@ function subscribe(listener: () => void): () => void { }; } -function getVersionSnapshot(): number { - return entriesVersion; -} - /** * Resolve the URL to store for a captured FilePart. A `data:` URL is written * to disk and stored as a `file://` URI; an `http(s)` URL is stored as-is. @@ -166,11 +159,15 @@ export function getFilePartCacheEntry(partId: string): FilePartCacheEntry | unde /** * Reactive lookup of a captured FilePart entry. Returns `undefined` until a - * write is recorded for `partId`. + * write is recorded for `partId`. The snapshot is the entry object itself so + * a post-mount write (e.g. an on-demand presign) re-renders every subscriber. */ export function useFilePartCache(partId: string): FilePartCacheEntry | undefined { - useSyncExternalStore(subscribe, getVersionSnapshot, getVersionSnapshot); - return entriesByPartId.get(partId); + return useSyncExternalStore( + subscribe, + () => entriesByPartId.get(partId), + () => entriesByPartId.get(partId) + ); } /** True only for `http:`, `https:`, and `data:` URLs. */ diff --git a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx index 25f6598049..441f2a34c5 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx @@ -644,6 +644,50 @@ describe('FilePartRenderer mounted', () => { await unmount(renderer); }); + it('opens the markdown modal after a tap during the presign resolves', async () => { + expoFileSystemMock.fileText.mockResolvedValue('# Attachment'); + const uuid = '33333333-3333-4333-8333-333333333333'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`, + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + const presignHolder: { + resolve?: (value: { signedUrl: string; key: string; expiresAt: string }) => void; + } = {}; + getAttachmentDownloadUrlMutate.mockReturnValueOnce( + new Promise(resolve => { + presignHolder.resolve = resolve; + }) + ); + + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: `${uuid}.md`, url: '' }) + ); + const root = renderer.root; + + // The presign is still in flight, so the chip is busy. A tap during that + // window must open the modal once the URL lands, without a second tap. + await press(first(pressableByLabel(root, `Preview ${uuid}.md`))); + expect(findByType(root, 'Modal')).toHaveLength(0); + + await act(async () => { + presignHolder.resolve?.({ + signedUrl: 'https://r2.example/signed', + key: 'k', + expiresAt: '2026-01-01T00:00:00Z', + }); + await Promise.resolve(); + }); + await flushAsync(); + + const markdown = findByType(root, 'ChatMarkdownText'); + expect(markdown).toHaveLength(1); + expect(markdown[0]?.props.value).toBe('# Attachment'); + + await unmount(renderer); + }); + it('shows a retry toast when the presign fails, then opens after a successful retry', async () => { expoFileSystemMock.fileText.mockResolvedValue('# Attachment'); const uuid = '22222222-2222-4222-8222-222222222222'; diff --git a/apps/mobile/src/components/agents/file-part-renderer.tsx b/apps/mobile/src/components/agents/file-part-renderer.tsx index 84dbb52654..1bf38d048e 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.tsx @@ -153,6 +153,11 @@ export function FilePartRenderer({ part }: Readonly) { return; } if (resolved.status === 'resolving') { + // A markdown chip tapped while the presign is in flight opens the + // modal as soon as the URL lands; the modal render is gated on `url`. + if (kind === 'markdown') { + setPreview('markdown'); + } return; } if (resolved.status === 'error') { From 050100035ce2bd8968406466c8598497a44ff806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 04:51:02 +0200 Subject: [PATCH 04/10] fix(cloud-agent-next): preserve file part url on live stream The wrapper's live-stream trim stripped every FilePart url to '', so the mobile onFilePart sink never saw the raw file:// sandbox path on the live path. The attachment reference was only captured from the history replay, which races the async part ingestion. Keep non-data: urls and strip only data: urls (inline base64) plus source.text.value. --- .../src/shared/trim-payload.test.ts | 67 +++++++++++++++++++ .../src/shared/trim-payload.ts | 10 ++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/services/cloud-agent-next/src/shared/trim-payload.test.ts b/services/cloud-agent-next/src/shared/trim-payload.test.ts index 1e05ed1a7c..c4a542607a 100644 --- a/services/cloud-agent-next/src/shared/trim-payload.test.ts +++ b/services/cloud-agent-next/src/shared/trim-payload.test.ts @@ -298,6 +298,73 @@ describe('trimPayload', () => { expect(result.properties.part.name).toBe('image.png'); }); + it('preserves a file:// url and strips only source.text.value', () => { + const data = createKilocodeEvent('message.part.updated', { + part: { + type: 'file', + url: 'file:///tmp/attachments/agent_1/user_1/4ad3d9d2-b461-4a55-ac05-ae970f22ee2a/brief.md', + name: 'brief.md', + source: { + text: { value: 'file content here', start: 0, end: 50 }, + type: 'file', + path: '/tmp/attachments/agent_1/user_1/4ad3d9d2-b461-4a55-ac05-ae970f22ee2a/brief.md', + }, + }, + }); + + const result = trimPayload('kilocode', data) as { + properties: { + part: { + url: string; + name: string; + source: { + text: { value: string; start: number; end: number }; + type: string; + path: string; + }; + }; + }; + }; + + expect(result.properties.part.url).toBe( + 'file:///tmp/attachments/agent_1/user_1/4ad3d9d2-b461-4a55-ac05-ae970f22ee2a/brief.md' + ); + expect(result.properties.part.source.text.value).toBe(''); + expect(result.properties.part.name).toBe('brief.md'); + expect(result.properties.part.source.type).toBe('file'); + expect(result.properties.part.source.path).toBe( + '/tmp/attachments/agent_1/user_1/4ad3d9d2-b461-4a55-ac05-ae970f22ee2a/brief.md' + ); + }); + + it('preserves an http(s) url and strips only source.text.value', () => { + const data = createKilocodeEvent('message.part.updated', { + part: { + type: 'file', + url: 'https://example.com/brief.md', + name: 'brief.md', + source: { + text: { value: 'file content here', start: 0, end: 50 }, + type: 'file', + path: '/foo', + }, + }, + }); + + const result = trimPayload('kilocode', data) as { + properties: { + part: { + url: string; + name: string; + source: { text: { value: string }; type: string; path: string }; + }; + }; + }; + + expect(result.properties.part.url).toBe('https://example.com/brief.md'); + expect(result.properties.part.source.text.value).toBe(''); + }); + it('strips top-level url and source.text.value', () => { const data = { event: 'message.part.updated', diff --git a/services/cloud-agent-next/src/shared/trim-payload.ts b/services/cloud-agent-next/src/shared/trim-payload.ts index 8d1ddacf81..bd33e1090c 100644 --- a/services/cloud-agent-next/src/shared/trim-payload.ts +++ b/services/cloud-agent-next/src/shared/trim-payload.ts @@ -14,7 +14,15 @@ function truncate(s: string, max: number): string { } function stripFilePartFields(part: Record): Record { - const out: Record = { ...part, url: '' }; + // Preserve small, non-`data:` URLs (`file://` sandbox paths, `http(s)`). + // The mobile client captures the cloud-agent attachment reference from the + // raw `file://` URL before the SDK strip; the live stream must keep it. + // `data:` URLs carry inline base64 bytes and are stripped for size. + const url = part.url; + const out: Record = { + ...part, + ...(typeof url === 'string' && url.startsWith('data:') ? { url: '' } : {}), + }; const source = part.source; if (isRecord(source)) { const text = source.text; From 2c8b8cf9937d0e89e48c292499abd889de3a4c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 05:07:31 +0200 Subject: [PATCH 05/10] fix(cloud-agent-next): preserve small inline data urls on live stream The wrapper's live-stream trim stripped every data: url to '', but the Kilo CLI inlines image file parts as data:, so the live first-message image never reached the mobile client and showed 'Image unavailable'. Preserve small data: urls (<=512KiB) on top-level file parts; tool attachments and larger data: urls still strip for size. --- .../src/shared/trim-payload.test.ts | 40 +++++++++++++++---- .../src/shared/trim-payload.ts | 32 +++++++++++---- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/services/cloud-agent-next/src/shared/trim-payload.test.ts b/services/cloud-agent-next/src/shared/trim-payload.test.ts index c4a542607a..0bc680fdff 100644 --- a/services/cloud-agent-next/src/shared/trim-payload.test.ts +++ b/services/cloud-agent-next/src/shared/trim-payload.test.ts @@ -5,6 +5,7 @@ import { MAX_TOOL_OUTPUT_LENGTH, MAX_RAW_INPUT_LENGTH, MAX_STDOUT_LENGTH, + MAX_INLINE_FILE_URL_LENGTH, } from './trim-payload.js'; function createKilocodeEvent(event: string, properties: unknown) { @@ -246,7 +247,7 @@ describe('trimPayload', () => { }); describe('message.part.updated — file part', () => { - it('strips url and source.text.value', () => { + it('preserves a small data: url and strips source.text.value', () => { const data = createKilocodeEvent('message.part.updated', { part: { type: 'file', @@ -274,14 +275,14 @@ describe('trimPayload', () => { }; }; - expect(result.properties.part.url).toBe(''); + expect(result.properties.part.url).toBe('data:image/png;base64,abc123'); expect(result.properties.part.source.text.value).toBe(''); expect(result.properties.part.name).toBe('image.png'); expect(result.properties.part.source.type).toBe('file'); expect(result.properties.part.source.path).toBe('/foo'); }); - it('strips only url when source is absent', () => { + it('preserves a small data: url when source is absent', () => { const data = createKilocodeEvent('message.part.updated', { part: { type: 'file', @@ -294,6 +295,29 @@ describe('trimPayload', () => { properties: { part: { url: string; name: string } }; }; + expect(result.properties.part.url).toBe('data:image/png;base64,abc123'); + expect(result.properties.part.name).toBe('image.png'); + }); + + it('strips a large data: url on a top-level file part', () => { + const largeDataUrl = `data:image/png;base64,${'A'.repeat(MAX_INLINE_FILE_URL_LENGTH + 1)}`; + const data = createKilocodeEvent('message.part.updated', { + part: { + type: 'file', + url: largeDataUrl, + name: 'image.png', + source: { + text: { value: 'file content here', start: 0, end: 50 }, + type: 'file', + path: '/foo', + }, + }, + }); + + const result = trimPayload('kilocode', data) as { + properties: { part: { url: string; name: string } }; + }; + expect(result.properties.part.url).toBe(''); expect(result.properties.part.name).toBe('image.png'); }); @@ -365,7 +389,7 @@ describe('trimPayload', () => { expect(result.properties.part.source.text.value).toBe(''); }); - it('strips top-level url and source.text.value', () => { + it('preserves a small top-level data: url and strips source.text.value', () => { const data = { event: 'message.part.updated', part: { @@ -392,7 +416,7 @@ describe('trimPayload', () => { }; }; - expect(result.part.url).toBe(''); + expect(result.part.url).toBe('data:image/png;base64,top-level'); expect(result.part.source.text.value).toBe(''); expect(result.part.name).toBe('image.png'); expect(result.part.source.type).toBe('file'); @@ -401,7 +425,7 @@ describe('trimPayload', () => { expect(result.part.source.text.end).toBe(50); }); - it('strips both top-level and properties file parts', () => { + it('preserves small data: urls on both top-level and properties file parts', () => { const data = { event: 'message.part.updated', part: { @@ -423,9 +447,9 @@ describe('trimPayload', () => { properties: { part: { url: string; source: { text: { value: string } } } }; }; - expect(result.part.url).toBe(''); + expect(result.part.url).toBe('data:image/png;base64,top-level'); expect(result.part.source.text.value).toBe(''); - expect(result.properties.part.url).toBe(''); + expect(result.properties.part.url).toBe('data:image/png;base64,properties'); expect(result.properties.part.source.text.value).toBe(''); }); }); diff --git a/services/cloud-agent-next/src/shared/trim-payload.ts b/services/cloud-agent-next/src/shared/trim-payload.ts index bd33e1090c..a1d72fdc27 100644 --- a/services/cloud-agent-next/src/shared/trim-payload.ts +++ b/services/cloud-agent-next/src/shared/trim-payload.ts @@ -4,6 +4,15 @@ export const MAX_TOOL_OUTPUT_LENGTH = 10_000; export const MAX_RAW_INPUT_LENGTH = 10_000; export const MAX_STDOUT_LENGTH = 10_000; +/** + * Inline `data:` URLs up to this length are preserved on top-level file parts + * so the mobile client can render small inlined images live. Half the 1 MiB + * ingest-frame budget (`MAX_INGEST_EVENT_BYTES` in `ingest-frame.ts`), so a + * single inline file part never alone exceeds it. Larger `data:` URLs are + * stripped for size. + */ +export const MAX_INLINE_FILE_URL_LENGTH = 512 * 1024; + function isRecord(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v); } @@ -13,15 +22,24 @@ function truncate(s: string, max: number): string { return s.slice(0, max) + '\n\n[…truncated]'; } -function stripFilePartFields(part: Record): Record { - // Preserve small, non-`data:` URLs (`file://` sandbox paths, `http(s)`). - // The mobile client captures the cloud-agent attachment reference from the - // raw `file://` URL before the SDK strip; the live stream must keep it. - // `data:` URLs carry inline base64 bytes and are stripped for size. +function stripFilePartFields( + part: Record, + options?: { preserveSmallDataUrls?: boolean } +): Record { + // Preserve small, non-`data:` URLs (`file://` sandbox paths, `http(s)`) and, + // on top-level file parts, small inline `data:` URLs. The mobile client + // captures the cloud-agent attachment reference from the raw `file://` URL + // before the SDK strip, and renders small inline `data:` images directly; + // the live stream must keep both. Large `data:` URLs carry inline base64 + // bytes and are stripped for size. const url = part.url; + const stripDataUrl = + typeof url === 'string' && + url.startsWith('data:') && + (!options?.preserveSmallDataUrls || url.length > MAX_INLINE_FILE_URL_LENGTH); const out: Record = { ...part, - ...(typeof url === 'string' && url.startsWith('data:') ? { url: '' } : {}), + ...(stripDataUrl ? { url: '' } : {}), }; const source = part.source; if (isRecord(source)) { @@ -63,7 +81,7 @@ function trimPart(part: Record): Record { } if (partType === 'file') { - return stripFilePartFields(part); + return stripFilePartFields(part, { preserveSmallDataUrls: true }); } if (partType === 'tool') { From 38bb73df44deebc5b851c657555528b0306f9b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 05:41:51 +0200 Subject: [PATCH 06/10] test(cloud-agent-next): align ingest-frame test with new trim contract The live-stream trim now preserves small inline data: URLs on top-level file parts. Update the ingest-frame test to assert the new contract: a small data: URL is preserved, a large one is stripped, and source.text.value is always stripped. --- .../src/shared/ingest-frame.test.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/services/cloud-agent-next/src/shared/ingest-frame.test.ts b/services/cloud-agent-next/src/shared/ingest-frame.test.ts index b6146985cd..ac46f583fb 100644 --- a/services/cloud-agent-next/src/shared/ingest-frame.test.ts +++ b/services/cloud-agent-next/src/shared/ingest-frame.test.ts @@ -7,6 +7,7 @@ import { isLifecycleIngestEvent, prepareIngestFrame, } from './ingest-frame.js'; +import { MAX_INLINE_FILE_URL_LENGTH } from './trim-payload.js'; import type { IngestEvent } from './protocol.js'; describe('prepareIngestFrame', () => { @@ -23,7 +24,7 @@ describe('prepareIngestFrame', () => { expect(frame.bytes).toBeLessThanOrEqual(MAX_INGEST_EVENT_BYTES); }); - it('applies payload trimming to file parts before serialization', () => { + it('preserves a small inline data: URL on a top-level file part and strips source.text.value', () => { const rawDataUrl = 'data:image/png;base64,wrapper-private-image'; const rawSourceText = 'wrapper private source text'; @@ -41,6 +42,30 @@ describe('prepareIngestFrame', () => { timestamp: '2026-04-14T08:00:00.000Z', }); + expect(frame.kind).toBe('send'); + if (frame.kind !== 'send') return; + expect(frame.serialized).toContain(rawDataUrl); + expect(frame.serialized).not.toContain(rawSourceText); + }); + + it('strips a large inline data: URL from a top-level file part and strips source.text.value', () => { + const rawDataUrl = 'data:image/png;base64,' + 'x'.repeat(MAX_INLINE_FILE_URL_LENGTH); + const rawSourceText = 'wrapper private source text'; + + const frame = prepareIngestFrame({ + streamEventType: 'kilocode', + data: { + event: 'message.part.updated', + type: 'message.part.updated', + part: { + type: 'file', + url: rawDataUrl, + source: { text: { value: rawSourceText } }, + }, + }, + timestamp: '2026-04-14T08:00:00.000Z', + }); + expect(frame.kind).toBe('send'); if (frame.kind !== 'send') return; expect(frame.serialized).not.toContain(rawDataUrl); From 2047fa328dbe52e0b980ec6d813542bec71c2e2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 05:41:54 +0200 Subject: [PATCH 07/10] test(mobile): cover remount and image presign retry Add a remount test proving the module-level cache survives unmount/remount without a second presign, and an image presign-failure test proving the retry row re-presigns and renders the inline image. --- .../file-part-renderer.mounted.test.tsx | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx index 441f2a34c5..a1249de4ac 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx @@ -751,6 +751,72 @@ describe('FilePartRenderer mounted', () => { await unmount(renderer); }); + it('reuses the cached presigned URL after an unmount and remount', async () => { + expoFileSystemMock.fileText.mockResolvedValue('# Attachment'); + const uuid = '77777777-7777-4777-8777-777777777777'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`, + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: `${uuid}.md`, url: '' }) + ); + await flushAsync(); + + expect(getAttachmentDownloadUrlMutate).toHaveBeenCalledTimes(1); + + await unmount(renderer); + + const remounted = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: `${uuid}.md`, url: '' }) + ); + await flushAsync(); + + expect(getAttachmentDownloadUrlMutate).toHaveBeenCalledTimes(1); + + await press(first(pressableByLabel(remounted.root, `Preview ${uuid}.md`))); + await flushAsync(); + + const markdown = findByType(remounted.root, 'ChatMarkdownText'); + expect(markdown).toHaveLength(1); + expect(markdown[0]?.props.value).toBe('# Attachment'); + + await unmount(remounted); + }); + + it('retries the presign after an image presign failure', async () => { + const uuid = '88888888-8888-4888-8888-888888888888'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.png`, + mime: 'image/png', + filename: `${uuid}.png`, + }); + getAttachmentDownloadUrlMutate.mockRejectedValueOnce(new Error('presign failed')); + + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'image/png', filename: `${uuid}.png`, url: '' }) + ); + const root = renderer.root; + + await flushAsync(); + + expect(pressableByLabel(root, 'Image unavailable, retry loading')).toHaveLength(1); + expect(texts(root)).toContain('Image unavailable'); + + await press(first(pressableByLabel(root, 'Image unavailable, retry loading'))); + await flushAsync(); + + const image = findByType(root, 'Image')[0]; + if (!image) { + throw new Error('image not found'); + } + expect(image.props.source).toEqual({ uri: 'https://r2.example/signed' }); + + await unmount(renderer); + }); + it('toasts "Preview unavailable" for a part with no URL and no cache entry', async () => { const renderer = await mount( makeFilePart({ id: 'part-1', mime: 'application/pdf', filename: 'report.pdf', url: '' }) From 5952f221b4eba80d30b6c87716c1640e8048c353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 05:41:57 +0200 Subject: [PATCH 08/10] fix(mobile): add pressable feedback to image retry rows The two image retry Pressables lacked active opacity feedback. Add active:opacity-80 to match the chip Pressable. --- apps/mobile/src/components/agents/file-part-renderer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/agents/file-part-renderer.tsx b/apps/mobile/src/components/agents/file-part-renderer.tsx index 1bf38d048e..732d20d221 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.tsx @@ -185,7 +185,7 @@ export function FilePartRenderer({ part }: Readonly) { } })(); }} - className="my-1 flex-row items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 dark:bg-neutral-900" + className="my-1 flex-row items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 active:opacity-80 dark:bg-neutral-900" accessibilityRole="button" accessibilityLabel="Image unavailable, retry loading" > @@ -249,7 +249,7 @@ export function FilePartRenderer({ part }: Readonly) { onPress={() => { resolved.retry?.(); }} - className="my-1 flex-row items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 dark:bg-neutral-900" + className="my-1 flex-row items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 active:opacity-80 dark:bg-neutral-900" accessibilityRole="button" accessibilityLabel="Image unavailable, retry loading" > From 1aaf16bb3eeef1830724acf032619faa4e66e2e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 11:51:56 +0200 Subject: [PATCH 09/10] fix(mobile): surface presign failure after a tap during resolve - Show the retry toast and reset preview when a markdown tap during the presign is followed by a presign failure, instead of a silent no-op. - Add mounted tests for the imageFailed retry press with and without an attachment ref, and for the markdown presign-failure leg. - Correct the attachment path comment (sessionId, not agentId) and the resolveCacheUrl failure-mode comment (undefined return, not rejection). --- .../src/components/agents/file-part-cache.ts | 2 +- .../components/agents/file-part-preview.ts | 4 +- .../file-part-renderer.mounted.test.tsx | 117 +++++++++++++++++- .../components/agents/file-part-renderer.tsx | 9 ++ 4 files changed, 128 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/components/agents/file-part-cache.ts b/apps/mobile/src/components/agents/file-part-cache.ts index 99be6d97fc..ce57d64df1 100644 --- a/apps/mobile/src/components/agents/file-part-cache.ts +++ b/apps/mobile/src/components/agents/file-part-cache.ts @@ -111,7 +111,7 @@ export function cacheFilePart( /** * Replace a cached entry with a freshly resolved URL (e.g. a re-presigned * download URL). Preserves any attachment reference and clears the failed - * mark. A `resolveCacheUrl` rejection returns without writing. + * mark. If `resolveCacheUrl` returns `undefined`, the entry is not written. */ export function overwriteFilePartCacheEntry( partId: string, diff --git a/apps/mobile/src/components/agents/file-part-preview.ts b/apps/mobile/src/components/agents/file-part-preview.ts index c9cc32b260..4d4a5fc592 100644 --- a/apps/mobile/src/components/agents/file-part-preview.ts +++ b/apps/mobile/src/components/agents/file-part-preview.ts @@ -4,8 +4,8 @@ import { isMarkdownPath } from './read-tool-markdown'; export type CloudAgentAttachmentRef = { messageUuid: string; filename: string }; // Persisted history and live events carry the wrapper's sandbox URL -// `file:///tmp/attachments////` -// (`services/cloud-agent-next/wrapper/src/session-bootstrap.ts`). +// `file:///tmp/attachments////` +// (`services/cloud-agent-next/src/utils/attachment-download.ts`). // Removal condition: none — stored messages keep this form permanently. const CLOUD_AGENT_ATTACHMENT_URL = /^file:\/\/\/tmp\/attachments\/[^/]+\/[^/]+\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\/([^/]+)$/; diff --git a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx index a1249de4ac..c2abc947ad 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx @@ -6,7 +6,11 @@ import TestRenderer, { act } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FilePartRenderer } from './file-part-renderer'; -import { __resetFilePartCacheForTests, cacheFilePart } from './file-part-cache'; +import { + __resetFilePartCacheForTests, + cacheFilePart, + overwriteFilePartCacheEntry, +} from './file-part-cache'; import { __resetFilePartUrlResolverForTests } from './file-part-url-resolver'; type FileInstance = { @@ -892,4 +896,115 @@ describe('FilePartRenderer mounted', () => { await unmount(renderer); }); + + it('re-presigns on image retry when the cache entry carries an attachment ref', async () => { + const uuid = '99999999-9999-4999-8999-999999999999'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.png`, + mime: 'image/png', + filename: `${uuid}.png`, + }); + overwriteFilePartCacheEntry('part-1', { + url: 'https://r2.example/signed', + mime: 'image/png', + filename: `${uuid}.png`, + }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'image/png', filename: `${uuid}.png`, url: '' }) + ); + const root = renderer.root; + + const image = findByType(root, 'Image')[0]; + if (!image) { + throw new Error('image not found'); + } + await act(async () => { + await Promise.resolve(); + (image.props.onError as () => void)(); + }); + + expect(pressableByLabel(root, 'Image unavailable, retry loading')).toHaveLength(1); + + const callsBefore = getAttachmentDownloadUrlMutate.mock.calls.length; + await press(first(pressableByLabel(root, 'Image unavailable, retry loading'))); + await flushAsync(); + + expect(getAttachmentDownloadUrlMutate.mock.calls.length).toBe(callsBefore + 1); + expect(pressableByLabel(root, 'Image unavailable, retry loading')).toHaveLength(0); + const reRendered = findByType(root, 'Image')[0]; + expect(reRendered?.props.source).toEqual({ uri: 'https://r2.example/signed' }); + + await unmount(renderer); + }); + + it('re-renders the same URL on image retry when there is no attachment ref', async () => { + cacheFilePart('part-1', { + url: 'data:image/png;base64,QUJD', + mime: 'image/png', + filename: 'shot.png', + }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'image/png', filename: 'shot.png', url: '' }) + ); + const root = renderer.root; + + const image = findByType(root, 'Image')[0]; + if (!image) { + throw new Error('image not found'); + } + expect(image.props.source).toEqual({ uri: 'file:///cache/session-file-parts/part-1-shot.png' }); + + await act(async () => { + await Promise.resolve(); + (image.props.onError as () => void)(); + }); + + expect(pressableByLabel(root, 'Image unavailable, retry loading')).toHaveLength(1); + + await press(first(pressableByLabel(root, 'Image unavailable, retry loading'))); + await flushAsync(); + + expect(getAttachmentDownloadUrlMutate).not.toHaveBeenCalled(); + expect(pressableByLabel(root, 'Image unavailable, retry loading')).toHaveLength(0); + const reRendered = findByType(root, 'Image')[0]; + expect(reRendered?.props.source).toEqual({ + uri: 'file:///cache/session-file-parts/part-1-shot.png', + }); + + await unmount(renderer); + }); + + it('toasts when a markdown tap during the presign is followed by a presign failure', async () => { + const uuid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`, + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + const presignHolder: { reject?: (error: Error) => void } = {}; + getAttachmentDownloadUrlMutate.mockReturnValueOnce( + new Promise((_resolve, reject) => { + presignHolder.reject = reject; + }) + ); + + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: `${uuid}.md`, url: '' }) + ); + const root = renderer.root; + + await press(first(pressableByLabel(root, `Preview ${uuid}.md`))); + expect(findByType(root, 'Modal')).toHaveLength(0); + + await act(async () => { + presignHolder.reject?.(new Error('presign failed')); + await Promise.resolve(); + }); + await flushAsync(); + + expect(toastMock.error).toHaveBeenCalledWith('Could not load this file. Try again.'); + expect(findByType(root, 'Modal')).toHaveLength(0); + + await unmount(renderer); + }); }); diff --git a/apps/mobile/src/components/agents/file-part-renderer.tsx b/apps/mobile/src/components/agents/file-part-renderer.tsx index 732d20d221..f21a16e1cc 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.tsx @@ -168,6 +168,15 @@ export function FilePartRenderer({ part }: Readonly) { toast.error('Preview unavailable'); } + // A markdown tap during the presign sets `preview` before the URL lands. + // If the presign then fails, surface the failure instead of a silent no-op. + useEffect(() => { + if (preview !== null && resolved.status === 'error') { + toast.error('Could not load this file. Try again.'); + setPreview(null); + } + }, [preview, resolved.status]); + if (kind === 'image') { if (url) { if (imageFailed) { From a5171dad80fb3300556530668cf0f607d1af1511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 12:04:18 +0200 Subject: [PATCH 10/10] refactor(mobile): drop redundant attempt state in url resolver clearFilePartResolveFailed writes a new entry and emits, so the effect re-runs via the cached dep; the attempt counter is a dead second trigger. --- apps/mobile/src/components/agents/file-part-url-resolver.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/components/agents/file-part-url-resolver.ts b/apps/mobile/src/components/agents/file-part-url-resolver.ts index c2445ce63c..6257828ca9 100644 --- a/apps/mobile/src/components/agents/file-part-url-resolver.ts +++ b/apps/mobile/src/components/agents/file-part-url-resolver.ts @@ -1,5 +1,5 @@ import { type FilePart } from '@kilocode/cloud-agent-sdk'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { trpcClient } from '@/lib/trpc'; @@ -36,7 +36,6 @@ export type ResolvedFilePartUrl = { */ export function useResolvedFilePartUrl(part: FilePart): ResolvedFilePartUrl { const cached = useFilePartCache(part.id); - const [attempt, setAttempt] = useState(0); const url = cached?.url ?? (isUsableFilePartUrl(part.url) ? part.url : undefined); const ref = cached?.attachmentRef ?? parseCloudAgentAttachmentUrl(part.url); @@ -69,7 +68,7 @@ export function useResolvedFilePartUrl(part: FilePart): ResolvedFilePartUrl { inFlight.delete(part.id); } })(); - }, [part.id, part.url, part.mime, part.filename, cached, attempt]); + }, [part.id, part.url, part.mime, part.filename, cached]); if (url !== undefined) { return { status: 'ready', url, ...(ref ? { attachmentRef: ref } : {}) }; @@ -83,7 +82,6 @@ export function useResolvedFilePartUrl(part: FilePart): ResolvedFilePartUrl { attachmentRef: ref, retry: () => { clearFilePartResolveFailed(part.id); - setAttempt(a => a + 1); }, }; }