refactor(web): use the shared formatBytes in chat attachments (#2071)

Delete the two byte-identical local formatBytes copies from the chat-attachment
modules and repoint all call sites at the canonical lib/utils/format-bytes.ts,
which its own doc comment asks new code to use.

The copies had no upper clamp (>= 1 TB rendered "1 undefined") and no NaN or
negative guard (NaN rendered "NaN undefined"). Size labels now carry one
decimal ("10.0 MB" instead of "10 MB") because the canonical helper does not
strip a trailing zero; unit and e2e expectations updated to match.
This commit is contained in:
João Salavisa
2026-07-30 22:40:50 +01:00
committed by GitHub
parent 5e486fccf9
commit b082d5e1b9
14 changed files with 36 additions and 37 deletions
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { ToastProvider } from "@/components/toast-provider";
import { MAX_FILES, MAX_TOTAL_SIZE, processFile } from "@/components/task/chat/file-attachment";
import { formatBytes } from "@/lib/utils/format-bytes";
import { TaskFormInputs } from "./task-create-dialog-selectors";
import type { TaskFormInputsHandle } from "./task-create-dialog-types";
@@ -292,7 +293,7 @@ describe("TaskFormInputs attachment feedback", () => {
const warning = await screen.findByTestId(TOAST_MESSAGE_TEST_ID);
expect(warning.textContent).toContain("Attachment limit reached");
expect(warning.textContent).toContain(
`Attachments can total up to ${MAX_TOTAL_SIZE / 1024 / 1024} MB.`,
`Attachments can total up to ${formatBytes(MAX_TOTAL_SIZE)}.`,
);
});
@@ -312,7 +313,7 @@ describe("TaskFormInputs attachment feedback", () => {
const warning = await screen.findByTestId(TOAST_MESSAGE_TEST_ID);
expect(warning.textContent).toContain("Attachment is too large");
expect(warning.textContent).toContain(
"copied-image.png is 14 MB. The maximum file size is 10 MB.",
"copied-image.png is 14.0 MB. The maximum file size is 10.0 MB.",
);
});
@@ -8,9 +8,9 @@ import { IconPaperclip } from "@tabler/icons-react";
import { Combobox } from "./combobox";
import { scoreBranch } from "@/lib/utils/branch-filter";
import { BranchRefreshButton } from "./branch-refresh-button";
import { formatBytes } from "@/lib/utils/format-bytes";
import {
processFile,
formatBytes,
MAX_FILES,
MAX_TOTAL_SIZE,
type FileAttachment,
@@ -2,7 +2,7 @@
import { memo } from "react";
import type { FileAttachmentContextItem } from "@/lib/types/context";
import { formatBytes } from "../file-attachment";
import { formatBytes } from "@/lib/utils/format-bytes";
import { ContextChip } from "./context-chip";
export const FileAttachmentItem = memo(function FileAttachmentItem({
@@ -3,7 +3,8 @@
import { memo } from "react";
import { IconFile, IconX } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { formatBytes, type FileAttachment } from "./file-attachment";
import { formatBytes } from "@/lib/utils/format-bytes";
import { type FileAttachment } from "./file-attachment";
type FileAttachmentPreviewProps = {
attachments: FileAttachment[];
@@ -4,6 +4,7 @@
*/
import { generateUUID } from "@/lib/utils";
import { formatBytes } from "@/lib/utils/format-bytes";
export type FileAttachment = {
id: string;
@@ -22,14 +23,6 @@ export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB per file
export const MAX_TOTAL_SIZE = 20 * 1024 * 1024; // 20MB total
export const MAX_FILES = 10;
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
function isPreviewableImage(mimeType: string): boolean {
return PREVIEWABLE_IMAGE_TYPES.includes(mimeType);
}
@@ -3,6 +3,7 @@
import { memo } from "react";
import { IconX } from "@tabler/icons-react";
import { cn, generateUUID } from "@/lib/utils";
import { formatBytes } from "@/lib/utils/format-bytes";
export type ImageAttachment = {
id: string;
@@ -22,14 +23,6 @@ export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB per image
export const MAX_TOTAL_SIZE = 20 * 1024 * 1024; // 20MB total
export const MAX_IMAGES = 10;
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
type ImageAttachmentPreviewProps = {
attachments: ImageAttachment[];
onRemove: (id: string) => void;
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import { useToast } from "@/components/toast-provider";
import { formatBytes, MAX_FILES, MAX_FILE_SIZE, MAX_TOTAL_SIZE } from "./file-attachment";
import { formatBytes } from "@/lib/utils/format-bytes";
import { MAX_FILES, MAX_FILE_SIZE, MAX_TOTAL_SIZE } from "./file-attachment";
export function useAttachmentCountFeedback() {
const { toast } = useToast();
@@ -4,6 +4,7 @@ import React from "react";
import { ToastProvider } from "@/components/toast-provider";
import { useChatInputState } from "./use-chat-input-state";
import { MAX_FILES, MAX_FILE_SIZE, MAX_TOTAL_SIZE } from "./file-attachment";
import { formatBytes } from "@/lib/utils/format-bytes";
import type { TipTapInputHandle } from "./tiptap-input";
import type { EntityReference } from "@/lib/types/entity-reference";
@@ -234,9 +235,7 @@ describe("useChatInputState attachment feedback", () => {
expect(result.current.attachments).toHaveLength(2);
expect(screen.getAllByTestId("toast-message")).toHaveLength(1);
expect(toastMessage()).toContain("Attachment limit reached");
expect(toastMessage()).toContain(
`Attachments can total up to ${MAX_TOTAL_SIZE / 1024 / 1024} MB.`,
);
expect(toastMessage()).toContain(`Attachments can total up to ${formatBytes(MAX_TOTAL_SIZE)}.`);
});
it("warns when a pasted attachment exceeds the file size limit", async () => {
@@ -250,7 +249,7 @@ describe("useChatInputState attachment feedback", () => {
expect(toastMessage()).toContain("Attachment is too large");
expect(toastMessage()).toContain(
`recording.mov is 11 MB. The maximum file size is ${MAX_FILE_SIZE / 1024 / 1024} MB.`,
`recording.mov is 11.0 MB. The maximum file size is ${formatBytes(MAX_FILE_SIZE)}.`,
);
expect(result.current.attachments).toEqual([]);
});
@@ -18,13 +18,8 @@ import {
setChatDraftContent,
restoreAttachmentPreview,
} from "@/lib/local-storage";
import {
processFile,
formatBytes,
MAX_FILES,
MAX_TOTAL_SIZE,
type FileAttachment,
} from "./file-attachment";
import { formatBytes } from "@/lib/utils/format-bytes";
import { processFile, MAX_FILES, MAX_TOTAL_SIZE, type FileAttachment } from "./file-attachment";
import {
useAttachmentCountFeedback,
useAttachmentFileFeedback,
@@ -3,6 +3,7 @@ import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ToastProvider } from "@/components/toast-provider";
import { MAX_FILES, MAX_FILE_SIZE, MAX_TOTAL_SIZE } from "./chat/file-attachment";
import { formatBytes } from "@/lib/utils/format-bytes";
import { useDialogAttachments } from "./session-dialog-shared";
afterEach(cleanup);
@@ -114,7 +115,7 @@ describe("useDialogAttachments", () => {
]);
expect(screen.getAllByTestId(toastMessageTestId)).toHaveLength(1);
expect(screen.getByTestId(toastMessageTestId).textContent).toContain(
`Attachments can total up to ${MAX_TOTAL_SIZE / 1024 / 1024} MB.`,
`Attachments can total up to ${formatBytes(MAX_TOTAL_SIZE)}.`,
);
});
});
@@ -14,9 +14,9 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { IconGitBranch, IconPaperclip } from "@tabler/icons-react";
import { AgentLogo } from "@/components/agent-logo";
import { formatBytes } from "@/lib/utils/format-bytes";
import {
processFile,
formatBytes,
MAX_FILES,
MAX_TOTAL_SIZE,
type FileAttachment,
@@ -42,7 +42,9 @@ test.describe("oversized attachment feedback on mobile", () => {
const warning = testPage
.getByTestId("toast-message")
.filter({ hasText: "Attachment is too large" });
await expect(warning).toContainText("recording.mov is 11 MB. The maximum file size is 10 MB.");
await expect(warning).toContainText(
"recording.mov is 11.0 MB. The maximum file size is 10.0 MB.",
);
await expect
.poll(async () => {
@@ -25,7 +25,9 @@ test("warns when a pasted image is too large for a new task", async ({ testPage
const warning = testPage
.getByTestId("toast-message")
.filter({ hasText: "Attachment is too large" });
await expect(warning).toContainText("copied-image.png is 14 MB. The maximum file size is 10 MB.");
await expect(warning).toContainText(
"copied-image.png is 14.0 MB. The maximum file size is 10.0 MB.",
);
const overlay = testPage.locator('[data-slot="dialog-overlay"]:visible');
await expect(overlay).toBeVisible();
+11
View File
@@ -27,4 +27,15 @@ describe("formatBytes", () => {
expect(formatBytes(1024 * 1024 * 1024)).toBe("1.0 GB");
expect(formatBytes(1024 ** 4)).toBe("1.0 TB");
});
it("clamps to TB above the largest unit", () => {
expect(formatBytes(1024 ** 5)).toBe("1024.0 TB");
expect(formatBytes(1024 ** 6)).toBe("1048576.0 TB");
});
it("returns '-' for non-finite input", () => {
expect(formatBytes(NaN)).toBe("-");
expect(formatBytes(Infinity)).toBe("-");
expect(formatBytes(-Infinity)).toBe("-");
});
});