mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site/src/pages/AgentsPage): persist chat draft attachments (#24709)
When switching between existing `/agents` chats, unsent file attachments were kept only in React state and were lost when the chat page remounted. This adds chat-scoped draft attachment persistence for the compose input, keeps edit-mode attachments isolated from compose drafts, warns when a file cannot be saved to localStorage, and cleans up restored draft payloads after upload or send. > Mux is acting on Mike's behalf.
This commit is contained in:
@@ -382,6 +382,10 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
content: renderTabContent(tab.id),
|
||||
}));
|
||||
|
||||
const isEditing =
|
||||
editing.editingMessageId !== null ||
|
||||
editing.editingQueuedMessageID !== null;
|
||||
|
||||
const titleElement = (
|
||||
<title>
|
||||
{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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 && (
|
||||
<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 === "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}`}
|
||||
<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"
|
||||
>
|
||||
<XIcon aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{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={() => 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>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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, []);
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user