fix: parse FILE markers in attachment fallback (#3795)

This commit is contained in:
Darshan Poudel
2026-06-22 11:23:29 -07:00
committed by GitHub
parent fd1b4e6f3f
commit fc02e47505
4 changed files with 124 additions and 8 deletions
+35
View File
@@ -256,6 +256,41 @@ describe('parseMessageImages', () => {
expect(result.text).toBe('');
expect(result.dataUris).toHaveLength(1);
});
it('strips FILE markers without including them in dataUris', () => {
const result = parseMessageImages('read this [FILE:data:application/pdf;base64,xyz]');
expect(result.text).toBe('read this');
expect(result.dataUris).toEqual([]);
});
it('handles mixed IMAGE and FILE markers', () => {
const result = parseMessageImages(
'check this [IMAGE:data:image/png;base64,a] and [FILE:data:application/pdf;base64,b] thanks'
);
expect(result.text).toBe('check this and thanks');
expect(result.dataUris).toEqual(['data:image/png;base64,a']);
});
it('strips multiple FILE markers', () => {
const result = parseMessageImages(
'[FILE:data:application/pdf;base64,a] [FILE:data:text/plain;base64,b]'
);
expect(result.text).toBe('');
expect(result.dataUris).toEqual([]);
});
it('preserves paragraph breaks (double newlines) in user text', () => {
const result = parseMessageImages('first paragraph\n\nsecond paragraph');
expect(result.text).toBe('first paragraph\n\nsecond paragraph');
});
it('preserves newlines when stripping a marker', () => {
const result = parseMessageImages(
'first paragraph\n\n[FILE:data:application/pdf;base64,x]\n\nsecond paragraph'
);
expect(result.text).toBe('first paragraph\n\n\n\nsecond paragraph');
expect(result.text).toContain('\n\n');
});
});
describe('formatFileSize', () => {
+8 -2
View File
@@ -247,8 +247,9 @@ export function buildMessageWithAttachments(text: string, attachments: Attachmen
}
/**
* Parse `[IMAGE:<data-uri>]` markers out of a stored message string.
* Returns the clean text (markers removed) and the list of data URIs found.
* Parse `[IMAGE:<data-uri>]` and `[FILE:<data-uri>]` markers out of a stored message string.
* Returns the clean text (markers removed) and the list of image data URIs found.
* File markers are stripped from text but not returned (file data lives in extraMetadata).
*/
export function parseMessageImages(content: string): { text: string; dataUris: string[] } {
const dataUris: string[] = [];
@@ -257,6 +258,11 @@ export function parseMessageImages(content: string): { text: string; dataUris: s
dataUris.push(uri);
return '';
})
.replace(/\[FILE:([^\]]+)\]/g, '') // Strip file markers
// Collapse only runs of plain spaces (not \s) left behind by marker
// removal — using \s here would also eat intentional newlines/paragraph
// breaks in the user's own text.
.replace(/ {2,}/g, ' ')
.trim();
return { text, dataUris };
}
+14 -6
View File
@@ -1835,6 +1835,13 @@ const Conversations = ({
)}
{visibleMessages.map(msg => {
const isAgentTextMode = msg.sender === 'agent' && agentMessageViewMode === 'text';
// Parsed once per message: for current messages (extraMetadata
// present, or agent messages) msg.content already has no markers,
// so this is a no-op. For legacy persisted user messages with raw
// [IMAGE:...]/[FILE:...] markers and no extraMetadata, this is
// what keeps the marker text out of both the rendered bubble and
// the copy-to-clipboard action.
const parsedContent = parseMessageImages(msg.content ?? '');
return (
<div key={msg.id}>
<div
@@ -1893,9 +1900,10 @@ const Conversations = ({
) : (
<div className="flex flex-col items-end gap-1">
{(() => {
const displayText = parsedContent.text;
const dataUris = Array.isArray(msg.extraMetadata?.attachmentDataUris)
? (msg.extraMetadata.attachmentDataUris as string[])
: parseMessageImages(msg.content ?? '').dataUris;
: parsedContent.dataUris;
const hasImages = dataUris.length > 0;
// Document attachments carry no image data-URI (only
// images do); surface them as filename chips from the
@@ -1953,14 +1961,14 @@ const Conversations = ({
))}
</div>
)}
{(msg.content || showTime) && (
{(displayText || showTime) && (
<div className="rounded-2xl px-4 py-2.5 bg-primary-500 text-white rounded-br-md break-words overflow-hidden">
{msg.content && (
<BubbleMarkdown content={msg.content} tone="user" />
{displayText && (
<BubbleMarkdown content={displayText} tone="user" />
)}
{showTime && (
<p
className={`${msg.content ? 'mt-1' : ''} text-[10px] text-white/60`}>
className={`${displayText ? 'mt-1' : ''} text-[10px] text-white/60`}>
{formatRelativeTime(msg.createdAt)}
</p>
)}
@@ -1974,7 +1982,7 @@ const Conversations = ({
<button
type="button"
data-analytics-id="chat-message-copy"
onClick={() => handleCopyMessage(msg.id, msg.content)}
onClick={() => handleCopyMessage(msg.id, parsedContent.text)}
className={`absolute -top-1 ${
isAgentTextMode
? 'right-0'
@@ -637,6 +637,73 @@ describe('Conversations — attachment feature', () => {
expect(document.body.textContent).toContain('report.pdf');
});
});
it('strips raw IMAGE/FILE markers from a legacy message with no extraMetadata', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
const thread = makeThread({ id: 'legacy-thread', title: 'Legacy Thread' });
const dataUri = 'data:image/png;base64,legacy123';
const message = {
id: 'msg-legacy-1',
content: `read this [IMAGE:${dataUri}] and [FILE:data:application/pdf;base64,xyz]`,
type: 'text' as const,
sender: 'user' as const,
createdAt: new Date().toISOString(),
extraMetadata: {},
};
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
mockGetThreadMessages.mockResolvedValue({ messages: [message], count: 1 });
const store = buildStore({
thread: {
threads: [thread],
selectedThreadId: thread.id,
activeThreadIds: {},
welcomeThreadId: null,
messagesByThreadId: { [thread.id]: [message] },
messages: [message],
isLoadingThreads: false,
isLoadingMessages: false,
messagesError: null,
},
socket: socketState('connected'),
});
const { default: Conversations } = await import('../Conversations');
render(
<Provider store={store}>
<MemoryRouter>
<SidebarSlotProvider>
<SidebarSlotOutlet />
<Conversations />
</SidebarSlotProvider>
</MemoryRouter>
</Provider>
);
// The image marker's data URI still renders as an <img> (parsed out for display)...
await waitFor(() => {
const img = document.querySelector(`img[src="${dataUri}"]`);
expect(img).not.toBeNull();
});
// ...but the raw marker syntax must never leak into the rendered bubble text.
expect(document.body.textContent).not.toContain('[IMAGE:');
expect(document.body.textContent).not.toContain('[FILE:');
expect(document.body.textContent).toContain('read this');
expect(document.body.textContent).toContain('and');
// Copy-to-clipboard must use the same cleaned text as the bubble, not the
// raw msg.content with markers still embedded.
await act(async () => {
fireEvent.click(screen.getByTitle('Copy response'));
});
expect(writeText).toHaveBeenCalledWith('read this and');
expect(writeText).not.toHaveBeenCalledWith(expect.stringContaining('[IMAGE:'));
expect(writeText).not.toHaveBeenCalledWith(expect.stringContaining('[FILE:'));
});
});
describe('Conversations — thread rename', () => {