Skip to content

Commit 07243dc

Browse files
romanlutzCopilot
andauthored
FIX (frontend): omit misleading byte size on media attachment chip (#1896)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ed67a1a commit 07243dc

6 files changed

Lines changed: 169 additions & 4 deletions

File tree

frontend/src/components/Chat/ChatInputArea.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,37 @@ describe("ChatInputArea", () => {
497497
expect(screen.getByRole("button", { name: /send message/i })).toBeEnabled();
498498
});
499499

500+
it("should render attachment chip without size label when size is undefined", async () => {
501+
// Regression guard for the media-chip bug: when an attachment forwarded
502+
// via "Copy to input box in a new conversation" has no known size (e.g.
503+
// its url is a /api/media?path=... reference), the chip must not print a
504+
// bogus "(... B)" derived from the URL string length.
505+
const ref = React.createRef<ChatInputAreaHandle>();
506+
507+
render(
508+
<TestWrapper>
509+
<ChatInputArea ref={ref} {...defaultProps} />
510+
</TestWrapper>
511+
);
512+
513+
React.act(() => {
514+
ref.current?.addAttachment({
515+
type: "image",
516+
name: "forwarded.png",
517+
url: "/api/media?path=%2Fdbdata%2Fprompt-memory-entries%2Fimages%2Fimg.png",
518+
mimeType: "image/png",
519+
// no size
520+
});
521+
});
522+
523+
await waitFor(() => {
524+
expect(screen.getByText(/forwarded\.png/)).toBeInTheDocument();
525+
});
526+
527+
// No "(NNN B)" / "(N.N KB)" / "(N.N MB)" label should be rendered.
528+
expect(screen.queryByText(/\(\s*\d+(?:\.\d+)?\s*[KM]?B\s*\)/)).toBeNull();
529+
});
530+
500531
it("should show single-turn banner when singleTurnLimitReached is true", () => {
501532
render(
502533
<TestWrapper>

frontend/src/components/Chat/ChatInputArea.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ function AttachmentList({ attachments, mediaConversions, onRemove, onClearMediaC
8484
{att.type === 'audio' && '🎵'}
8585
{att.type === 'video' && '🎥'}
8686
{att.type === 'file' && '📄'}
87-
{' '}{att.name} ({formatFileSize(att.size)})
87+
{' '}{att.name}{att.size != null ? ` (${formatFileSize(att.size)})` : ''}
8888
</Caption1>
8989
</span>
9090
<Button

frontend/src/components/Chat/ChatWindow.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,6 @@ export default function ChatWindow({
222222
name: basenameFromValue(textConversion.convertedValue, `output.${kind}`),
223223
url,
224224
mimeType: 'application/octet-stream',
225-
size: 0,
226225
})
227226
}
228227

frontend/src/types/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ export interface MessageAttachment {
77
name: string
88
url: string
99
mimeType: string
10-
size: number
10+
/**
11+
* Decoded byte count when known. Omitted for path / URL / scheme-prefixed
12+
* values (e.g. `/api/media?path=...`) where the value is a reference, not
13+
* the payload, so its string length would be meaningless.
14+
*/
15+
size?: number
1116
file?: File
1217
/** Backend piece ID — preserved so remix/copy can trace back to the original piece */
1318
pieceId?: string

frontend/src/utils/messageMapper.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,121 @@ describe("messageMapper", () => {
398398
});
399399
});
400400

401+
describe("pieceToAttachment size handling", () => {
402+
function makeMediaMessage(
403+
converted_value: string,
404+
converted_value_data_type = "image_path",
405+
converted_value_mime_type: string | undefined = "image/png",
406+
): BackendMessage {
407+
return {
408+
turn_number: 1,
409+
role: "assistant",
410+
pieces: [
411+
{
412+
piece_id: "p1abcdef",
413+
original_value_data_type: "text",
414+
converted_value_data_type,
415+
original_value: "prompt",
416+
converted_value,
417+
converted_value_mime_type,
418+
scores: [],
419+
response_error: "none",
420+
},
421+
],
422+
created_at: "2026-02-15T00:00:00Z",
423+
};
424+
}
425+
426+
it("omits size for /api/media path URLs (the originally reported bug)", () => {
427+
// Reproduces the screenshot: a backend-served PNG referenced via
428+
// /api/media?path=... would otherwise produce a chip like "(119 B)"
429+
// because value.length was used as size.
430+
const url = "/api/media?path=C%3A%5CUsers%5Cromanlutz%5Cgit%5CPyRIT%5Cdbdata%5Cprompt-memory-entries%5Cimages%5Cimage_150c901b9db7.png";
431+
expect(url.length).toBeGreaterThan(50); // sanity: long enough for the old bug to bite
432+
433+
const result = backendMessageToFrontend(makeMediaMessage(url));
434+
435+
expect(result.attachments).toHaveLength(1);
436+
expect(result.attachments![0].url).toBe(url);
437+
expect(result.attachments![0].size).toBeUndefined();
438+
});
439+
440+
it("omits size for Windows absolute paths", () => {
441+
const result = backendMessageToFrontend(
442+
makeMediaMessage("C:\\Users\\me\\dbdata\\prompt-memory-entries\\images\\img.png")
443+
);
444+
expect(result.attachments![0].size).toBeUndefined();
445+
});
446+
447+
it("omits size for Unix absolute paths", () => {
448+
const result = backendMessageToFrontend(
449+
makeMediaMessage("/dbdata/prompt-memory-entries/images/img.png")
450+
);
451+
expect(result.attachments![0].size).toBeUndefined();
452+
});
453+
454+
it("omits size for data: / blob: / file: URI schemes", () => {
455+
for (const url of [
456+
"data:image/png;base64,aGVsbG8=",
457+
"blob:https://example.com/abc-123",
458+
"file:///tmp/img.png",
459+
]) {
460+
const result = backendMessageToFrontend(makeMediaMessage(url));
461+
expect(result.attachments![0].size).toBeUndefined();
462+
}
463+
});
464+
465+
it("sets size to the decoded byte count for base64-inlined media", () => {
466+
// btoa("hello world") = "aGVsbG8gd29ybGQ=" — 11 decoded bytes.
467+
const base64 = "aGVsbG8gd29ybGQ=";
468+
const result = backendMessageToFrontend(makeMediaMessage(base64));
469+
470+
expect(result.attachments![0].url).toBe(`data:image/png;base64,${base64}`);
471+
expect(result.attachments![0].size).toBe(11);
472+
});
473+
474+
it("computes base64 size correctly without padding", () => {
475+
// 16-char base64 with no padding decodes to floor(16 * 3 / 4) = 12 bytes.
476+
const base64 = "YWJjZGVmZ2hpamtsbW5vcA"; // "abcdefghijklmnop" without '=' padding
477+
const result = backendMessageToFrontend(makeMediaMessage(base64));
478+
expect(result.attachments![0].size).toBe(16);
479+
});
480+
481+
it("omits size on the original-side attachment for path values", () => {
482+
// Same /api/media URL on original_value — used when originalAttachments
483+
// is populated via pieceToAttachment(piece, 'original').
484+
const url = "/api/media?path=output%2Fimage.png";
485+
const msg: BackendMessage = {
486+
turn_number: 1,
487+
role: "assistant",
488+
pieces: [
489+
{
490+
piece_id: "p1abcdef",
491+
original_value_data_type: "image_path",
492+
converted_value_data_type: "image_path",
493+
original_value: url,
494+
converted_value: "aW1hZ2VkYXRhYWFhYWFh", // 20 chars base64
495+
converted_value_mime_type: "image/png",
496+
original_value_mime_type: "image/png",
497+
scores: [],
498+
response_error: "none",
499+
},
500+
],
501+
created_at: "2026-02-15T00:00:00Z",
502+
};
503+
504+
const result = backendMessageToFrontend(msg);
505+
506+
// Converted attachment should be present with computed size.
507+
expect(result.attachments).toHaveLength(1);
508+
expect(result.attachments![0].size).toBe(15); // floor(20 * 3 / 4)
509+
510+
// originalAttachments only populated when URLs differ — they do here.
511+
expect(result.originalAttachments).toHaveLength(1);
512+
expect(result.originalAttachments![0].size).toBeUndefined();
513+
});
514+
});
515+
401516
describe("backendMessagesToFrontend", () => {
402517
it("should convert multiple messages", () => {
403518
const messages: BackendMessage[] = [

frontend/src/utils/messageMapper.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,16 @@ function extractReasoningSummaries(value: string): string[] {
104104
return []
105105
}
106106

107+
/**
108+
* Compute the decoded byte count of a base64-encoded string. Whitespace and
109+
* trailing `=` padding are stripped before applying the standard
110+
* `floor(n * 3 / 4)` formula.
111+
*/
112+
function decodedBase64ByteCount(value: string): number {
113+
const stripped = value.replace(/\s+/g, '').replace(/=+$/, '')
114+
return Math.floor((stripped.length * 3) / 4)
115+
}
116+
107117
/**
108118
* Build a frontend MessageAttachment from a backend piece.
109119
*
@@ -134,12 +144,17 @@ function pieceToAttachment(
134144
const filename = isOriginal ? piece.original_filename : piece.converted_filename
135145
const fallbackName = `${prefix}${dataType}_${piece.piece_id.slice(0, 8)}`
136146

147+
// For base64-inlined media, derive the decoded byte count. For path / URL
148+
// values the string length is meaningless (e.g. /api/media?path=... is a
149+
// reference, not the payload), so size is omitted and the UI must hide it.
150+
const size = isBase64 ? decodedBase64ByteCount(value) : undefined
151+
137152
return {
138153
type: dataTypeToAttachmentType(dataType),
139154
name: filename || fallbackName,
140155
url,
141156
mimeType: mime,
142-
size: value.length,
157+
size,
143158
pieceId: piece.piece_id,
144159
metadata: piece.prompt_metadata || undefined,
145160
}

0 commit comments

Comments
 (0)