Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions app/src/components/channels/composer/attachment-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ export type StagedFile = {
name: string;
size?: number;
loading: boolean;
/**
* The model reads at most MAX_EXTRACTED_CHARACTERS of a text file, while
* the pick ceiling is MAX_FILE_BYTES. A file over the first may be read
* truncated — warned on the tile, never refused.
*/
mayTruncate?: boolean;
};

export function AttachmentStrip({
Expand Down Expand Up @@ -120,16 +126,43 @@ export function AttachmentStrip({
className="flex h-20 w-40 flex-col justify-between rounded-xl border border-border bg-muted/40 p-2"
key={file.id}
>
<IconFile className="size-5 text-muted-foreground" />
{/*
* `shrink-0` because the tile's height is fixed and the text below it is not. A third
* line, which is exactly what the truncation warning adds, takes the extra out of the
* only child that can give: this icon. It renders visibly smaller on precisely the
* tiles that carry the warning, which is the wrong moment to look broken.
*/}
<IconFile className="size-5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="truncate font-medium text-xs" title={file.name}>
{file.name}
</p>
{file.loading ? (
<p className="text-muted-foreground text-xs">Uploading…</p>
) : file.size === undefined ? null : (
<p className="text-muted-foreground text-xs">
/*
* ON THE SIZE LINE, NOT UNDER IT, AND THAT IS A LAYOUT CONSTRAINT RATHER THAN A
* PREFERENCE. The tile's height is fixed so that a file and a thumbnail read as
* one row. A third line does not fit: measured in Chromium at this repository's
* 15px root, the two in-flow children need 63.75px inside a 58px box. Something
* has to give, and the only choices are the icon shrinking to 13px or the text
* spilling past the border. Folding the warning onto this line keeps two.
*
* `truncate` is the guarantee, not the wording: `formatBytes` is unbounded, so a
* large enough file would wrap this line and put the third one back. The short
* phrasing is what keeps it from ellipsizing at any realistic size; `title`
* carries the sentence that actually explains it.
*/
<p
className="truncate text-muted-foreground text-xs"
title={
file.mayTruncate === true
? "The model reads the first 120,000 characters of this file."
: undefined
}
>
{formatBytes(file.size)}
{file.mayTruncate === true ? " · may be cut" : null}
</p>
)}
</div>
Expand Down
21 changes: 21 additions & 0 deletions app/src/components/channels/composer/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
attachmentUrl,
classifyAttachment,
MAX_ATTACHMENTS_PER_MESSAGE,
mayBeTruncatedForModel,
mediaTypeOf,
shouldClaimPaste,
} from "@/lib/channels/attachments";
Expand Down Expand Up @@ -1004,6 +1005,26 @@ export function Composer({
name: attachment.filename ?? "Attachment",
size: attachment.size,
loading: attachment.status === "uploading",
/*
* Warned, never refused. The server accepts the whole file and the
* model reads its first MAX_EXTRACTED_CHARACTERS; mayBeTruncatedForModel
* is exact in the safe direction (N bytes decode to at most N chars),
* so a file at or under the ceiling cannot be cut. Only text reaches
* the extraction path: images are excluded by the filter above, and an
* unnamed pick whose type is still unknown gets no warning until the
* server has sniffed it (then the strip re-renders off the url source).
*
* THE FILTER IS THE ONLY IMAGE TEST, DELIBERATELY. `attachment.type` is
* the browser's claim, fixed at pick time and never revised; the filter
* runs `stagedModality`, which prefers what the server sniffed. Testing
* both would not narrow this to text, it would only subtract: a file the
* browser called a PNG and the server read as text lands in this strip,
* goes down the extraction path, and is exactly the one that gets cut.
*/
mayTruncate:
attachment.status !== "uploading" &&
attachment.size !== undefined &&
mayBeTruncatedForModel(attachment.size),
})),
[staged],
);
Expand Down
1 change: 1 addition & 0 deletions app/src/lib/channels/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
MAX_EXTRACTED_CHARACTERS,
MAX_FILE_BYTES,
MAX_IMAGE_BYTES,
mayBeTruncatedForModel,
mediaTypeOf,
namesNoFormat,
shouldClaimPaste,
Expand Down
26 changes: 26 additions & 0 deletions app/tests/composer-attachment-strip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,29 @@ test("an unnamed image going up falls back to the same word the finished one use

expect(getByRole("img", { name: "Attachment, uploading" })).toBeTruthy();
});

test("a text file that fits shows no truncation warning", () => {
// mayTruncate is what composer.tsx sets from mayBeTruncatedForModel: a file
// at or under MAX_EXTRACTED_CHARACTERS cannot be cut (N bytes decode to at
// most N chars), so the tile must not warn about it.
const { queryByText } = render(
<AttachmentStrip files={[notes]} images={[]} onRemove={() => {}} />,
);

expect(queryByText(/may be cut/)).toBeNull();
});

