Feature/v2/smartfolders rebuild (#6480)

Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
This commit is contained in:
Reece Browne
2026-06-10 11:18:14 +01:00
committed by GitHub
co-authored by aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
parent e0fc5061de
commit e2536daeb8
49 changed files with 9166 additions and 103 deletions
@@ -0,0 +1,278 @@
import React, { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Text, ActionIcon, ScrollArea } from "@mantine/core";
import CloseIcon from "@mui/icons-material/Close";
import {
CardModalPhase,
CARD_MODAL_TIMINGS,
} from "@app/hooks/useCardModalAnimation";
interface CardExpansionModalProps {
phase: CardModalPhase;
cardRect: DOMRect | null;
textExpanded: boolean;
onClose: () => void;
icon: React.ReactNode;
count: number;
labelSingular: string;
labelPlural: string;
children: React.ReactNode;
footer?: React.ReactNode;
toolbar?: React.ReactNode;
widthRem?: number;
heightRem?: number;
/** Replace ScrollArea with a flex-fill container so children can expand to full body height */
fillHeight?: boolean;
}
const MODAL_W_REM = 56;
const MODAL_H_REM = 38;
const HEADER_H_REM = 3.5;
const MODAL_TOP_FRACTION = 0.12;
const EASING = "cubic-bezier(0.22,1,0.36,1)";
export function CardExpansionModal({
phase,
cardRect,
textExpanded,
onClose,
icon,
count,
labelSingular,
labelPlural,
children,
footer,
toolbar,
widthRem,
heightRem,
fillHeight,
}: CardExpansionModalProps) {
const { t } = useTranslation();
const [viewportW, setViewportW] = useState(window.innerWidth);
const [viewportH, setViewportH] = useState(window.innerHeight);
useEffect(() => {
const handler = () => {
setViewportW(window.innerWidth);
setViewportH(window.innerHeight);
};
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
if (phase === "closed" || !cardRect) return null;
const rootFontSize = parseFloat(
getComputedStyle(document.documentElement).fontSize,
);
const modalW = Math.min(
(widthRem ?? MODAL_W_REM) * rootFontSize,
viewportW * 0.9,
);
const modalH = Math.min(
(heightRem ?? MODAL_H_REM) * rootFontSize,
viewportH * 0.85,
);
const headerH = HEADER_H_REM * rootFontSize;
const finalLeft = (viewportW - modalW) / 2;
const finalTop = Math.min(
viewportH * MODAL_TOP_FRACTION,
viewportH - modalH - 16,
);
const isAtCard = phase === "entering" || phase === "closing-header";
const isAtHeader = phase === "header-open";
const cardH = isAtCard ? cardRect.height : isAtHeader ? headerH : modalH;
const getTransition = () => {
const s = CARD_MODAL_TIMINGS;
if (phase === "header-open")
return `top ${s.headerStretch}ms ${EASING}, left ${s.headerStretch}ms ${EASING}, width ${s.headerStretch}ms ${EASING}, height ${s.headerStretch}ms ${EASING}`;
if (phase === "open") return `height ${s.bodyDrop}ms ${EASING}`;
if (phase === "closing-body") return `height ${s.closeBody}ms ease-in`;
if (phase === "closing-header")
return `top ${s.closeStretch}ms ${EASING}, left ${s.closeStretch}ms ${EASING}, width ${s.closeStretch}ms ${EASING}, height ${s.closeStretch}ms ${EASING}, opacity ${s.closeStretch}ms ease`;
return "none";
};
const backdropOpacity =
phase === "entering" || phase === "closing-header" ? 0 : 1;
const cardOpacity = phase === "closing-header" ? 0 : 1;
const showBody = phase === "open" || phase === "closing-body";
return createPortal(
<div style={{ position: "fixed", inset: 0, zIndex: 300 }}>
<div
onClick={onClose}
style={{
position: "absolute",
inset: 0,
backgroundColor: "rgba(0,0,0,0.55)",
opacity: backdropOpacity,
transition: "opacity 220ms ease",
willChange: "opacity",
}}
/>
<div
style={{
position: "fixed",
top: isAtCard ? cardRect.top : finalTop,
left: isAtCard ? cardRect.left : finalLeft,
width: isAtCard ? cardRect.width : modalW,
height: cardH,
opacity: cardOpacity,
transition: getTransition(),
willChange: "top, left, width, height, opacity",
borderRadius: "var(--mantine-radius-md)",
overflow: "hidden",
backgroundColor: "var(--bg-toolbar)",
display: "flex",
flexDirection: "column",
boxShadow: "0 1.5rem 3rem rgba(0,0,0,0.3)",
}}
>
{/* Header */}
<div
style={{
position: "relative",
height: headerH,
flexShrink: 0,
borderBottom: "0.0625rem solid var(--border-subtle)",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
position: "absolute",
left: "1rem",
display: "flex",
alignItems: "center",
}}
>
{icon}
</div>
<Text
component="span"
fw={800}
style={{ fontSize: "1.375rem", lineHeight: 1, margin: 0 }}
>
{count}
</Text>
<div
style={{
maxWidth: textExpanded ? "16rem" : "0",
opacity: textExpanded ? 1 : 0,
overflow: "hidden",
whiteSpace: "nowrap",
display: "flex",
alignItems: "center",
transition: `max-width 100ms ${EASING}, opacity 80ms ease`,
}}
>
<Text
component="span"
c="dimmed"
style={{
fontSize: "1rem",
paddingLeft: "0.5rem",
lineHeight: 1,
margin: 0,
}}
>
{count === 1 ? labelSingular : labelPlural}
</Text>
</div>
<ActionIcon
variant="subtle"
size="lg"
color="gray"
onClick={onClose}
aria-label={t("close", "Close")}
style={{
position: "absolute",
top: "0.25rem",
right: "0.375rem",
}}
>
<CloseIcon style={{ fontSize: "1.25rem" }} />
</ActionIcon>
</div>
</div>
{showBody && (
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
minHeight: 0,
backgroundColor: "var(--bg-toolbar)",
}}
>
{toolbar && (
<div
style={{
flexShrink: 0,
borderBottom: "0.0625rem solid var(--border-subtle)",
}}
>
{toolbar}
</div>
)}
{fillHeight ? (
<div
style={{
flex: 1,
minHeight: 0,
padding: "0.75rem 1rem",
display: "flex",
flexDirection: "column",
}}
>
{children}
</div>
) : (
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<div
style={{
padding: "0.75rem 1rem",
backgroundColor: "var(--bg-toolbar)",
}}
>
{children}
</div>
</ScrollArea>
)}
{footer && (
<div
style={{
padding: "0.75rem 1rem",
borderTop: "0.0625rem solid var(--border-subtle)",
flexShrink: 0,
}}
>
{footer}
</div>
)}
</div>
)}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,56 @@
import { Modal, Text, Button, Stack, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { WatchedFolder } from "@app/types/watchedFolders";
interface DeleteFolderConfirmModalProps {
opened: boolean;
folder: WatchedFolder | null;
onConfirm: () => void;
onCancel: () => void;
}
export function DeleteFolderConfirmModal({
opened,
folder,
onConfirm,
onCancel,
}: DeleteFolderConfirmModalProps) {
const { t } = useTranslation();
if (!folder) return null;
return (
<Modal
opened={opened}
onClose={onCancel}
title={t("watchedFolders.deleteConfirmTitle", "Delete folder?")}
centered
size="sm"
>
<Stack gap="md">
{folder.isDefault && (
<Text size="sm" c="orange">
{t(
"watchedFolders.defaultFolderWarning",
"This is a default folder and will be recreated on next reload.",
)}
</Text>
)}
<Text size="sm">
{t(
"watchedFolders.deleteConfirmBody",
"This will remove the folder and its run history. Files already downloaded are not affected.",
)}
</Text>
<Group gap="sm" justify="flex-end">
<Button variant="outline" size="sm" onClick={onCancel}>
{t("cancel", "Cancel")}
</Button>
<Button color="red" size="sm" onClick={onConfirm}>
{t("delete", "Delete")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,101 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Center, Text, Box, Loader } from "@mantine/core";
import { FileId } from "@app/types/fileContext";
import { fileStorage } from "@app/services/fileStorage";
import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF";
import { PdfViewerToolbar } from "@app/components/viewer/PdfViewerToolbar";
import { ViewerProvider } from "@app/contexts/ViewerContext";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
interface FilePreviewModalProps {
fileId?: FileId | null;
/** Pass a File directly (e.g. outputs not stored in IDB). */
file?: File | null;
fileName: string;
onClose: () => void;
}
export function FilePreviewModal({
fileId,
file: fileProp,
fileName,
onClose,
}: FilePreviewModalProps) {
const { t } = useTranslation();
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
useEffect(() => {
if (fileProp) {
setFile(fileProp);
setError(false);
setLoading(false);
return;
}
if (!fileId) {
setFile(null);
setError(false);
setLoading(false);
return;
}
setError(false);
setLoading(true);
fileStorage
.getStirlingFile(fileId)
.then((f) => {
if (f) setFile(f);
else setError(true);
})
.catch(() => setError(true))
.finally(() => setLoading(false));
}, [fileId, fileProp]);
const opened = !!(fileId || fileProp);
return (
<Modal
opened={opened}
onClose={onClose}
title={fileName}
size="90%"
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
styles={{
body: {
height: "82vh",
padding: 0,
overflow: "hidden",
display: "flex",
flexDirection: "column",
},
}}
>
{loading ? (
<Center h="100%">
<Loader size="sm" />
</Center>
) : error ? (
<Center h="100%">
<Text c="dimmed">
{t(
"watchedFolders.workbench.previewLoadFailed",
"Could not load file preview.",
)}
</Text>
</Center>
) : !file ? (
<Center h="100%">
<Loader size="sm" />
</Center>
) : (
<ViewerProvider>
<PdfViewerToolbar />
<Box style={{ flex: 1, minHeight: 0 }}>
<LocalEmbedPDF file={file} fileName={fileName} />
</Box>
</ViewerProvider>
)}
</Modal>
);
}
@@ -0,0 +1,7 @@
/**
* Icon picker for Watched Folder create/edit.
* Re-exports IconSelector from the automate tools so smart folder components
* don't need to reach into the automate tools directory.
*/
export { default as IconPicker } from "@app/components/tools/automate/IconSelector";
@@ -0,0 +1,72 @@
import React from "react";
import { Text } from "@mantine/core";
interface StatCardProps {
icon: React.ReactNode;
count: React.ReactNode;
label: string;
hoverColor?: string;
onClick?: (rect: DOMRect) => void;
disabled?: boolean;
isActive?: boolean;
}
export function StatCard({
icon,
count,
label,
hoverColor,
onClick,
disabled,
isActive,
}: StatCardProps) {
const isClickable = !!onClick && !disabled;
return (
<div
onClick={(e) => {
if (isClickable) onClick(e.currentTarget.getBoundingClientRect());
}}
style={{
padding: "0.5rem 0.75rem 2rem",
borderRadius: "var(--mantine-radius-sm)",
border: `0.0625rem solid ${isActive && hoverColor ? hoverColor : "var(--border-subtle)"}`,
backgroundColor:
isActive && hoverColor ? `${hoverColor}10` : "var(--bg-toolbar)",
textAlign: "center",
cursor: isClickable ? "pointer" : "default",
transition: "border-color 0.15s ease, background-color 0.15s ease",
}}
onMouseEnter={(e) => {
if (isClickable && hoverColor && !isActive)
e.currentTarget.style.borderColor = hoverColor;
}}
onMouseLeave={(e) => {
if (isClickable && !isActive)
e.currentTarget.style.borderColor = "var(--border-subtle)";
}}
>
<div style={{ display: "block", marginBottom: "0.375rem" }}>{icon}</div>
<Text
fw={800}
style={{
fontSize: "1.375rem",
lineHeight: 1,
marginBottom: "0.25rem",
}}
>
{count}
</Text>
<Text
style={{
fontSize: "0.5625rem",
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
c="dimmed"
>
{label}
</Text>
</div>
);
}
@@ -0,0 +1,159 @@
import { useState } from "react";
import { Box, Button, Text, ActionIcon, Group, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutlined";
import { WatchedFolder } from "@app/types/watchedFolders";
import { FolderRunStatus } from "@app/hooks/useFolderRunStatuses";
import { iconMap } from "@app/components/tools/automate/iconMap";
interface WatchedFolderCardProps {
folder: WatchedFolder;
isActive: boolean;
status: FolderRunStatus;
onSelect: () => void;
onEdit: (e: React.MouseEvent) => void;
onDelete: (e: React.MouseEvent) => void;
onFileDrop?: (fileIds: string[]) => void;
}
export function WatchedFolderCard({
folder,
isActive,
status,
onSelect,
onEdit,
onDelete,
onFileDrop,
}: WatchedFolderCardProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const IconComponent =
iconMap[folder.icon as keyof typeof iconMap] || iconMap.FolderIcon;
const handleDragOver = (e: React.DragEvent) => {
const types = e.dataTransfer.types;
if (
!types.includes("watchedFolderFileId") &&
!types.includes("watchedFolderFileIds")
)
return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDragOver(true);
};
const handleDragLeave = () => setIsDragOver(false);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const multiRaw = e.dataTransfer.getData("watchedFolderFileIds");
if (multiRaw) {
try {
const ids: string[] = JSON.parse(multiRaw);
if (ids.length > 0 && onFileDrop) onFileDrop(ids);
return;
} catch {
/* fall through */
}
}
const fileId = e.dataTransfer.getData("watchedFolderFileId");
if (fileId && onFileDrop) onFileDrop([fileId]);
};
return (
<Box
className="tool-button-container"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={
isDragOver
? {
backgroundColor: "rgba(59,130,246,0.10)",
borderRadius: "var(--mantine-radius-sm)",
}
: undefined
}
>
<Button
variant={isActive ? "light" : "subtle"}
className="tool-button"
fullWidth
justify="flex-start"
px="sm"
leftSection={
<Box
style={{
width: 18,
height: 18,
borderRadius: "50%",
backgroundColor: `${folder.accentColor}22`,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<IconComponent
style={{ fontSize: 11, color: folder.accentColor }}
/>
</Box>
}
rightSection={
isHovered ? (
<Group gap={2} onClick={(e) => e.stopPropagation()}>
<ActionIcon
size="xs"
variant="subtle"
onClick={onEdit}
aria-label={t("watchedFolders.card.edit", "Edit folder")}
>
<EditIcon style={{ fontSize: 11 }} />
</ActionIcon>
{!folder.isDefault && (
<ActionIcon
size="xs"
variant="subtle"
color="red"
onClick={onDelete}
aria-label={t("watchedFolders.card.delete", "Delete folder")}
>
<DeleteIcon style={{ fontSize: 11 }} />
</ActionIcon>
)}
</Group>
) : folder.isPaused ? (
<PauseCircleOutlineIcon
style={{ fontSize: 12, color: "var(--mantine-color-dimmed)" }}
/>
) : status === "processing" ? (
<Loader size={10} color={folder.accentColor} />
) : status === "done" ? (
<CheckCircleIcon
style={{ fontSize: 12, color: "var(--color-green-500)" }}
/>
) : null
}
onClick={onSelect}
>
<Text
size="sm"
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{folder.name}
</Text>
</Button>
</Box>
);
}
@@ -0,0 +1,679 @@
import { useState, useCallback, useEffect } from "react";
import {
Box,
Text,
Stack,
Group,
ActionIcon,
Button,
Loader,
ScrollArea,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import EditIcon from "@mui/icons-material/Edit";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import CloseIcon from "@mui/icons-material/Close";
import FolderPlusIcon from "@mui/icons-material/CreateNewFolder";
import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutlined";
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutlined";
import { useWatchedFolders } from "@app/hooks/useWatchedFolders";
import { useFolderRunStatuses } from "@app/hooks/useFolderRunStatuses";
import {
useFolderAutomation,
resolveInputFile,
} from "@app/hooks/useFolderAutomation";
import { WatchedFolder } from "@app/types/watchedFolders";
import { type FileId } from "@app/types/file";
import { AutomationConfig } from "@app/types/automation";
import { automationStorage } from "@app/services/automationStorage";
import { watchedFolderFileStorage } from "@app/services/watchedFolderFileStorage";
import { getWatchedFolderDraggedFileIds } from "@app/components/watchedFolders/watchedFolderDragState";
import { fileStorage } from "@app/services/fileStorage";
import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail";
import { WatchedFolderManagementModal } from "@app/components/watchedFolders/WatchedFolderManagementModal";
import { DeleteFolderConfirmModal } from "@app/components/watchedFolders/DeleteFolderConfirmModal";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import {
WATCHED_FOLDER_VIEW_ID,
WATCHED_FOLDER_WORKBENCH_ID,
} from "@app/components/watchedFolders/WatchedFoldersRegistration";
import { timeAgo } from "@app/components/watchedFolders/WatchedFolderWorkbenchView";
import "@app/components/watchedFolders/WatchedFolders.css";
export function humaniseOp(op: string): string {
return op
.replace(/-pdf$|-pages$|-documents?$/i, "")
.replace(/[-_]/g, " ")
.replace(/\bocr\b/gi, "OCR")
.replace(/\b\w/g, (c) => c.toUpperCase())
.trim();
}
interface FolderCardProps {
folder: WatchedFolder;
status: "idle" | "processing" | "done";
isProcessing: boolean;
onEdit: (folder: WatchedFolder) => void;
onDelete: (folder: WatchedFolder) => void;
onOpen: (folderId: string) => void;
onDropFiles: (folder: WatchedFolder, files: File[]) => void;
onDropSidebarFile: (folder: WatchedFolder, fileIds: string[]) => void;
onTogglePause: (folder: WatchedFolder) => void;
}
function FolderCard({
folder,
status,
isProcessing,
onEdit,
onDelete,
onOpen,
onDropFiles,
onDropSidebarFile,
onTogglePause,
}: FolderCardProps) {
const { t } = useTranslation();
const [automation, setAutomation] = useState<AutomationConfig | null>(null);
const [fileCount, setFileCount] = useState(0);
const [lastAdded, setLastAdded] = useState<Date | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
// True while a drag hovers this card AND every dragged file is already in it
// (so the drop would be a no-op — see the guard in processFiles).
const [dragAlreadyPresent, setDragAlreadyPresent] = useState(false);
// Ids of files currently in this folder, used for the live dragover check.
const [memberIds, setMemberIds] = useState<Set<string>>(new Set());
useEffect(() => {
automationStorage.getAutomation(folder.automationId).then(setAutomation);
const loadData = () =>
watchedFolderFileStorage.getFolderData(folder.id).then((record) => {
if (!record) {
setFileCount(0);
setLastAdded(null);
setMemberIds(new Set());
return;
}
const files = Object.values(record.files);
setFileCount(files.length);
setMemberIds(new Set(Object.keys(record.files)));
const dates = files
.map((f) => new Date(f.addedAt))
.filter((d) => !isNaN(d.getTime()));
setLastAdded(
dates.length
? new Date(Math.max(...dates.map((d) => d.getTime())))
: null,
);
});
loadData();
const unsub = watchedFolderFileStorage.onFolderChange((changedId) => {
if (changedId === folder.id) loadData();
});
return unsub;
}, [folder.id, folder.automationId]);
const isPaused = folder.isPaused ?? false;
const isActive = !isPaused && (isProcessing || status === "processing");
const isDone = !isPaused && status === "done" && !isActive;
const statusDotColor = isPaused
? "var(--mantine-color-dimmed)"
: isActive
? "var(--mantine-color-blue-filled)"
: isDone
? "var(--color-green-500)"
: "var(--text-muted)";
const statusDotPulse = isActive;
const statusLabel = isPaused
? t("watchedFolders.status.paused", "Paused")
: isActive
? t("watchedFolders.status.processing", "Processing")
: isDone
? t("watchedFolders.status.done", "Done")
: t("watchedFolders.status.active", "Active");
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
// Live feedback: are all the files being dragged already in this folder?
const dragged = getWatchedFolderDraggedFileIds();
setDragAlreadyPresent(
dragged.length > 0 && dragged.every((id) => memberIds.has(id)),
);
};
const handleDragLeave = (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
setDragAlreadyPresent(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
setDragAlreadyPresent(false);
const multiRaw = e.dataTransfer.getData("watchedFolderFileIds");
if (multiRaw) {
try {
const ids: string[] = JSON.parse(multiRaw);
if (ids.length > 0) {
onDropSidebarFile(folder, ids);
return;
}
} catch {
/* fall through */
}
}
const sidebarFileId = e.dataTransfer.getData("watchedFolderFileId");
if (sidebarFileId) {
onDropSidebarFile(folder, [sidebarFileId]);
} else if (e.dataTransfer.files.length > 0) {
onDropFiles(folder, Array.from(e.dataTransfer.files));
}
};
// `automation` is loaded by the per-card effect above; the steps it holds
// are no longer rendered on the card but the load is preserved.
void automation;
return (
<div
className={`wf-card${isDragOver ? " is-drop-target" : ""}${
isDragOver && dragAlreadyPresent ? " is-already-member" : ""
}`}
role="listitem"
tabIndex={0}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => onOpen(folder.id)}
onKeyDown={(e) => {
if (e.key === "Enter") onOpen(folder.id);
}}
>
{isDragOver && dragAlreadyPresent && (
<div className="wf-card-drag-message" aria-hidden="true">
{t("watchedFolders.alreadyInFolder", "Already in this folder")}
</div>
)}
<div
className="wf-card-thumb"
style={{
background: `linear-gradient(135deg, color-mix(in srgb, ${folder.accentColor} 18%, var(--bg-surface)), color-mix(in srgb, ${folder.accentColor} 6%, var(--bg-surface)))`,
}}
>
<FolderThumbnail
color={folder.accentColor}
fileCount={fileCount}
size="thumb"
/>
</div>
<div className="wf-card-body">
<div className="wf-card-name" title={folder.name}>
{folder.name}
</div>
<div className="wf-card-meta">
<span>
{fileCount}{" "}
{fileCount === 1
? t("watchedFolders.home.file", "file")
: t("watchedFolders.home.files", "files")}
</span>
{lastAdded && (
<>
<span>·</span>
<span>{timeAgo(lastAdded, t)}</span>
</>
)}
<span style={{ flex: 1 }} />
<span
className={`wf-card-status-dot${statusDotPulse ? " is-pulsing" : ""}`}
style={{ backgroundColor: statusDotColor }}
/>
<span style={{ fontSize: "0.7rem" }}>{statusLabel}</span>
</div>
</div>
<div className="wf-card-actions" onClick={(e) => e.stopPropagation()}>
<ActionIcon
size="md"
variant="subtle"
onClick={() => onTogglePause(folder)}
aria-label={
isPaused
? t("watchedFolders.home.resume", "Resume")
: t("watchedFolders.home.pause", "Pause")
}
title={
isPaused
? t("watchedFolders.home.resume", "Resume")
: t("watchedFolders.home.pause", "Pause")
}
>
{isPaused ? (
<PlayCircleOutlineIcon style={{ fontSize: "1.125rem" }} />
) : (
<PauseCircleOutlineIcon style={{ fontSize: "1.125rem" }} />
)}
</ActionIcon>
<ActionIcon
size="md"
variant="subtle"
onClick={() => onEdit(folder)}
aria-label={t("watchedFolders.home.editFolder", "Edit folder")}
>
<EditIcon style={{ fontSize: "1.125rem" }} />
</ActionIcon>
<ActionIcon
size="md"
variant="subtle"
color="red"
onClick={() => onDelete(folder)}
aria-label={t("watchedFolders.home.deleteFolder", "Delete folder")}
>
<DeleteOutlineIcon style={{ fontSize: "1.125rem" }} />
</ActionIcon>
</div>
</div>
);
}
function HowItWorks() {
const { t } = useTranslation();
const [dismissed, setDismissed] = useState(
() => sessionStorage.getItem("wf_howItWorks_dismissed") === "1",
);
if (dismissed) return null;
const steps = [
{
n: "1",
title: t("watchedFolders.howItWorks.step1Title", "Drop files"),
desc: t(
"watchedFolders.howItWorks.step1Desc",
"Drag PDFs onto any Watched Folder card — or send them from your file list",
),
},
{
n: "2",
title: t("watchedFolders.howItWorks.step2Title", "Pipeline runs"),
desc: t(
"watchedFolders.howItWorks.step2Desc",
"Your configured tools process each file automatically",
),
},
{
n: "3",
title: t("watchedFolders.howItWorks.step3Title", "Output ready"),
desc: t(
"watchedFolders.howItWorks.step3Desc",
"Download processed files from inside the folder",
),
},
];
return (
<Box
mt="lg"
style={{
padding: "1rem 1.25rem",
borderRadius: "var(--mantine-radius-md)",
border: "0.0625rem solid var(--border-subtle)",
backgroundColor: "var(--bg-toolbar)",
}}
>
<Group gap="xs" mb="sm" justify="space-between">
<Group gap="xs">
<InfoOutlinedIcon
style={{
fontSize: "1rem",
color: "var(--mantine-color-blue-filled)",
}}
/>
<Text fw={600} size="xs">
{t("watchedFolders.howItWorks.title", "How Watched Folders work")}
</Text>
</Group>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
onClick={() => {
sessionStorage.setItem("wf_howItWorks_dismissed", "1");
setDismissed(true);
}}
aria-label={t("watchedFolders.actions.dismiss", "Dismiss")}
>
<CloseIcon
style={{ fontSize: "0.75rem", color: "var(--mantine-color-text)" }}
/>
</ActionIcon>
</Group>
<Group gap="xl" wrap="nowrap" align="flex-start">
{steps.map((step) => (
<Group
key={step.n}
gap="sm"
wrap="nowrap"
align="flex-start"
style={{ flex: 1 }}
>
<Box
style={{
width: "1.375rem",
height: "1.375rem",
borderRadius: "50%",
backgroundColor: "var(--mantine-color-blue-light)",
color: "var(--mantine-color-blue-filled)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.6875rem",
fontWeight: 700,
flexShrink: 0,
}}
>
{step.n}
</Box>
<Stack gap={2}>
<Text size="xs" fw={600}>
{step.title}
</Text>
<Text size="xs" c="dimmed" style={{ lineHeight: 1.5 }}>
{step.desc}
</Text>
</Stack>
</Group>
))}
</Group>
</Box>
);
}
function EmptyState({ onCreate }: { onCreate: () => void }) {
const { t } = useTranslation();
return (
<div className="wf-empty">
<span className="wf-empty-icon">
<FolderPlusIcon style={{ fontSize: "2.5rem" }} />
</span>
<div className="wf-empty-title">
{t("watchedFolders.home.emptyTitle", "Automate your PDF workflows")}
</div>
<div className="wf-empty-hint">
{t(
"watchedFolders.home.emptyDesc",
"Set up a Watched Folder once. Drop PDFs in and they're automatically compressed, OCR'd, split, merged — whatever your pipeline does.",
)}
</div>
<Button
size="md"
leftSection={<AddIcon style={{ fontSize: "1.125rem" }} />}
onClick={onCreate}
mt="sm"
>
{t("watchedFolders.home.create", "Create your first Watched Folder")}
</Button>
<HowItWorks />
</div>
);
}
export function WatchedFolderHomePage() {
const { t } = useTranslation();
const { folders, loading, deleteFolder, updateFolder, refreshFolders } =
useWatchedFolders();
const statuses = useFolderRunStatuses(folders);
const { toolRegistry, setCustomWorkbenchViewData } = useToolWorkflow();
const { actions } = useNavigationActions();
const { processBatch } = useFolderAutomation(toolRegistry);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editFolder, setEditFolder] = useState<WatchedFolder | null>(null);
const [editAutomation, setEditAutomation] = useState<AutomationConfig | null>(
null,
);
const [processingFolderIds, setProcessingFolderIds] = useState<Set<string>>(
new Set(),
);
const [deleteTarget, setDeleteTarget] = useState<WatchedFolder | null>(null);
const navigateToFolder = useCallback(
(folderId: string) => {
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId });
actions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
},
[setCustomWorkbenchViewData, actions],
);
const handleEdit = useCallback(async (folder: WatchedFolder) => {
setEditFolder(folder);
const automation = await automationStorage.getAutomation(
folder.automationId,
);
setEditAutomation(automation);
setCreateModalOpen(true);
}, []);
const handleModalClose = () => {
setCreateModalOpen(false);
setEditFolder(null);
setEditAutomation(null);
};
const processFiles = useCallback(
async (folder: WatchedFolder, files: File[]) => {
const pdfs = files.filter((f) => f.name.toLowerCase().endsWith(".pdf"));
if (pdfs.length === 0) return;
// Load existing folder data once so we can skip files already in the folder.
const existingData = await watchedFolderFileStorage.getFolderData(
folder.id,
);
// Register files sequentially — addFileToFolder/updateFileMetadata are read-modify-write
// without IDB transactions, so concurrent calls on the same folder lose updates.
const items: Array<{
file: File;
inputFileId: string;
ownedByFolder: boolean;
}> = [];
for (const file of pdfs) {
const { inputFileId, ownedByFolder } = await resolveInputFile(file);
// Guard against multiple adds: if this file is already in the folder,
// skip it entirely rather than resetting its status and reprocessing.
// (Dropping the same sidebar file onto a card again is a no-op.)
if (existingData?.files[inputFileId]) {
continue;
}
await watchedFolderFileStorage.addFileToFolder(folder.id, inputFileId, {
status: "pending",
name: file.name,
ownedByFolder: ownedByFolder || undefined,
});
items.push({ file, inputFileId, ownedByFolder });
}
// Nothing new to process (every dropped file was already in the folder).
if (items.length === 0) return;
if (folder.isPaused) return;
setProcessingFolderIds((prev) => new Set([...prev, folder.id]));
try {
await processBatch(folder, items);
} finally {
setProcessingFolderIds((prev) => {
const next = new Set(prev);
next.delete(folder.id);
return next;
});
}
},
[processBatch],
);
const handleTogglePause = useCallback(
async (folder: WatchedFolder) => {
const resuming = folder.isPaused;
const updatedFolder = { ...folder, isPaused: !folder.isPaused };
await updateFolder(updatedFolder);
refreshFolders();
if (resuming) {
const record = await watchedFolderFileStorage.getFolderData(folder.id);
if (record) {
const pendingEntries = Object.entries(record.files).filter(
([, meta]) => meta.status === "pending",
);
if (pendingEntries.length > 0) {
const items: Array<{
file: File;
inputFileId: string;
ownedByFolder: boolean;
}> = [];
for (const [id, meta] of pendingEntries) {
const stirlingFile = await fileStorage.getStirlingFile(
id as FileId,
);
if (stirlingFile) {
items.push({
file: stirlingFile,
inputFileId: id,
ownedByFolder: meta.ownedByFolder ?? false,
});
}
}
if (items.length > 0) processBatch(updatedFolder, items);
}
}
}
},
[updateFolder, refreshFolders, processBatch],
);
const handleDropSidebarFile = useCallback(
async (folder: WatchedFolder, fileIds: string[]) => {
const results = await Promise.all(
fileIds.map((id) => fileStorage.getStirlingFile(id as FileId)),
);
const stirlingFiles = results.filter(Boolean) as File[];
if (stirlingFiles.length > 0) processFiles(folder, stirlingFiles);
},
[processFiles],
);
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
await deleteFolder(deleteTarget.id);
setDeleteTarget(null);
};
return (
<Box
style={{
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
}}
>
<ScrollArea style={{ flex: 1 }}>
<Box p="xl">
{loading ? (
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "4rem",
}}
>
<Loader size="md" />
</Box>
) : folders.length === 0 ? (
<EmptyState onCreate={() => setCreateModalOpen(true)} />
) : (
<Stack gap="md">
<HowItWorks />
<div className="wf-grid" role="list">
{folders.map((folder) => {
const status = statuses[folder.id] ?? "idle";
const isProcessing =
processingFolderIds.has(folder.id) ||
status === "processing";
return (
<FolderCard
key={folder.id}
folder={folder}
status={status}
isProcessing={isProcessing}
onEdit={handleEdit}
onDelete={setDeleteTarget}
onOpen={navigateToFolder}
onDropFiles={processFiles}
onDropSidebarFile={handleDropSidebarFile}
onTogglePause={handleTogglePause}
/>
);
})}
<div
className="wf-new-tile"
role="button"
tabIndex={0}
onClick={() => setCreateModalOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ")
setCreateModalOpen(true);
}}
>
<span className="wf-new-tile-icon">
<FolderPlusIcon style={{ fontSize: "1.5rem" }} />
</span>
<Text size="sm" fw={600}>
{t(
"watchedFolders.home.addAnother",
"Add another Watched Folder",
)}
</Text>
<Text size="xs" c="dimmed">
{t(
"watchedFolders.home.addAnotherDesc",
"Automatically process files with a new pipeline",
)}
</Text>
</div>
</div>
</Stack>
)}
</Box>
</ScrollArea>
<WatchedFolderManagementModal
opened={createModalOpen}
editFolder={editFolder}
existingAutomation={editAutomation}
onClose={handleModalClose}
onSaved={refreshFolders}
/>
<DeleteFolderConfirmModal
opened={!!deleteTarget}
folder={deleteTarget}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteTarget(null)}
/>
</Box>
);
}
@@ -0,0 +1,911 @@
import { useState, useCallback, useRef, useEffect } from "react";
import {
Button,
Stack,
Group,
TextInput,
ColorInput,
NumberInput,
Text,
Alert,
Switch,
Select,
Box,
Collapse,
Tooltip,
Modal,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { WatchedFolder } from "@app/types/watchedFolders";
import { AutomationConfig, AutomationMode } from "@app/types/automation";
import { IconPicker as IconSelector } from "@app/components/watchedFolders/IconPicker";
import AutomationCreation from "@app/components/tools/automate/AutomationCreation";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
import { folderDirectoryHandleStorage } from "@app/services/folderDirectoryHandleStorage";
import {
canReadLocalFolder,
canWriteLocalFolder,
FS_WRITE_UNSUPPORTED_MSG,
} from "@app/utils/fsAccessCapability";
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import {
Z_INDEX_AUTOMATE_MODAL,
Z_INDEX_AUTOMATE_DROPDOWN,
} from "@app/styles/zIndex";
const ACCENT_SWATCHES = [
"#3b82f6",
"#0ea5e9",
"#14b8a6",
"#22c55e",
"#f97316",
"#ef4444",
"#9333ea",
"#ec4899",
"#6366f1",
"#eab308",
"#64748b",
"#0f172a",
];
function SectionLabel({ children }: { children: string }) {
return (
<Text
size="xs"
fw={600}
tt="uppercase"
style={{
letterSpacing: "0.06em",
color: "var(--tool-subcategory-text-color)",
marginBottom: "0.5rem",
}}
>
{children}
</Text>
);
}
interface WatchedFolderManagementModalProps {
opened: boolean;
editFolder?: WatchedFolder | null;
existingAutomation?: AutomationConfig | null;
onClose: () => void;
onSaved: () => void;
}
export function WatchedFolderManagementModal({
opened,
editFolder,
existingAutomation,
onClose,
onSaved,
}: WatchedFolderManagementModalProps) {
const { t } = useTranslation();
const { toolRegistry } = useToolWorkflow();
const isEditMode = !!editFolder;
const [name, setName] = useState(editFolder?.name ?? "");
const [icon, setIcon] = useState(editFolder?.icon ?? "FolderIcon");
const [accentColor, setAccentColor] = useState(
editFolder?.accentColor ?? "#3b82f6",
);
const [maxRetries, setMaxRetries] = useState<number>(
editFolder?.maxRetries ?? 3,
);
const [retryDelayMinutes, setRetryDelayMinutes] = useState<number>(
editFolder?.retryDelayMinutes ?? 5,
);
const [outputMode, setOutputMode] = useState<"new_file" | "new_version">(
editFolder?.outputMode ?? "new_file",
);
const [outputName, setOutputName] = useState(
editFolder?.outputName ?? editFolder?.name ?? "",
);
const [outputNamePosition, setOutputNamePosition] = useState<
"prefix" | "suffix" | "auto-number"
>(editFolder?.outputNamePosition ?? "prefix");
const [inputSource, setInputSource] = useState<
NonNullable<WatchedFolder["inputSource"]>
>(editFolder?.inputSource ?? "idb");
const outputNameDirty = useRef(!!editFolder?.outputName);
const [saving, setSaving] = useState(false);
const [outputDirName, setOutputDirName] = useState<string | null>(
editFolder?.hasOutputDirectory ? "(loading…)" : null,
);
const pendingDirHandle = useRef<FileSystemDirectoryHandle | null>(null);
const [inputDirName, setInputDirName] = useState<string | null>(
editFolder?.inputSource === "local-folder" ? "(loading…)" : null,
);
const pendingInputDirHandle = useRef<FileSystemDirectoryHandle | null>(null);
const [nameError, setNameError] = useState("");
const [automationError, setAutomationError] = useState("");
const [saveError, setSaveError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(isEditMode);
const automationSaveTrigger = useRef<(() => void) | null>(null);
const resetState = useCallback(() => {
setName(editFolder?.name ?? "");
setIcon(editFolder?.icon ?? "FolderIcon");
setAccentColor(editFolder?.accentColor ?? "#3b82f6");
setMaxRetries(editFolder?.maxRetries ?? 3);
setRetryDelayMinutes(editFolder?.retryDelayMinutes ?? 5);
setOutputMode(editFolder?.outputMode ?? "new_file");
setOutputName(editFolder?.outputName ?? editFolder?.name ?? "");
setOutputNamePosition(
(editFolder?.outputNamePosition as "prefix" | "suffix" | "auto-number") ??
"prefix",
);
setInputSource(editFolder?.inputSource ?? "idb");
setInputDirName(
editFolder?.inputSource === "local-folder" ? "(loading…)" : null,
);
outputNameDirty.current = !!editFolder?.outputName;
setShowAdvanced(!!editFolder);
setNameError("");
setAutomationError("");
setSaveError(null);
setSaving(false);
}, [editFolder]);
useEffect(() => {
if (opened) {
resetState();
pendingDirHandle.current = null;
pendingInputDirHandle.current = null;
if (editFolder?.hasOutputDirectory && editFolder.id) {
folderDirectoryHandleStorage
.get(editFolder.id)
.then((h) => setOutputDirName(h?.name ?? null));
} else {
setOutputDirName(null);
}
if (editFolder?.inputSource === "local-folder" && editFolder.id) {
folderDirectoryHandleStorage
.getInput(editFolder.id)
.then((h) => setInputDirName(h?.name ?? null));
} else {
setInputDirName(null);
}
}
}, [opened, resetState, editFolder]);
const handleClose = () => {
resetState();
onClose();
};
const handleAutomationComplete = useCallback(
async (automation: AutomationConfig) => {
const trimmedName = name.trim();
try {
const retryFields = { maxRetries, retryDelayMinutes };
const hasOutputDirectory = outputDirName !== null;
const folderData = {
name: trimmedName,
description: "",
icon,
accentColor,
automationId: automation.id,
...retryFields,
outputMode:
outputMode === "new_version" ? ("new_version" as const) : undefined,
outputName: outputName.trim() || undefined,
outputNamePosition:
outputNamePosition !== "prefix" ? outputNamePosition : undefined,
hasOutputDirectory,
inputSource: inputSource !== "idb" ? inputSource : undefined,
};
if (isEditMode && editFolder) {
const wasLocalFolder = editFolder.inputSource === "local-folder";
await watchedFolderStorage.updateFolder({
...editFolder,
...folderData,
});
if (pendingDirHandle.current) {
await folderDirectoryHandleStorage.set(
editFolder.id,
pendingDirHandle.current,
);
} else if (!hasOutputDirectory) {
await folderDirectoryHandleStorage.remove(editFolder.id);
}
if (inputSource === "local-folder") {
if (pendingInputDirHandle.current) {
await folderDirectoryHandleStorage.setInput(
editFolder.id,
pendingInputDirHandle.current,
);
}
} else if (wasLocalFolder) {
await folderDirectoryHandleStorage.removeInput(editFolder.id);
}
} else {
const newFolder = await watchedFolderStorage.createFolder(folderData);
if (pendingDirHandle.current) {
await folderDirectoryHandleStorage.set(
newFolder.id,
pendingDirHandle.current,
);
}
if (inputSource === "local-folder" && pendingInputDirHandle.current) {
await folderDirectoryHandleStorage.setInput(
newFolder.id,
pendingInputDirHandle.current,
);
}
}
resetState();
onSaved();
onClose();
} catch (error) {
console.error("Failed to save smart folder:", error);
setSaveError(
t(
"watchedFolders.modal.saveFailed",
"Failed to save folder. Please try again.",
),
);
} finally {
setSaving(false);
}
},
[
name,
icon,
accentColor,
outputMode,
outputName,
outputNamePosition,
outputDirName,
maxRetries,
retryDelayMinutes,
inputSource,
isEditMode,
editFolder,
resetState,
onSaved,
onClose,
t,
],
);
const handleSave = () => {
const trimmedName = name.trim();
if (!trimmedName) {
setNameError(
t("watchedFolders.modal.nameRequired", "Folder name is required"),
);
return;
}
if (trimmedName.length > 50) {
setNameError(
t(
"watchedFolders.modal.nameTooLong",
"Folder name must be 50 characters or less",
),
);
return;
}
setAutomationError("");
setSaving(true);
automationSaveTrigger.current?.();
};
const title = isEditMode
? t("watchedFolders.modal.editTitle", "Edit Watched Folder")
: t("watchedFolders.modal.createTitle", "New Watched Folder");
return (
<Modal
opened={opened}
onClose={handleClose}
title={title}
size="min(90rem, 94vw)"
centered
zIndex={Z_INDEX_AUTOMATE_MODAL}
styles={{
content: {
height: "min(90vh, 60rem)",
display: "flex",
flexDirection: "column",
},
body: {
flex: 1,
minHeight: 0,
padding: 0,
display: "flex",
flexDirection: "column",
},
}}
>
{/* Body: two-column layout */}
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
{/* ── Left panel: folder config ── */}
<div
style={{
width: "28rem",
flexShrink: 0,
borderRight: "0.0625rem solid var(--border-subtle)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<div
style={{ flex: 1, overflowY: "auto", padding: "1.25rem 1.5rem" }}
>
<Stack gap="lg">
{/* ── Identity ── */}
<div>
<SectionLabel>
{t("watchedFolders.modal.sectionFolder", "Folder")}
</SectionLabel>
<Stack gap="xs">
<Group gap="xs" align="flex-end">
<TextInput
placeholder={t(
"watchedFolders.modal.namePlaceholder",
"My Watched Folder",
)}
value={name}
onChange={(e) => {
const val = e.currentTarget.value;
setName(val);
setNameError("");
if (!outputNameDirty.current) setOutputName(val);
}}
error={nameError}
withAsterisk
maxLength={50}
style={{ flex: 1 }}
size="sm"
/>
<IconSelector value={icon} onChange={setIcon} size="sm" />
</Group>
<ColorInput
label={t("watchedFolders.modal.color", "Accent colour")}
value={accentColor}
onChange={setAccentColor}
format="hex"
swatches={ACCENT_SWATCHES}
size="sm"
popoverProps={{
withinPortal: true,
zIndex: Z_INDEX_AUTOMATE_DROPDOWN,
}}
/>
</Stack>
</div>
{/* ── Source & Output ── */}
<div>
<SectionLabel>
{t(
"watchedFolders.modal.sectionSourceOutput",
"Source & Output",
)}
</SectionLabel>
<Stack gap="sm">
<Select
label={t(
"watchedFolders.modal.inputSource",
"Input source",
)}
value={inputSource}
onChange={(v) =>
v &&
setInputSource(
v as NonNullable<WatchedFolder["inputSource"]>,
)
}
data={[
{
value: "idb",
label: t(
"watchedFolders.modal.inputSourceBrowser",
"Browser — drop files here",
),
},
{
value: "local-folder",
label: canReadLocalFolder
? t(
"watchedFolders.modal.inputSourceLocal",
"Local folder (auto-scan)",
)
: t(
"watchedFolders.modal.inputSourceLocalUnsupported",
"Local folder (auto-scan) — Chrome/Edge only",
),
disabled: !canReadLocalFolder,
},
]}
size="sm"
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_AUTOMATE_DROPDOWN,
}}
/>
{/* Local-folder input directory picker */}
{inputSource === "local-folder" && (
<Box
style={{
marginLeft: "0.75rem",
paddingLeft: "0.75rem",
borderLeft: "2px solid var(--border-subtle)",
}}
>
<Box
style={{
padding: "0.5rem 0.75rem",
borderRadius: "var(--mantine-radius-sm)",
border: `0.0625rem solid ${inputDirName ? "var(--mantine-color-green-filled)" : "var(--mantine-color-yellow-5)"}`,
backgroundColor: inputDirName
? "var(--mantine-color-green-light)"
: "var(--mantine-color-yellow-light)",
}}
>
<Group gap="xs" align="center" wrap="nowrap">
<FolderSpecialIcon
style={{
fontSize: "1rem",
color: inputDirName
? "var(--color-green-500)"
: "var(--mantine-color-yellow-6)",
flexShrink: 0,
}}
/>
<Stack gap={1} style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" fw={500}>
{t(
"watchedFolders.modal.inputFolder",
"Input folder",
)}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{inputDirName ??
t(
"watchedFolders.modal.inputFolderNotChosen",
"No folder chosen — required for auto-scan",
)}
</Text>
</Stack>
<Button
size="xs"
variant="subtle"
onClick={async () => {
try {
const handle = await (
window as unknown as {
showDirectoryPicker: (options?: {
mode?: "read" | "readwrite";
}) => Promise<FileSystemDirectoryHandle>;
}
).showDirectoryPicker({ mode: "read" });
pendingInputDirHandle.current = handle;
setInputDirName(handle.name);
} catch {
/* cancelled */
}
}}
>
{inputDirName
? t("watchedFolders.modal.changeFolder", "Change")
: t(
"watchedFolders.modal.chooseFolder",
"Choose",
)}
</Button>
{inputDirName && (
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => {
pendingInputDirHandle.current = null;
setInputDirName(null);
}}
>
{t("watchedFolders.modal.clearFolder", "Clear")}
</Button>
)}
</Group>
</Box>
<Text size="xs" c="dimmed" mt={6}>
{t(
"watchedFolders.modal.autoScanHelp",
"New PDF files in this folder are processed automatically every 10 seconds.",
)}
</Text>
</Box>
)}
{/* Local output folder */}
<Box
style={{
padding: "0.5rem 0.75rem",
borderRadius: "var(--mantine-radius-sm)",
border: `0.0625rem solid ${outputDirName ? "var(--mantine-color-green-filled)" : "var(--border-subtle)"}`,
backgroundColor: outputDirName
? "var(--mantine-color-green-light)"
: "transparent",
}}
>
<Group gap="xs" align="center" wrap="nowrap">
<FolderSpecialIcon
style={{
fontSize: "1rem",
color: outputDirName
? "var(--color-green-500)"
: "var(--mantine-color-dimmed)",
flexShrink: 0,
}}
/>
<Stack gap={1} style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" fw={500}>
{t(
"watchedFolders.modal.localOutputFolder",
"Local output folder",
)}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{!canWriteLocalFolder
? t(
"watchedFolders.modal.outputFolderUnsupported",
"Not supported in this browser",
)
: (outputDirName ??
t(
"watchedFolders.modal.outputFolderNotSet",
"Not set — outputs stay in app",
))}
</Text>
</Stack>
<Tooltip
label={FS_WRITE_UNSUPPORTED_MSG}
disabled={canWriteLocalFolder}
withinPortal
zIndex={Z_INDEX_AUTOMATE_DROPDOWN}
>
<Button
size="xs"
variant="subtle"
disabled={!canWriteLocalFolder}
onClick={async () => {
try {
const handle = await (
window as unknown as {
showDirectoryPicker: (options?: {
mode?: "read" | "readwrite";
}) => Promise<FileSystemDirectoryHandle>;
}
).showDirectoryPicker({ mode: "readwrite" });
pendingDirHandle.current = handle;
setOutputDirName(handle.name);
} catch {
/* cancelled */
}
}}
>
{outputDirName
? t("watchedFolders.modal.changeFolder", "Change")
: t("watchedFolders.modal.chooseFolder", "Choose")}
</Button>
</Tooltip>
{outputDirName && (
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => {
pendingDirHandle.current = null;
setOutputDirName(null);
}}
>
{t("watchedFolders.modal.clearFolder", "Clear")}
</Button>
)}
</Group>
</Box>
</Stack>
</div>
{/* ── Advanced (collapsible) ── */}
<div>
<button
onClick={() => setShowAdvanced((v) => !v)}
style={{
display: "flex",
alignItems: "center",
gap: "0.35rem",
background: "none",
border: "none",
cursor: "pointer",
padding: "0.25rem 0",
width: "100%",
color: "var(--tool-subcategory-text-color)",
fontSize: "0.7rem",
fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
}}
>
<ChevronRightIcon
sx={{
fontSize: "1rem",
transform: showAdvanced ? "rotate(90deg)" : "none",
transition: "transform 160ms ease",
}}
/>
{t("watchedFolders.modal.advanced", "Advanced")}
</button>
<Collapse in={showAdvanced} transitionDuration={180}>
<Stack gap="sm" mt="sm">
{/* Replace original */}
<Switch
label={t(
"watchedFolders.modal.replaceOriginal",
"Replace original file",
)}
description={
outputMode === "new_version"
? t(
"watchedFolders.modal.outputModeVersionDesc",
"Output replaces input as a new version",
)
: t(
"watchedFolders.modal.outputModeNewDesc",
"Output saved as a separate new file",
)
}
checked={outputMode === "new_version"}
onChange={(e) =>
setOutputMode(
e.currentTarget.checked ? "new_version" : "new_file",
)
}
size="sm"
/>
{/* Filename prefix / suffix */}
<Box
style={{
opacity: outputMode === "new_version" ? 0.4 : 1,
pointerEvents:
outputMode === "new_version" ? "none" : "auto",
}}
>
<Group gap="xs" align="flex-end">
{outputNamePosition === "auto-number" ? (
<Box style={{ flex: 1 }}>
<Text size="xs" fw={500} mb={4}>
{t(
"watchedFolders.modal.autoNumber",
"Auto-number",
)}
</Text>
<Text size="xs" c="dimmed">
{t(
"watchedFolders.modal.autoNumberExample",
"e.g. document.pdf → document (1).pdf",
)}
</Text>
</Box>
) : (
<TextInput
label={
outputNamePosition === "suffix"
? t(
"watchedFolders.modal.outputNameSuffix",
"Filename suffix",
)
: t(
"watchedFolders.modal.outputNamePrefix",
"Filename prefix",
)
}
value={outputName}
onChange={(e) => {
outputNameDirty.current = true;
setOutputName(e.currentTarget.value);
}}
maxLength={100}
size="sm"
style={{ flex: 1 }}
/>
)}
<Select
size="xs"
value={outputNamePosition}
onChange={(v) =>
v &&
setOutputNamePosition(
v as "prefix" | "suffix" | "auto-number",
)
}
data={[
{
value: "prefix",
label: t(
"watchedFolders.modal.positionPrefix",
"Prefix",
),
},
{
value: "suffix",
label: t(
"watchedFolders.modal.positionSuffix",
"Suffix",
),
},
{
value: "auto-number",
label: t(
"watchedFolders.modal.autoNumber",
"Auto-number",
),
},
]}
style={{ width: "8rem", flexShrink: 0 }}
mb={4}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_AUTOMATE_DROPDOWN,
}}
/>
</Group>
</Box>
{/* Retry settings */}
<Group gap="sm" grow>
<NumberInput
label={t(
"watchedFolders.modal.maxRetries",
"Max auto retries",
)}
value={maxRetries}
onChange={(v) =>
setMaxRetries(
typeof v === "number"
? Math.max(0, Math.min(10, v))
: 0,
)
}
min={0}
max={10}
size="sm"
/>
<NumberInput
label={t(
"watchedFolders.modal.retryDelay",
"Retry interval (min)",
)}
value={retryDelayMinutes}
onChange={(v) =>
setRetryDelayMinutes(
typeof v === "number"
? Math.max(1, Math.min(60, v))
: 5,
)
}
min={1}
max={60}
size="sm"
disabled={maxRetries === 0}
/>
</Group>
</Stack>
</Collapse>
</div>
</Stack>
</div>
{/* Footer actions */}
<div
style={{
padding: "1rem 1.5rem",
borderTop: "0.0625rem solid var(--border-subtle)",
flexShrink: 0,
}}
>
{saveError && (
<Alert
color="red"
variant="light"
onClose={() => setSaveError(null)}
withCloseButton
mb="sm"
>
{saveError}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="subtle"
size="sm"
color="gray"
onClick={handleClose}
>
{t("cancel", "Cancel")}
</Button>
<Button
size="sm"
onClick={handleSave}
loading={saving}
disabled={!name.trim()}
>
{isEditMode
? t("watchedFolders.modal.saveChanges", "Save changes")
: t("watchedFolders.modal.createFolder", "Create folder")}
</Button>
</Group>
</div>
</div>
{/* ── Right panel: automation steps ── */}
<div
style={{
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<div style={{ padding: "1rem 1.5rem 0.5rem", flexShrink: 0 }}>
<SectionLabel>
{t("watchedFolders.modal.sectionSteps", "Steps")}
</SectionLabel>
{automationError && (
<Text size="xs" c="red" mt={4}>
{automationError}
</Text>
)}
</div>
<div
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
padding: "0 1.5rem 1.5rem",
}}
>
<AutomationCreation
mode={isEditMode ? AutomationMode.EDIT : AutomationMode.CREATE}
existingAutomation={existingAutomation ?? undefined}
onBack={handleClose}
onComplete={handleAutomationComplete}
onSaveFailed={() => {
setSaving(false);
setAutomationError(
t(
"watchedFolders.modal.automationRequired",
"Add at least one configured step before saving.",
),
);
}}
toolRegistry={toolRegistry}
hideMetadata
nameOverride={
name.trim() ||
t(
"watchedFolders.modal.automationNameFallback",
"Watched Folder Automation",
)
}
saveTriggerRef={automationSaveTrigger}
/>
</div>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,169 @@
import { useState } from "react";
import { Box, Button, Text, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import { useWatchedFolders } from "@app/hooks/useWatchedFolders";
import { useFolderRunStatuses } from "@app/hooks/useFolderRunStatuses";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { WatchedFolderManagementModal } from "@app/components/watchedFolders/WatchedFolderManagementModal";
import { DeleteFolderConfirmModal } from "@app/components/watchedFolders/DeleteFolderConfirmModal";
import { WatchedFolderCard } from "@app/components/watchedFolders/WatchedFolderCard";
import { WatchedFolder } from "@app/types/watchedFolders";
import { AutomationConfig } from "@app/types/automation";
import { automationStorage } from "@app/services/automationStorage";
import {
WATCHED_FOLDER_VIEW_ID,
WATCHED_FOLDER_WORKBENCH_ID,
} from "@app/components/watchedFolders/WatchedFoldersRegistration";
export function WatchedFolderSection() {
const { t } = useTranslation();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editFolder, setEditFolder] = useState<WatchedFolder | null>(null);
const [editAutomation, setEditAutomation] = useState<AutomationConfig | null>(
null,
);
const [deleteTarget, setDeleteTarget] = useState<WatchedFolder | null>(null);
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
const { folders, loading, deleteFolder, refreshFolders } =
useWatchedFolders();
const statuses = useFolderRunStatuses(folders);
const { setCustomWorkbenchViewData } = useToolWorkflow();
const { actions } = useNavigationActions();
const handleFolderClick = (folderId: string) => {
setActiveFolderId(folderId);
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId });
actions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
};
const handleEditFolder = async (
e: React.MouseEvent,
folder: WatchedFolder,
) => {
e.stopPropagation();
setEditFolder(folder);
const automation = await automationStorage.getAutomation(
folder.automationId,
);
setEditAutomation(automation);
setCreateModalOpen(true);
};
const handleDeleteClick = (e: React.MouseEvent, folder: WatchedFolder) => {
e.stopPropagation();
setDeleteTarget(folder);
};
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
if (activeFolderId === deleteTarget.id) {
setActiveFolderId(null);
actions.setWorkbench("fileEditor");
}
await deleteFolder(deleteTarget.id);
setDeleteTarget(null);
};
const handleModalClose = () => {
setCreateModalOpen(false);
setEditFolder(null);
setEditAutomation(null);
};
return (
<Box
style={{
borderTop: "1px solid var(--border-subtle)",
backgroundColor: "var(--bg-toolbar)",
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
}}
>
<Box px="sm" pt="xs" pb="2px" style={{ flexShrink: 0 }}>
<Box className="tool-subcategory-row">
<Text
className="tool-subcategory-row-title"
style={{ cursor: "pointer" }}
onClick={() => {
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, {
folderId: null,
});
actions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
}}
>
{t("watchedFolders.title", "Watched Folders")}
</Text>
<Box className="tool-subcategory-row-rule" />
</Box>
</Box>
<Box className="tool-picker-scrollable" style={{ flex: 1, minHeight: 0 }}>
<Stack gap={0} pb="xs">
{!loading && folders.length === 0 && (
<Text size="xs" c="dimmed" ta="center" py="sm" px="sm">
{t("watchedFolders.noFolders", "No watch folders yet")}
</Text>
)}
{folders.map((folder) => (
<WatchedFolderCard
key={folder.id}
folder={folder}
isActive={activeFolderId === folder.id}
status={statuses[folder.id] ?? "idle"}
onSelect={() => handleFolderClick(folder.id)}
onEdit={(e) => handleEditFolder(e, folder)}
onDelete={(e) => handleDeleteClick(e, folder)}
onFileDrop={(fileIds) => {
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, {
folderId: folder.id,
pendingFileIds: fileIds,
});
actions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
setActiveFolderId(folder.id);
}}
/>
))}
<Button
variant="subtle"
className="tool-button"
fullWidth
justify="flex-start"
px="sm"
leftSection={
<AddIcon
style={{ fontSize: 14, color: "var(--mantine-color-gray-5)" }}
/>
}
onClick={() => setCreateModalOpen(true)}
>
<Text size="sm" c="dimmed">
{t("watchedFolders.newFolder", "New folder")}
</Text>
</Button>
</Stack>
</Box>
<WatchedFolderManagementModal
opened={createModalOpen}
editFolder={editFolder}
existingAutomation={editAutomation}
onClose={handleModalClose}
onSaved={refreshFolders}
/>
<DeleteFolderConfirmModal
opened={!!deleteTarget}
folder={deleteTarget}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteTarget(null)}
/>
</Box>
);
}
@@ -0,0 +1,254 @@
/* Watched Folders home-page card grid.
*
* Mirrors the files-page card vocabulary (see filesPage/FilesPage.css) but
* with its own `.wf-*` namespace so the two surfaces can evolve
* independently. Values are intentionally copied rather than shared. */
@keyframes wf-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.35;
transform: scale(0.7);
}
}
.wf-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 1rem;
}
.wf-card {
position: relative;
display: flex;
flex-direction: column;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 0.85rem;
overflow: hidden;
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
transition:
transform 0.16s cubic-bezier(0.32, 0.72, 0.32, 1),
border-color 0.14s ease,
box-shadow 0.18s ease;
}
.wf-card:hover {
transform: translateY(-2px);
border-color: color-mix(
in srgb,
var(--accent-interactive, #6366f1) 35%,
var(--border-subtle)
);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.08),
0 8px 24px -8px rgba(0, 0, 0, 0.18);
}
.wf-card:focus-visible {
outline: 2px solid var(--accent-interactive, #6366f1);
outline-offset: 2px;
}
.wf-card.is-drop-target {
background: color-mix(
in srgb,
var(--accent-interactive, #6366f1) 10%,
var(--bg-surface)
);
box-shadow: 0 0 0 2px var(--accent-interactive, #6366f1);
border-color: var(--accent-interactive, #6366f1);
}
/* Dragging a file the folder already contains — signal it's a no-op, not an add. */
.wf-card.is-already-member {
background: color-mix(in srgb, #f59e0b 8%, var(--bg-surface));
box-shadow: 0 0 0 2px #f59e0b;
border-color: #f59e0b;
}
.wf-card-drag-message {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 0.5rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--text-primary);
background: color-mix(in srgb, var(--bg-surface) 72%, transparent);
backdrop-filter: blur(1px);
border-radius: inherit;
/* Let drag/drop events pass through to the card underneath. */
pointer-events: none;
}
.wf-card-thumb {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--text-muted) 5%, var(--bg-surface)),
var(--bg-surface)
);
aspect-ratio: 1.6 / 1;
padding: 0.75rem;
}
.wf-card-body {
padding: 0.65rem 0.85rem 0.7rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.wf-card-name {
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-primary);
letter-spacing: -0.005em;
}
.wf-card-meta {
font-size: 0.76rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 0.4rem;
}
.wf-card-status-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
}
.wf-card-status-dot.is-pulsing {
animation: wf-pulse 1.4s ease-in-out infinite;
}
.wf-card-actions {
position: absolute;
top: 0.4rem;
right: 0.4rem;
opacity: 0;
transition: opacity 0.12s ease;
z-index: 4;
display: flex;
gap: 0.15rem;
}
.wf-card:hover .wf-card-actions,
.wf-card:focus-within .wf-card-actions {
opacity: 1;
}
/* "New folder" tile - a dashed call-to-action that sits in the grid
alongside the real folder cards. */
.wf-new-tile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
text-align: center;
background: var(--bg-surface);
border: 1.5px dashed var(--border-subtle);
border-radius: 0.85rem;
padding: 1rem;
cursor: pointer;
color: var(--text-muted);
min-height: 9rem;
transition:
border-color 0.14s ease,
color 0.14s ease,
background-color 0.14s ease;
}
.wf-new-tile:hover {
border-color: var(--accent-interactive, #6366f1);
color: var(--accent-interactive, #6366f1);
background: color-mix(
in srgb,
var(--accent-interactive, #6366f1) 6%,
var(--bg-surface)
);
}
.wf-new-tile-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
background: color-mix(
in srgb,
var(--accent-interactive, #6366f1) 12%,
transparent
);
color: var(--accent-interactive, #6366f1);
}
.wf-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 4rem 1.5rem;
color: var(--text-muted);
text-align: center;
}
.wf-empty-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 5rem;
height: 5rem;
border-radius: 50%;
background: color-mix(
in srgb,
var(--accent-interactive, #6366f1) 12%,
transparent
);
color: var(--accent-interactive, #6366f1);
margin-bottom: 0.25rem;
}
.wf-empty-title {
font-size: 1.15rem;
color: var(--text-primary);
font-weight: 600;
letter-spacing: -0.01em;
}
.wf-empty-hint {
font-size: 0.9rem;
color: var(--text-muted);
max-width: 30rem;
line-height: 1.5;
}
@media (prefers-reduced-motion: reduce) {
.wf-card,
.wf-card:hover {
transform: none;
transition: none;
}
}
@@ -0,0 +1,54 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { WatchedFolderWorkbenchView } from "@app/components/watchedFolders/WatchedFolderWorkbenchView";
import { seedDefaultFolders } from "@app/data/watchedFolderPresets";
import { useWatchedFolderUrlSync } from "@app/hooks/useWatchedFolderUrlSync";
export const WATCHED_FOLDER_VIEW_ID = "watchedFolder";
export const WATCHED_FOLDER_WORKBENCH_ID = "custom:watchedFolder" as const;
export default function WatchedFoldersRegistration() {
const { t } = useTranslation();
const {
registerCustomWorkbenchView,
unregisterCustomWorkbenchView,
clearCustomWorkbenchViewData,
} = useToolWorkflow();
useWatchedFolderUrlSync();
// Keep refs to latest cleanup callbacks so the registration effect doesn't
// re-run (and tear down) when unregisterCustomWorkbenchView changes identity
// due to NavigationContext re-renders.
const unregisterRef = useRef(unregisterCustomWorkbenchView);
const clearRef = useRef(clearCustomWorkbenchViewData);
useEffect(() => {
unregisterRef.current = unregisterCustomWorkbenchView;
});
useEffect(() => {
clearRef.current = clearCustomWorkbenchViewData;
});
useEffect(() => {
seedDefaultFolders();
}, []);
useEffect(() => {
registerCustomWorkbenchView({
id: WATCHED_FOLDER_VIEW_ID,
workbenchId: WATCHED_FOLDER_WORKBENCH_ID,
label: t("watchedFolders.sidebarTitle", "Watched Folders"),
component: WatchedFolderWorkbenchView,
// Show the standard workbench bar (view switcher) so users can navigate
// back to Viewer / Active Files instead of being stranded in this view.
hideTopControls: false,
hideToolPanel: true,
});
return () => {
clearRef.current(WATCHED_FOLDER_VIEW_ID);
unregisterRef.current(WATCHED_FOLDER_VIEW_ID);
};
}, [registerCustomWorkbenchView]);
return null;
}