diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 232617f0ee58f..2ab08280d93fc 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -382,6 +382,10 @@ export const AgentChatPageView: FC = ({ content: renderTabContent(tab.id), })); + const isEditing = + editing.editingMessageId !== null || + editing.editingQueuedMessageID !== null; + const titleElement = ( {chatTitle ? pageTitle(chatTitle, "Agents") : pageTitle("Agents")} @@ -502,6 +506,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({ initialEditorState={editing.initialEditorState} remountKey={editing.remountKey} onContentChange={editing.handleContentChange} + isEditing={isEditing} editingQueuedMessageID={editing.editingQueuedMessageID} onStartQueueEdit={editing.handleStartQueueEdit} onCancelQueueEdit={editing.handleCancelQueueEdit} diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 4694aa21d7068..1a5a1f0fdcc04 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -55,8 +55,11 @@ import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth"; import { useOverflowCount } from "../hooks/useOverflowCount"; import { useSpeechRecognition } from "../hooks/useSpeechRecognition"; import { formatProviderLabel } from "../utils/modelOptions"; -import type { UploadState } from "./AttachmentPreview"; -import { AttachmentPreview } from "./AttachmentPreview"; +import { + AttachmentPreview, + isUploadInProgress, + type UploadState, +} from "./AttachmentPreview"; import { ModelSelector, type ModelSelectorOption } from "./ChatElements"; import { ChatMessageInput, @@ -71,6 +74,7 @@ import { WorkspacePill } from "./WorkspacePill"; export { ImageThumbnail, + isUploadInProgress, type UploadState, } from "./AttachmentPreview"; export type { ChatMessageInputRef } from "./ChatMessageInput/ChatMessageInput"; @@ -578,8 +582,8 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ internalRef.current?.focus(); } }, [isLoading]); - const isUploading = attachments.some( - (f) => uploadStates?.get(f)?.status === "uploading", + const hasActiveUploads = attachments.some((file) => + isUploadInProgress(uploadStates?.get(file)), ); const hasUploadedAttachments = attachments.some( (f) => uploadStates?.get(f)?.status === "uploaded", @@ -594,7 +598,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ !isLoading && hasModelOptions && hasSendableContent && - !isUploading; + !hasActiveUploads; const handleSubmit = () => { const text = internalRef.current?.getValue()?.trim() ?? ""; @@ -606,7 +610,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ !hasFileReferences && !isDisabled && !isLoading && - !isUploading && + !hasActiveUploads && queuedMessages.length > 0 && onPromoteQueuedMessage ) { @@ -618,7 +622,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ (!text && !hasUploadedAttachments && !hasFileReferences) || isDisabled || isLoading || - isUploading || + hasActiveUploads || !hasModelOptions ) { return; diff --git a/site/src/pages/AgentsPage/components/AttachmentPreview.stories.tsx b/site/src/pages/AgentsPage/components/AttachmentPreview.stories.tsx index 94a772ed8a096..18450db2005cf 100644 --- a/site/src/pages/AgentsPage/components/AttachmentPreview.stories.tsx +++ b/site/src/pages/AgentsPage/components/AttachmentPreview.stories.tsx @@ -83,6 +83,41 @@ export const Uploading: Story = { }, }; +export const PendingUpload: Story = { + args: (() => { + const file = createMockFile("pending.png", "image/png"); + return { + attachments: [file], + uploadStates: new Map<File, UploadState>([[file, { status: "pending" }]]), + previewUrls: new Map<File, string>([[file, TINY_PNG]]), + }; + })(), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(await canvas.findByTitle("Loading spinner")).toBeInTheDocument(); + }, +}; + +export const DraftWarning: Story = { + args: (() => { + const file = createMockFile("large-draft.txt", "text/plain"); + const warning = + "This file is attached for now, but it could not be saved as a draft."; + return { + attachments: [file], + uploadStates: new Map<File, UploadState>([ + [file, { status: "uploading", draftWarning: warning }], + ]), + }; + })(), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + await canvas.findByText(/could not be saved as a draft/i), + ).toBeInTheDocument(); + }, +}; + export const UploadError: Story = { args: (() => { const file = createMockFile("broken.png", "image/png"); diff --git a/site/src/pages/AgentsPage/components/AttachmentPreview.tsx b/site/src/pages/AgentsPage/components/AttachmentPreview.tsx index fd56fbd69b271..a3d8f07b00e5f 100644 --- a/site/src/pages/AgentsPage/components/AttachmentPreview.tsx +++ b/site/src/pages/AgentsPage/components/AttachmentPreview.tsx @@ -17,11 +17,15 @@ import { } from "../utils/fetchTextAttachment"; export type UploadState = { - status: "uploading" | "uploaded" | "error"; + status: "pending" | "uploading" | "uploaded" | "error"; fileId?: string; error?: string; + draftWarning?: string; }; +export const isUploadInProgress = (state: UploadState | undefined): boolean => + state?.status === "pending" || state?.status === "uploading"; + /** Renders an image thumbnail from a pre-created preview URL. */ export const ImageThumbnail: FC<{ previewUrl: string; @@ -106,109 +110,131 @@ export const AttachmentPreview: FC<{ } }; + const draftWarnings = Array.from( + new Set( + attachments.flatMap((file) => { + const warning = uploadStates?.get(file)?.draftWarning; + return warning ? [warning] : []; + }), + ), + ); + return ( - <div className="flex gap-2 overflow-x-auto border-b border-border-default/50 px-3 py-2"> - {attachments.map((file, index) => { - const uploadState = uploadStates?.get(file); - const previewUrl = previewUrls?.get(file) ?? ""; - const textContent = textContents?.get(file); - const textFileId = - uploadState?.status === "uploaded" ? uploadState.fileId : undefined; - const hasTextAttachment = - file.type === "text/plain" && - (textContent !== undefined || textFileId !== undefined); - return ( - <div - // Key combines file metadata with index as a fallback for - // duplicate names. Acceptable for a small, append-only list. - key={`${file.name}-${file.size}-${file.lastModified}-${index}`} - className="group relative" - > - {file.type.startsWith("image/") && previewUrl ? ( - <button - type="button" - className="border-0 bg-transparent p-0 cursor-pointer transition-opacity hover:opacity-80" - onClick={() => onPreview?.(previewUrl)} - > - <ImageThumbnail previewUrl={previewUrl} name={file.name} /> - </button> - ) : hasTextAttachment ? ( - <button - type="button" - aria-label={`View ${file.name}`} - className="flex h-16 w-28 flex-col items-start justify-start overflow-hidden rounded-md border-0 bg-surface-tertiary p-2 text-left transition-colors hover:bg-surface-quaternary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link" - onClick={async () => { - const nextContent = await loadTextAttachmentContent( - textContent, - textFileId, - ); - if (nextContent !== undefined) { - onTextPreview?.(nextContent, file.name); - } - }} - > - <span className="line-clamp-3 w-full font-mono text-2xs text-content-secondary"> - {formatTextAttachmentPreview(textContent ?? "")} - </span> - </button> - ) : ( - <div className="flex h-16 w-16 items-center justify-center rounded-md border border-border-default bg-surface-secondary text-xs text-content-secondary"> - {file.name.split(".").pop()?.toUpperCase() || "FILE"} - </div> - )} - {hasTextAttachment && ( + <div className="border-b border-border-default/50"> + <div className="flex gap-2 overflow-x-auto px-3 py-2"> + {attachments.map((file, index) => { + const uploadState = uploadStates?.get(file); + const previewUrl = previewUrls?.get(file) ?? ""; + const textContent = textContents?.get(file); + const textFileId = + uploadState?.status === "uploaded" ? uploadState.fileId : undefined; + const hasTextAttachment = + file.type === "text/plain" && + (textContent !== undefined || textFileId !== undefined); + return ( + <div + // Key combines file metadata with index as a fallback for + // duplicate names. Acceptable for a small, append-only list. + key={`${file.name}-${file.size}-${file.lastModified}-${index}`} + className="group relative" + > + {file.type.startsWith("image/") && previewUrl ? ( + <button + type="button" + className="border-0 bg-transparent p-0 cursor-pointer transition-opacity hover:opacity-80" + onClick={() => onPreview?.(previewUrl)} + > + <ImageThumbnail previewUrl={previewUrl} name={file.name} /> + </button> + ) : hasTextAttachment ? ( + <button + type="button" + aria-label={`View ${file.name}`} + className="flex h-16 w-28 flex-col items-start justify-start overflow-hidden rounded-md border-0 bg-surface-tertiary p-2 text-left transition-colors hover:bg-surface-quaternary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link" + onClick={async () => { + const nextContent = await loadTextAttachmentContent( + textContent, + textFileId, + ); + if (nextContent !== undefined) { + onTextPreview?.(nextContent, file.name); + } + }} + > + <span className="line-clamp-3 w-full font-mono text-2xs text-content-secondary"> + {formatTextAttachmentPreview(textContent ?? "")} + </span> + </button> + ) : ( + <div className="flex h-16 w-16 items-center justify-center rounded-md border border-border-default bg-surface-secondary text-xs text-content-secondary"> + {file.name.split(".").pop()?.toUpperCase() || "FILE"} + </div> + )} + {hasTextAttachment && ( + <button + type="button" + onClick={async () => { + const nextContent = await loadTextAttachmentContent( + textContent, + textFileId, + ); + onInlineText?.(file, nextContent); + }} + className="absolute -bottom-2 -right-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-0 bg-surface-primary text-content-secondary shadow-sm opacity-0 transition-opacity hover:bg-surface-secondary hover:text-content-primary group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100" + aria-label="Paste inline" + > + <ClipboardPasteIcon + aria-hidden="true" + className="h-3.5 w-3.5" + /> + </button> + )} + {(uploadState?.status === "pending" || + uploadState?.status === "uploading") && ( + <div className="absolute inset-0 flex items-center justify-center rounded-md bg-overlay"> + <Spinner className="h-5 w-5 text-white" loading /> + </div> + )} + {uploadState?.status === "error" && ( + <Tooltip> + <TooltipTrigger asChild> + <div + className="absolute inset-0 flex items-center justify-center rounded-md bg-overlay" + role="img" + aria-label="Upload error" + > + <AlertTriangleIcon className="h-5 w-5 text-content-warning" /> + </div> + </TooltipTrigger> + <TooltipContent side="top"> + <p className="max-w-xs text-xs"> + {uploadState.error ?? "Upload failed"} + </p> + </TooltipContent> + </Tooltip> + )} <button type="button" - onClick={async () => { - const nextContent = await loadTextAttachmentContent( - textContent, - textFileId, - ); - onInlineText?.(file, nextContent); - }} - className="absolute -bottom-2 -right-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-0 bg-surface-primary text-content-secondary shadow-sm opacity-0 transition-opacity hover:bg-surface-secondary hover:text-content-primary group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100" - aria-label="Paste inline" + onClick={() => onRemove(file)} + className="absolute -right-2 -top-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-0 bg-surface-primary text-content-secondary shadow-sm opacity-0 transition-opacity hover:bg-surface-secondary hover:text-content-primary group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100" + aria-label={`Remove ${file.name}`} > - <ClipboardPasteIcon - aria-hidden="true" - className="h-3.5 w-3.5" - /> + <XIcon aria-hidden="true" className="h-3.5 w-3.5" /> </button> - )} - {uploadState?.status === "uploading" && ( - <div className="absolute inset-0 flex items-center justify-center rounded-md bg-overlay"> - <Spinner className="h-5 w-5 text-white" loading /> - </div> - )} - {uploadState?.status === "error" && ( - <Tooltip> - <TooltipTrigger asChild> - <div - className="absolute inset-0 flex items-center justify-center rounded-md bg-overlay" - role="img" - aria-label="Upload error" - > - <AlertTriangleIcon className="h-5 w-5 text-content-warning" /> - </div> - </TooltipTrigger> - <TooltipContent side="top"> - <p className="max-w-xs text-xs"> - {uploadState.error ?? "Upload failed"} - </p> - </TooltipContent> - </Tooltip> - )} - <button - type="button" - onClick={() => onRemove(file)} - className="absolute -right-2 -top-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-0 bg-surface-primary text-content-secondary shadow-sm opacity-0 transition-opacity hover:bg-surface-secondary hover:text-content-primary group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100" - aria-label={`Remove ${file.name}`} - > - <XIcon aria-hidden="true" className="h-3.5 w-3.5" /> - </button> - </div> - ); - })} + </div> + ); + })} + </div> + {draftWarnings.length > 0 && ( + <div className="space-y-1 px-3 pb-2 text-xs text-content-warning"> + {draftWarnings.map((warning) => ( + <div key={warning} className="flex items-start gap-1.5"> + <AlertTriangleIcon className="mt-0.5 h-3.5 w-3.5 shrink-0" /> + <span>{warning}</span> + </div> + ))} + </div> + )} </div> ); }; diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 426544cc4c160..6477e2af5e9c2 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -1,8 +1,9 @@ -import { type FC, Profiler, type ReactNode, useEffect } from "react"; +import { type FC, Profiler, type ReactNode, useEffect, useRef } from "react"; import { toast } from "sonner"; import type { UrlTransform } from "streamdown"; import type * as TypesGen from "#/api/typesGenerated"; import { cn } from "#/utils/cn"; +import { useChatDraftAttachments } from "../hooks/useChatDraftAttachments"; import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth"; import { useFileAttachments } from "../hooks/useFileAttachments"; import { getChatFileURL } from "../utils/chatAttachments"; @@ -11,6 +12,7 @@ import { AgentChatInput, type AttachedWorkspaceInfo, type ChatMessageInputRef, + isUploadInProgress, type UploadState, } from "./AgentChatInput"; import { ConversationTimeline } from "./ChatConversation/ConversationTimeline"; @@ -173,6 +175,7 @@ interface ChatPageInputProps { serializedEditorState: string, hasFileReferences: boolean, ) => void; + isEditing: boolean; editingQueuedMessageID: number | null; onStartQueueEdit: ( id: number, @@ -233,6 +236,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({ initialEditorState, remountKey, onContentChange, + isEditing, editingQueuedMessageID, onStartQueueEdit, onCancelQueueEdit, @@ -297,6 +301,16 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({ const latestContextUsage = rawUsage ? { ...rawUsage, compressionThreshold, lastInjectedContext } : rawUsage; + const composeAttachments = useChatDraftAttachments(organizationId, chatId); + const editAttachments = useFileAttachments(organizationId); + const { + setAttachments: setEditAttachments, + setPreviewUrls: setEditPreviewUrls, + setUploadStates: setEditUploadStates, + resetAttachments: resetEditAttachments, + } = editAttachments; + const wasEditingRef = useRef(isEditing); + const modeAttachments = isEditing ? editAttachments : composeAttachments; const { attachments, textContents, @@ -304,19 +318,32 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({ previewUrls, handleAttach, handleRemoveAttachment, - resetAttachments, - setAttachments, - setPreviewUrls, - setUploadStates, - } = useFileAttachments(organizationId); - // Pre-populate attachments from existing file blocks when - // entering edit mode on a message with images. + } = modeAttachments; + + // Edit attachments are scoped to the chat being edited, not the compose + // draft. Clear them when navigation changes the chat scope. + const editScopeRef = useRef({ organizationId, chatId }); + useEffect(() => { + const previous = editScopeRef.current; + const scopeChanged = + previous.organizationId !== organizationId || previous.chatId !== chatId; + editScopeRef.current = { organizationId, chatId }; + if (scopeChanged) { + resetEditAttachments(); + } + }, [organizationId, chatId, resetEditAttachments]); + + // Pre-populate the edit bucket from existing file blocks only + // while explicitly editing a message. + useEffect(() => { + if (!isEditing) { + return; + } if (!editingFileBlocks || editingFileBlocks.length === 0) { - // Clear attachments when exiting edit mode. - setAttachments([]); - setUploadStates(new Map()); - setPreviewUrls(new Map()); + setEditAttachments([]); + setEditUploadStates(new Map()); + setEditPreviewUrls(new Map()); return; } const fileBlocks = editingFileBlocks.filter( @@ -329,8 +356,8 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({ // read because the existing file_id is reused at send time. return new File([], `attachment-${i}.${ext}`, { type: mt }); }); - setAttachments(files); - setPreviewUrls( + setEditAttachments(files); + setEditPreviewUrls( new Map( files.map((f, i) => [f, getChatFileURL(fileBlocks[i].file_id ?? "")]), ), @@ -345,8 +372,23 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({ }); } } - setUploadStates(newUploadStates); - }, [editingFileBlocks, setAttachments, setPreviewUrls, setUploadStates]); + setEditUploadStates(newUploadStates); + }, [ + isEditing, + editingFileBlocks, + setEditAttachments, + setEditPreviewUrls, + setEditUploadStates, + ]); + + // Exiting edit mode should only clear the edit bucket. Compose draft + // attachments must survive canceling or completing an edit. + useEffect(() => { + if (wasEditingRef.current && !isEditing) { + resetEditAttachments(); + } + wasEditingRef.current = isEditing; + }, [isEditing, resetEditAttachments]); const isStreaming = hasStreamState || chatStatus === "running" || chatStatus === "pending"; @@ -355,6 +397,13 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({ <AgentChatInput onSend={(message) => { void (async () => { + const hasActiveUploads = attachments.some((file) => + isUploadInProgress(uploadStates.get(file)), + ); + if (hasActiveUploads) { + toast.warning("Wait for file uploads to finish before sending."); + return; + } // Collect uploaded attachment metadata for the optimistic // transcript builder while keeping the server payload // shape unchanged downstream. @@ -386,7 +435,11 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({ // Attachments preserved for retry on failure. return; } - resetAttachments(); + if (isEditing) { + editAttachments.resetAttachments(); + } else { + composeAttachments.resetAttachments(); + } })(); }} attachments={attachments} diff --git a/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.test.ts b/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.test.ts new file mode 100644 index 0000000000000..39fc10614e8f9 --- /dev/null +++ b/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.test.ts @@ -0,0 +1,526 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { API } from "#/api/api"; +import { chatDraftAttachmentStorageKey } from "../utils/chatDraftAttachmentStorage"; +import { + resetChatDraftAttachmentRegistryForTest, + useChatDraftAttachments, +} from "./useChatDraftAttachments"; + +type Deferred<T> = { + promise: Promise<T>; + resolve: (value: T | PromiseLike<T>) => void; + reject: (reason?: unknown) => void; +}; + +const createDeferred = <T>(): Deferred<T> => { + let resolve!: (value: T | PromiseLike<T>) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise<T>((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +const orgID = "org-1"; +const chatID = "chat-a"; +const storageKey = chatDraftAttachmentStorageKey(orgID, chatID); + +const parseStoredDrafts = () => + JSON.parse(localStorage.getItem(storageKey) ?? "[]"); + +describe("useChatDraftAttachments", () => { + let originalCreateObjectURL: typeof URL.createObjectURL | undefined; + let originalRevokeObjectURL: typeof URL.revokeObjectURL | undefined; + + beforeEach(() => { + localStorage.clear(); + resetChatDraftAttachmentRegistryForTest(); + originalCreateObjectURL = URL.createObjectURL; + originalRevokeObjectURL = URL.revokeObjectURL; + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: vi.fn(() => "blob:attachment-preview"), + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response()); + }); + + afterEach(() => { + resetChatDraftAttachmentRegistryForTest(); + localStorage.clear(); + vi.restoreAllMocks(); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: originalCreateObjectURL, + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: originalRevokeObjectURL, + }); + }); + + it("restores chat draft attachments by organization and chat without duplicating active uploads", async () => { + const upload = createDeferred<{ id: string }>(); + const uploadSpy = vi + .spyOn(API.experimental, "uploadChatFile") + .mockReturnValue(upload.promise); + const { result, unmount } = renderHook(() => + useChatDraftAttachments(orgID, chatID), + ); + const file = new File(["hello"], "note.txt", { + type: "text/plain", + lastModified: 1, + }); + + act(() => { + result.current.handleAttach([file]); + }); + + await vi.waitFor(() => { + const stored = parseStoredDrafts(); + expect(stored).toHaveLength(1); + expect(stored[0]).toMatchObject({ + status: "uploading", + fileName: "note.txt", + organizationId: orgID, + chatId: chatID, + }); + expect(stored[0].payload).toEqual(expect.any(String)); + }); + expect(uploadSpy).toHaveBeenCalledTimes(1); + unmount(); + + const otherChat = renderHook(() => + useChatDraftAttachments(orgID, "chat-b"), + ); + expect(otherChat.result.current.attachments).toHaveLength(0); + otherChat.unmount(); + + const restored = renderHook(() => useChatDraftAttachments(orgID, chatID)); + expect(restored.result.current.attachments).toHaveLength(1); + expect(restored.result.current.attachments[0].name).toBe("note.txt"); + expect( + restored.result.current.uploadStates.get( + restored.result.current.attachments[0], + ), + ).toMatchObject({ status: "uploading" }); + await vi.waitFor(() => { + expect( + restored.result.current.textContents.get( + restored.result.current.attachments[0], + ), + ).toBe("hello"); + }); + expect(uploadSpy).toHaveBeenCalledTimes(1); + + await act(async () => { + upload.resolve({ id: "file-1" }); + }); + await vi.waitFor(() => { + const state = restored.result.current.uploadStates.get( + restored.result.current.attachments[0], + ); + expect(state).toMatchObject({ status: "uploaded", fileId: "file-1" }); + }); + const stored = parseStoredDrafts(); + expect(stored).toHaveLength(1); + expect(stored[0]).toMatchObject({ status: "uploaded", fileId: "file-1" }); + expect(stored[0].payload).toBeUndefined(); + restored.unmount(); + }); + + it("shares an active upload across simultaneous hook instances", async () => { + const upload = createDeferred<{ id: string }>(); + const uploadSpy = vi + .spyOn(API.experimental, "uploadChatFile") + .mockReturnValue(upload.promise); + const first = renderHook(() => useChatDraftAttachments(orgID, chatID)); + const file = new File(["hello"], "shared.txt", { + type: "text/plain", + lastModified: 11, + }); + + act(() => { + first.result.current.handleAttach([file]); + }); + + await vi.waitFor(() => { + expect(parseStoredDrafts()).toHaveLength(1); + }); + const second = renderHook(() => useChatDraftAttachments(orgID, chatID)); + + await vi.waitFor(() => { + expect(first.result.current.attachments).toHaveLength(1); + expect(second.result.current.attachments).toHaveLength(1); + }); + expect(uploadSpy).toHaveBeenCalledTimes(1); + + await act(async () => { + upload.resolve({ id: "file-shared" }); + }); + await vi.waitFor(() => { + const firstState = first.result.current.uploadStates.get( + first.result.current.attachments[0], + ); + const secondState = second.result.current.uploadStates.get( + second.result.current.attachments[0], + ); + expect(firstState).toMatchObject({ + status: "uploaded", + fileId: "file-shared", + }); + expect(secondState).toMatchObject({ + status: "uploaded", + fileId: "file-shared", + }); + }); + + first.unmount(); + second.unmount(); + }); + + it("does not resurrect removed in-flight attachments after upload completion", async () => { + const upload = createDeferred<{ id: string }>(); + vi.spyOn(API.experimental, "uploadChatFile").mockReturnValue( + upload.promise, + ); + const { result, unmount } = renderHook(() => + useChatDraftAttachments(orgID, chatID), + ); + const file = new File(["hello"], "photo.png", { + type: "image/png", + lastModified: 2, + }); + + act(() => { + result.current.handleAttach([file]); + }); + await vi.waitFor(() => { + expect(result.current.attachments).toHaveLength(1); + }); + + act(() => { + result.current.handleRemoveAttachment(file); + }); + expect(result.current.attachments).toHaveLength(0); + expect(localStorage.getItem(storageKey)).toBeNull(); + + await act(async () => { + upload.resolve({ id: "file-removed" }); + }); + expect(result.current.attachments).toHaveLength(0); + expect(localStorage.getItem(storageKey)).toBeNull(); + unmount(); + }); + + it("keeps failed uploads attached with an error state", async () => { + const upload = createDeferred<{ id: string }>(); + vi.spyOn(API.experimental, "uploadChatFile").mockReturnValue( + upload.promise, + ); + const { result, unmount } = renderHook(() => + useChatDraftAttachments(orgID, chatID), + ); + const file = new File(["hello"], "failed.txt", { + type: "text/plain", + lastModified: 12, + }); + + act(() => { + result.current.handleAttach([file]); + }); + await vi.waitFor(() => { + expect(result.current.attachments).toHaveLength(1); + }); + + await act(async () => { + upload.reject(new Error("network down")); + }); + await vi.waitFor(() => { + const state = result.current.uploadStates.get(file); + expect(state).toMatchObject({ status: "error" }); + expect(state?.error).toContain("network down"); + }); + expect(result.current.attachments).toHaveLength(1); + + unmount(); + }); + + it("keeps quota-limited attachments in memory and clears the warning after metadata persists", async () => { + const upload = createDeferred<{ id: string }>(); + vi.spyOn(API.experimental, "uploadChatFile").mockReturnValue( + upload.promise, + ); + const realSetItem = Storage.prototype.setItem; + vi.spyOn(Storage.prototype, "setItem").mockImplementation(function ( + this: Storage, + key: string, + value: string, + ) { + if (key === storageKey && String(value).includes('"payload"')) { + throw new DOMException("Quota exceeded", "QuotaExceededError"); + } + return realSetItem.call(this, key, value); + }); + const { result, unmount } = renderHook(() => + useChatDraftAttachments(orgID, chatID), + ); + const file = new File(["hello"], "large.txt", { + type: "text/plain", + lastModified: 3, + }); + + act(() => { + result.current.handleAttach([file]); + }); + + await vi.waitFor(() => { + const state = result.current.uploadStates.get(file); + expect(state?.draftWarning).toContain("could not be saved as a draft"); + }); + expect(localStorage.getItem(storageKey)).toBeNull(); + + await act(async () => { + upload.resolve({ id: "file-lightweight" }); + }); + await vi.waitFor(() => { + const state = result.current.uploadStates.get(file); + expect(state).toMatchObject({ + status: "uploaded", + fileId: "file-lightweight", + }); + expect(state?.draftWarning).toBeUndefined(); + }); + const stored = parseStoredDrafts(); + expect(stored).toHaveLength(1); + expect(stored[0]).toMatchObject({ + status: "uploaded", + fileId: "file-lightweight", + }); + expect(stored[0].payload).toBeUndefined(); + unmount(); + }); + + it("keeps uploaded attachments usable when metadata cannot be persisted", async () => { + const upload = createDeferred<{ id: string }>(); + vi.spyOn(API.experimental, "uploadChatFile").mockReturnValue( + upload.promise, + ); + const realSetItem = Storage.prototype.setItem; + vi.spyOn(Storage.prototype, "setItem").mockImplementation(function ( + this: Storage, + key: string, + value: string, + ) { + if (key === storageKey && String(value).includes("file-unpersisted")) { + throw new DOMException("Quota exceeded", "QuotaExceededError"); + } + return realSetItem.call(this, key, value); + }); + const { result, unmount } = renderHook(() => + useChatDraftAttachments(orgID, chatID), + ); + const file = new File(["hello"], "metadata-fails.txt", { + type: "text/plain", + lastModified: 13, + }); + + act(() => { + result.current.handleAttach([file]); + }); + await vi.waitFor(() => { + expect(parseStoredDrafts()).toHaveLength(1); + }); + + await act(async () => { + upload.resolve({ id: "file-unpersisted" }); + }); + await vi.waitFor(() => { + const state = result.current.uploadStates.get(file); + expect(state).toMatchObject({ + status: "uploaded", + fileId: "file-unpersisted", + }); + expect(state?.draftWarning).toContain("could not be saved as a draft"); + }); + expect(parseStoredDrafts()[0].payload).toEqual(expect.any(String)); + + unmount(); + }); + + it("rejects files over the attachment size limit without uploading", () => { + const uploadSpy = vi.spyOn(API.experimental, "uploadChatFile"); + const { result, unmount } = renderHook(() => + useChatDraftAttachments(orgID, chatID), + ); + const file = new File([new Uint8Array(10 * 1024 * 1024 + 1)], "huge.txt", { + type: "text/plain", + }); + + act(() => { + result.current.handleAttach([file]); + }); + + expect(uploadSpy).not.toHaveBeenCalled(); + expect(result.current.attachments).toHaveLength(1); + expect(result.current.uploadStates.get(file)).toMatchObject({ + status: "error", + error: expect.stringContaining("Maximum is 10 MB"), + }); + expect(localStorage.getItem(storageKey)).toBeNull(); + unmount(); + }); + + it("resetAttachments clears storage, registry entries, and previews", async () => { + const upload = createDeferred<{ id: string }>(); + vi.spyOn(API.experimental, "uploadChatFile").mockReturnValue( + upload.promise, + ); + const { result, unmount } = renderHook(() => + useChatDraftAttachments(orgID, chatID), + ); + const file = new File(["hello"], "reset.png", { + type: "image/png", + lastModified: 14, + }); + + act(() => { + result.current.handleAttach([file]); + }); + await vi.waitFor(() => { + expect(parseStoredDrafts()).toHaveLength(1); + }); + + act(() => { + result.current.resetAttachments(); + }); + expect(result.current.attachments).toHaveLength(0); + expect(localStorage.getItem(storageKey)).toBeNull(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:attachment-preview"); + + await act(async () => { + upload.resolve({ id: "file-reset" }); + }); + expect(result.current.attachments).toHaveLength(0); + expect(localStorage.getItem(storageKey)).toBeNull(); + unmount(); + }); + + it("stale reset clears its original chat without clearing the current chat", async () => { + const firstUpload = createDeferred<{ id: string }>(); + const secondUpload = createDeferred<{ id: string }>(); + vi.spyOn(API.experimental, "uploadChatFile") + .mockReturnValueOnce(firstUpload.promise) + .mockReturnValueOnce(secondUpload.promise); + const secondChatID = "chat-b"; + const secondStorageKey = chatDraftAttachmentStorageKey(orgID, secondChatID); + const { result, rerender, unmount } = renderHook( + ({ chatId }) => useChatDraftAttachments(orgID, chatId), + { initialProps: { chatId: chatID } }, + ); + const firstFile = new File(["first"], "first.txt", { + type: "text/plain", + lastModified: 15, + }); + + act(() => { + result.current.handleAttach([firstFile]); + }); + await vi.waitFor(() => { + expect(parseStoredDrafts()).toHaveLength(1); + }); + const resetFirstChat = result.current.resetAttachments; + rerender({ chatId: secondChatID }); + const secondFile = new File(["second"], "second.txt", { + type: "text/plain", + lastModified: 16, + }); + + act(() => { + result.current.handleAttach([secondFile]); + }); + await vi.waitFor(() => { + expect(localStorage.getItem(secondStorageKey)).not.toBeNull(); + }); + + act(() => { + resetFirstChat(); + }); + expect(localStorage.getItem(storageKey)).toBeNull(); + expect(localStorage.getItem(secondStorageKey)).not.toBeNull(); + expect(result.current.attachments).toHaveLength(1); + expect(result.current.attachments[0].name).toBe("second.txt"); + + await act(async () => { + firstUpload.resolve({ id: "file-first" }); + secondUpload.resolve({ id: "file-second" }); + }); + await vi.waitFor(() => { + expect(result.current.uploadStates.get(secondFile)).toMatchObject({ + status: "uploaded", + fileId: "file-second", + }); + }); + expect(result.current.attachments).toHaveLength(1); + expect(localStorage.getItem(storageKey)).toBeNull(); + unmount(); + }); + + it("prunes corrupt and wrong-scope stored records during restore", () => { + localStorage.setItem( + storageKey, + JSON.stringify([ + { + status: "uploaded", + clientId: "good", + fileId: "file-good", + fileName: "good.png", + fileType: "image/png", + lastModified: 4, + size: 10, + organizationId: orgID, + chatId: chatID, + }, + { + status: "uploaded", + clientId: "other-org", + fileId: "file-other", + fileName: "other.png", + fileType: "image/png", + lastModified: 4, + size: 10, + organizationId: "org-2", + chatId: chatID, + }, + { + status: "pending", + clientId: "mismatched-payload", + fileName: "bad.txt", + fileType: "text/plain", + lastModified: 5, + size: 10, + organizationId: orgID, + chatId: chatID, + payload: "data:text/html;base64,PGgxPkhlbGxvPC9oMT4=", + }, + { status: "uploaded", clientId: "bad" }, + ]), + ); + + const { result, unmount } = renderHook(() => + useChatDraftAttachments(orgID, chatID), + ); + + expect(result.current.attachments).toHaveLength(1); + expect(result.current.attachments[0].name).toBe("good.png"); + const stored = parseStoredDrafts(); + expect(stored).toHaveLength(1); + expect(stored[0].clientId).toBe("good"); + unmount(); + }); +}); diff --git a/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.ts b/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.ts new file mode 100644 index 0000000000000..d7c0aad4c6c40 --- /dev/null +++ b/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.ts @@ -0,0 +1,690 @@ +import { useEffect, useRef, useState } from "react"; +import { API } from "#/api/api"; +import type { UploadState } from "../components/AgentChatInput"; +import { getChatFileURL } from "../utils/chatAttachments"; +import { + clearChatDraftAttachmentRecords, + fileToDataURL, + type RestoredChatDraftAttachment, + removeChatDraftAttachmentRecord, + restoreChatDraftAttachments, + upsertChatDraftAttachmentRecord, +} from "../utils/chatDraftAttachmentStorage"; +import { + formatAgentAttachmentTooLargeError, + formatAgentAttachmentUploadError, + maxAgentAttachmentSize, + readAgentAttachmentText, +} from "../utils/fileAttachmentLimits"; + +const maxTextPreviewSize = 1024 * 1024; + +const pendingDraftWarning = + "This file is attached for now, but it could not be saved as a draft. If you leave this chat before it uploads or sends, it may be lost."; +const uploadedDraftWarning = + "This file is usable in this session, but it could not be saved as a draft."; + +type DraftUploadStatus = UploadState["status"]; + +type DraftAttachmentView = { + clientId: string; + file: File; + fileId?: string; + status: DraftUploadStatus; + error?: string; + draftWarning?: string; + previewUrl?: string; + previewUrlKind?: "blob" | "chatFile"; + textContent?: string; +}; + +type UploadRegistrySnapshot = { + clientId: string; + organizationId: string; + chatId: string; + file: File; + fileId?: string; + status: DraftUploadStatus; + error?: string; + draftWarning?: string; + removed: boolean; +}; + +type UploadRegistrySubscriber = (snapshot: UploadRegistrySnapshot) => void; + +type UploadRegistryEntry = { + clientId: string; + organizationId: string; + chatId: string; + file: File; + generation: number; + status: DraftUploadStatus; + fileId?: string; + error?: string; + draftWarning?: string; + removed: boolean; + uploadStarted: boolean; + subscribers: Set<UploadRegistrySubscriber>; +}; + +// Uploads outlive one chat page instance. The registry lets a remount rejoin +// an in-flight upload by clientId instead of starting a duplicate server +// upload. Async completions must check generation so removed drafts cannot +// write storage or notify UI again. +const activeDraftUploads = new Map<string, UploadRegistryEntry>(); + +let fallbackClientIdCounter = 0; + +const isTerminalRegistryStatus = (entry: UploadRegistryEntry) => + entry.status === "uploaded" || entry.status === "error"; + +const pruneTerminalRegistryEntry = (entry: UploadRegistryEntry) => { + if (entry.subscribers.size === 0 && isTerminalRegistryStatus(entry)) { + activeDraftUploads.delete(entry.clientId); + } +}; + +const createClientId = () => { + const cryptoObject = + typeof globalThis.crypto !== "undefined" ? globalThis.crypto : undefined; + if (cryptoObject?.randomUUID) { + return cryptoObject.randomUUID(); + } + if (cryptoObject?.getRandomValues) { + const values = new Uint32Array(2); + cryptoObject.getRandomValues(values); + return `draft-${Date.now()}-${Array.from(values, (value) => value.toString(36)).join("-")}`; + } + fallbackClientIdCounter += 1; + return `draft-${Date.now()}-${fallbackClientIdCounter}`; +}; + +const createBlobPreview = (file: File): string | undefined => { + if (file.type === "text/plain" || typeof URL.createObjectURL !== "function") { + return undefined; + } + try { + return URL.createObjectURL(file); + } catch { + return undefined; + } +}; + +const revokeBlobPreview = (view: DraftAttachmentView) => { + if (view.previewUrlKind === "blob" && view.previewUrl?.startsWith("blob:")) { + URL.revokeObjectURL(view.previewUrl); + } +}; + +type DraftAttachmentPreview = Pick< + DraftAttachmentView, + "previewUrl" | "previewUrlKind" +>; + +const computePreview = ( + file: File, + status: DraftUploadStatus, + fileId?: string, + current?: DraftAttachmentPreview, +): DraftAttachmentPreview => { + if (status === "uploaded") { + if (fileId && file.type.startsWith("image/")) { + return { previewUrl: getChatFileURL(fileId), previewUrlKind: "chatFile" }; + } + return {}; + } + if (current) { + return current; + } + const previewUrl = createBlobPreview(file); + return { previewUrl, previewUrlKind: previewUrl ? "blob" : undefined }; +}; + +const snapshotFromEntry = ( + entry: UploadRegistryEntry, +): UploadRegistrySnapshot => ({ + clientId: entry.clientId, + organizationId: entry.organizationId, + chatId: entry.chatId, + file: entry.file, + fileId: entry.fileId, + status: entry.status, + error: entry.error, + draftWarning: entry.draftWarning, + removed: entry.removed, +}); + +const notifySubscribers = (entry: UploadRegistryEntry) => { + const snapshot = snapshotFromEntry(entry); + for (const subscriber of entry.subscribers) { + subscriber(snapshot); + } +}; + +const isCurrentGeneration = (entry: UploadRegistryEntry, generation: number) => + !entry.removed && entry.generation === generation; + +const createRegistryEntry = ( + clientId: string, + organizationId: string, + chatId: string, + file: File, +): UploadRegistryEntry => { + const existing = activeDraftUploads.get(clientId); + if (existing) { + return existing; + } + const entry: UploadRegistryEntry = { + clientId, + organizationId, + chatId, + file, + generation: 1, + status: "pending", + removed: false, + uploadStarted: false, + subscribers: new Set(), + }; + activeDraftUploads.set(clientId, entry); + return entry; +}; + +const persistUploadPayload = async ( + entry: UploadRegistryEntry, + generation: number, +) => { + try { + const payload = await fileToDataURL(entry.file); + if ( + !isCurrentGeneration(entry, generation) || + entry.status === "uploaded" + ) { + return; + } + const result = upsertChatDraftAttachmentRecord({ + status: entry.status === "pending" ? "pending" : "uploading", + clientId: entry.clientId, + fileName: entry.file.name, + fileType: entry.file.type, + lastModified: entry.file.lastModified, + size: entry.file.size, + organizationId: entry.organizationId, + chatId: entry.chatId, + payload, + }); + if (result.ok || !isCurrentGeneration(entry, generation)) { + return; + } + entry.draftWarning = pendingDraftWarning; + notifySubscribers(entry); + } catch { + if (!isCurrentGeneration(entry, generation)) { + return; + } + entry.draftWarning = pendingDraftWarning; + notifySubscribers(entry); + } +}; + +const persistUploadedRecord = ( + entry: UploadRegistryEntry, + generation: number, +) => { + if (!entry.fileId || !isCurrentGeneration(entry, generation)) { + return; + } + const result = upsertChatDraftAttachmentRecord({ + status: "uploaded", + clientId: entry.clientId, + fileId: entry.fileId, + fileName: entry.file.name, + fileType: entry.file.type, + lastModified: entry.file.lastModified, + size: entry.file.size, + organizationId: entry.organizationId, + chatId: entry.chatId, + }); + if (result.ok) { + entry.draftWarning = undefined; + return; + } + entry.draftWarning = uploadedDraftWarning; +}; + +const beginUpload = (entry: UploadRegistryEntry) => { + if (entry.uploadStarted) { + return; + } + entry.uploadStarted = true; + const generation = entry.generation; + entry.status = "uploading"; + notifySubscribers(entry); + void persistUploadPayload(entry, generation); + void (async () => { + try { + const result = await API.experimental.uploadChatFile( + entry.file, + entry.organizationId, + ); + if (!isCurrentGeneration(entry, generation)) { + return; + } + entry.status = "uploaded"; + entry.fileId = result.id; + entry.error = undefined; + persistUploadedRecord(entry, generation); + if (entry.file.type.startsWith("image/")) { + void fetch(getChatFileURL(result.id)).catch(() => undefined); + } + notifySubscribers(entry); + pruneTerminalRegistryEntry(entry); + } catch (error) { + if (!isCurrentGeneration(entry, generation)) { + return; + } + entry.status = "error"; + entry.error = formatAgentAttachmentUploadError(error); + notifySubscribers(entry); + pruneTerminalRegistryEntry(entry); + } + })(); +}; + +const removeRegistryEntry = (clientId: string) => { + const entry = activeDraftUploads.get(clientId); + if (!entry) { + return; + } + entry.generation += 1; + entry.removed = true; + activeDraftUploads.delete(clientId); + notifySubscribers(entry); +}; + +const viewsFromRestored = ( + restored: readonly RestoredChatDraftAttachment[], +): DraftAttachmentView[] => + restored.map(({ record, file }) => { + const status = record.status === "uploaded" ? "uploaded" : record.status; + const fileId = record.status === "uploaded" ? record.fileId : undefined; + return { + clientId: record.clientId, + file, + fileId, + status, + ...computePreview(file, status, fileId), + }; + }); + +const viewFromSnapshot = ( + snapshot: UploadRegistrySnapshot, +): DraftAttachmentView => ({ + clientId: snapshot.clientId, + file: snapshot.file, + fileId: snapshot.fileId, + status: snapshot.status, + error: snapshot.error, + draftWarning: snapshot.draftWarning, + ...computePreview(snapshot.file, snapshot.status, snapshot.fileId), +}); + +const applySnapshot = ( + views: readonly DraftAttachmentView[], + snapshot: UploadRegistrySnapshot, +): DraftAttachmentView[] => { + if (snapshot.removed) { + return views.filter((view) => { + if (view.clientId !== snapshot.clientId) { + return true; + } + revokeBlobPreview(view); + return false; + }); + } + let found = false; + const next = views.map((view) => { + if (view.clientId !== snapshot.clientId) { + return view; + } + found = true; + const nextPreview = computePreview( + snapshot.file, + snapshot.status, + snapshot.fileId, + { previewUrl: view.previewUrl, previewUrlKind: view.previewUrlKind }, + ); + if (view.previewUrl !== nextPreview.previewUrl) { + revokeBlobPreview(view); + } + return { + ...view, + file: snapshot.file, + fileId: snapshot.fileId, + status: snapshot.status, + error: snapshot.error, + draftWarning: snapshot.draftWarning, + previewUrl: nextPreview.previewUrl, + previewUrlKind: nextPreview.previewUrlKind, + }; + }); + if (found) { + return next; + } + return [...next, viewFromSnapshot(snapshot)]; +}; + +const isSameScope = ( + entry: UploadRegistryEntry, + organizationId: string, + chatId: string, +) => + !entry.removed && + entry.organizationId === organizationId && + entry.chatId === chatId; + +const getDraftScopeKey = ( + organizationId: string | undefined, + chatId: string | undefined, +) => (organizationId && chatId ? `${organizationId}:${chatId}` : undefined); + +const removeRegistryEntriesForScope = ( + organizationId: string, + chatId: string, +) => { + for (const entry of Array.from(activeDraftUploads.values())) { + if (entry.organizationId === organizationId && entry.chatId === chatId) { + removeRegistryEntry(entry.clientId); + } + } +}; + +const hydrateViews = ( + organizationId: string | undefined, + chatId: string | undefined, +) => { + if (!organizationId || !chatId) { + return []; + } + let views = viewsFromRestored( + restoreChatDraftAttachments(organizationId, chatId), + ); + for (const entry of activeDraftUploads.values()) { + if (!isSameScope(entry, organizationId, chatId)) { + continue; + } + views = applySnapshot(views, snapshotFromEntry(entry)); + } + return views; +}; + +const unsubscribeAllEntries = (subscriptions: { + current: Map<string, () => void>; +}) => { + for (const unsubscribe of subscriptions.current.values()) { + unsubscribe(); + } + subscriptions.current.clear(); +}; + +const subscribeToEntry = ( + entry: UploadRegistryEntry, + subscriptions: { current: Map<string, () => void> }, + subscriber: UploadRegistrySubscriber, +) => { + if (entry.removed || subscriptions.current.has(entry.clientId)) { + return; + } + entry.subscribers.add(subscriber); + subscriptions.current.set(entry.clientId, () => { + entry.subscribers.delete(subscriber); + pruneTerminalRegistryEntry(entry); + }); +}; + +type SetDraftAttachmentViews = ( + updater: (prev: DraftAttachmentView[]) => DraftAttachmentView[], +) => void; + +const queueTextContentReads = ( + candidateViews: readonly DraftAttachmentView[], + setDraftViews: SetDraftAttachmentViews, + shouldApplyResult: () => boolean, +) => { + for (const view of candidateViews) { + if ( + view.status === "error" || + view.status === "uploaded" || + view.textContent !== undefined || + view.file.type !== "text/plain" || + view.file.size > maxTextPreviewSize + ) { + continue; + } + void readAgentAttachmentText(view.file) + .then((content) => { + if (!shouldApplyResult()) { + return; + } + setDraftViews((prev) => { + let updated = false; + const next = prev.map((current) => { + if (current.clientId !== view.clientId) { + return current; + } + updated = true; + return { ...current, textContent: content }; + }); + return updated ? next : prev; + }); + }) + .catch((error) => { + console.error("Failed to read text file content:", error); + }); + } +}; + +export function useChatDraftAttachments( + organizationId: string | undefined, + chatId: string | undefined, +) { + const [views, setViews] = useState(() => + hydrateViews(organizationId, chatId), + ); + const viewsRef = useRef(views); + const subscriptionsRef = useRef(new Map<string, () => void>()); + const scopeRef = useRef(getDraftScopeKey(organizationId, chatId)); + const [subscriber] = useState<UploadRegistrySubscriber>( + () => + function handleUploadRegistrySnapshot(snapshot: UploadRegistrySnapshot) { + setViews((prev) => applySnapshot(prev, snapshot)); + }, + ); + + useEffect(() => { + viewsRef.current = views; + }, [views]); + + useEffect(() => { + return () => { + scopeRef.current = undefined; + unsubscribeAllEntries(subscriptionsRef); + for (const view of viewsRef.current) { + revokeBlobPreview(view); + } + }; + }, []); + + useEffect(() => { + const scopeKey = getDraftScopeKey(organizationId, chatId); + scopeRef.current = scopeKey; + unsubscribeAllEntries(subscriptionsRef); + if (!organizationId || !chatId || !scopeKey) { + setViews([]); + return; + } + const previousViews = viewsRef.current; + const restored = restoreChatDraftAttachments(organizationId, chatId); + let nextViews = viewsFromRestored(restored); + for (const entry of activeDraftUploads.values()) { + if (!isSameScope(entry, organizationId, chatId)) { + continue; + } + subscribeToEntry(entry, subscriptionsRef, subscriber); + nextViews = applySnapshot(nextViews, snapshotFromEntry(entry)); + } + const restoredEntriesToStart: UploadRegistryEntry[] = []; + for (const { record, file } of restored) { + if (record.status === "uploaded") { + continue; + } + const entry = createRegistryEntry( + record.clientId, + organizationId, + chatId, + file, + ); + subscribeToEntry(entry, subscriptionsRef, subscriber); + restoredEntriesToStart.push(entry); + } + for (const view of previousViews) { + revokeBlobPreview(view); + } + setViews(nextViews); + queueTextContentReads( + nextViews, + setViews, + () => scopeRef.current === scopeKey, + ); + for (const entry of restoredEntriesToStart) { + beginUpload(entry); + } + }, [organizationId, chatId, subscriber]); + + const handleAttach = (files: File[]) => { + const scopeKey = getDraftScopeKey(organizationId, chatId); + const entriesToStart: UploadRegistryEntry[] = []; + const nextViews: DraftAttachmentView[] = []; + for (const file of files) { + const clientId = createClientId(); + const baseView: DraftAttachmentView = { + clientId, + file, + status: "pending", + }; + if (file.size > maxAgentAttachmentSize) { + nextViews.push({ + ...baseView, + status: "error", + error: formatAgentAttachmentTooLargeError(file.size), + }); + continue; + } + if (!organizationId || !chatId || !scopeKey) { + nextViews.push({ + ...baseView, + status: "error", + error: "Unable to upload: no chat context.", + }); + continue; + } + const view = { ...baseView, ...computePreview(file, "pending") }; + const entry = createRegistryEntry(clientId, organizationId, chatId, file); + subscribeToEntry(entry, subscriptionsRef, subscriber); + nextViews.push(view); + entriesToStart.push(entry); + } + setViews((prev) => [...prev, ...nextViews]); + for (const entry of entriesToStart) { + beginUpload(entry); + } + queueTextContentReads( + nextViews, + setViews, + () => scopeRef.current === scopeKey, + ); + }; + + const handleRemoveAttachment = (attachment: number | File) => { + const index = + typeof attachment === "number" + ? attachment + : views.findIndex((view) => view.file === attachment); + const removed = index >= 0 ? views[index] : undefined; + if (!removed) { + return; + } + if (organizationId && chatId) { + removeChatDraftAttachmentRecord(organizationId, chatId, removed.clientId); + } + removeRegistryEntry(removed.clientId); + setViews((prev) => + prev.filter((view) => { + if (view.clientId !== removed.clientId) { + return true; + } + revokeBlobPreview(view); + return false; + }), + ); + }; + + const resetAttachments = () => { + if (!organizationId || !chatId) { + setViews([]); + return; + } + clearChatDraftAttachmentRecords(organizationId, chatId); + const resetScopeKey = getDraftScopeKey(organizationId, chatId); + if (scopeRef.current !== resetScopeKey) { + removeRegistryEntriesForScope(organizationId, chatId); + return; + } + for (const view of viewsRef.current) { + revokeBlobPreview(view); + removeRegistryEntry(view.clientId); + } + setViews([]); + }; + + // React Compiler memoizes pure derived values in this directory. + // Keep these inline rather than adding manual memoization. + const attachments = views.map((view) => view.file); + const uploadStates = new Map<File, UploadState>(); + const previewUrls = new Map<File, string>(); + const textContents = new Map<File, string>(); + for (const view of views) { + uploadStates.set(view.file, { + status: view.status, + fileId: view.fileId, + error: view.error, + draftWarning: view.draftWarning, + }); + if (view.previewUrl) { + previewUrls.set(view.file, view.previewUrl); + } + if (view.textContent !== undefined) { + textContents.set(view.file, view.textContent); + } + } + + return { + attachments, + textContents, + uploadStates, + previewUrls, + handleAttach, + handleRemoveAttachment, + resetAttachments, + }; +} + +/** @internal Exported for tests. */ +export const resetChatDraftAttachmentRegistryForTest = () => { + for (const entry of activeDraftUploads.values()) { + entry.generation += 1; + entry.removed = true; + notifySubscribers(entry); + } + activeDraftUploads.clear(); + fallbackClientIdCounter = 0; +}; diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts index d91411f5ad776..d26b01e2ba7fa 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts @@ -6,9 +6,14 @@ import { useState, } from "react"; import { API } from "#/api/api"; -import { getErrorDetail, getErrorMessage } from "#/api/errors"; import type { UploadState } from "../components/AgentChatInput"; import { getChatFileURL } from "../utils/chatAttachments"; +import { + formatAgentAttachmentTooLargeError, + formatAgentAttachmentUploadError, + maxAgentAttachmentSize, + readAgentAttachmentText, +} from "../utils/fileAttachmentLimits"; /** @internal Exported for testing. */ export const persistedAttachmentsStorageKey = "agents.persisted-attachments"; @@ -240,9 +245,7 @@ export function useFileAttachments( void fetch(getChatFileURL(result.id)); } } catch (err: unknown) { - const message = getErrorMessage(err, "Upload failed"); - const detail = getErrorDetail(err); - const errorMessage = detail ? `${message} ${detail}` : message; + const errorMessage = formatAgentAttachmentUploadError(err); setUploadStates((prev) => new Map(prev).set(file, { status: "error", @@ -254,7 +257,6 @@ export function useFileAttachments( }; const handleAttach = (files: File[]) => { - const maxSize = 10 * 1024 * 1024; // 10 MB setAttachments((prev) => [...prev, ...files]); setPreviewUrls((prev) => { const next = new Map(prev); @@ -267,13 +269,8 @@ export function useFileAttachments( }); // Read text content for preview, but skip oversized files. for (const file of files) { - if (file.type === "text/plain" && file.size <= maxSize) { - // Defensive: some test environments lack File.prototype.text(). - const readText = - typeof file.text === "function" - ? file.text() - : new Response(file).text(); - void readText + if (file.type === "text/plain" && file.size <= maxAgentAttachmentSize) { + void readAgentAttachmentText(file) .then((content) => { setTextContents((prev) => { const next = new Map(prev); @@ -287,11 +284,11 @@ export function useFileAttachments( } } for (const file of files) { - if (file.size > maxSize) { + if (file.size > maxAgentAttachmentSize) { setUploadStates((prev) => new Map(prev).set(file, { status: "error" as const, - error: `File too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum is 10 MB.`, + error: formatAgentAttachmentTooLargeError(file.size), }), ); } else { diff --git a/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.test.ts b/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.test.ts new file mode 100644 index 0000000000000..e04f2d703d7fd --- /dev/null +++ b/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + chatDraftAttachmentStorageKey, + clearChatDraftAttachmentRecords, + fileToDataURL, + removeChatDraftAttachmentRecord, + restoreChatDraftAttachments, + upsertChatDraftAttachmentRecord, +} from "./chatDraftAttachmentStorage"; +import { readAgentAttachmentText } from "./fileAttachmentLimits"; + +const organizationId = "org-1"; +const chatId = "chat-1"; +const storageKey = chatDraftAttachmentStorageKey(organizationId, chatId); + +const readStoredRecords = () => + JSON.parse(localStorage.getItem(storageKey) ?? "[]"); + +describe("chatDraftAttachmentStorage", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("restores persisted pending attachments from data URLs", async () => { + const file = new File(["hello"], "note.txt", { + type: "text/plain", + lastModified: 1, + }); + + const result = upsertChatDraftAttachmentRecord({ + status: "pending", + clientId: "client-1", + fileName: file.name, + fileType: file.type, + lastModified: file.lastModified, + size: file.size, + organizationId, + chatId, + payload: await fileToDataURL(file), + }); + + expect(result).toEqual({ ok: true }); + const restored = restoreChatDraftAttachments(organizationId, chatId); + expect(restored).toHaveLength(1); + expect(restored[0].file.name).toBe("note.txt"); + expect(restored[0].file.type).toBe("text/plain"); + expect(await readAgentAttachmentText(restored[0].file)).toBe("hello"); + }); + + it("prunes malformed records and data URLs that do not match metadata", () => { + localStorage.setItem( + storageKey, + JSON.stringify([ + { + status: "uploaded", + clientId: "good", + fileId: "file-good", + fileName: "good.png", + fileType: "image/png", + lastModified: 2, + size: 10, + organizationId, + chatId, + }, + { + status: "pending", + clientId: "wrong-media-type", + fileName: "bad.txt", + fileType: "text/plain", + lastModified: 3, + size: 10, + organizationId, + chatId, + payload: "data:text/html;base64,PGgxPmJhZDwvaDE+", + }, + { + status: "pending", + clientId: "bad-base64", + fileName: "bad.txt", + fileType: "text/plain", + lastModified: 4, + size: 10, + organizationId, + chatId, + payload: "data:text/plain;base64,not valid base64!", + }, + { status: "uploaded", clientId: "partial" }, + ]), + ); + + const restored = restoreChatDraftAttachments(organizationId, chatId); + + expect(restored).toHaveLength(1); + expect(restored[0].record.clientId).toBe("good"); + expect(readStoredRecords()).toEqual([ + expect.objectContaining({ clientId: "good" }), + ]); + }); + + it("deduplicates records by clientId and uploaded fileId", () => { + upsertChatDraftAttachmentRecord({ + status: "uploaded", + clientId: "client-a", + fileId: "file-1", + fileName: "first.png", + fileType: "image/png", + lastModified: 5, + size: 10, + organizationId, + chatId, + }); + upsertChatDraftAttachmentRecord({ + status: "uploaded", + clientId: "client-b", + fileId: "file-1", + fileName: "duplicate.png", + fileType: "image/png", + lastModified: 6, + size: 10, + organizationId, + chatId, + }); + upsertChatDraftAttachmentRecord({ + status: "uploaded", + clientId: "client-a", + fileId: "file-2", + fileName: "updated.png", + fileType: "image/png", + lastModified: 7, + size: 10, + organizationId, + chatId, + }); + + const records = readStoredRecords(); + expect(records).toHaveLength(2); + expect(records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientId: "client-a", fileId: "file-2" }), + expect.objectContaining({ clientId: "client-b", fileId: "file-1" }), + ]), + ); + }); + + it("prunes expired draft records from older chat keys", () => { + const oldKey = chatDraftAttachmentStorageKey(organizationId, "old-chat"); + localStorage.setItem( + oldKey, + JSON.stringify([ + { + status: "uploaded", + clientId: "expired", + fileId: "file-expired", + fileName: "expired.png", + fileType: "image/png", + lastModified: 10, + size: 10, + updatedAt: Date.now() - 31 * 24 * 60 * 60 * 1000, + organizationId, + chatId: "old-chat", + }, + ]), + ); + + restoreChatDraftAttachments(organizationId, chatId); + + expect(localStorage.getItem(oldKey)).toBeNull(); + }); + + it("removes individual records and clears a chat scope", () => { + upsertChatDraftAttachmentRecord({ + status: "uploaded", + clientId: "client-a", + fileId: "file-a", + fileName: "a.png", + fileType: "image/png", + lastModified: 8, + size: 10, + organizationId, + chatId, + }); + upsertChatDraftAttachmentRecord({ + status: "uploaded", + clientId: "client-b", + fileId: "file-b", + fileName: "b.png", + fileType: "image/png", + lastModified: 9, + size: 10, + organizationId, + chatId, + }); + + expect( + removeChatDraftAttachmentRecord(organizationId, chatId, "client-a"), + ).toEqual({ ok: true }); + expect(readStoredRecords()).toEqual([ + expect.objectContaining({ clientId: "client-b" }), + ]); + expect(clearChatDraftAttachmentRecords(organizationId, chatId)).toEqual({ + ok: true, + }); + expect(localStorage.getItem(storageKey)).toBeNull(); + }); +}); diff --git a/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.ts b/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.ts new file mode 100644 index 0000000000000..586ecd6267c7e --- /dev/null +++ b/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.ts @@ -0,0 +1,381 @@ +type ChatDraftAttachmentRecord = { + clientId: string; + fileName: string; + fileType: string; + lastModified: number; + size: number; + updatedAt?: number; + organizationId: string; + chatId: string; +} & ( + | { + status: "pending" | "uploading"; + payload: string; + } + | { + status: "uploaded"; + fileId: string; + } +); + +export type RestoredChatDraftAttachment = { + record: ChatDraftAttachmentRecord; + file: File; +}; + +type ChatDraftAttachmentPersistResult = + | { ok: true } + | { ok: false; reason: "quota" | "unavailable" }; + +const storageKeyPrefix = "agents.chat-draft-attachments"; +const maxStoredDraftAgeMs = 30 * 24 * 60 * 60 * 1000; + +export const chatDraftAttachmentStorageKey = ( + organizationId: string, + chatId: string, +) => `${storageKeyPrefix}.${organizationId}.${chatId}`; + +const isRecordObject = (value: unknown): value is Record<string, unknown> => + typeof value === "object" && value !== null; + +const isString = (value: unknown): value is string => typeof value === "string"; + +const isFiniteNumber = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value); + +const isQuotaError = (error: unknown): boolean => { + if (!(error instanceof DOMException)) { + return false; + } + return ( + error.name === "QuotaExceededError" || + error.name === "NS_ERROR_DOM_QUOTA_REACHED" + ); +}; + +const safeSetItem = ( + key: string, + value: string, +): ChatDraftAttachmentPersistResult => { + try { + localStorage.setItem(key, value); + return { ok: true }; + } catch (error) { + return { ok: false, reason: isQuotaError(error) ? "quota" : "unavailable" }; + } +}; + +const safeRemoveItem = (key: string) => { + try { + localStorage.removeItem(key); + } catch { + // Ignore storage cleanup failures. The in-memory draft remains usable. + } +}; + +const validateRecord = ( + value: unknown, + organizationId: string, + chatId: string, +): ChatDraftAttachmentRecord | null => { + if (!isRecordObject(value)) { + return null; + } + const { + clientId, + fileName, + fileType, + lastModified, + size, + updatedAt, + organizationId: recordOrganizationId, + chatId: recordChatId, + status, + } = value; + if ( + !isString(clientId) || + !isString(fileName) || + !isString(fileType) || + !isFiniteNumber(lastModified) || + !isFiniteNumber(size) || + !isString(recordOrganizationId) || + !isString(recordChatId) || + recordOrganizationId !== organizationId || + recordChatId !== chatId + ) { + return null; + } + const recordUpdatedAt = isFiniteNumber(updatedAt) ? updatedAt : Date.now(); + if (status === "pending" || status === "uploading") { + const { payload } = value; + if (!isString(payload)) { + return null; + } + return { + status, + clientId, + fileName, + fileType, + lastModified, + size, + updatedAt: recordUpdatedAt, + organizationId: recordOrganizationId, + chatId: recordChatId, + payload, + }; + } + if (status === "uploaded") { + const { fileId } = value; + if (!isString(fileId)) { + return null; + } + return { + status, + clientId, + fileId, + fileName, + fileType, + lastModified, + size, + updatedAt: recordUpdatedAt, + organizationId: recordOrganizationId, + chatId: recordChatId, + }; + } + return null; +}; + +const dedupeRecords = ( + records: readonly ChatDraftAttachmentRecord[], +): ChatDraftAttachmentRecord[] => { + const byClientId = new Map<string, ChatDraftAttachmentRecord>(); + for (const record of records) { + byClientId.set(record.clientId, record); + } + const byFileId = new Set<string>(); + const deduped: ChatDraftAttachmentRecord[] = []; + for (const record of byClientId.values()) { + if (record.status === "uploaded") { + if (byFileId.has(record.fileId)) { + continue; + } + byFileId.add(record.fileId); + } + deduped.push(record); + } + return deduped; +}; + +const writeRecords = ( + organizationId: string, + chatId: string, + records: readonly ChatDraftAttachmentRecord[], +): ChatDraftAttachmentPersistResult => { + const key = chatDraftAttachmentStorageKey(organizationId, chatId); + const deduped = dedupeRecords(records); + if (deduped.length === 0) { + safeRemoveItem(key); + return { ok: true }; + } + return safeSetItem(key, JSON.stringify(deduped)); +}; + +const pruneExpiredChatDraftAttachmentStorageKeys = () => { + const now = Date.now(); + try { + for (let index = localStorage.length - 1; index >= 0; index--) { + const key = localStorage.key(index); + if (!key?.startsWith(`${storageKeyPrefix}.`)) { + continue; + } + const stored = localStorage.getItem(key); + if (!stored) { + continue; + } + let parsed: unknown; + try { + parsed = JSON.parse(stored); + } catch { + continue; + } + if (!Array.isArray(parsed)) { + continue; + } + const activeRecords = parsed.filter((entry) => { + if (!isRecordObject(entry) || !isFiniteNumber(entry.updatedAt)) { + return true; + } + return now - entry.updatedAt <= maxStoredDraftAgeMs; + }); + if (activeRecords.length === 0) { + safeRemoveItem(key); + } else if (activeRecords.length !== parsed.length) { + safeSetItem(key, JSON.stringify(activeRecords)); + } + } + } catch { + // Ignore storage sweep failures. The active chat restore still runs below. + } +}; + +const readRecords = ( + organizationId: string, + chatId: string, +): ChatDraftAttachmentRecord[] => { + const key = chatDraftAttachmentStorageKey(organizationId, chatId); + let stored: string | null = null; + try { + stored = localStorage.getItem(key); + } catch { + return []; + } + if (!stored) { + return []; + } + let parsed: unknown; + try { + parsed = JSON.parse(stored); + } catch { + safeRemoveItem(key); + return []; + } + if (!Array.isArray(parsed)) { + safeRemoveItem(key); + return []; + } + const records = parsed.flatMap((entry) => { + const record = validateRecord(entry, organizationId, chatId); + return record ? [record] : []; + }); + const deduped = dedupeRecords(records); + if (deduped.length !== parsed.length) { + writeRecords(organizationId, chatId, deduped); + } + return deduped; +}; + +const getRecordMetadata = (record: ChatDraftAttachmentRecord) => ({ + fileName: record.fileName, + fileType: record.fileType, + lastModified: record.lastModified, +}); + +export const fileToDataURL = (file: File): Promise<string> => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => + reject(reader.error ?? new Error("Failed to read file.")); + reader.onload = () => { + if (typeof reader.result === "string") { + resolve(reader.result); + return; + } + reject(new Error("Failed to read file.")); + }; + reader.readAsDataURL(file); + }); + +const fileFromDataURL = ( + payload: string, + metadata: { fileName: string; fileType: string; lastModified: number }, +): File | null => { + const commaIndex = payload.indexOf(","); + if (commaIndex === -1 || !payload.startsWith("data:")) { + return null; + } + const header = payload.slice(0, commaIndex); + if (!header.toLowerCase().includes(";base64")) { + return null; + } + const payloadMediaType = header.slice("data:".length).split(";")[0]; + if ( + metadata.fileType && + payloadMediaType && + payloadMediaType.toLowerCase() !== metadata.fileType.toLowerCase() + ) { + return null; + } + try { + const binary = atob(payload.slice(commaIndex + 1)); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return new File([bytes], metadata.fileName, { + type: metadata.fileType, + lastModified: metadata.lastModified, + }); + } catch { + return null; + } +}; + +const fileForRecord = (record: ChatDraftAttachmentRecord): File | null => { + if (record.status === "uploaded") { + return new File([], record.fileName, { + type: record.fileType, + lastModified: record.lastModified, + }); + } + return fileFromDataURL(record.payload, getRecordMetadata(record)); +}; + +export const restoreChatDraftAttachments = ( + organizationId: string | undefined, + chatId: string | undefined, +): RestoredChatDraftAttachment[] => { + if (!organizationId || !chatId) { + return []; + } + pruneExpiredChatDraftAttachmentStorageKeys(); + const restored: RestoredChatDraftAttachment[] = []; + const validRecords: ChatDraftAttachmentRecord[] = []; + for (const record of readRecords(organizationId, chatId)) { + const file = fileForRecord(record); + if (!file) { + continue; + } + restored.push({ record, file }); + validRecords.push(record); + } + writeRecords(organizationId, chatId, validRecords); + return restored; +}; + +export const upsertChatDraftAttachmentRecord = ( + record: ChatDraftAttachmentRecord, +): ChatDraftAttachmentPersistResult => { + const recordWithTimestamp = { ...record, updatedAt: Date.now() }; + const records = readRecords(record.organizationId, record.chatId).filter( + (existing) => { + if (existing.clientId === record.clientId) { + return false; + } + return !( + existing.status === "uploaded" && + recordWithTimestamp.status === "uploaded" && + existing.fileId === recordWithTimestamp.fileId + ); + }, + ); + return writeRecords(record.organizationId, record.chatId, [ + ...records, + recordWithTimestamp, + ]); +}; + +export const removeChatDraftAttachmentRecord = ( + organizationId: string, + chatId: string, + clientId: string, +): ChatDraftAttachmentPersistResult => { + const records = readRecords(organizationId, chatId).filter( + (record) => record.clientId !== clientId, + ); + return writeRecords(organizationId, chatId, records); +}; + +export const clearChatDraftAttachmentRecords = ( + organizationId: string, + chatId: string, +): ChatDraftAttachmentPersistResult => writeRecords(organizationId, chatId, []); diff --git a/site/src/pages/AgentsPage/utils/fileAttachmentLimits.ts b/site/src/pages/AgentsPage/utils/fileAttachmentLimits.ts new file mode 100644 index 0000000000000..9911bbef10c73 --- /dev/null +++ b/site/src/pages/AgentsPage/utils/fileAttachmentLimits.ts @@ -0,0 +1,31 @@ +import { getErrorDetail, getErrorMessage } from "#/api/errors"; + +export const maxAgentAttachmentSize = 10 * 1024 * 1024; + +export const formatAgentAttachmentTooLargeError = (fileSize: number): string => + `File too large (${(fileSize / 1024 / 1024).toFixed(1)} MB). Maximum is ${maxAgentAttachmentSize / 1024 / 1024} MB.`; + +export const formatAgentAttachmentUploadError = (error: unknown): string => { + const message = getErrorMessage(error, "Upload failed"); + const detail = getErrorDetail(error); + return detail ? `${message}. ${detail}` : message; +}; + +export const readAgentAttachmentText = (file: File): Promise<string> => { + if (typeof file.text === "function") { + return file.text(); + } + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => + reject(reader.error ?? new Error("Failed to read file content.")); + reader.onload = () => { + if (typeof reader.result === "string") { + resolve(reader.result); + return; + } + reject(new Error("Failed to read file content.")); + }; + reader.readAsText(file); + }); +};