diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 4ad681e9..076ac900 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -21,6 +21,9 @@ export interface AttachedFile { serverPath?: string; vscodePath?: string; vscodeSource?: 'file' | 'selection'; + /** Shared ID linking entries extracted from the same document (PPTX, DOCX, etc.). + * Removing any entry with this ID cascades to all entries in the group. */ + sourceDocumentId?: string; } export type EditPermissionMode = 'allow' | 'ask' | 'deny' | 'full'; diff --git a/packages/ui/src/sync/input-store.test.ts b/packages/ui/src/sync/input-store.test.ts index db6a0740..33880228 100644 --- a/packages/ui/src/sync/input-store.test.ts +++ b/packages/ui/src/sync/input-store.test.ts @@ -276,4 +276,98 @@ describe("input-store attachments", () => { const textAttachment = useInputStore.getState().attachedFiles[1] expect((await textAttachment?.file.text())?.includes("[design-image-2.png]")).toBe(true) }) + + testWithMockFileReader("extracted document entries share a sourceDocumentId for cascade removal", async () => { + const archive = zipSync({ + "word/document.xml": strToU8(`Diagram`), + "word/_rels/document.xml.rels": strToU8(``), + "word/media/image.png": pngBytes, + }) + const addPromise = useInputStore.getState().addAttachedFile(new File([archive], "design.docx")) + + await waitForReaderCount(1) + resolveReader(pendingReaders[0], "data:text/plain;base64,RG9jdW1lbnQ=") + await waitForReaderCount(2) + resolveReader(pendingReaders[1], "data:image/png;base64,AQID") + + expect(await addPromise).toBe(true) + const files = useInputStore.getState().attachedFiles + expect(files).toHaveLength(2) + expect(files[0].filename).toBe("design.docx") + expect(files[1].filename).toBe("design-image-1.png") + + // All entries from the same document extraction share the same sourceDocumentId + expect(files[0].sourceDocumentId).toBeDefined() + expect(files[0].sourceDocumentId).toBe(files[1].sourceDocumentId) + + // Removing any entry in the group cascade-removes all entries + useInputStore.getState().removeAttachedFile(files[0].id) + expect(useInputStore.getState().attachedFiles).toEqual([]) + }) + + testWithMockFileReader("removing an extracted image child also cascade-removes the document group", async () => { + const archive = zipSync({ + "word/document.xml": strToU8(`Diagram`), + "word/_rels/document.xml.rels": strToU8(``), + "word/media/image.png": pngBytes, + }) + const addPromise = useInputStore.getState().addAttachedFile(new File([archive], "design.docx")) + + await waitForReaderCount(1) + resolveReader(pendingReaders[0], "data:text/plain;base64,RG9jdW1lbnQ=") + await waitForReaderCount(2) + resolveReader(pendingReaders[1], "data:image/png;base64,AQID") + + expect(await addPromise).toBe(true) + const files = useInputStore.getState().attachedFiles + expect(files).toHaveLength(2) + expect(files[0].filename).toBe("design.docx") + expect(files[1].filename).toBe("design-image-1.png") + + // Removing the image child also cascade-removes the entire group + useInputStore.getState().removeAttachedFile(files[1].id) + expect(useInputStore.getState().attachedFiles).toEqual([]) + }) + + testWithMockFileReader("non-document attachments do not have sourceDocumentId and remove individually", async () => { + const addPromise = useInputStore.getState().addAttachedFile( + new File(["hello"], "hello.txt", { type: "text/plain" }) + ) + expect(pendingReaders).toHaveLength(1) + resolveReader(pendingReaders[0], "data:text/plain;base64,aGVsbG8=") + + expect(await addPromise).toBe(true) + const files = useInputStore.getState().attachedFiles + expect(files).toHaveLength(1) + expect(files[0].sourceDocumentId).toBeUndefined() + + useInputStore.getState().removeAttachedFile(files[0].id) + expect(useInputStore.getState().attachedFiles).toEqual([]) + }) + + testWithMockFileReader("PPTX slide extraction cascades removal of all slide images", async () => { + const archive = zipSync({ + "ppt/slides/slide1.xml": strToU8(``), + "ppt/slides/_rels/slide1.xml.rels": strToU8(``), + "ppt/media/image1.png": pngBytes, + }) + const addPromise = useInputStore.getState().addAttachedFile(new File([archive], "deck.pptx")) + + await waitForReaderCount(1) + resolveReader(pendingReaders[0], "data:text/plain;base64,RG9jdW1lbnQ=") + await waitForReaderCount(2) + resolveReader(pendingReaders[1], "data:image/png;base64,AQID") + + expect(await addPromise).toBe(true) + const files = useInputStore.getState().attachedFiles + expect(files).toHaveLength(2) + expect(files[0].filename).toBe("deck.pptx") + expect(files[1].filename).toBe("deck-image-1.png") + expect(files[0].sourceDocumentId).toBeDefined() + expect(files[0].sourceDocumentId).toBe(files[1].sourceDocumentId) + + // Removing the text entry cascades to the slide image + useInputStore.getState().removeAttachedFile(files[0].id) + expect(useInputStore.getState().attachedFiles).toEqual([]) + }) }) diff --git a/packages/ui/src/sync/input-store.ts b/packages/ui/src/sync/input-store.ts index 8f4d0635..9cc095c4 100644 --- a/packages/ui/src/sync/input-store.ts +++ b/packages/ui/src/sync/input-store.ts @@ -175,6 +175,8 @@ export const useInputStore = create()((set, get) => ({ if (hasGeneratedFilenameCollision(generatedFilenames, get().attachedFiles)) continue const attachedFiles: AttachedFile[] = [] + const isDocumentExtraction = preparedFiles.length > 1 + const sourceDocumentId = isDocumentExtraction ? `${Date.now()}-${Math.random().toString(36).slice(2)}` : undefined for (const prepared of preparedFiles) { let dataUrl: string try { @@ -191,6 +193,7 @@ export const useInputStore = create()((set, get) => ({ filename: prepared.file.name, size: prepared.file.size, source: "local", + sourceDocumentId, }) } @@ -202,7 +205,13 @@ export const useInputStore = create()((set, get) => ({ }, removeAttachedFile: (id) => - set((s) => ({ attachedFiles: s.attachedFiles.filter((f) => f.id !== id) })), + set((s) => { + const target = s.attachedFiles.find((f) => f.id === id) + if (target?.sourceDocumentId) { + return { attachedFiles: s.attachedFiles.filter((f) => f.sourceDocumentId !== target.sourceDocumentId) } + } + return { attachedFiles: s.attachedFiles.filter((f) => f.id !== id) } + }), setAttachedFiles: (files) => { attachmentReadGeneration += 1