test("a text file over the extraction ceiling warns it may be read truncated", () => {
// The gap this closes: the server accepts up to MAX_FILE_BYTES and the
// model reads the first MAX_EXTRACTED_CHARACTERS, so a file between the two
// arrives truncated with nothing on screen saying so. Warned, never refused.
const { getByText } = render(
<AttachmentStrip
files={[{ ...notes, id: "5", mayTruncate: true }]}
images={[]}
onRemove={() => {}}
/>,
);

expect(getByText(/· may be cut/)).toBeTruthy();
});
42 changes: 42 additions & 0 deletions app/tests/composer-attachments-ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -404,3 +404,45 @@ test("a file the browser called an image is drawn as a file once the server read
expect(container.querySelector("img")).toBeNull();
expect(container.textContent).toContain("notes.png");
});

/**
* THE WARNING IS DERIVED, AND THIS IS THE CASE THE DERIVATION CAN GET WRONG.
*
* A tile renders the warning it is handed; whether it is handed one is decided in the composer, off
* the same `stagedModality` the file filter uses. Those have to be the same test. The file below is
* the one that proves it: the browser calls it a PNG, the server reads it as text, so it lands in
* the file strip and goes down the extraction path, and at 120,001 bytes the model sees roughly a
* tenth of it. Deciding on the browser's claim instead would drop the warning on exactly this file.
*/
test("warns on a large file the browser called an image and the server read as text", async () => {
serverSniffs("text/plain");
const view = render(
<Composer channelId="channel-1" compact onSubmit={() => {}} />,
);
const { container } = view;

drop(container.querySelector("form") as HTMLFormElement, [
new File(["x".repeat(120_001)], "notes.png", { type: "image/png" }),
]);

await uploaded(view, "notes.png");

expect(container.textContent).toContain("may be cut");
});

/** The same file under the ceiling earns no warning, so the one above is the size and not the path. */
test("does not warn on a small file the server read as text", async () => {
serverSniffs("text/plain");
const view = render(
<Composer channelId="channel-1" compact onSubmit={() => {}} />,
);
const { container } = view;

drop(container.querySelector("form") as HTMLFormElement, [
new File(["hello"], "notes.png", { type: "image/png" }),
]);

await uploaded(view, "notes.png");

expect(container.textContent).not.toContain("may be cut");
});
10 changes: 4 additions & 6 deletions shared/attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
MAX_ATTACHMENTS_PER_MESSAGE,
MAX_EXTRACTED_CHARACTERS,
MAX_FILE_BYTES,
mayBeTruncatedForModel,
mediaTypeOf,
namesNoFormat,
shouldClaimPaste,
Expand Down Expand Up @@ -135,13 +136,10 @@ describe("what is uploaded and what the model reads are different limits", () =>
// characters. A file at or under the character ceiling therefore CANNOT be
// truncated, which is what makes `file.size > MAX_EXTRACTED_CHARACTERS` a
// warning that never fires on a file that arrives whole.
const mayBeTruncated = (byteLength: number) =>
byteLength > MAX_EXTRACTED_CHARACTERS;

expect(mayBeTruncated(MAX_EXTRACTED_CHARACTERS)).toBe(false);
expect(mayBeTruncated(MAX_EXTRACTED_CHARACTERS + 1)).toBe(true);
expect(mayBeTruncatedForModel(MAX_EXTRACTED_CHARACTERS)).toBe(false);
expect(mayBeTruncatedForModel(MAX_EXTRACTED_CHARACTERS + 1)).toBe(true);
// The case the gap is about: an accepted upload that will still be cut.
expect(mayBeTruncated(MAX_FILE_BYTES)).toBe(true);
expect(mayBeTruncatedForModel(MAX_FILE_BYTES)).toBe(true);
});
});

Expand Down
12 changes: 12 additions & 0 deletions shared/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@ export const MAX_FILE_BYTES = 1024 * 1024;
*/
export const MAX_EXTRACTED_CHARACTERS = 120_000;

/**
* Whether a file of this many BYTES may reach the model truncated.
*
* Exact in the safe direction: UTF-8 decodes N bytes to at most N characters,
* so `size <= MAX_EXTRACTED_CHARACTERS` cannot be cut, and `size > ...` may be
* (fewer characters for multi-byte scripts, hence "may"). Callers show a
* warning, never a refusal — the server still accepts and reads the prefix.
*/
export function mayBeTruncatedForModel(sizeBytes: number): boolean {
return sizeBytes > MAX_EXTRACTED_CHARACTERS;
}

/**
* `image/svg+xml` is deliberately absent.
*
Expand Down
Loading