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("-"); + }); });