fix(input): ignore stale attachment reads (#1150)
* fix(input): ignore stale attachment reads * fix(input): centralize attachment replacement * fix(input): route queued attachment restores through store action --------- Co-authored-by: Isaac Sanchez <isanchez-hawkins@arize.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Isaac Sanchez
Bohdan Triapitsyn
parent
01c19706f4
commit
811aa50312
@@ -1646,21 +1646,21 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
if (normalized.includes('payload too large') || normalized.includes('413') || normalized.includes('entity too large')) {
|
if (normalized.includes('payload too large') || normalized.includes('413') || normalized.includes('entity too large')) {
|
||||||
toast.error(t('chat.chatInput.toast.attachmentsTooLarge'));
|
toast.error(t('chat.chatInput.toast.attachmentsTooLarge'));
|
||||||
if (allAttachments.length > 0) {
|
if (allAttachments.length > 0) {
|
||||||
useInputStore.setState({ attachedFiles: allAttachments });
|
useInputStore.getState().setAttachedFiles(allAttachments);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSoftNetworkError) {
|
if (isSoftNetworkError) {
|
||||||
if (allAttachments.length > 0) {
|
if (allAttachments.length > 0) {
|
||||||
useInputStore.setState({ attachedFiles: allAttachments });
|
useInputStore.getState().setAttachedFiles(allAttachments);
|
||||||
toast.error(t('chat.chatInput.toast.sendAttachmentsFailed'));
|
toast.error(t('chat.chatInput.toast.sendAttachmentsFailed'));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allAttachments.length > 0) {
|
if (allAttachments.length > 0) {
|
||||||
useInputStore.setState({ attachedFiles: allAttachments });
|
useInputStore.getState().setAttachedFiles(allAttachments);
|
||||||
}
|
}
|
||||||
toast.error(rawMessage || t('chat.chatInput.toast.messageSendFailed'));
|
toast.error(rawMessage || t('chat.chatInput.toast.messageSendFailed'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,9 +88,7 @@ export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsPro
|
|||||||
if (popped) {
|
if (popped) {
|
||||||
if (popped.attachments && popped.attachments.length > 0) {
|
if (popped.attachments && popped.attachments.length > 0) {
|
||||||
const currentAttachments = useInputStore.getState().attachedFiles;
|
const currentAttachments = useInputStore.getState().attachedFiles;
|
||||||
useInputStore.setState({
|
useInputStore.getState().setAttachedFiles([...currentAttachments, ...popped.attachments]);
|
||||||
attachedFiles: [...currentAttachments, ...popped.attachments]
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
onEditMessage(popped.content, popped.attachments);
|
onEditMessage(popped.content, popped.attachments);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { beforeEach, describe, expect, test } from "bun:test"
|
||||||
|
import { useInputStore } from "./input-store"
|
||||||
|
|
||||||
|
class MockFileReader {
|
||||||
|
result: string | ArrayBuffer | null = null
|
||||||
|
onload: ((this: FileReader, event: ProgressEvent<FileReader>) => unknown) | null = null
|
||||||
|
|
||||||
|
readAsDataURL() {
|
||||||
|
pendingReaders.push(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingReaders: MockFileReader[] = []
|
||||||
|
|
||||||
|
const resolveReader = (reader: MockFileReader, result: string) => {
|
||||||
|
reader.result = result
|
||||||
|
reader.onload?.call(reader as unknown as FileReader, {} as ProgressEvent<FileReader>)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("input-store attachments", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
pendingReaders.length = 0
|
||||||
|
globalThis.FileReader = MockFileReader as unknown as typeof FileReader
|
||||||
|
useInputStore.setState({
|
||||||
|
pendingInputText: null,
|
||||||
|
pendingInputMode: "replace",
|
||||||
|
pendingSyntheticParts: null,
|
||||||
|
activeEditorFile: null,
|
||||||
|
})
|
||||||
|
useInputStore.getState().setAttachedFiles([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not attach a local file that finishes reading after attachments are cleared", async () => {
|
||||||
|
const addPromise = useInputStore.getState().addAttachedFile(new File(["hello"], "hello.txt", { type: "text/plain" }))
|
||||||
|
expect(pendingReaders).toHaveLength(1)
|
||||||
|
|
||||||
|
useInputStore.getState().clearAttachedFiles()
|
||||||
|
resolveReader(pendingReaders[0], "data:text/plain;base64,aGVsbG8=")
|
||||||
|
await addPromise
|
||||||
|
|
||||||
|
expect(useInputStore.getState().attachedFiles).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not attach a local file after attached files are replaced", async () => {
|
||||||
|
const addPromise = useInputStore.getState().addAttachedFile(new File(["hello"], "hello.txt", { type: "text/plain" }))
|
||||||
|
expect(pendingReaders).toHaveLength(1)
|
||||||
|
|
||||||
|
useInputStore.getState().setAttachedFiles([])
|
||||||
|
resolveReader(pendingReaders[0], "data:text/plain;base64,aGVsbG8=")
|
||||||
|
await addPromise
|
||||||
|
|
||||||
|
expect(useInputStore.getState().attachedFiles).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not attach a local file after attached files are restored", async () => {
|
||||||
|
const addPromise = useInputStore.getState().addAttachedFile(new File(["hello"], "hello.txt", { type: "text/plain" }))
|
||||||
|
expect(pendingReaders).toHaveLength(1)
|
||||||
|
|
||||||
|
const restored = new File(["restored"], "restored.txt", { type: "text/plain" })
|
||||||
|
useInputStore.getState().setAttachedFiles([{
|
||||||
|
id: "restored",
|
||||||
|
file: restored,
|
||||||
|
dataUrl: "data:text/plain;base64,cmVzdG9yZWQ=",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
filename: "restored.txt",
|
||||||
|
size: restored.size,
|
||||||
|
source: "local",
|
||||||
|
}])
|
||||||
|
resolveReader(pendingReaders[0], "data:text/plain;base64,aGVsbG8=")
|
||||||
|
await addPromise
|
||||||
|
|
||||||
|
expect(useInputStore.getState().attachedFiles.map((file) => file.filename)).toEqual(["restored.txt"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not attach a VS Code selection that finishes reading after attachments are cleared", async () => {
|
||||||
|
const addPromise = useInputStore.getState().addVSCodeSelectionAttachment(
|
||||||
|
"/workspace/hello.txt",
|
||||||
|
new File(["hello"], "hello.txt", { type: "text/plain" })
|
||||||
|
)
|
||||||
|
expect(pendingReaders).toHaveLength(1)
|
||||||
|
|
||||||
|
useInputStore.getState().clearAttachedFiles()
|
||||||
|
resolveReader(pendingReaders[0], "data:text/plain;base64,aGVsbG8=")
|
||||||
|
await addPromise
|
||||||
|
|
||||||
|
expect(useInputStore.getState().attachedFiles).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ import type { AttachedFile } from "@/stores/types/sessionTypes"
|
|||||||
|
|
||||||
const FILE_URI_PREFIX = "file://"
|
const FILE_URI_PREFIX = "file://"
|
||||||
const pendingVSCodeSelectionKeys = new Set<string>()
|
const pendingVSCodeSelectionKeys = new Set<string>()
|
||||||
|
let attachmentReadGeneration = 0
|
||||||
|
|
||||||
const encodeFilePath = (filepath: string): string => {
|
const encodeFilePath = (filepath: string): string => {
|
||||||
let normalized = filepath.replace(/\\/g, "/")
|
let normalized = filepath.replace(/\\/g, "/")
|
||||||
@@ -72,6 +73,7 @@ export type InputState = {
|
|||||||
consumePendingSyntheticParts: () => SyntheticContextPart[] | null
|
consumePendingSyntheticParts: () => SyntheticContextPart[] | null
|
||||||
addAttachedFile: (file: File) => Promise<void>
|
addAttachedFile: (file: File) => Promise<void>
|
||||||
removeAttachedFile: (id: string) => void
|
removeAttachedFile: (id: string) => void
|
||||||
|
setAttachedFiles: (files: AttachedFile[]) => void
|
||||||
clearAttachedFiles: () => void
|
clearAttachedFiles: () => void
|
||||||
addVSCodeFileAttachment: (path: string, name: string, fileSize: number | null) => void
|
addVSCodeFileAttachment: (path: string, name: string, fileSize: number | null) => void
|
||||||
addVSCodeSelectionAttachment: (path: string, file: File) => Promise<void>
|
addVSCodeSelectionAttachment: (path: string, file: File) => Promise<void>
|
||||||
@@ -107,11 +109,13 @@ export const useInputStore = create<InputState>()((set, get) => ({
|
|||||||
|
|
||||||
addAttachedFile: async (file: File) => {
|
addAttachedFile: async (file: File) => {
|
||||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
const generation = attachmentReadGeneration
|
||||||
const dataUrl = await new Promise<string>((resolve) => {
|
const dataUrl = await new Promise<string>((resolve) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = () => resolve(reader.result as string)
|
reader.onload = () => resolve(reader.result as string)
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
})
|
})
|
||||||
|
if (generation !== attachmentReadGeneration) return
|
||||||
const attached: AttachedFile = {
|
const attached: AttachedFile = {
|
||||||
id,
|
id,
|
||||||
file,
|
file,
|
||||||
@@ -127,7 +131,15 @@ export const useInputStore = create<InputState>()((set, get) => ({
|
|||||||
removeAttachedFile: (id) =>
|
removeAttachedFile: (id) =>
|
||||||
set((s) => ({ attachedFiles: s.attachedFiles.filter((f) => f.id !== id) })),
|
set((s) => ({ attachedFiles: s.attachedFiles.filter((f) => f.id !== id) })),
|
||||||
|
|
||||||
clearAttachedFiles: () => set({ attachedFiles: [] }),
|
setAttachedFiles: (files) => {
|
||||||
|
attachmentReadGeneration += 1
|
||||||
|
set({ attachedFiles: files })
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAttachedFiles: () => {
|
||||||
|
attachmentReadGeneration += 1
|
||||||
|
set({ attachedFiles: [] })
|
||||||
|
},
|
||||||
|
|
||||||
addVSCodeFileAttachment: (path: string, name: string, fileSize: number | null) => {
|
addVSCodeFileAttachment: (path: string, name: string, fileSize: number | null) => {
|
||||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
@@ -155,6 +167,7 @@ export const useInputStore = create<InputState>()((set, get) => ({
|
|||||||
|
|
||||||
addVSCodeSelectionAttachment: async (path: string, file: File) => {
|
addVSCodeSelectionAttachment: async (path: string, file: File) => {
|
||||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
const generation = attachmentReadGeneration
|
||||||
const selectionKey = getVSCodeSelectionKey(path, file.name)
|
const selectionKey = getVSCodeSelectionKey(path, file.name)
|
||||||
const isDuplicate = get().attachedFiles.some(
|
const isDuplicate = get().attachedFiles.some(
|
||||||
(f) => f.source === 'vscode' && f.vscodeSource === 'selection' && f.filename === file.name && f.vscodePath === path
|
(f) => f.source === 'vscode' && f.vscodeSource === 'selection' && f.filename === file.name && f.vscodePath === path
|
||||||
@@ -171,6 +184,7 @@ export const useInputStore = create<InputState>()((set, get) => ({
|
|||||||
} finally {
|
} finally {
|
||||||
pendingVSCodeSelectionKeys.delete(selectionKey)
|
pendingVSCodeSelectionKeys.delete(selectionKey)
|
||||||
}
|
}
|
||||||
|
if (generation !== attachmentReadGeneration) return
|
||||||
const attached: AttachedFile = {
|
const attached: AttachedFile = {
|
||||||
id,
|
id,
|
||||||
file,
|
file,
|
||||||
|
|||||||
Reference in New Issue
Block a user