diff --git a/frontend/public/images/google-drive.svg b/frontend/public/images/google-drive.svg
new file mode 100644
index 0000000000..03b2f21290
--- /dev/null
+++ b/frontend/public/images/google-drive.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/core/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx
index ec34d0f0c6..c24f632b38 100644
--- a/frontend/src/core/components/fileEditor/FileEditor.tsx
+++ b/frontend/src/core/components/fileEditor/FileEditor.tsx
@@ -1,4 +1,5 @@
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
+import { flushSync } from "react-dom";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import {
@@ -306,8 +307,23 @@ const FileEditor = ({
// Insert files at the calculated position
newOrder.splice(insertIndex, 0, ...filesToMove);
- // Update file order
- reorderFiles(newOrder);
+ // Animate the reorder using the View Transitions API where available.
+ // Each FileEditorThumbnail carries a stable `view-transition-name`, so
+ // the browser snapshots each card before and after the DOM reorder and
+ // interpolates the positions automatically. `flushSync` forces React to
+ // apply the reorderFiles dispatch synchronously inside the transition
+ // callback so the BEFORE/AFTER snapshots capture the correct frames.
+ const applyReorder = () => reorderFiles(newOrder);
+ const docWithViewTransition = document as Document & {
+ startViewTransition?: (cb: () => void) => unknown;
+ };
+ if (typeof docWithViewTransition.startViewTransition === "function") {
+ docWithViewTransition.startViewTransition(() => {
+ flushSync(applyReorder);
+ });
+ } else {
+ applyReorder();
+ }
// Update status
const moveCount = filesToMove.length;
diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx
index dab8fd7b5b..81ea1d27d3 100644
--- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx
+++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx
@@ -99,6 +99,7 @@ const FileEditorThumbnail = ({
// ---- Drag state ----
const [isDragging, setIsDragging] = useState(false);
+ const [isDragOver, setIsDragOver] = useState(false);
const dragElementRef = useRef(null);
const [showHoverMenu, setShowHoverMenu] = useState(false);
const isMobile = useIsMobile();
@@ -210,7 +211,10 @@ const FileEditorThumbnail = ({
const sourceData = source.data;
return sourceData.type === "file" && sourceData.fileId !== file.id;
},
+ onDragEnter: () => setIsDragOver(true),
+ onDragLeave: () => setIsDragOver(false),
onDrop: ({ source }) => {
+ setIsDragOver(false);
const sourceData = source.data;
if (sourceData.type === "file" && onReorderFiles) {
const sourceFileId = sourceData.fileId as FileId;
@@ -435,7 +439,22 @@ const FileEditorThumbnail = ({
data-selected={isSelected}
data-supported={isSupported}
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
- style={{ opacity: isDragging ? 0.9 : 1 }}
+ style={
+ {
+ opacity: isDragging ? 0.4 : 1,
+ outline: isDragOver
+ ? "3px dashed var(--mantine-color-blue-5, #3b82f6)"
+ : undefined,
+ outlineOffset: isDragOver ? "2px" : undefined,
+ transform: isDragOver ? "scale(1.02)" : undefined,
+ transition:
+ "outline 120ms ease, transform 120ms ease, opacity 120ms ease",
+ // Tag each card with a stable, unique view-transition-name so the
+ // browser can animate the reorder (see FileEditor.handleReorderFiles,
+ // which dispatches reorderFiles inside document.startViewTransition).
+ viewTransitionName: `file-card-${file.id}`,
+ } as React.CSSProperties
+ }
tabIndex={0}
role="listitem"
aria-selected={isSelected}
diff --git a/frontend/src/core/components/fileManager/FileInfoCard.tsx b/frontend/src/core/components/fileManager/FileInfoCard.tsx
index 411e05e966..594442aefb 100644
--- a/frontend/src/core/components/fileManager/FileInfoCard.tsx
+++ b/frontend/src/core/components/fileManager/FileInfoCard.tsx
@@ -277,7 +277,12 @@ const FileInfoCard: React.FC = ({
{t("fileManager.storageState", "Storage")}
-
+
{t("fileManager.localOnly", "Local only")}
diff --git a/frontend/src/core/components/fileManager/FileListItem.tsx b/frontend/src/core/components/fileManager/FileListItem.tsx
index d8c7cb5dd2..88b2b4e58b 100644
--- a/frontend/src/core/components/fileManager/FileListItem.tsx
+++ b/frontend/src/core/components/fileManager/FileListItem.tsx
@@ -270,7 +270,12 @@ const FileListItem: React.FC = ({
: t("storageShare.roleViewer", "Viewer")}
) : isLocalOnly ? (
-
+
{t("fileManager.localOnly", "Local only")}
) : uploadEnabled && isOutOfSync ? (
diff --git a/frontend/src/core/components/shared/LandingPage.css b/frontend/src/core/components/shared/LandingPage.css
index 9b09a0d068..e818217600 100644
--- a/frontend/src/core/components/shared/LandingPage.css
+++ b/frontend/src/core/components/shared/LandingPage.css
@@ -138,3 +138,15 @@
.landing-btn-icon {
color: var(--accent-interactive) !important;
}
+
+/* Dropzone accept/reject outlines. Mantine 8 no longer supports nested
+ * `&[data-accept]` selectors inside the `styles` prop object, so these are
+ * plain CSS attribute selectors on a class applied to the Dropzone root. */
+.landing-dropzone[data-accept] {
+ outline: 2px dashed var(--accent-interactive);
+ outline-offset: 4px;
+}
+.landing-dropzone[data-reject] {
+ outline: 2px dashed var(--mantine-color-red-6);
+ outline-offset: 4px;
+}
diff --git a/frontend/src/core/components/shared/LandingPage.tsx b/frontend/src/core/components/shared/LandingPage.tsx
index 2690463120..e52eb98b56 100644
--- a/frontend/src/core/components/shared/LandingPage.tsx
+++ b/frontend/src/core/components/shared/LandingPage.tsx
@@ -61,20 +61,12 @@ const LandingPage = () => {
activateOnClick={false}
enablePointerEvents
aria-label={terminology.dropFilesHere}
- className="flex min-h-0 flex-1 cursor-default flex-col items-center justify-center border-none bg-transparent px-4 py-8 shadow-none outline-none"
+ className="landing-dropzone flex min-h-0 flex-1 cursor-default flex-col items-center justify-center border-none bg-transparent px-4 py-8 shadow-none outline-none"
styles={{
root: {
border: "none !important",
backgroundColor: "transparent",
overflow: "visible",
- "&[data-accept]": {
- outline: "2px dashed var(--accent-interactive)",
- outlineOffset: 4,
- },
- "&[data-reject]": {
- outline: "2px dashed var(--mantine-color-red-6)",
- outlineOffset: 4,
- },
},
inner: {
overflow: "visible",
diff --git a/frontend/src/core/contexts/FilesModalContext.tsx b/frontend/src/core/contexts/FilesModalContext.tsx
index 3665bbce0f..aec1915954 100644
--- a/frontend/src/core/contexts/FilesModalContext.tsx
+++ b/frontend/src/core/contexts/FilesModalContext.tsx
@@ -382,9 +382,15 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
if (actions.addStirlingFileStubs) {
await actions.addStirlingFileStubs(localStubs, { selectFiles: false });
+ // Union newly picked files with the current selection so tools like
+ // Compare that depend on multi-file selection don't lose existing
+ // selections when the user picks an additional file from the modal.
const requestedIds = localStubs.map((s) => s.id);
+ const currentSelected = fileCtx.selectors
+ .getSelectedStirlingFileStubs()
+ .map((s) => s.id);
const nextSelection = Array.from(
- new Set([...requestedIds, ...selectedFromServer]),
+ new Set([...currentSelected, ...requestedIds, ...selectedFromServer]),
);
actions.setSelectedFiles(nextSelection);
} else {
diff --git a/frontend/src/core/hooks/tools/shared/useBaseTool.ts b/frontend/src/core/hooks/tools/shared/useBaseTool.ts
index cd1194c7e9..df21dc3b3b 100644
--- a/frontend/src/core/hooks/tools/shared/useBaseTool.ts
+++ b/frontend/src/core/hooks/tools/shared/useBaseTool.ts
@@ -48,10 +48,19 @@ export function useBaseTool<
minFiles?: number;
/** When true, uses the full file selection rather than the viewer-scoped single file. */
ignoreViewerScope?: boolean;
+ /**
+ * When true, skips the "reset params on 0→N files" effect. Tools that
+ * manage their own parameter lifecycle (e.g. Compare, which auto-fills slots
+ * from the loaded files) opt out so useBaseTool's reset doesn't race and
+ * clobber the tool's own setParameters.
+ */
+ skipResetParamsOnFirstFiles?: boolean;
},
): BaseToolReturn {
const minFiles = options?.minFiles ?? 1;
const ignoreViewerScope = options?.ignoreViewerScope ?? false;
+ const skipResetParamsOnFirstFiles =
+ options?.skipResetParamsOnFirstFiles ?? false;
const { onPreviewFile, onComplete, onError } = props;
const viewerScopedFiles = useViewScopedFiles(ignoreViewerScope);
@@ -123,12 +132,16 @@ export function useBaseTool<
const currentFileCount = effectiveFiles.length;
const prevFileCount = previousFileCount.current;
- if (prevFileCount === 0 && currentFileCount > 0) {
+ if (
+ prevFileCount === 0 &&
+ currentFileCount > 0 &&
+ !skipResetParamsOnFirstFiles
+ ) {
params.resetParameters();
}
previousFileCount.current = currentFileCount;
- }, [effectiveFiles.length]);
+ }, [effectiveFiles.length, skipResetParamsOnFirstFiles]);
// Standard handlers
const handleExecute = useCallback(async () => {
diff --git a/frontend/src/core/styles/theme.css b/frontend/src/core/styles/theme.css
index 6c7edfae26..46809c07a3 100644
--- a/frontend/src/core/styles/theme.css
+++ b/frontend/src/core/styles/theme.css
@@ -1091,3 +1091,12 @@
fill: #c56565;
fill-opacity: 1;
}
+
+/* Snappier View-Transitions for FileEditor card reordering. Each thumbnail
+ * is tagged with `view-transition-name: file-card-` (see
+ * FileEditorThumbnail.tsx); the browser FLIPs it between old and new
+ * positions on reorder. Override the sluggish 250ms default. */
+::view-transition-group(*) {
+ animation-duration: 160ms;
+ animation-timing-function: cubic-bezier(0.2, 0, 0.2, 1);
+}
diff --git a/frontend/src/core/tests/compare/CompareE2E.spec.ts b/frontend/src/core/tests/compare/CompareE2E.spec.ts
new file mode 100644
index 0000000000..5fc568bacd
--- /dev/null
+++ b/frontend/src/core/tests/compare/CompareE2E.spec.ts
@@ -0,0 +1,305 @@
+/**
+ * End-to-End Tests for Compare Tool
+ *
+ * Regression coverage for the Compare slot auto-fill flow.
+ *
+ * Background: when a user has one file in the workbench and picks a second
+ * file from the "My Files" picker (triggering handleRecentFileSelect), the
+ * existing selection used to be replaced rather than unioned, so the Original
+ * slot jumped to the new file and the Edited slot stayed empty. See fix in
+ * FilesModalContext.handleRecentFileSelect that unions the newly picked IDs
+ * with the current selection.
+ *
+ * The end-to-end invariant this test guards: after two distinct PDFs have been
+ * added to the workbench through the file modal's add buttons, both Compare
+ * slots are populated and the Compare action button is enabled.
+ *
+ * All backend API calls are mocked via page.route() — no real backend required.
+ * The Vite dev server must be running (handled by playwright.config.ts webServer).
+ */
+
+import { test, expect, type Page } from "@playwright/test";
+import path from "path";
+
+const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
+const PDF_A = path.join(FIXTURES_DIR, "compare_sample_a.pdf");
+const PDF_B = path.join(FIXTURES_DIR, "compare_sample_b.pdf");
+
+async function mockAppApis(page: Page) {
+ await page.route("**/api/v1/info/status", (route) =>
+ route.fulfill({ json: { status: "UP" } }),
+ );
+ await page.route("**/api/v1/config/app-config", (route) =>
+ route.fulfill({
+ json: {
+ enableLogin: false,
+ languages: ["en-GB"],
+ defaultLocale: "en-GB",
+ },
+ }),
+ );
+ await page.route("**/api/v1/auth/me", (route) =>
+ route.fulfill({
+ json: {
+ id: 1,
+ username: "testuser",
+ email: "test@example.com",
+ roles: ["ROLE_USER"],
+ },
+ }),
+ );
+ await page.route("**/api/v1/config/endpoints-availability", (route) =>
+ route.fulfill({ json: { compare: { enabled: true } } }),
+ );
+ await page.route("**/api/v1/config/endpoint-enabled*", (route) =>
+ route.fulfill({ json: true }),
+ );
+ await page.route("**/api/v1/config/group-enabled*", (route) =>
+ route.fulfill({ json: true }),
+ );
+ await page.route("**/api/v1/ui-data/footer-info", (route) =>
+ route.fulfill({ json: {} }),
+ );
+ await page.route("**/api/v1/proprietary/**", (route) =>
+ route.fulfill({ json: {} }),
+ );
+}
+
+async function navigateToCompare(page: Page) {
+ await page.locator('[data-tour="tool-button-compare"]').first().click();
+ await page.waitForSelector('[data-testid="compare-slot-base"]', {
+ timeout: 5000,
+ });
+}
+
+async function uploadIntoSlot(
+ page: Page,
+ role: "base" | "comparison",
+ filePath: string,
+ expectedFilename: string,
+) {
+ await page.locator(`[data-testid="compare-slot-${role}-add"]`).click();
+ await page.waitForSelector(".mantine-Modal-overlay", {
+ state: "visible",
+ timeout: 5000,
+ });
+ await page.locator('[data-testid="file-input"]').setInputFiles(filePath);
+ await page.waitForSelector(".mantine-Modal-overlay", {
+ state: "hidden",
+ timeout: 10000,
+ });
+
+ const slot = page.locator(`[data-testid="compare-slot-${role}"]`);
+ await expect(slot).toHaveAttribute("data-slot-state", "filled", {
+ timeout: 10000,
+ });
+ await expect(slot).toHaveAttribute("data-slot-filename", expectedFilename);
+}
+
+test.describe("Compare tool slot selection", () => {
+ test.beforeEach(async ({ page }) => {
+ await mockAppApis(page);
+ await page.goto("/?bypassOnboarding=true");
+ await page.waitForSelector('[data-tour="tool-button-compare"]', {
+ timeout: 10000,
+ });
+ });
+
+ test("Original slot fills when a PDF is dropped via the landing dropzone", async ({
+ page,
+ }) => {
+ // Regression guard for the "middle state" bug: the landing dropzone path
+ // (FileManager's hidden file-input used by the drop-prompt on the empty
+ // workbench) dispatches ADD_FILES and SET_SELECTED_FILES on a different
+ // cadence than the modal-based upload. During the window where files.ids
+ // has the new file but selectedFileIds is still empty, the slot would
+ // show the placeholder unless the auto-fill falls back to allIds.
+ await navigateToCompare(page);
+
+ const baseSlot = page.locator('[data-testid="compare-slot-base"]');
+ await expect(baseSlot).toHaveAttribute("data-slot-state", "empty");
+
+ // Target the visible landing-prompt file input on the empty Compare tool.
+ const visibleFileInput = page.locator(
+ 'input[type="file"]:not([data-testid="file-input"])',
+ );
+ await visibleFileInput.first().setInputFiles(PDF_A);
+
+ // Assert the slot fills within a short window. Without the allIds
+ // fallback the slot stayed on the placeholder for 4+ seconds in manual
+ // testing because the SET_SELECTED_FILES dispatch lagged the ADD_FILES
+ // dispatch by many renders.
+ await expect(baseSlot).toHaveAttribute("data-slot-state", "filled", {
+ timeout: 2000,
+ });
+ await expect(baseSlot).toHaveAttribute(
+ "data-slot-filename",
+ "compare_sample_a.pdf",
+ );
+ });
+
+ test("uploading a PDF via the Original add button fills the Original slot", async ({
+ page,
+ }) => {
+ await navigateToCompare(page);
+
+ const baseSlot = page.locator('[data-testid="compare-slot-base"]');
+ const comparisonSlot = page.locator(
+ '[data-testid="compare-slot-comparison"]',
+ );
+
+ await expect(baseSlot).toHaveAttribute("data-slot-state", "empty");
+ await expect(comparisonSlot).toHaveAttribute("data-slot-state", "empty");
+
+ await uploadIntoSlot(page, "base", PDF_A, "compare_sample_a.pdf");
+
+ await expect(comparisonSlot).toHaveAttribute("data-slot-state", "empty");
+ });
+
+ test("uploading into both slots fills them and enables the Compare button", async ({
+ page,
+ }) => {
+ await navigateToCompare(page);
+
+ // Original slot first
+ await uploadIntoSlot(page, "base", PDF_A, "compare_sample_a.pdf");
+
+ // Edited slot second. This upload goes through handleFileUpload, which
+ // adds via the internal selectFiles: true path (union with existing
+ // selection). If the union breaks — as it did in FilesModalContext before
+ // the fix to handleRecentFileSelect — the Original slot would be clobbered
+ // and the Edited slot would remain empty.
+ await uploadIntoSlot(page, "comparison", PDF_B, "compare_sample_b.pdf");
+
+ // Both slots must remain populated with their respective files.
+ const baseSlot = page.locator('[data-testid="compare-slot-base"]');
+ const comparisonSlot = page.locator(
+ '[data-testid="compare-slot-comparison"]',
+ );
+ await expect(baseSlot).toHaveAttribute("data-slot-state", "filled");
+ await expect(baseSlot).toHaveAttribute(
+ "data-slot-filename",
+ "compare_sample_a.pdf",
+ );
+ await expect(comparisonSlot).toHaveAttribute("data-slot-state", "filled");
+ await expect(comparisonSlot).toHaveAttribute(
+ "data-slot-filename",
+ "compare_sample_b.pdf",
+ );
+
+ // Compare button should be enabled now that both slots are set.
+ const compareButton = page.getByRole("button", { name: "Compare" });
+ await expect(compareButton).toBeEnabled();
+ });
+
+ test("picking a stored file from the recent-files modal unions it with the current selection", async ({
+ page,
+ }) => {
+ // This test specifically guards the handleRecentFileSelect code path in
+ // FilesModalContext. Setup:
+ // 1. Upload PDF_A so it lands in IndexedDB storage.
+ // 2. Reload the page — workbench resets but IndexedDB persists.
+ // 3. Upload PDF_B fresh so only PDF_B is in the workbench and selected.
+ // 4. Open the Files modal and pick PDF_A from "Recent".
+ // Without the fix, step 4 REPLACES the selection with [PDF_A] and the
+ // Compare Original slot jumps to PDF_A while Edited stays empty. With the
+ // fix, the selection becomes [PDF_B, PDF_A] and both slots populate.
+
+ // Step 1 — upload PDF_A so it's persisted in IndexedDB.
+ await navigateToCompare(page);
+ await uploadIntoSlot(page, "base", PDF_A, "compare_sample_a.pdf");
+
+ // Step 2 — reload. Workbench clears but IndexedDB retains PDF_A.
+ // page.goto reuses the mocked routes and re-applies bypassOnboarding.
+ await page.goto("/?bypassOnboarding=true");
+ await page.waitForSelector('[data-tour="tool-button-compare"]', {
+ timeout: 10000,
+ });
+ await navigateToCompare(page);
+ await expect(
+ page.locator('[data-testid="compare-slot-base"]'),
+ ).toHaveAttribute("data-slot-state", "empty");
+
+ // Step 3 — upload PDF_B fresh. Now workbench = [PDF_B], IndexedDB = [A, B].
+ await uploadIntoSlot(page, "base", PDF_B, "compare_sample_b.pdf");
+ await expect(
+ page.locator('[data-testid="compare-slot-comparison"]'),
+ ).toHaveAttribute("data-slot-state", "empty");
+
+ // Step 4 — open the modal from the sidebar Files button and pick PDF_A
+ // from the recent list.
+ await page.getByTestId("files-button").click();
+ await page.waitForSelector(".mantine-Modal-overlay", {
+ state: "visible",
+ timeout: 5000,
+ });
+
+ // The recent-files list renders an entry per stored file. Click the
+ // checkbox for PDF_A (NOT the ACTIVE entry for PDF_B). We target the
+ // label text to disambiguate.
+ const pdfARow = page
+ .locator(".mantine-Modal-root")
+ .locator("text=compare_sample_a.pdf")
+ .first();
+ await pdfARow.click();
+
+ // Confirm selection via "Open File".
+ await page.getByRole("button", { name: "Open File" }).click();
+ await page.waitForSelector(".mantine-Modal-overlay", {
+ state: "hidden",
+ timeout: 10000,
+ });
+
+ // Both slots must be populated. Specifically, the Original slot must still
+ // be PDF_B (the pre-existing selection, not clobbered) and the Edited slot
+ // must be PDF_A (newly unioned in).
+ const baseSlot = page.locator('[data-testid="compare-slot-base"]');
+ const comparisonSlot = page.locator(
+ '[data-testid="compare-slot-comparison"]',
+ );
+ await expect(baseSlot).toHaveAttribute("data-slot-state", "filled", {
+ timeout: 10000,
+ });
+ await expect(comparisonSlot).toHaveAttribute("data-slot-state", "filled", {
+ timeout: 10000,
+ });
+
+ const compareButton = page.getByRole("button", { name: "Compare" });
+ await expect(compareButton).toBeEnabled();
+ });
+
+ test("Clear selected empties both slots, clears the selection banner, and removes files from the workbench", async ({
+ page,
+ }) => {
+ await navigateToCompare(page);
+ await uploadIntoSlot(page, "base", PDF_A, "compare_sample_a.pdf");
+ await uploadIntoSlot(page, "comparison", PDF_B, "compare_sample_b.pdf");
+
+ // Sanity: both slots filled before the clear.
+ await expect(
+ page.locator('[data-testid="compare-slot-base"]'),
+ ).toHaveAttribute("data-slot-state", "filled");
+ await expect(
+ page.locator('[data-testid="compare-slot-comparison"]'),
+ ).toHaveAttribute("data-slot-state", "filled");
+
+ // Open the Clear confirmation modal and confirm.
+ await page.getByRole("button", { name: "Clear selected" }).click();
+ await page.getByRole("button", { name: "Clear and return" }).click();
+
+ // The tool re-mounts on workbench switch; wait for the empty-state base slot.
+ await page.waitForSelector(
+ '[data-testid="compare-slot-base"][data-slot-state="empty"]',
+ { timeout: 10000 },
+ );
+ await expect(
+ page.locator('[data-testid="compare-slot-comparison"]'),
+ ).toHaveAttribute("data-slot-state", "empty");
+
+ // The "N files selected" / "Selected: X" banner must be gone because the
+ // files have been removed from the workbench.
+ await expect(
+ page.getByText(/\d+\s+files?\s+selected|Selected:\s+/i),
+ ).toHaveCount(0);
+ });
+});
diff --git a/frontend/src/core/tests/test-fixtures/compare_sample_a.pdf b/frontend/src/core/tests/test-fixtures/compare_sample_a.pdf
new file mode 100644
index 0000000000..f625c11469
Binary files /dev/null and b/frontend/src/core/tests/test-fixtures/compare_sample_a.pdf differ
diff --git a/frontend/src/core/tests/test-fixtures/compare_sample_b.pdf b/frontend/src/core/tests/test-fixtures/compare_sample_b.pdf
new file mode 100644
index 0000000000..67f895ebf1
--- /dev/null
+++ b/frontend/src/core/tests/test-fixtures/compare_sample_b.pdf
@@ -0,0 +1,74 @@
+%PDF-1.3
+%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
+1 0 obj
+<<
+/F1 2 0 R /F2 3 0 R
+>>
+endobj
+2 0 obj
+<<
+/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
+>>
+endobj
+3 0 obj
+<<
+/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
+>>
+endobj
+4 0 obj
+<<
+/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
+/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
+>> /Rotate 0 /Trans <<
+
+>>
+ /Type /Page
+>>
+endobj
+5 0 obj
+<<
+/PageMode /UseNone /Pages 7 0 R /Type /Catalog
+>>
+endobj
+6 0 obj
+<<
+/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
+ /Subject (unspecified) /Title (untitled) /Trapped /False
+>>
+endobj
+7 0 obj
+<<
+/Count 1 /Kids [ 4 0 R ] /Type /Pages
+>>
+endobj
+8 0 obj
+<<
+/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
+>>
+stream
+GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp3Iif`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^ngmo`i~>endstream
+endobj
+xref
+0 9
+0000000000 65535 f
+0000000073 00000 n
+0000000114 00000 n
+0000000221 00000 n
+0000000333 00000 n
+0000000526 00000 n
+0000000594 00000 n
+0000000890 00000 n
+0000000949 00000 n
+trailer
+<<
+/ID
+[<46f6a3460762da2956d1d3fc19ab996f><46f6a3460762da2956d1d3fc19ab996f>]
+% ReportLab generated PDF document -- digest (http://www.reportlab.com)
+
+/Info 6 0 R
+/Root 5 0 R
+/Size 9
+>>
+startxref
+1186
+%%EOF
diff --git a/frontend/src/core/tools/Compare.tsx b/frontend/src/core/tools/Compare.tsx
index 0bc8052639..c88ee801a9 100644
--- a/frontend/src/core/tools/Compare.tsx
+++ b/frontend/src/core/tools/Compare.tsx
@@ -59,6 +59,9 @@ const Compare = (props: BaseToolProps) => {
{
minFiles: 2,
ignoreViewerScope: true,
+ // Compare auto-fills slots from loaded files via its own effect; the
+ // shared reset races and clobbers those writes.
+ skipResetParamsOnFirstFiles: true,
},
);
@@ -92,6 +95,17 @@ const Compare = (props: BaseToolProps) => {
} catch {
console.error("Failed to clear selections");
}
+ // Also remove the loaded files from the workbench so the selection
+ // indicator resets to zero. Files stay in IndexedDB so users can re-pick
+ // them from "Recent" later.
+ try {
+ const loadedIds = fileState.files.ids as FileId[];
+ if (loadedIds.length > 0) {
+ void fileActions.removeFiles(loadedIds, false);
+ }
+ } catch {
+ console.error("Failed to remove workbench files");
+ }
clearCustomWorkbenchViewData(CUSTOM_VIEW_ID);
navigationActions.setWorkbench(getDefaultWorkbench());
}, [
@@ -99,6 +113,7 @@ const Compare = (props: BaseToolProps) => {
base.params,
clearCustomWorkbenchViewData,
fileActions,
+ fileState.files.ids,
navigationActions,
]);
@@ -168,9 +183,33 @@ const Compare = (props: BaseToolProps) => {
return;
}
- const nextBase = (selectedIds[0] ?? null) as FileId | null;
- const nextComp = (selectedIds[1] ?? null) as FileId | null;
+ // Sticky slot mapping: preserve the current slot if its file is still selected,
+ // otherwise fill empty slots from the selection in order. This lets users add/remove
+ // files from the selection without the other slot being clobbered.
+ //
+ // Fallback: when there's no selection yet but files are loaded in the workbench,
+ // draw from `allIds`. This covers the brief window between ADD_FILES and
+ // SET_SELECTED_FILES during an upload (the selection dispatch can lag the file
+ // dispatch by several renders), which would otherwise leave the slot showing
+ // the "Select the original PDF" placeholder until the selection catches up.
+ const sourceIds = selectedIds.length > 0 ? selectedIds : allIds;
base.params.setParameters((prev) => {
+ const prevBase = prev.baseFileId as FileId | null;
+ const prevComp = prev.comparisonFileId as FileId | null;
+
+ const keepBase =
+ prevBase && sourceIds.includes(prevBase) ? prevBase : null;
+ const nextBase: FileId | null =
+ keepBase ?? ((sourceIds[0] ?? null) as FileId | null);
+
+ const keepComp =
+ prevComp && sourceIds.includes(prevComp) && prevComp !== nextBase
+ ? prevComp
+ : null;
+ const nextComp: FileId | null =
+ keepComp ??
+ ((sourceIds.find((id) => id !== nextBase) ?? null) as FileId | null);
+
if (prev.baseFileId === nextBase && prev.comparisonFileId === nextComp)
return prev;
return { ...prev, baseFileId: nextBase, comparisonFileId: nextComp };
@@ -443,6 +482,8 @@ const Compare = (props: BaseToolProps) => {
return (
{
{shouldShowAddButton && (
{
return (