diff --git a/web/src/embedded/chat/components/messages/assistant-message.tsx b/web/src/embedded/chat/components/messages/assistant-message.tsx index 28ff0b2..121b899 100644 --- a/web/src/embedded/chat/components/messages/assistant-message.tsx +++ b/web/src/embedded/chat/components/messages/assistant-message.tsx @@ -24,7 +24,10 @@ import { CueStreamdownVideo } from '../markdown/overrides/video'; import { AttachmentImage } from './attachment-image'; import { AssistantMessageOperations } from './assistant-message-operations'; import { AssistantStateSection } from './assistant-state-section'; -import { FileAttachmentCard } from './file-attachment'; +import { + FileAttachmentCard, + shouldRenderFileReferenceFallback, +} from './file-attachment'; import { PermissionMessage } from './permission-message'; import { QuestionMessage } from './question-message'; import { SaveFileMessage } from './save-file-message'; @@ -119,16 +122,30 @@ export type ToolMessageRenderer = ( const AssistantAudio = ({ content, artifactId, + onRequestFileAccess, }: { content: SessionHistoryMessageContent; artifactId?: string; + onRequestFileAccess?: (message: string) => void | Promise; }) => { const resolvedUrl = useResolvedUrl({ src: content.url, filePath: content.fileRef?.path, environmentId: content.fileRef?.environmentId, }); - if (!resolvedUrl) return null; + if (!resolvedUrl) { + return shouldRenderFileReferenceFallback(content) ? ( + + ) : null; + } return ( void | Promise; }) => { const resolvedUrl = useResolvedUrl({ src: content.url, filePath: content.fileRef?.path, environmentId: content.fileRef?.environmentId, }); - if (!resolvedUrl) return null; + if (!resolvedUrl) { + return shouldRenderFileReferenceFallback(content) ? ( + + ) : null; + } return ( void | Promise ) { switch (content.type) { case 'text': @@ -247,6 +281,21 @@ function renderContentBlock( ) : null; case 'image': + if (shouldRenderFileReferenceFallback(content)) { + return ( + + ); + } + return content.url || content.fileRef?.path ? ( + ) : null; case 'video': if (content.url || content.fileRef?.path) { - return ; + return ( + + ); } return null; case 'file': @@ -282,6 +343,7 @@ function renderContentBlock( environmentId={content.fileRef?.environmentId ?? undefined} size="" data-artifact={key} + onRequestFileAccess={onRequestFileAccess} /> ); default: @@ -299,6 +361,7 @@ export const AssistantMessage = ({ isAnimating = false, hideOperations = false, renderToolMessage, + onSendMessage, }: { items: AssistantGroupItem[]; allMessages: SessionHistoryMessage[]; @@ -309,6 +372,7 @@ export const AssistantMessage = ({ isAnimating?: boolean; hideOperations?: boolean; renderToolMessage?: ToolMessageRenderer; + onSendMessage?: (text: string) => void; }) => { const hasCurrentExecutionState = items.some( item => @@ -633,7 +697,8 @@ export const AssistantMessage = ({ prepared, `${msg.id || itemIndex}-${ci}`, isAnimating, - msg.messageId ?? msg.id + msg.messageId ?? msg.id, + onSendMessage ); }) .filter(Boolean); diff --git a/web/src/embedded/chat/components/messages/conversation-thread.tsx b/web/src/embedded/chat/components/messages/conversation-thread.tsx index 3b9baa1..9c32d42 100644 --- a/web/src/embedded/chat/components/messages/conversation-thread.tsx +++ b/web/src/embedded/chat/components/messages/conversation-thread.tsx @@ -267,6 +267,7 @@ export const ConversationMessageGroupView = ({ activeTurnUserMessageId, isStreaming, renderToolMessage, + onSendMessage, }: { group: ConversationMessageGroup; index: number; @@ -277,6 +278,7 @@ export const ConversationMessageGroupView = ({ activeTurnUserMessageId?: string; isStreaming: boolean; renderToolMessage?: ToolMessageRenderer; + onSendMessage?: (text: string) => void; }) => { switch (group.type) { case 'user': @@ -286,6 +288,7 @@ export const ConversationMessageGroupView = ({ @@ -311,6 +314,7 @@ export const ConversationMessageGroupView = ({ isAnimating={isActiveAssistantTurn} hideOperations={hideOperations} renderToolMessage={renderToolMessage} + onSendMessage={onSendMessage} /> @@ -327,12 +331,14 @@ export const ConversationThread = ({ isStreaming, className, renderToolMessage, + onSendMessage, }: { messages: SessionHistoryMessage[]; assistantStateSequence: AssistantState[]; isStreaming: boolean; className?: string; renderToolMessage?: ToolMessageRenderer; + onSendMessage?: (text: string) => void; }) => { const { allMessages, @@ -361,6 +367,7 @@ export const ConversationThread = ({ activeTurnUserMessageId={activeTurnUserMessageId} isStreaming={isStreaming} renderToolMessage={renderToolMessage} + onSendMessage={onSendMessage} /> ))} diff --git a/web/src/embedded/chat/components/messages/copy.tsx b/web/src/embedded/chat/components/messages/copy.tsx index d6a6f9b..7a0f675 100644 --- a/web/src/embedded/chat/components/messages/copy.tsx +++ b/web/src/embedded/chat/components/messages/copy.tsx @@ -46,13 +46,13 @@ export function collectUserMessageContent( } else { result.texts.push(c.text); } - } else if (c.type === 'image' && c.url) { + } else if (c.type === 'image' && (c.url || c.fileRef?.path)) { result.images.push(c); } else if (c.type === 'audio') { result.audios.push(c); } else if (c.type === 'video') { result.videos.push(c); - } else if (c.type === 'file' && c.url) { + } else if (c.type === 'file' && (c.url || c.fileRef?.path)) { result.files.push({ filename: c.fileName ?? 'unknown', contentType: c.mimeType ?? 'application/octet-stream', diff --git a/web/src/embedded/chat/components/messages/file-attachment.tsx b/web/src/embedded/chat/components/messages/file-attachment.tsx index 82366b1..ce84d1c 100644 --- a/web/src/embedded/chat/components/messages/file-attachment.tsx +++ b/web/src/embedded/chat/components/messages/file-attachment.tsx @@ -1,6 +1,10 @@ import { cn } from '@/utils/cn'; import { Menu } from '@/utils/webview-context-menu'; import { useCallback, useEffect, useState } from 'react'; +import { ExclamationmarkTriangleFillSFSymbolMedium } from '@/assets/sf-symbols/medium/exclamationmark.triangle.fill'; +import { DocumentFillSFSymbolMedium } from '@/assets/sf-symbols/medium/document.fill'; +import { isVFSLikeEnvironmentId } from '@/utils/agent-file-url'; +import { hasNativeJSBridge } from '@/utils/bridge-runtime'; import { preparePreviewAsset, previewAttachmentSource, @@ -101,6 +105,88 @@ function getFriendlyFileType(filename: string, contentType: string): string { return contentType; } +export function describeFileReferenceEnvironment( + environmentId?: string +): string | null { + const trimmed = environmentId?.trim(); + if (!trimmed || isVFSLikeEnvironmentId(trimmed)) { + return null; + } + + const normalized = trimmed.toLowerCase().replace(/_/g, '-'); + if (normalized === 'local-vm' || normalized.startsWith('local-vm-')) { + return 'safe workspace on this Mac'; + } + if (normalized === 'cloud-vm') { + return 'safe workspace on this Mac'; + } + if (normalized === 'local' || normalized.startsWith('local-')) { + return 'this Mac'; + } + + return trimmed; +} + +export function isWebInaccessibleFileReference({ + environmentId, + nativeBridgeAvailable, + path, + url, +}: { + environmentId?: string; + nativeBridgeAvailable: boolean; + path?: string; + url?: string; +}): boolean { + return Boolean( + path?.trim() && + !url?.trim() && + !nativeBridgeAvailable && + !isVFSLikeEnvironmentId(environmentId) + ); +} + +export function shouldRenderFileReferenceFallback(content: { + url?: string | null; + fileRef?: { + path?: string | null; + environmentId?: string | null; + } | null; +}): boolean { + return Boolean( + !content.url && + content.fileRef?.path && + !isVFSLikeEnvironmentId(content.fileRef.environmentId) + ); +} + +export function buildFileAccessRequestMessage({ + environmentId, + environmentLabel, + filename, + path, +}: { + environmentId?: string; + environmentLabel?: string | null; + filename: string; + path: string; +}): string { + const environmentLine = + environmentLabel && environmentId && environmentLabel !== environmentId + ? `${environmentLabel} (${environmentId})` + : (environmentLabel ?? environmentId ?? 'unknown environment'); + + return [ + 'Please provide a complete, accessible version of this file attachment.', + '', + `File: ${filename}`, + `Location: ${path}`, + `Environment: ${environmentLine}`, + '', + 'The current Bridge web view only has this incomplete file reference and cannot access the file. Please either attach/export a downloadable copy, or provide the complete information needed to access it here.', + ].join('\n'); +} + /** * Hook to fetch file thumbnail from Swift bridge using QuickLook */ @@ -140,6 +226,7 @@ export const FileAttachmentCard = ({ environmentId, size, className, + onRequestFileAccess, ...props }: { filename: string; @@ -149,14 +236,31 @@ export const FileAttachmentCard = ({ environmentId?: string; size?: string; className?: string; + onRequestFileAccess?: (message: string) => void | Promise; } & React.HTMLAttributes) => { - const iconUrl = useFileThumbnail(path); + const sourcePath = path?.trim() || undefined; + const sourceUrl = url?.trim() || undefined; + const nativeBridgeAvailable = hasNativeJSBridge(); + const [accessRequestState, setAccessRequestState] = useState< + 'idle' | 'sending' | 'sent' + >('idle'); + const iconUrl = useFileThumbnail(sourcePath); const resolvedUrl = useResolvedUrl({ - src: url, - filePath: path, + src: sourceUrl, + filePath: sourcePath, environmentId, }); const fileType = getFriendlyFileType(filename, contentType); + const canPreview = Boolean( + resolvedUrl || (sourcePath && nativeBridgeAvailable) + ); + const isUnavailableInWeb = isWebInaccessibleFileReference({ + environmentId, + nativeBridgeAvailable, + path: sourcePath, + url: sourceUrl, + }); + const environmentLabel = describeFileReferenceEnvironment(environmentId); useEffect(() => { if (!resolvedUrl) { @@ -167,6 +271,10 @@ export const FileAttachmentCard = ({ const handleContextMenu = useCallback( (event: React.MouseEvent) => { + if (!canPreview) { + return; + } + const menu = Menu.create().pushItem({ title: 'Preview', icon: iconUrl @@ -176,7 +284,7 @@ export const FileAttachmentCard = ({ void previewAttachmentSource(resolvedUrl, { fileName: filename, mimeType: contentType, - fallbackPath: path, + fallbackPath: sourcePath, environmentId, }); }, @@ -184,36 +292,108 @@ export const FileAttachmentCard = ({ menu.popup(event); }, - [contentType, environmentId, filename, iconUrl, path, resolvedUrl] + [ + canPreview, + contentType, + environmentId, + filename, + iconUrl, + resolvedUrl, + sourcePath, + ] + ); + + const handlePreview = useCallback(() => { + if (!canPreview) { + return; + } + + void previewAttachmentSource(resolvedUrl, { + fileName: filename, + mimeType: contentType, + fallbackPath: sourcePath, + environmentId, + }); + }, [ + canPreview, + contentType, + environmentId, + filename, + resolvedUrl, + sourcePath, + ]); + + const handleRequestAccess = useCallback( + async (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (!sourcePath || accessRequestState !== 'idle') { + return; + } + + const message = buildFileAccessRequestMessage({ + environmentId, + environmentLabel, + filename, + path: sourcePath, + }); + + setAccessRequestState('sending'); + try { + if (onRequestFileAccess) { + await onRequestFileAccess(message); + } else { + await window.jsb?.MessagesBridge?.sendMessage(message); + } + setAccessRequestState('sent'); + } catch (error) { + console.error('Failed to request accessible file:', error); + setAccessRequestState('idle'); + } + }, + [ + accessRequestState, + environmentId, + environmentLabel, + filename, + onRequestFileAccess, + sourcePath, + ] ); return (
- void previewAttachmentSource(resolvedUrl, { - fileName: filename, - mimeType: contentType, - fallbackPath: path, - environmentId, - }) - } + onClick={canPreview ? handlePreview : undefined} className={cn( 'p-3 min-w-[40%] max-w-full w-full text-left', 'flex items-center gap-3', - 'rounded-lg border border-border', - 'bg-surface-card', - 'transition-colors hover:bg-fill-soft active:bg-fill-medium', - 'cursor-pointer', + 'rounded-lg border', + 'transition-colors', + isUnavailableInWeb + ? 'border-warning-fg/20 bg-warning-bg' + : 'border-border bg-surface-card', + canPreview + ? 'cursor-pointer hover:bg-fill-soft active:bg-fill-medium' + : 'cursor-default', className )} {...props} >
- {iconUrl ? ( + {isUnavailableInWeb ? ( +
@@ -221,6 +401,47 @@ export const FileAttachmentCard = ({
{fileType} {size && `• ${size}`}
+ {isUnavailableInWeb && sourcePath && ( +
+
+ Location: {sourcePath} +
+ {environmentLabel && ( +
+ Environment: {environmentLabel} +
+ )} +
+ Content incomplete — file unavailable. +
+
+ Open Bridge in that environment, or ask the agent to provide a + downloadable copy. +
+ +
+ )}
); diff --git a/web/src/embedded/chat/components/messages/index.tsx b/web/src/embedded/chat/components/messages/index.tsx index b565a95..b791f57 100644 --- a/web/src/embedded/chat/components/messages/index.tsx +++ b/web/src/embedded/chat/components/messages/index.tsx @@ -91,6 +91,7 @@ export const Messages = ({ allMessages={allMessages} currentAssistantStateSequence={currentAssistantState?.sequence} isStreaming={isStreaming} + onSendMessage={onSendMessage} /> ))}
); })} diff --git a/web/src/embedded/chat/components/messages/user-message.tsx b/web/src/embedded/chat/components/messages/user-message.tsx index c966079..018a82b 100644 --- a/web/src/embedded/chat/components/messages/user-message.tsx +++ b/web/src/embedded/chat/components/messages/user-message.tsx @@ -11,7 +11,10 @@ import type { } from '../../types/history'; import { cn } from '@/utils/cn'; import { animate } from 'motion'; -import { FileAttachmentCard } from './file-attachment'; +import { + FileAttachmentCard, + shouldRenderFileReferenceFallback, +} from './file-attachment'; import { AttachmentImage } from './attachment-image'; import { LinkifiedText } from '../linkified-text'; import { MaskedScrollArea } from '../masked-scrollarea'; @@ -82,13 +85,30 @@ function renderEmptyTag(text: string): string { return text.replace(//g, ''); } -const UserAudio = ({ content }: { content: SessionHistoryMessageContent }) => { +const UserAudio = ({ + content, + onRequestFileAccess, +}: { + content: SessionHistoryMessageContent; + onRequestFileAccess?: (message: string) => void | Promise; +}) => { const resolvedUrl = useResolvedUrl({ src: content.url, filePath: content.fileRef?.path, environmentId: content.fileRef?.environmentId, }); - if (!resolvedUrl) return null; + if (!resolvedUrl) { + return shouldRenderFileReferenceFallback(content) ? ( + + ) : null; + } return ( { ); }; -const UserVideo = ({ content }: { content: SessionHistoryMessageContent }) => { +const UserVideo = ({ + content, + onRequestFileAccess, +}: { + content: SessionHistoryMessageContent; + onRequestFileAccess?: (message: string) => void | Promise; +}) => { const resolvedUrl = useResolvedUrl({ src: content.url, filePath: content.fileRef?.path, environmentId: content.fileRef?.environmentId, }); - if (!resolvedUrl) return null; + if (!resolvedUrl) { + return shouldRenderFileReferenceFallback(content) ? ( + + ) : null; + } return ( { export const UserMessage = ({ message, enterAnimation = false, + onSendMessage, }: { message: SessionHistoryMessage; enterAnimation?: boolean; + onSendMessage?: (text: string) => void; }) => { const ref = useRef(null); const minimapOptions = useMinimapOptions(); @@ -208,6 +247,7 @@ export const UserMessage = ({ url={file.url} environmentId={file.environmentId} size={file.size} + onRequestFileAccess={onSendMessage} className={cn( files.length > 2 && files.length % 2 !== 0 && @@ -223,8 +263,25 @@ export const UserMessage = ({
- {images.map((image, i) => - image.url || image.fileRef?.path ? ( + {images.map((image, i) => { + if (shouldRenderFileReferenceFallback(image)) { + return ( + + ); + } + + return image.url || image.fileRef?.path ? ( - ) : null - )} + ) : null; + })}
)} @@ -244,7 +301,11 @@ export const UserMessage = ({
{audios.map((audio, i) => audio.url || audio.fileRef?.path ? ( - + ) : null )}
@@ -254,7 +315,11 @@ export const UserMessage = ({
{videos.map((video, i) => video.url || video.fileRef?.path ? ( - + ) : null )}
diff --git a/web/tests/embedded/assistant-message-attachments.test.tsx b/web/tests/embedded/assistant-message-attachments.test.tsx index 0a611eb..de07a5b 100644 --- a/web/tests/embedded/assistant-message-attachments.test.tsx +++ b/web/tests/embedded/assistant-message-attachments.test.tsx @@ -46,7 +46,33 @@ vi.mock( ); vi.mock('../../src/embedded/chat/components/messages/file-attachment', () => ({ - FileAttachmentCard: () => null, + FileAttachmentCard: ({ + filename, + path, + ...props + }: { + filename?: string; + path?: string; + [key: string]: unknown; + }) => ( +
+ ), + shouldRenderFileReferenceFallback: (content: { + url?: string | null; + fileRef?: { path?: string | null; environmentId?: string | null } | null; + }) => + Boolean( + !content.url && + content.fileRef?.path && + !['', 'vfs'].includes( + content.fileRef.environmentId?.trim().toLowerCase() ?? '' + ) + ), })); vi.mock( @@ -149,6 +175,34 @@ describe('AssistantMessage attachments', () => { )}"` ); }); + + it('renders inaccessible assistant image refs as file cards', async () => { + const message = makeMessage({ + content: [ + { + type: 'image', + fileRef: { + path: '/tmp/cat.png', + environmentId: 'local-vm-123', + }, + fileName: 'cat.png', + mimeType: 'image/png', + }, + ], + }); + + const markup = renderToStaticMarkup( + + ); + + expect(markup).toContain('data-testid="file-attachment-card"'); + expect(markup).toContain(`data-filename="cat.png"`); + expect(markup).toContain(`data-source-path="/tmp/cat.png"`); + }); }); function escapeAttribute(value: string) { diff --git a/web/tests/embedded/file-attachment-card.test.tsx b/web/tests/embedded/file-attachment-card.test.tsx new file mode 100644 index 0000000..cfd423c --- /dev/null +++ b/web/tests/embedded/file-attachment-card.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +/* oxlint-disable no-await-in-loop */ + +import React from 'react'; +import { flushSync } from 'react-dom'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FileAttachmentCard } from '../../src/embedded/chat/components/messages/file-attachment'; + +async function waitForCondition(condition: () => boolean, timeoutMs = 1000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) { + return; + } + await new Promise(resolve => setTimeout(resolve, 0)); + flushSync(() => {}); + } + throw new Error('Timed out waiting for condition'); +} + +const mountedRoots: Root[] = []; + +describe('FileAttachmentCard', () => { + afterEach(async () => { + for (const root of mountedRoots.splice(0)) { + flushSync(() => { + root.unmount(); + }); + await waitForCondition(() => true); + } + document.body.innerHTML = ''; + vi.clearAllMocks(); + }); + + it('renders an incomplete-content state and asks the agent for access', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mountedRoots.push(root); + const requestedMessages: string[] = []; + + flushSync(() => { + root.render( + { + requestedMessages.push(message); + }} + /> + ); + }); + + expect(container.textContent).toContain( + 'Content incomplete — file unavailable.' + ); + expect(container.textContent).toContain('Location: /tmp/debug.txt'); + expect(container.textContent).toContain( + 'Environment: safe workspace on this Mac' + ); + + const button = container.querySelector('button'); + expect(button?.textContent).toBe('Ask agent to make accessible'); + + flushSync(() => { + button?.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }) + ); + }); + + await waitForCondition(() => requestedMessages.length === 1); + expect(requestedMessages[0]).toContain('File: debug.txt'); + expect(requestedMessages[0]).toContain('Location: /tmp/debug.txt'); + await waitForCondition(() => + Boolean(container.textContent?.includes('Request sent')) + ); + expect(container.textContent).toContain('Request sent'); + expect(button?.disabled).toBe(true); + + flushSync(() => { + button?.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }) + ); + }); + expect(requestedMessages).toHaveLength(1); + }); +}); diff --git a/web/tests/embedded/file-attachment.test.ts b/web/tests/embedded/file-attachment.test.ts new file mode 100644 index 0000000..497f989 --- /dev/null +++ b/web/tests/embedded/file-attachment.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { + buildFileAccessRequestMessage, + describeFileReferenceEnvironment, + isWebInaccessibleFileReference, + shouldRenderFileReferenceFallback, +} from '../../src/embedded/chat/components/messages/file-attachment'; + +describe('file attachment reference states', () => { + it('marks path-only non-vfs refs as inaccessible without the native bridge', () => { + expect( + isWebInaccessibleFileReference({ + path: '/tmp/debug.txt', + environmentId: 'local-vm-123', + nativeBridgeAvailable: false, + }) + ).toBe(true); + }); + + it('keeps refs accessible when a browser url or native bridge is available', () => { + expect( + isWebInaccessibleFileReference({ + path: '/tmp/debug.txt', + url: 'https://files.example.com/debug.txt', + environmentId: 'local-vm-123', + nativeBridgeAvailable: false, + }) + ).toBe(false); + + expect( + isWebInaccessibleFileReference({ + path: '/tmp/debug.txt', + environmentId: 'local-vm-123', + nativeBridgeAvailable: true, + }) + ).toBe(false); + }); + + it('describes known file reference environments for the card details', () => { + expect(describeFileReferenceEnvironment('local-vm-123')).toBe( + 'safe workspace on this Mac' + ); + expect(describeFileReferenceEnvironment('local_macos')).toBe('this Mac'); + expect(describeFileReferenceEnvironment('remote-web-01')).toBe( + 'remote-web-01' + ); + expect(describeFileReferenceEnvironment('vfs')).toBe(null); + }); + + it('builds an agent request that includes the missing file reference', () => { + const message = buildFileAccessRequestMessage({ + filename: 'debug.txt', + path: '/tmp/debug.txt', + environmentId: 'local-vm-123', + environmentLabel: 'safe workspace on this Mac', + }); + + expect(message).toContain('Please provide a complete, accessible version'); + expect(message).toContain('File: debug.txt'); + expect(message).toContain('Location: /tmp/debug.txt'); + expect(message).toContain( + 'Environment: safe workspace on this Mac (local-vm-123)' + ); + expect(message).toContain('incomplete file reference'); + }); + + it('uses the shared fallback predicate for non-vfs path-only content', () => { + expect( + shouldRenderFileReferenceFallback({ + fileRef: { + path: '/tmp/cat.png', + environmentId: 'local-vm-123', + }, + }) + ).toBe(true); + + expect( + shouldRenderFileReferenceFallback({ + url: 'https://files.example.com/cat.png', + fileRef: { + path: '/tmp/cat.png', + environmentId: 'local-vm-123', + }, + }) + ).toBe(false); + + expect( + shouldRenderFileReferenceFallback({ + fileRef: { + path: '/.agent/deliveries/cat.png', + environmentId: 'vfs', + }, + }) + ).toBe(false); + }); +}); diff --git a/web/tests/embedded/history.test.ts b/web/tests/embedded/history.test.ts index 838e522..d7632e5 100644 --- a/web/tests/embedded/history.test.ts +++ b/web/tests/embedded/history.test.ts @@ -151,6 +151,35 @@ describe('embedded chat history filtering', () => { ]); }); + it('keeps user file cards when only a file ref path is present', () => { + const message = makeMessage({ + id: 'path-only-file', + role: 'user', + content: [ + { + type: 'file', + fileName: 'debug.txt', + mimeType: 'text/plain', + fileRef: { + path: '/tmp/debug.txt', + environmentId: 'local-vm-123', + }, + }, + ], + }); + + expect(collectUserMessageContent(message).files).toEqual([ + { + filename: 'debug.txt', + contentType: 'text/plain', + path: '/tmp/debug.txt', + url: undefined, + environmentId: 'local-vm-123', + size: '', + }, + ]); + }); + it('strips all user reminder blocks from displayed user text', () => { const message = makeMessage({ id: 'macos-user-turn',