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:
Michael Suchacz
2026-04-26 03:29:09 +02:00
committed by GitHub
parent 056203f8fc
commit aee85040f0
11 changed files with 2093 additions and 140 deletions
@@ -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);
});
};