From b082d5e1b95444af4ca7ba141fad35a1433c5bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Thu, 30 Jul 2026 22:40:50 +0100 Subject: [PATCH] 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. --- .../components/task-create-dialog-selectors.test.tsx | 5 +++-- apps/web/components/task-create-dialog-selectors.tsx | 2 +- .../task/chat/context-items/file-attachment-item.tsx | 2 +- .../components/task/chat/file-attachment-preview.tsx | 3 ++- apps/web/components/task/chat/file-attachment.ts | 9 +-------- .../components/task/chat/image-attachment-preview.tsx | 9 +-------- .../task/chat/use-attachment-file-feedback.ts | 3 ++- .../components/task/chat/use-chat-input-state.test.ts | 7 +++---- apps/web/components/task/chat/use-chat-input-state.ts | 9 ++------- .../components/task/session-dialog-shared.test.tsx | 3 ++- apps/web/components/task/session-dialog-shared.tsx | 2 +- .../tests/chat/mobile-attachment-size-warning.spec.ts | 4 +++- .../tests/task/create-task-attachment-warning.spec.ts | 4 +++- apps/web/lib/utils/format-bytes.test.ts | 11 +++++++++++ 14 files changed, 36 insertions(+), 37 deletions(-) diff --git a/apps/web/components/task-create-dialog-selectors.test.tsx b/apps/web/components/task-create-dialog-selectors.test.tsx index a9fe157680..7a39d57322 100644 --- a/apps/web/components/task-create-dialog-selectors.test.tsx +++ b/apps/web/components/task-create-dialog-selectors.test.tsx @@ -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.", ); }); diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index ed0a88cbd2..1ca9a42dd5 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -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, diff --git a/apps/web/components/task/chat/context-items/file-attachment-item.tsx b/apps/web/components/task/chat/context-items/file-attachment-item.tsx index 17aee6776f..bc892d53c3 100644 --- a/apps/web/components/task/chat/context-items/file-attachment-item.tsx +++ b/apps/web/components/task/chat/context-items/file-attachment-item.tsx @@ -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({ diff --git a/apps/web/components/task/chat/file-attachment-preview.tsx b/apps/web/components/task/chat/file-attachment-preview.tsx index daf9de0e38..a4ce99cea2 100644 --- a/apps/web/components/task/chat/file-attachment-preview.tsx +++ b/apps/web/components/task/chat/file-attachment-preview.tsx @@ -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[]; diff --git a/apps/web/components/task/chat/file-attachment.ts b/apps/web/components/task/chat/file-attachment.ts index d33b9789f8..5048efaf15 100644 --- a/apps/web/components/task/chat/file-attachment.ts +++ b/apps/web/components/task/chat/file-attachment.ts @@ -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); } diff --git a/apps/web/components/task/chat/image-attachment-preview.tsx b/apps/web/components/task/chat/image-attachment-preview.tsx index 1cf3dc1b19..18fc2c7448 100644 --- a/apps/web/components/task/chat/image-attachment-preview.tsx +++ b/apps/web/components/task/chat/image-attachment-preview.tsx @@ -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; diff --git a/apps/web/components/task/chat/use-attachment-file-feedback.ts b/apps/web/components/task/chat/use-attachment-file-feedback.ts index af0d44ff21..3a24255869 100644 --- a/apps/web/components/task/chat/use-attachment-file-feedback.ts +++ b/apps/web/components/task/chat/use-attachment-file-feedback.ts @@ -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(); diff --git a/apps/web/components/task/chat/use-chat-input-state.test.ts b/apps/web/components/task/chat/use-chat-input-state.test.ts index a8a39242f8..ce4496fbd8 100644 --- a/apps/web/components/task/chat/use-chat-input-state.test.ts +++ b/apps/web/components/task/chat/use-chat-input-state.test.ts @@ -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([]); }); diff --git a/apps/web/components/task/chat/use-chat-input-state.ts b/apps/web/components/task/chat/use-chat-input-state.ts index 259e6fc372..f3caaad69b 100644 --- a/apps/web/components/task/chat/use-chat-input-state.ts +++ b/apps/web/components/task/chat/use-chat-input-state.ts @@ -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, diff --git a/apps/web/components/task/session-dialog-shared.test.tsx b/apps/web/components/task/session-dialog-shared.test.tsx index 41f1da30b3..bc4f53c1a2 100644 --- a/apps/web/components/task/session-dialog-shared.test.tsx +++ b/apps/web/components/task/session-dialog-shared.test.tsx @@ -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)}.`, ); }); }); diff --git a/apps/web/components/task/session-dialog-shared.tsx b/apps/web/components/task/session-dialog-shared.tsx index ed83af7900..624cf591dd 100644 --- a/apps/web/components/task/session-dialog-shared.tsx +++ b/apps/web/components/task/session-dialog-shared.tsx @@ -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, diff --git a/apps/web/e2e/tests/chat/mobile-attachment-size-warning.spec.ts b/apps/web/e2e/tests/chat/mobile-attachment-size-warning.spec.ts index 1aaf840431..f8ee6966e2 100644 --- a/apps/web/e2e/tests/chat/mobile-attachment-size-warning.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-attachment-size-warning.spec.ts @@ -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 () => { diff --git a/apps/web/e2e/tests/task/create-task-attachment-warning.spec.ts b/apps/web/e2e/tests/task/create-task-attachment-warning.spec.ts index 7afcf7d584..c32eebf07f 100644 --- a/apps/web/e2e/tests/task/create-task-attachment-warning.spec.ts +++ b/apps/web/e2e/tests/task/create-task-attachment-warning.spec.ts @@ -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(); diff --git a/apps/web/lib/utils/format-bytes.test.ts b/apps/web/lib/utils/format-bytes.test.ts index 794ff77b10..1a234cca83 100644 --- a/apps/web/lib/utils/format-bytes.test.ts +++ b/apps/web/lib/utils/format-bytes.test.ts @@ -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("-"); + }); });