Watch homepage improvements

This commit is contained in:
Reece
2026-03-12 01:09:14 +00:00
parent c31d7a7c5d
commit 02bd302b4a
12 changed files with 669 additions and 137 deletions
@@ -96,7 +96,7 @@ export function CardExpansionModal({
willChange: 'top, left, width, height, opacity',
borderRadius: 'var(--mantine-radius-md)',
overflow: 'hidden',
backgroundColor: 'var(--mantine-color-body)',
backgroundColor: 'var(--bg-toolbar)',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 1.5rem 3rem rgba(0,0,0,0.3)',
@@ -150,9 +150,9 @@ export function CardExpansionModal({
{/* Body — only mount once open */}
{showBody && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, backgroundColor: 'var(--bg-toolbar)' }}>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<div style={{ padding: '0.75rem 1rem' }}>
<div style={{ padding: '0.75rem 1rem', backgroundColor: 'var(--bg-toolbar)' }}>
{children}
</div>
</ScrollArea>
@@ -160,7 +160,6 @@ export function CardExpansionModal({
<div style={{
padding: '0.75rem 1rem',
borderTop: '0.0625rem solid var(--border-subtle)',
backgroundColor: 'var(--bg-toolbar)',
flexShrink: 0,
}}>
{footer}
@@ -15,18 +15,49 @@ interface SmartFolderCardProps {
onSelect: () => void;
onEdit: (e: React.MouseEvent) => void;
onDelete: (e: React.MouseEvent) => void;
onFileDrop?: (fileIds: string[]) => void;
}
export function SmartFolderCard({ folder, isActive, status, onSelect, onEdit, onDelete }: SmartFolderCardProps) {
export function SmartFolderCard({ folder, isActive, status, onSelect, onEdit, onDelete, onFileDrop }: SmartFolderCardProps) {
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('watchfolderfileid') && !types.includes('watchfolderfileids')) 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('watchFolderFileIds');
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('watchFolderFileId');
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: `${folder.accentColor}18`, borderRadius: 'var(--mantine-radius-sm)' } : undefined}
>
<Button
variant={isActive ? 'light' : 'subtle'}
@@ -16,6 +16,7 @@ import { AutomationConfig } from '@app/types/automation';
import { iconMap } from '@app/components/tools/automate/iconMap';
import { automationStorage } from '@app/services/automationStorage';
import { folderStorage } from '@app/services/folderStorage';
import { fileStorage } from '@app/services/fileStorage';
import { folderRunStateStorage } from '@app/services/folderRunStateStorage';
import { executeAutomationSequence } from '@app/utils/automationExecutor';
import { SmartFolderManagementModal } from '@app/components/smartFolders/SmartFolderManagementModal';
@@ -51,6 +52,7 @@ interface FolderCardProps {
onDelete: (folder: SmartFolder) => void;
onOpen: (folderId: string) => void;
onDropFiles: (folder: SmartFolder, files: File[]) => void;
onDropSidebarFile: (folder: SmartFolder, fileIds: string[]) => void;
onTogglePause: (folder: SmartFolder) => void;
}
@@ -62,6 +64,7 @@ function FolderCard({
onDelete,
onOpen,
onDropFiles,
onDropSidebarFile,
onTogglePause,
}: FolderCardProps) {
const { t } = useTranslation();
@@ -105,7 +108,19 @@ function FolderCard({
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
onDropFiles(folder, Array.from(e.dataTransfer.files));
const multiRaw = e.dataTransfer.getData('watchFolderFileIds');
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('watchFolderFileId');
if (sidebarFileId) {
onDropSidebarFile(folder, [sidebarFileId]);
} else if (e.dataTransfer.files.length > 0) {
onDropFiles(folder, Array.from(e.dataTransfer.files));
}
};
const ops = automation?.operations ?? [];
@@ -512,7 +527,14 @@ export function SmartFolderHomePage() {
for (const file of pdfs) {
const inputFileId = `input-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
await folderStorage.addFileToFolder(folder.id, inputFileId, { status: 'processing' });
const originalFileId = (file as any).fileId as string | undefined;
await folderStorage.addFileToFolder(folder.id, inputFileId, {
status: 'processing',
inputFileId,
name: file.name,
...(originalFileId ? { originalFileId } : {}),
});
await folderStorage.storeInputFile(folder.id, inputFileId, file, file.name);
try {
const resultFiles = await executeAutomationSequence(automation, [file], toolRegistry);
const existingRuns = await folderRunStateStorage.getFolderRunState(folder.id);
@@ -542,6 +564,15 @@ export function SmartFolderHomePage() {
[toolRegistry]
);
const handleDropSidebarFile = useCallback(
async (folder: SmartFolder, fileIds: string[]) => {
const results = await Promise.all(fileIds.map(id => fileStorage.getStirlingFile(id as any)));
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);
@@ -615,6 +646,7 @@ export function SmartFolderHomePage() {
onDelete={setDeleteTarget}
onOpen={navigateToFolder}
onDropFiles={processFiles}
onDropSidebarFile={handleDropSidebarFile}
onTogglePause={handleTogglePause}
/>
);
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import {
Modal,
Button,
@@ -66,6 +66,10 @@ export function SmartFolderManagementModal({
setSaving(false);
}, [editFolder]);
useEffect(() => {
if (opened) resetState();
}, [opened, resetState]);
const handleClose = () => {
resetState();
onClose();
@@ -107,6 +107,11 @@ export function SmartFolderSection() {
onSelect={() => handleFolderClick(folder.id)}
onEdit={(e) => handleEditFolder(e, folder)}
onDelete={(e) => handleDeleteClick(e, folder)}
onFileDrop={(fileIds) => {
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId: folder.id, pendingFileIds: fileIds });
actions.setWorkbench(SMART_FOLDER_WORKBENCH_ID);
setActiveFolderId(folder.id);
}}
/>
))}
@@ -1,20 +1,19 @@
import { useState, useCallback, useEffect } from 'react';
import { Box, Text } from '@mantine/core';
import { Box, Text, ScrollArea } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { FileManagerProvider } from '@app/contexts/FileManagerContext';
import FileListArea from '@app/components/fileManager/FileListArea';
import { useFileManager } from '@app/hooks/useFileManager';
import { useAllFiles } from '@app/contexts/FileContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { StirlingFileStub } from '@app/types/fileContext';
import { SMART_FOLDER_VIEW_ID } from '@app/components/smartFolders/SmartFoldersRegistration';
import { SMART_FOLDER_VIEW_ID, SMART_FOLDER_WORKBENCH_ID } from '@app/components/smartFolders/SmartFoldersRegistration';
import { WatchFolderFileList } from '@app/components/smartFolders/WatchFolderFileList';
import { useNavigationActions } from '@app/contexts/NavigationContext';
export function SmartFolderSidebarPanel() {
const { t } = useTranslation();
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
const { loadRecentFiles } = useFileManager();
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
const { fileIds: activeFileIds } = useAllFiles();
const { customWorkbenchViews, setCustomWorkbenchViewData } = useToolWorkflow();
const { actions } = useNavigationActions();
const smartFolderView = customWorkbenchViews.find(v => v.id === SMART_FOLDER_VIEW_ID);
const folderId = smartFolderView?.data?.folderId ?? null;
@@ -26,21 +25,19 @@ export function SmartFolderSidebarPanel() {
useEffect(() => {
refreshRecentFiles();
window.addEventListener('stirling:files-changed', refreshRecentFiles);
return () => window.removeEventListener('stirling:files-changed', refreshRecentFiles);
}, [refreshRecentFiles]);
const handleRemoveFileByIndex = useCallback(async (index: number) => {
await handleRemoveFile(index, recentFiles, setRecentFiles);
}, [handleRemoveFile, recentFiles]);
const handleSendToFolder = useCallback(async (fileId: string, targetFolderId: string) => {
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId: targetFolderId, pendingFileId: fileId });
actions.setWorkbench(SMART_FOLDER_WORKBENCH_ID);
}, [setCustomWorkbenchViewData, actions]);
// Double-clicking a file sends it to the currently open folder (no-op on home page)
const handleFilesSelected = useCallback((files: StirlingFileStub[]) => {
if (files.length === 0 || !folderId) return;
files.forEach((file, i) => {
setTimeout(() => {
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId, pendingFileId: file.id });
}, i * 50);
});
}, [folderId, setCustomWorkbenchViewData]);
const handleNavigateToFolder = useCallback((targetFolderId: string) => {
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId: targetFolderId });
actions.setWorkbench(SMART_FOLDER_WORKBENCH_ID);
}, [setCustomWorkbenchViewData, actions]);
return (
<Box style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
@@ -49,21 +46,14 @@ export function SmartFolderSidebarPanel() {
{t('smartFolders.sidebarFiles', 'My Files')}
</Text>
</Box>
<FileManagerProvider
recentFiles={recentFiles}
onRecentFilesSelected={handleFilesSelected}
onNewFilesSelect={() => {}}
onClose={() => {}}
isFileSupported={(name) => name.toLowerCase().endsWith('.pdf')}
isOpen={true}
onFileRemove={handleRemoveFileByIndex}
modalHeight="100%"
refreshRecentFiles={refreshRecentFiles}
isLoading={loading}
activeFileIds={activeFileIds}
>
<FileListArea scrollAreaHeight="100%" scrollAreaStyle={{ flex: 1, minHeight: 0 }} />
</FileManagerProvider>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<WatchFolderFileList
files={recentFiles}
folderId={folderId}
onSendToFolder={handleSendToFolder}
onNavigateToFolder={handleNavigateToFolder}
/>
</ScrollArea>
</Box>
);
}
@@ -43,14 +43,18 @@ import { SmartFolderHomePage } from '@app/components/smartFolders/SmartFolderHom
import { useNavigationActions } from '@app/contexts/NavigationContext';
interface SmartFolderWorkbenchViewProps {
data: { folderId: string | null; pendingFileId?: string };
data: { folderId: string | null; pendingFileId?: string; pendingFileIds?: string[] };
}
interface StepProgress {
stepIndex: number;
operationName: string;
status: 'running' | 'completed' | 'error';
error?: string;
function timeAgo(date: Date): string {
const secs = Math.floor((Date.now() - date.getTime()) / 1000);
if (secs < 60) return 'just now';
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function humaniseOp(op: string): string {
@@ -85,7 +89,6 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
const { recentRuns, setRecentRuns } = useFolderRunState(folderId);
const [isDragOver, setIsDragOver] = useState(false);
const [stepProgresses, setStepProgresses] = useState<StepProgress[]>([]);
const [outputFiles, setOutputFiles] = useState<OutputFileRecord[]>([]);
const [inputFiles, setInputFiles] = useState<InputFileRecord[]>([]);
const [automation, setAutomation] = useState<AutomationConfig | null>(null);
@@ -96,9 +99,15 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
const handledPendingRef = useRef<string | null>(null);
useEffect(() => {
folderStorage.getOutputFilesByFolder(folderId).then(setOutputFiles);
folderStorage.getInputFilesByFolder(folderId).then(setInputFiles);
}, [folderId, recentRuns]);
const load = () => {
if (!folderId) return;
folderStorage.getOutputFilesByFolder(folderId).then(setOutputFiles);
folderStorage.getInputFilesByFolder(folderId).then(setInputFiles);
};
load();
const unsub = folderStorage.onFolderChange((id) => { if (id === folderId) load(); });
return unsub;
}, [folderId]);
useEffect(() => {
if (folder?.automationId) {
@@ -122,31 +131,13 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
await updateFileMetadata(inputFileId, { status: 'processing' });
setStepProgresses(prev => [
...prev,
{ stepIndex: 0, operationName: auto.operations[0]?.operation ?? '', status: 'running' },
]);
const resultFiles = await executeAutomationSequence(
auto,
[inputFile],
toolRegistry,
(stepIndex, operationName) => {
setStepProgresses(prev => {
const updated = prev.filter(p => !(p.stepIndex === stepIndex && p.operationName === operationName));
return [...updated, { stepIndex, operationName, status: 'running' }];
});
},
(stepIndex) => {
setStepProgresses(prev =>
prev.map(p => p.stepIndex === stepIndex ? { ...p, status: 'completed' } : p)
);
},
(stepIndex, error) => {
setStepProgresses(prev =>
prev.map(p => p.stepIndex === stepIndex ? { ...p, status: 'error', error } : p)
);
}
() => {},
() => {},
() => {}
);
const newRuns: SmartFolderRunEntry[] = [...recentRuns];
@@ -177,12 +168,13 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
);
const handleFiles = useCallback(
async (files: FileList | File[]) => {
async (files: FileList | File[], sourceFileId?: string) => {
const fileArray = Array.from(files);
for (const file of fileArray) {
if (!file.name.toLowerCase().endsWith('.pdf')) continue;
const inputFileId = `input-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
await addFile(inputFileId, { status: 'pending', inputFileId, name: file.name });
const originalFileId = sourceFileId ?? (file as any).fileId as string | undefined;
await addFile(inputFileId, { status: 'pending', inputFileId, name: file.name, ...(originalFileId ? { originalFileId } : {}) });
await folderStorage.storeInputFile(folderId, inputFileId, file, file.name);
runAutomation(file, inputFileId);
}
@@ -191,12 +183,30 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
);
useEffect(() => {
const { pendingFileId } = data;
const { pendingFileId, pendingFileIds } = data;
// Multi-file pending (from sidebar multi-select drag to sidebar folder card)
if (pendingFileIds && pendingFileIds.length > 0) {
const key = pendingFileIds.join(',');
if (handledPendingRef.current === key) return;
handledPendingRef.current = key;
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId });
Promise.all(
pendingFileIds.map(id => fileStorage.getStirlingFile(id as FileId))
).then(async results => {
for (let i = 0; i < results.length; i++) {
if (results[i]) await handleFiles([results[i]!], pendingFileIds[i]);
}
});
return;
}
// Single pending file (legacy path)
if (!pendingFileId || handledPendingRef.current === pendingFileId) return;
handledPendingRef.current = pendingFileId;
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId });
fileStorage.getStirlingFile(pendingFileId as FileId).then((stirlingFile) => {
if (stirlingFile) handleFiles([stirlingFile]);
if (stirlingFile) handleFiles([stirlingFile], pendingFileId);
});
}, [data, folderId, handleFiles, setCustomWorkbenchViewData]);
@@ -204,7 +214,30 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
(e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files);
// Multi-file sidebar drag
const multiRaw = e.dataTransfer.getData('watchFolderFileIds');
if (multiRaw) {
try {
const ids: string[] = JSON.parse(multiRaw);
Promise.all(ids.map(id => fileStorage.getStirlingFile(id as FileId))).then(async results => {
for (let i = 0; i < results.length; i++) {
if (results[i]) await handleFiles([results[i]!], ids[i]);
}
});
return;
} catch { /* fall through */ }
}
// Single sidebar drag
const sidebarFileId = e.dataTransfer.getData('watchFolderFileId');
if (sidebarFileId) {
fileStorage.getStirlingFile(sidebarFileId as FileId).then((stirlingFile) => {
if (stirlingFile) handleFiles([stirlingFile], sidebarFileId);
});
} else if (e.dataTransfer.files.length > 0) {
handleFiles(e.dataTransfer.files);
}
},
[handleFiles]
);
@@ -482,43 +515,13 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
<ScrollArea style={{ flex: 1 }}>
<Stack gap="xs" px="md" pb="md">
{stepProgresses.length === 0 && fileIds.length === 0 ? (
{fileIds.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="lg">
{t('smartFolders.workbench.noActivity', 'No activity yet — drop a PDF to start')}
</Text>
) : (
<>
{/* Live step progress */}
{[...stepProgresses].reverse().map((step, i) => (
<Box
key={i}
style={{
padding: '0.625rem 0.75rem',
borderRadius: 'var(--mantine-radius-sm)',
backgroundColor: 'var(--mantine-color-default-hover)',
border: '0.0625rem solid var(--border-subtle)',
}}
>
<Group gap="xs" wrap="nowrap">
{step.status === 'running' && <Loader size="0.625rem" />}
{step.status === 'completed' && (
<CheckCircleOutlineIcon style={{ fontSize: '0.875rem', color: '#22c55e', flexShrink: 0 }} />
)}
{step.status === 'error' && (
<ErrorOutlineIcon style={{ fontSize: '0.875rem', color: '#ef4444', flexShrink: 0 }} />
)}
<Text size="xs" style={{ flex: 1, minWidth: 0 }} lineClamp={1}>
{`Step ${step.stepIndex + 1}: ${humaniseOp(step.operationName)}`}
</Text>
</Group>
{step.error && (
<Text size="xs" c="red" mt={4}>{step.error}</Text>
)}
</Box>
))}
{/* File status list */}
{fileIds.map((fileId) => {
{[...fileIds].reverse().map((fileId) => {
const meta = folderRecord?.files[fileId];
const status = meta?.status ?? 'pending';
const run = recentRuns.find(r => r.inputFileId === fileId);
@@ -569,9 +572,9 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
<DownloadIcon style={{ fontSize: '1rem' }} />
</ActionIcon>
)}
<Text size="xs" c="dimmed" style={{ fontSize: '0.625rem', flexShrink: 0 }}>
<Text size="xs" c="dimmed" style={{ fontSize: '0.625rem', flexShrink: 0, width: '3.5rem', textAlign: 'right' }}>
{(meta?.processedAt || meta?.addedAt)
? new Date((meta.processedAt ?? meta.addedAt)!).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
? timeAgo(new Date((meta.processedAt ?? meta.addedAt)!))
: ''}
</Text>
</Box>
@@ -0,0 +1,426 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { Box, Text, ActionIcon, Group, Popover, Stack, Button, Tooltip } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import AddIcon from '@mui/icons-material/Add';
import CheckIcon from '@mui/icons-material/Check';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import CloseIcon from '@mui/icons-material/Close';
import { StirlingFileStub } from '@app/types/fileContext';
import { useFolderMembership } from '@app/hooks/useFolderMembership';
import { useAllSmartFolders } from '@app/hooks/useAllSmartFolders';
import { iconMap } from '@app/components/tools/automate/iconMap';
interface WatchFolderFileListProps {
files: StirlingFileStub[];
/** Currently open folder id, or null if on home page */
folderId: string | null;
onSendToFolder: (fileId: string, folderId: string) => void;
onNavigateToFolder: (folderId: string) => void;
}
function formatSize(bytes?: number): string {
if (!bytes) return '';
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} B`;
}
function FileRow({
file,
index,
currentFolderId,
memberFolderIds,
isSelected,
onToggleSelect,
onSendToFolder,
onNavigateToFolder,
onDragStart,
}: {
file: StirlingFileStub;
index: number;
currentFolderId: string | null;
memberFolderIds: string[];
isSelected: boolean;
onToggleSelect: (idx: number, shiftKey: boolean) => void;
onSendToFolder: (fileId: string, folderId: string) => void;
onNavigateToFolder: (folderId: string) => void;
onDragStart: (e: React.DragEvent, file: StirlingFileStub) => void;
}) {
const { t } = useTranslation();
const folders = useAllSmartFolders();
const [hovered, setHovered] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const isInCurrentFolder = currentFolderId !== null && memberFolderIds.includes(currentFolderId);
const otherFolderIds = memberFolderIds.filter(id => id !== currentFolderId);
const otherFolders = otherFolderIds.map(id => folders.find(f => f.id === id)).filter(Boolean) as typeof folders;
const handleRowClick = (e: React.MouseEvent) => {
// Don't trigger selection when clicking action areas
const target = e.target as HTMLElement;
if (target.closest('[data-no-select]')) return;
onToggleSelect(index, e.shiftKey);
};
return (
<Box
draggable
onDragStart={(e) => onDragStart(e, file)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={handleRowClick}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
padding: '0.3rem 0.5rem',
borderRadius: 'var(--mantine-radius-sm)',
borderBottom: '0.0625rem solid var(--border-subtle)',
cursor: 'grab',
backgroundColor: isSelected
? 'var(--mantine-color-blue-light)'
: hovered
? 'var(--mantine-color-default-hover)'
: 'transparent',
outline: isSelected ? '0.0625rem solid var(--mantine-color-blue-light-hover)' : 'none',
transition: 'background-color 0.1s ease',
minWidth: 0,
userSelect: 'none',
}}
>
{/* Drag handle */}
<DragIndicatorIcon
style={{
fontSize: '0.875rem',
color: hovered || isSelected ? 'var(--mantine-color-dimmed)' : 'transparent',
flexShrink: 0,
transition: 'color 0.1s ease',
}}
/>
{/* File info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" fw={500} style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.3 }}>
{file.name}
</Text>
{file.size && (
<Text size="xs" c="dimmed" style={{ fontSize: '0.625rem', lineHeight: 1.2 }}>
{formatSize(file.size)}
</Text>
)}
</Box>
{/* Folder badges for files in other folders */}
{otherFolders.slice(0, 1).map(memberFolder => (
<Tooltip key={memberFolder.id} label={t('smartFolders.fileList.openFolder', 'Open in {{name}}', { name: memberFolder.name })} withArrow>
<Box
data-no-select
onClick={() => onNavigateToFolder(memberFolder.id)}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.2rem',
padding: '0.125rem 0.375rem',
borderRadius: '1rem',
backgroundColor: `${memberFolder.accentColor}18`,
border: `0.0625rem solid ${memberFolder.accentColor}30`,
cursor: 'pointer',
flexShrink: 0,
maxWidth: '5rem',
}}
>
<Box style={{ width: '0.4rem', height: '0.4rem', borderRadius: '50%', backgroundColor: memberFolder.accentColor, flexShrink: 0 }} />
<Text style={{ fontSize: '0.5625rem', color: memberFolder.accentColor, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{memberFolder.name}
</Text>
<ChevronRightIcon style={{ fontSize: '0.5rem', color: memberFolder.accentColor, flexShrink: 0 }} />
</Box>
</Tooltip>
))}
{otherFolders.length > 1 && (
<Text style={{ fontSize: '0.5625rem', color: 'var(--mantine-color-dimmed)', flexShrink: 0 }}>
+{otherFolders.length - 1}
</Text>
)}
{/* Action button */}
{isInCurrentFolder ? (
<Tooltip label={t('smartFolders.fileList.alreadyInFolder', 'Already in this folder')} withArrow>
<ActionIcon data-no-select size="xs" variant="subtle" color="teal" style={{ flexShrink: 0 }} disabled>
<CheckIcon style={{ fontSize: '0.75rem' }} />
</ActionIcon>
</Tooltip>
) : currentFolderId !== null ? (
// Inside a folder — directly add
<Tooltip label={t('smartFolders.fileList.addToFolder', 'Add to this folder')} withArrow>
<ActionIcon
data-no-select
size="xs"
variant="subtle"
color="gray"
style={{ flexShrink: 0, opacity: hovered ? 1 : 0, transition: 'opacity 0.1s ease' }}
onClick={() => onSendToFolder(file.id, currentFolderId)}
>
<AddIcon style={{ fontSize: '0.75rem' }} />
</ActionIcon>
</Tooltip>
) : (
// Home page — folder picker popover
<Popover
opened={pickerOpen}
onChange={setPickerOpen}
position="right"
withArrow
shadow="md"
withinPortal
>
<Popover.Target>
<ActionIcon
data-no-select
size="xs"
variant="subtle"
color="gray"
style={{ flexShrink: 0, opacity: hovered || pickerOpen ? 1 : 0, transition: 'opacity 0.1s ease' }}
onClick={(e) => { e.stopPropagation(); setPickerOpen(o => !o); }}
>
<AddIcon style={{ fontSize: '0.75rem' }} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown p="xs" style={{ minWidth: '10rem' }}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase" mb="xs" style={{ fontSize: '0.5625rem', letterSpacing: '0.05em' }}>
{t('smartFolders.fileList.addToFolder', 'Add to folder')}
</Text>
<Stack gap={2}>
{folders.filter(f => !memberFolderIds.includes(f.id)).map(folder => {
const FolderIconComp = iconMap[folder.icon as keyof typeof iconMap] || iconMap.FolderIcon;
return (
<Button
key={folder.id}
variant="subtle"
size="xs"
justify="flex-start"
fullWidth
leftSection={
<Box style={{ width: 14, height: 14, borderRadius: '50%', backgroundColor: `${folder.accentColor}22`, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<FolderIconComp style={{ fontSize: 9, color: folder.accentColor }} />
</Box>
}
onClick={() => {
onSendToFolder(file.id, folder.id);
setPickerOpen(false);
}}
>
<Text size="xs" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{folder.name}
</Text>
</Button>
);
})}
{folders.filter(f => !memberFolderIds.includes(f.id)).length === 0 && (
<Text size="xs" c="dimmed" ta="center" py="xs">
{memberFolderIds.length > 0
? t('smartFolders.fileList.inAllFolders', 'Already in all folders')
: t('smartFolders.noFolders', 'No watch folders yet')}
</Text>
)}
</Stack>
</Popover.Dropdown>
</Popover>
)}
</Box>
);
}
function SectionHeader({ label, count }: { label: string; count: number }) {
return (
<Box style={{ padding: '0.5rem 0.5rem 0.25rem', display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
<Text style={{ fontSize: '0.5625rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--mantine-color-dimmed)' }}>
{label}
</Text>
<Text style={{ fontSize: '0.5625rem', color: 'var(--mantine-color-dimmed)' }}>
{count}
</Text>
</Box>
);
}
export function WatchFolderFileList({ files, folderId, onSendToFolder, onNavigateToFolder }: WatchFolderFileListProps) {
const { t } = useTranslation();
const membership = useFolderMembership();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const lastSelectedIdxRef = useRef<number | null>(null);
// Ref for drag ghost cleanup
const ghostRef = useRef<HTMLDivElement | null>(null);
const unassigned = files.filter(f => !membership.has(f.id));
const inFolders = files.filter(f => membership.has(f.id));
// All files in display order (unassigned first, then in-folders)
const orderedFiles = [...unassigned, ...inFolders];
const handleToggleSelect = useCallback((idx: number, shiftKey: boolean) => {
const file = orderedFiles[idx];
if (!file) return;
setSelectedIds(prev => {
const next = new Set(prev);
if (shiftKey && lastSelectedIdxRef.current !== null) {
// Range select
const start = Math.min(lastSelectedIdxRef.current, idx);
const end = Math.max(lastSelectedIdxRef.current, idx);
for (let i = start; i <= end; i++) {
if (orderedFiles[i]) next.add(orderedFiles[i].id);
}
} else {
if (next.has(file.id)) {
next.delete(file.id);
} else {
next.add(file.id);
}
}
return next;
});
lastSelectedIdxRef.current = idx;
}, [orderedFiles]);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
lastSelectedIdxRef.current = null;
}, []);
// Escape key to clear selection
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') clearSelection();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [clearSelection]);
const handleDragStart = useCallback((e: React.DragEvent, file: StirlingFileStub) => {
const isFileSelected = selectedIds.has(file.id);
const isMulti = isFileSelected && selectedIds.size > 1;
if (isMulti) {
const ids = Array.from(selectedIds);
e.dataTransfer.setData('watchFolderFileIds', JSON.stringify(ids));
// Also write single as fallback for older handlers
e.dataTransfer.setData('watchFolderFileId', ids[0]);
// Custom ghost showing "N files"
const ghost = document.createElement('div');
ghost.style.cssText = [
'position:fixed',
'top:-9999px',
'left:-9999px',
'padding:0.25rem 0.75rem',
'border-radius:0.5rem',
'background:var(--mantine-color-blue-filled,#3b82f6)',
'color:#fff',
'font-size:0.75rem',
'font-weight:600',
'white-space:nowrap',
'pointer-events:none',
'z-index:9999',
'font-family:var(--mantine-font-family,sans-serif)',
].join(';');
ghost.textContent = `${ids.length} files`;
document.body.appendChild(ghost);
ghostRef.current = ghost;
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2);
e.dataTransfer.effectAllowed = 'copy';
} else {
e.dataTransfer.setData('watchFolderFileId', file.id);
e.dataTransfer.effectAllowed = 'copy';
}
}, [selectedIds]);
// Clean up ghost and clear selection after drag ends
const handleDragEnd = useCallback(() => {
if (ghostRef.current) {
document.body.removeChild(ghostRef.current);
ghostRef.current = null;
}
clearSelection();
}, [clearSelection]);
if (files.length === 0) {
return (
<Text size="xs" c="dimmed" ta="center" py="xl" px="sm">
{t('smartFolders.fileList.empty', 'No files yet — upload a PDF to get started')}
</Text>
);
}
return (
<Box
style={{ padding: '0.25rem 0' }}
onDragEnd={handleDragEnd}
>
{/* Selection count pill */}
{selectedIds.size >= 2 && (
<Group
gap="xs"
align="center"
style={{
padding: '0.25rem 0.5rem',
margin: '0 0.25rem 0.25rem',
borderRadius: 'var(--mantine-radius-sm)',
backgroundColor: 'var(--mantine-color-blue-light)',
border: '0.0625rem solid var(--mantine-color-blue-light-hover)',
}}
>
<Text size="xs" fw={600} style={{ flex: 1, color: 'var(--mantine-color-blue-filled)', fontSize: '0.6875rem' }}>
{selectedIds.size} {t('smartFolders.fileList.selected', 'selected')}
</Text>
<ActionIcon size="xs" variant="transparent" onClick={clearSelection} style={{ color: 'var(--mantine-color-blue-filled)' }}>
<CloseIcon style={{ fontSize: '0.75rem' }} />
</ActionIcon>
</Group>
)}
{unassigned.length > 0 && (
<>
{inFolders.length > 0 && (
<SectionHeader label={t('smartFolders.fileList.unassigned', 'Unassigned')} count={unassigned.length} />
)}
{unassigned.map((file, i) => (
<FileRow
key={file.id}
file={file}
index={i}
currentFolderId={folderId}
memberFolderIds={[]}
isSelected={selectedIds.has(file.id)}
onToggleSelect={handleToggleSelect}
onSendToFolder={onSendToFolder}
onNavigateToFolder={onNavigateToFolder}
onDragStart={handleDragStart}
/>
))}
</>
)}
{inFolders.length > 0 && (
<>
<SectionHeader label={t('smartFolders.fileList.inFolders', 'In folders')} count={inFolders.length} />
{inFolders.map((file, i) => (
<FileRow
key={file.id}
file={file}
index={unassigned.length + i}
currentFolderId={folderId}
memberFolderIds={membership.get(file.id) ?? []}
isSelected={selectedIds.has(file.id)}
onToggleSelect={handleToggleSelect}
onSendToFolder={onSendToFolder}
onNavigateToFolder={onNavigateToFolder}
onDragStart={handleDragStart}
/>
))}
</>
)}
</Box>
);
}
@@ -1,4 +1,5 @@
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Stack, Text, ScrollArea } from '@mantine/core';
import { ToolRegistryEntry, ToolRegistry, getToolSupportsAutomate } from '@app/data/toolsTaxonomy';
@@ -28,7 +29,9 @@ export default function ToolSelector({
const [opened, setOpened] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [shouldAutoFocus, setShouldAutoFocus] = useState(false);
const [dropdownRect, setDropdownRect] = useState<DOMRect | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Filter out excluded tools (like 'automate' itself) and tools that don't support automation
const baseFilteredTools = useMemo(() => {
@@ -111,10 +114,28 @@ export default function ToolSelector({
setShouldAutoFocus(true); // Request auto-focus for the input
};
// Track container rect for portal positioning
useEffect(() => {
if (!opened) return;
const updateRect = () => {
if (containerRef.current) setDropdownRect(containerRef.current.getBoundingClientRect());
};
updateRect();
window.addEventListener('scroll', updateRect, true);
window.addEventListener('resize', updateRect);
return () => {
window.removeEventListener('scroll', updateRect, true);
window.removeEventListener('resize', updateRect);
};
}, [opened]);
// Handle click outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
const target = event.target as Node;
const inContainer = containerRef.current?.contains(target);
const inDropdown = dropdownRef.current?.contains(target);
if (!inContainer && !inDropdown) {
setOpened(false);
setSearchTerm('');
}
@@ -181,24 +202,31 @@ export default function ToolSelector({
/>
)}
{/* Custom dropdown */}
{opened && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: Z_INDEX_AUTOMATE_DROPDOWN,
backgroundColor: 'var(--mantine-color-body)',
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: 'var(--mantine-radius-sm)',
boxShadow: 'var(--mantine-shadow-sm)',
marginTop: '4px',
minWidth: '16rem'
}}
>
<ScrollArea h={350}>
{/* Custom dropdown — rendered in a portal so it escapes modal overflow */}
{opened && dropdownRect && createPortal(
(() => {
const spaceBelow = window.innerHeight - dropdownRect.bottom - 8;
const spaceAbove = dropdownRect.top - 8;
const maxH = Math.max(120, Math.min(350, spaceBelow > 120 ? spaceBelow : spaceAbove));
const openUpward = spaceBelow < 120 && spaceAbove > spaceBelow;
const top = openUpward ? dropdownRect.top - maxH - 4 : dropdownRect.bottom + 4;
return (
<div
ref={dropdownRef}
style={{
position: 'fixed',
top,
left: dropdownRect.left,
width: dropdownRect.width,
zIndex: Z_INDEX_AUTOMATE_DROPDOWN,
backgroundColor: 'var(--mantine-color-body)',
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: 'var(--mantine-radius-sm)',
boxShadow: 'var(--mantine-shadow-sm)',
minWidth: '16rem',
}}
>
<ScrollArea h={maxH}>
<Stack gap="sm" p="sm">
{displayGroups.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" p="md">
@@ -212,7 +240,10 @@ export default function ToolSelector({
)}
</Stack>
</ScrollArea>
</div>
</div>
);
})(),
document.body
)}
</div>
);
+14 -5
View File
@@ -8,9 +8,9 @@ import { useState, useEffect } from 'react';
import { folderStorage } from '@app/services/folderStorage';
import { useAllSmartFolders } from '@app/hooks/useAllSmartFolders';
export function useFolderMembership(): Map<string, string> {
export function useFolderMembership(): Map<string, string[]> {
const folders = useAllSmartFolders();
const [membership, setMembership] = useState<Map<string, string>>(new Map());
const [membership, setMembership] = useState<Map<string, string[]>>(new Map());
useEffect(() => {
if (folders.length === 0) {
@@ -19,13 +19,19 @@ export function useFolderMembership(): Map<string, string> {
}
const load = async () => {
const map = new Map<string, string>();
const map = new Map<string, string[]>();
const add = (fileId: string, folderId: string) => {
const existing = map.get(fileId);
if (existing) { if (!existing.includes(folderId)) existing.push(folderId); }
else map.set(fileId, [folderId]);
};
for (const folder of folders) {
try {
const record = await folderStorage.getFolderData(folder.id);
if (record) {
Object.keys(record.files).forEach(fileId => {
map.set(fileId, folder.id);
Object.entries(record.files).forEach(([fileId, meta]) => {
add(fileId, folder.id);
if (meta?.originalFileId) add(meta.originalFileId, folder.id);
});
}
} catch {
@@ -36,6 +42,9 @@ export function useFolderMembership(): Map<string, string> {
};
load();
window.addEventListener('folder-storage-changed', load);
return () => window.removeEventListener('folder-storage-changed', load);
}, [folders]);
return membership;
@@ -81,6 +81,7 @@ class FileStorageService {
reject(request.error);
};
request.onsuccess = () => {
window.dispatchEvent(new CustomEvent('stirling:files-changed'));
resolve();
};
} catch (error) {
+1
View File
@@ -22,6 +22,7 @@ export interface FolderFileMetadata {
processedAt?: Date;
displayFileId?: string; // output file id
inputFileId?: string;
originalFileId?: string; // original StirlingFile UUID (when sent from sidebar)
errorMessage?: string;
failedAttempts?: number;
name?: string; // original filename