From e2536daeb80b5f02ce94a0bf767e26f464534a7c Mon Sep 17 00:00:00 2001
From: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
Date: Wed, 10 Jun 2026 11:18:14 +0100
Subject: [PATCH] Feature/v2/smartfolders rebuild (#6480)
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
---
.../public/locales/en-GB/translation.toml | 182 +-
frontend/editor/public/sw-folder-retry.js | 88 +
.../src/core/components/AppProviders.tsx | 5 +-
.../src/core/components/layout/Workbench.tsx | 6 +-
.../core/components/shared/FileSidebar.tsx | 143 +-
.../components/shared/FileSidebarFileItem.css | 64 +-
.../components/shared/FileSidebarFileItem.tsx | 86 +-
.../core/components/shared/WorkbenchBar.tsx | 44 +-
.../tools/automate/AutomationCreation.tsx | 188 +-
.../watchedFolders/watchedFolderDragState.ts | 23 +
.../editor/src/core/constants/featureFlags.ts | 14 +
.../src/core/contexts/FolderFileContext.tsx | 17 +
.../src/core/hooks/useAllWatchedFolders.ts | 33 +
.../src/core/hooks/useCardModalAnimation.ts | 72 +
.../src/core/hooks/useFolderMembership.ts | 57 +
.../core/services/watchedFolderFileStorage.ts | 214 ++
.../src/core/services/watchedFolderStorage.ts | 168 ++
frontend/editor/src/core/styles/theme.css | 4 +
.../core/tests/live/watched-folders.spec.ts | 577 ++++
.../editor/src/core/types/watchedFolders.ts | 63 +
.../src/core/utils/fsAccessCapability.ts | 31 +
frontend/editor/src/proprietary/App.tsx | 3 +
.../watchedFolders/CardExpansionModal.tsx | 278 ++
.../DeleteFolderConfirmModal.tsx | 56 +
.../watchedFolders/FilePreviewModal.tsx | 101 +
.../components/watchedFolders/IconPicker.tsx | 7 +
.../components/watchedFolders/StatCard.tsx | 72 +
.../watchedFolders/WatchedFolderCard.tsx | 159 ++
.../watchedFolders/WatchedFolderHomePage.tsx | 679 +++++
.../WatchedFolderManagementModal.tsx | 911 ++++++
.../watchedFolders/WatchedFolderSection.tsx | 169 ++
.../WatchedFolderWorkbenchView.tsx | 2442 +++++++++++++++++
.../watchedFolders/WatchedFolders.css | 254 ++
.../WatchedFoldersRegistration.tsx | 54 +
.../src/proprietary/constants/featureFlags.ts | 15 +
.../contexts/FolderFileContext.tsx | 75 +
.../proprietary/data/watchedFolderPresets.ts | 120 +
.../proprietary/hooks/useFolderAutomation.ts | 416 +++
.../src/proprietary/hooks/useFolderData.ts | 137 +
.../proprietary/hooks/useFolderOutputIds.ts | 47 +
.../proprietary/hooks/useFolderRunState.ts | 58 +
.../proprietary/hooks/useFolderRunStatuses.ts | 108 +
.../proprietary/hooks/useLocalFolderPoller.ts | 154 ++
.../hooks/useWatchedFolderUrlSync.ts | 199 ++
.../proprietary/hooks/useWatchedFolders.ts | 112 +
.../services/folderDirectoryHandleStorage.ts | 160 ++
.../services/folderRetryScheduleStorage.ts | 166 ++
.../services/folderRunStateStorage.ts | 154 ++
.../services/folderSeenFilesStorage.ts | 84 +
49 files changed, 9166 insertions(+), 103 deletions(-)
create mode 100644 frontend/editor/public/sw-folder-retry.js
create mode 100644 frontend/editor/src/core/components/watchedFolders/watchedFolderDragState.ts
create mode 100644 frontend/editor/src/core/constants/featureFlags.ts
create mode 100644 frontend/editor/src/core/contexts/FolderFileContext.tsx
create mode 100644 frontend/editor/src/core/hooks/useAllWatchedFolders.ts
create mode 100644 frontend/editor/src/core/hooks/useCardModalAnimation.ts
create mode 100644 frontend/editor/src/core/hooks/useFolderMembership.ts
create mode 100644 frontend/editor/src/core/services/watchedFolderFileStorage.ts
create mode 100644 frontend/editor/src/core/services/watchedFolderStorage.ts
create mode 100644 frontend/editor/src/core/tests/live/watched-folders.spec.ts
create mode 100644 frontend/editor/src/core/types/watchedFolders.ts
create mode 100644 frontend/editor/src/core/utils/fsAccessCapability.ts
create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/CardExpansionModal.tsx
create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/DeleteFolderConfirmModal.tsx
create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/FilePreviewModal.tsx
create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/IconPicker.tsx
create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/StatCard.tsx
create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderCard.tsx
create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderHomePage.tsx
create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderManagementModal.tsx
create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderSection.tsx
create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderWorkbenchView.tsx
create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/WatchedFolders.css
create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/WatchedFoldersRegistration.tsx
create mode 100644 frontend/editor/src/proprietary/constants/featureFlags.ts
create mode 100644 frontend/editor/src/proprietary/contexts/FolderFileContext.tsx
create mode 100644 frontend/editor/src/proprietary/data/watchedFolderPresets.ts
create mode 100644 frontend/editor/src/proprietary/hooks/useFolderAutomation.ts
create mode 100644 frontend/editor/src/proprietary/hooks/useFolderData.ts
create mode 100644 frontend/editor/src/proprietary/hooks/useFolderOutputIds.ts
create mode 100644 frontend/editor/src/proprietary/hooks/useFolderRunState.ts
create mode 100644 frontend/editor/src/proprietary/hooks/useFolderRunStatuses.ts
create mode 100644 frontend/editor/src/proprietary/hooks/useLocalFolderPoller.ts
create mode 100644 frontend/editor/src/proprietary/hooks/useWatchedFolderUrlSync.ts
create mode 100644 frontend/editor/src/proprietary/hooks/useWatchedFolders.ts
create mode 100644 frontend/editor/src/proprietary/services/folderDirectoryHandleStorage.ts
create mode 100644 frontend/editor/src/proprietary/services/folderRetryScheduleStorage.ts
create mode 100644 frontend/editor/src/proprietary/services/folderRunStateStorage.ts
create mode 100644 frontend/editor/src/proprietary/services/folderSeenFilesStorage.ts
diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml
index 719509b71..39db57af9 100644
--- a/frontend/editor/public/locales/en-GB/translation.toml
+++ b/frontend/editor/public/locales/en-GB/translation.toml
@@ -3203,7 +3203,7 @@ creditsRemaining = "{{current}} of {{total}} remaining"
creditsThisMonth = "Monthly credits"
current = "Current Plan"
customPricing = "Custom"
-customSmartFolders = "Custom Smart Folders"
+customWatchedFolders = "Custom Watched Folders"
dedicatedSupportSlas = "Dedicated support & SLAs"
enterpriseSubscription = "Enterprise"
everythingInCredits = "Everything in Credits, plus:"
@@ -3511,6 +3511,7 @@ addFiles = "Add Files"
[fileManager]
active = "Active"
+addToWatchedFolder = "Add to Watched Folder"
addToUpload = "Add to Upload"
changesNotUploaded = "Changes not uploaded"
clearAll = "Clear All"
@@ -5773,6 +5774,7 @@ label = "SMTP Username"
[quickAccess]
access = "Access"
+watchedFolders = "Watched Folders"
accessAddPerson = "Add another person"
accessBack = "Back"
accessCopyLink = "Copy link"
@@ -8150,6 +8152,184 @@ confirm = "Extract"
message = "This ZIP contains {{count}} files. Extract anyway?"
title = "Large ZIP File"
+[watchedFolders]
+alreadyInFolder = "Already in this folder"
+deleteConfirmBody = "This will remove the folder and its run history. Files already downloaded are not affected."
+deleteConfirmTitle = "Delete folder?"
+defaultFolderWarning = "This is a default folder and will be recreated on next reload."
+folderNotFound = "Folder not found"
+newFolder = "New folder"
+noFolders = "No watched folders yet"
+sidebarTitle = "Watched Folders"
+title = "Watched Folders"
+sidebarFiles = "My Files"
+
+[watchedFolders.modal]
+automation = "Automation"
+automationSaved = "Automation saved"
+createFolder = "Create Folder"
+stepsSaved = "Steps saved — click Create Folder to finish"
+automationRequired = "Add at least one configured step before saving."
+color = "Accent color"
+createTitle = "New Watched Folder"
+description = "Description"
+descriptionPlaceholder = "What does this folder do?"
+editTitle = "Edit Watched Folder"
+icon = "Icon"
+name = "Folder name"
+namePlaceholder = "My Watched Folder"
+nameRequired = "Folder name is required"
+nameTooLong = "Folder name must be 50 characters or less"
+saveChanges = "Save Changes"
+saveFailed = "Failed to save folder. Please try again."
+automationNameFallback = "Watched Folder automation"
+retryLabel = "Auto-retry"
+maxRetries = "Max retries"
+maxRetriesDesc = "0 to disable"
+retryDelay = "Delay (minutes)"
+outputLabel = "Output"
+outputModeVersion = "Replace original?"
+outputModeVersionDesc = "Output becomes a new version of the input file rather than a separate file"
+outputModeNewDesc = "Output is saved as a new separate file"
+outputName = "Output filename prefix"
+outputNameSuffix = "Suffix"
+outputNamePlaceholderVersion = "Same as original"
+sectionFolder = "Folder"
+sectionSourceOutput = "Source & Output"
+sectionSteps = "Steps"
+inputSource = "Input source"
+inputSourceBrowser = "Browser — drop files here"
+inputSourceLocal = "Local folder (auto-scan)"
+inputSourceLocalUnsupported = "Local folder (auto-scan) — Chrome/Edge only"
+inputFolder = "Input folder"
+inputFolderNotChosen = "No folder chosen — required for auto-scan"
+changeFolder = "Change"
+chooseFolder = "Choose"
+clearFolder = "Clear"
+autoScanHelp = "New PDF files in this folder are processed automatically every 10 seconds."
+localOutputFolder = "Local output folder"
+outputFolderUnsupported = "Not supported in this browser"
+outputFolderNotSet = "Not set — outputs stay in app"
+advanced = "Advanced"
+replaceOriginal = "Replace original file"
+autoNumber = "Auto-number"
+autoNumberExample = "e.g. document.pdf → document (1).pdf"
+outputNamePrefix = "Filename prefix"
+positionPrefix = "Prefix"
+positionSuffix = "Suffix"
+
+[watchedFolders.home]
+create = "Create your first Watched Folder"
+dropHere = "Drop to process"
+editFolder = "Edit folder"
+empty = "No watched folders yet"
+file = "file"
+files = "files"
+openFolder = "Open folder"
+title = "Watched Folders"
+subtitle = "Folders that automatically process PDFs with your configured pipeline"
+emptyTitle = "Automate your PDF workflows"
+emptyDesc = "Set up a Watched Folder once. Drop PDFs in and they're automatically compressed, OCR'd, split, merged — whatever your pipeline does."
+addAnother = "Add another Watched Folder"
+addAnotherDesc = "Automatically process files with a new pipeline"
+resume = "Resume"
+pause = "Pause"
+deleteFolder = "Delete folder"
+noSteps = "No automation steps configured"
+
+[watchedFolders.card]
+edit = "Edit folder"
+delete = "Delete folder"
+
+[watchedFolders.status]
+done = "Done"
+processing = "Processing"
+paused = "Paused"
+active = "Active"
+
+[watchedFolders.howItWorks]
+title = "How Watched Folders work"
+step1Title = "Drop files"
+step1Desc = "Drag PDFs onto any Watched Folder card — or send them from your file list"
+step2Title = "Pipeline runs"
+step2Desc = "Your configured tools process each file automatically"
+step3Title = "Output ready"
+step3Desc = "Download processed files from inside the folder"
+
+[watchedFolders.workbench]
+addFiles = "Add files"
+inputs = "Inputs"
+outputs = "Outputs"
+failed = "Failed"
+dataSaved = "Saved"
+running = "Running"
+dropToProcess = "Drop to process"
+activity = "Activity"
+noActivity = "No activity yet — drop a PDF to start"
+noActivityMatch = "No matching activity"
+retryAll = "Retry all"
+retryIn = "retry in {{count}}m"
+retryingSoon = "retrying…"
+pause = "Pause"
+resume = "Resume"
+dayRunning = "day running"
+daysRunning = "days running"
+sortNewest = "Newest"
+sortOldest = "Oldest"
+sortNameAsc = "A → Z"
+sortNameDesc = "Z → A"
+filterAll = "All"
+filterPending = "Pending"
+search = "Search…"
+retryMixedConfirm = "Some selected files have already completed and will be skipped. Only failed files will be retried. Continue?"
+watching = "Watching: {{name}}"
+noInputFolder = "No input folder — edit to configure"
+countProcessing = "{{count}} processing"
+countQueued = "{{count}} queued"
+exportZip = "Export zip"
+exportSeparately = "Export separately"
+countSelected = "{{count}} selected"
+selectAll = "Select all {{count}}"
+delete = "Delete"
+clearSelection = "Clear selection"
+preview = "Preview"
+export = "Export"
+previewInput = "Preview input"
+previewOutput = "Preview output"
+directionIn = "in"
+directionOut = "out"
+queuedWaiting = "Queued — waiting to run"
+allTime = "All time"
+queued = "Queued"
+inputSize = "Input size"
+filesProcessedOverTime = "Files processed over time"
+legendComplete = "Complete"
+chartTooltipComplete = "{{count}} complete"
+chartTooltipFailed = "{{count}} failed"
+removeEntry = "Remove entry"
+removeEntries = "Remove {{count}} entries"
+deleteConfirmBody = "Remove notifications only clears the activity log. Delete outputs also removes the processed files from storage. Your original input files are never touched."
+cancel = "Cancel"
+removeNotificationsOnly = "Remove notifications only"
+deleteOutputs = "Delete outputs"
+previewLoadFailed = "Could not load file preview."
+
+[watchedFolders.actions]
+back = "Back"
+dismiss = "Dismiss"
+retry = "Retry"
+view = "View"
+download = "Download"
+downloadInput = "Download input"
+downloadOutput = "Download output"
+collapse = "Collapse"
+expand = "Expand"
+
+[watchedFolders.time]
+justNow = "just now"
+minutesAgo = "{{count}}m ago"
+hoursAgo = "{{count}}h ago"
+daysAgo = "{{count}}d ago"
[addStamp]
tags = "stamp,mark,seal,approved,rejected,confidential,stamp tool,rubber stamp,date stamp,approval stamp,received,void,copy,original"
diff --git a/frontend/editor/public/sw-folder-retry.js b/frontend/editor/public/sw-folder-retry.js
new file mode 100644
index 000000000..fbf0ed1fd
--- /dev/null
+++ b/frontend/editor/public/sw-folder-retry.js
@@ -0,0 +1,88 @@
+/**
+ * Service worker for Watched Folder retry scheduling.
+ *
+ * Reads the earliest pending retry from IndexedDB and sets a setTimeout for it.
+ * When the timer fires it posts PROCESS_DUE_RETRIES to all window clients so
+ * the main thread can atomically claim and process the due entries.
+ *
+ * Limitations:
+ * - Browsers may terminate idle service workers after ~30 s. The main thread
+ * therefore also drains due retries on mount and on visibilitychange as a
+ * fallback — no retries are lost, they may just fire slightly late.
+ * - Multiple clients each post a SCHEDULE_RETRY message; the SW deduplicates
+ * by resetting the timer each time, so only one notification is sent.
+ */
+
+const DB_NAME = "stirling-pdf-retry-schedule";
+const STORE_NAME = "retries";
+
+let retryTimer = null;
+
+self.addEventListener("install", () => self.skipWaiting());
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(self.clients.claim().then(scheduleNextTimer));
+});
+
+self.addEventListener("message", (event) => {
+ if (event.data?.type === "SCHEDULE_RETRY") {
+ scheduleNextTimer();
+ }
+});
+
+function openDB() {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(DB_NAME, 1);
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(new Error("SW: failed to open retry DB"));
+ // Create store if this SW activates before the main thread has opened the DB
+ req.onupgradeneeded = (event) => {
+ const db = event.target.result;
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ const store = db.createObjectStore(STORE_NAME, { keyPath: "id" });
+ store.createIndex("dueAt", "dueAt", { unique: false });
+ }
+ };
+ });
+}
+
+async function getEarliestDueAt() {
+ try {
+ const db = await openDB();
+ return new Promise((resolve) => {
+ const tx = db.transaction([STORE_NAME], "readonly");
+ const req = tx.objectStore(STORE_NAME).index("dueAt").openCursor();
+ req.onsuccess = () => resolve(req.result ? req.result.value.dueAt : null);
+ req.onerror = () => resolve(null);
+ });
+ } catch {
+ return null;
+ }
+}
+
+async function notifyClients() {
+ const clients = await self.clients.matchAll({
+ type: "window",
+ includeUncontrolled: true,
+ });
+ for (const client of clients) {
+ client.postMessage({ type: "PROCESS_DUE_RETRIES" });
+ }
+}
+
+async function scheduleNextTimer() {
+ if (retryTimer !== null) {
+ clearTimeout(retryTimer);
+ retryTimer = null;
+ }
+ const earliest = await getEarliestDueAt();
+ if (earliest === null) return;
+
+ const delay = Math.max(0, earliest - Date.now());
+ retryTimer = setTimeout(async () => {
+ retryTimer = null;
+ await notifyClients();
+ // Re-schedule for any remaining entries that were not yet due
+ await scheduleNextTimer();
+ }, delay);
+}
diff --git a/frontend/editor/src/core/components/AppProviders.tsx b/frontend/editor/src/core/components/AppProviders.tsx
index 6c0010945..d9f4864d8 100644
--- a/frontend/editor/src/core/components/AppProviders.tsx
+++ b/frontend/editor/src/core/components/AppProviders.tsx
@@ -33,6 +33,7 @@ import AppConfigLoader from "@app/components/shared/AppConfigLoader";
import { UpdateStartupPopup } from "@app/components/shared/UpdateStartupPopup";
import { RedactionProvider } from "@app/contexts/RedactionContext";
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
+import { FolderFileContextProvider } from "@app/contexts/FolderFileContext";
import { FolderProvider } from "@app/contexts/FolderContext";
// Component to initialize scarf tracking (must be inside AppConfigProvider)
@@ -148,7 +149,9 @@ export function AppProviders({