diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 55397ccb9e..233760298a 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -6062,6 +6062,14 @@ stepOf = "Step {{step}} of {{total}}" toolChainDesc = "Configure the tools this policy runs on each document." typesSelected = "{{count}} types selected" +[policy] +badgeEnforcing = "{{name}} enforcing..." +badgeRan = "{{name}} policy ran on this file" +blockingAction = "{{action}} blocked while enforcing policy, please wait..." +dismiss = "Dismiss overlay" +enforcingTitle = "Enforcing policy..." +viewAnyway = "View file (policy still enforcing)" + [portal.accountLink.card] billingNote = "Unattended processing bills against your org wallet." eyebrow = "Account link" diff --git a/frontend/editor/src/core/components/fileEditor/FileEditor.tsx b/frontend/editor/src/core/components/fileEditor/FileEditor.tsx index ed8944fb01..8cfef80d83 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/editor/src/core/components/fileEditor/FileEditor.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo, useEffect } from "react"; +import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { flushSync } from "react-dom"; import { Center, Box, LoadingOverlay } from "@mantine/core"; import { Dropzone } from "@mantine/dropzone"; @@ -19,6 +19,10 @@ import { FileId, StirlingFile } from "@app/types/fileContext"; import { alert } from "@app/components/toast"; import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges"; +import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges"; + +const EMPTY_POLICIES: FileItemPolicyRef[] = []; interface FileEditorProps { onOpenPageEditor?: () => void; @@ -31,6 +35,8 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"], }: FileEditorProps) => { + const policyFileBadges = usePolicyFileBadges(); + // Utility function to check if a file extension is supported const isFileSupported = useCallback( (fileName: string): boolean => { @@ -52,6 +58,14 @@ const FileEditor = ({ [state.files.byId, state.files.ids], ); + // Always-current refs so callbacks can read the latest stubs/selection without + // closing over them as deps — prevents every callback from regenerating whenever + // any stub changes (e.g. thumbnail load), which would bust React.memo on every thumbnail. + const stubsRef = useRef(activeStirlingFileStubs); + stubsRef.current = activeStirlingFileStubs; + const selectedFileIdsRef = useRef(selectedFileIds); + selectedFileIdsRef.current = selectedFileIds; + // Get navigation actions const { actions: navActions } = useNavigationActions(); @@ -141,7 +155,7 @@ const FileEditor = ({ // File reordering handler for drag and drop const handleReorderFiles = useCallback( (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => { - const currentIds = activeStirlingFileStubs.map((r) => r.id); + const currentIds = stubsRef.current.map((r) => r.id); // Find indices const sourceIndex = currentIds.findIndex((id) => id === sourceFileId); @@ -211,38 +225,27 @@ const FileEditor = ({ const moveCount = filesToMove.length; showStatus(`${moveCount > 1 ? `${moveCount} files` : "File"} reordered`); }, - [activeStirlingFileStubs, reorderFiles, _setStatus], + [reorderFiles, showStatus], ); // File operations using context const handleCloseFile = useCallback( (fileId: FileId) => { - const record = activeStirlingFileStubs.find((r) => r.id === fileId); + const record = stubsRef.current.find((r) => r.id === fileId); const file = record ? selectors.getFile(record.id) : null; if (record && file) { - // Remove file from context but keep in storage (close, don't delete) - const contextFileId = record.id; - removeFiles([contextFileId], false); - - // Remove from context selections - const currentSelected = selectedFileIds.filter( - (id) => id !== contextFileId, + removeFiles([record.id], false); + setSelectedFiles( + selectedFileIdsRef.current.filter((id) => id !== record.id), ); - setSelectedFiles(currentSelected); } }, - [ - activeStirlingFileStubs, - selectors, - removeFiles, - setSelectedFiles, - selectedFileIds, - ], + [selectors, removeFiles, setSelectedFiles], ); const handleDownloadFile = useCallback( async (fileId: FileId) => { - const record = activeStirlingFileStubs.find((r) => r.id === fileId); + const record = stubsRef.current.find((r) => r.id === fileId); const file = record ? selectors.getFile(record.id) : null; console.log("[FileEditor] handleDownloadFile called:", { fileId, @@ -278,12 +281,12 @@ const FileEditor = ({ } } }, - [activeStirlingFileStubs, selectors, fileActions], + [selectors, fileActions], ); const handleUnzipFile = useCallback( async (fileId: FileId) => { - const record = activeStirlingFileStubs.find((r) => r.id === fileId); + const record = stubsRef.current.find((r) => r.id === fileId); const file = record ? selectors.getFile(record.id) : null; if (record && file) { try { @@ -326,24 +329,19 @@ const FileEditor = ({ } } }, - [activeStirlingFileStubs, selectors, fileActions, removeFiles], + [selectors, fileActions, removeFiles], ); const handleViewFile = useCallback( (fileId: FileId) => { - const index = activeStirlingFileStubs.findIndex((r) => r.id === fileId); + const index = stubsRef.current.findIndex((r) => r.id === fileId); if (index !== -1) { setActiveFileId(fileId as string); setActiveFileIndex(index); navActions.setWorkbench("viewer"); } }, - [ - activeStirlingFileStubs, - setActiveFileId, - setActiveFileIndex, - navActions.setWorkbench, - ], + [setActiveFileId, setActiveFileIndex, navActions.setWorkbench], ); const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => { @@ -412,6 +410,10 @@ const FileEditor = ({ onUnzipFile={handleUnzipFile} toolMode={toolMode} isSupported={isFileSupported(record.name)} + policies={ + policyFileBadges.get(record.id as string) ?? + EMPTY_POLICIES + } /> ); })} diff --git a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.module.css b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.module.css index fcd0335238..f0e0ce91bc 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.module.css +++ b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.module.css @@ -123,6 +123,14 @@ left: 0; right: 0; bottom: calc(var(--file-meta-line-height) + var(--file-meta-gap)); + display: flex; + align-items: flex-start; + justify-content: center; + gap: 4px; + overflow: hidden; +} + +.fileNameText { display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; @@ -131,6 +139,12 @@ text-overflow: ellipsis; overflow-wrap: break-word; word-break: normal; + min-width: 0; +} + +/* Layout-only wrapper for the shared PolicyBadges row in the file-name line. */ +.fileNameBadges { + padding-top: 2px; } .fileMeta { @@ -207,6 +221,7 @@ pointer-events: auto; } +/* Policy badges pinned to top-right of thumbnail */ .statusDot { display: inline-block; width: 8px; diff --git a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx index 07d134f75c..fe834b43d7 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useRef, useMemo } from "react"; -import { Text, Modal, Group, Stack, Tooltip } from "@mantine/core"; +import { Text, Modal, Group, Loader, Stack, Tooltip } from "@mantine/core"; import { ActionIcon } from "@app/ui/ActionIcon"; import { Button } from "@app/ui/Button"; import { useIsMobile } from "@app/hooks/useIsMobile"; @@ -16,11 +16,17 @@ import HistoryIcon from "@mui/icons-material/History"; import PushPinIcon from "@mui/icons-material/PushPin"; import LockOpenIcon from "@mui/icons-material/LockOpen"; import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import { draggable, dropTargetForElements, } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { StirlingFileStub } from "@app/types/fileContext"; +import { + PolicyBadges, + type FileItemPolicyRef, +} from "@app/components/shared/PolicyBadges"; +import { PolicyEnforcingOverlay } from "@app/components/shared/PolicyEnforcingOverlay"; import { zipFileService } from "@app/services/zipFileService"; import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css"; @@ -57,6 +63,7 @@ interface FileEditorThumbnailProps { onUnzipFile?: (fileId: FileId) => void; toolMode?: boolean; isSupported?: boolean; + policies?: FileItemPolicyRef[]; } const FileEditorThumbnail = ({ @@ -67,6 +74,7 @@ const FileEditorThumbnail = ({ onDownloadFile, onUnzipFile, isSupported = true, + policies = [], }: FileEditorThumbnailProps) => { const { t } = useTranslation(); const { config } = useAppConfig(); @@ -286,8 +294,28 @@ const FileEditorThumbnail = ({ const [showVersionHistory, setShowVersionHistory] = useState(false); - const hoverActions = useMemo( - () => [ + const policyEnforcing = policies.some((p) => p.enforcing); + + const hoverActions = useMemo(() => { + const uploadLabel = isUploaded + ? t("fileManager.updateOnServer", "Update on Server") + : t("fileManager.uploadToServer", "Upload to Server"); + const enforcingTooltip = (action: string): React.ReactNode => ( + + + + + {t( + "policy.blockingAction", + "{{action}} blocked while enforcing policy, please wait...", + { action }, + )} + + + + + ); + return [ { id: "view", icon: , @@ -331,6 +359,10 @@ const FileEditorThumbnail = ({ id: "download", icon: , label: terminology.download, + disabled: policyEnforcing, + tooltip: policyEnforcing + ? enforcingTooltip(terminology.download) + : undefined, onClick: (e) => { e.stopPropagation(); onDownloadFile(file.id); @@ -341,9 +373,11 @@ const FileEditorThumbnail = ({ { id: "upload", icon: , - label: isUploaded - ? t("fileManager.updateOnServer", "Update on Server") - : t("fileManager.uploadToServer", "Upload to Server"), + label: uploadLabel, + disabled: policyEnforcing, + tooltip: policyEnforcing + ? enforcingTooltip(uploadLabel) + : undefined, onClick: (e: React.MouseEvent) => { e.stopPropagation(); setShowUploadModal(true); @@ -357,6 +391,10 @@ const FileEditorThumbnail = ({ id: "share", icon: , label: t("fileManager.share", "Share"), + disabled: policyEnforcing, + tooltip: policyEnforcing + ? enforcingTooltip(t("fileManager.share", "Share")) + : undefined, onClick: (e: React.MouseEvent) => { e.stopPropagation(); setShowShareModal(true); @@ -402,30 +440,30 @@ const FileEditorThumbnail = ({ }, color: "red", }, - ], - [ - t, - file.id, - file.name, - file.versionNumber, - isZipFile, - isCBZ, - isCBR, - isPinned, - actualFile, - terminology, - DownloadOutlinedIcon, - onViewFile, - onDownloadFile, - onUnzipFile, - handleCloseWithConfirmation, - canUpload, - canShare, - isUploaded, - pinFile, - unpinFile, - ], - ); + ]; + }, [ + t, + file.id, + file.name, + file.versionNumber, + isZipFile, + isCBZ, + isCBR, + isPinned, + actualFile, + terminology, + DownloadOutlinedIcon, + onViewFile, + onDownloadFile, + onUnzipFile, + handleCloseWithConfirmation, + policyEnforcing, + canUpload, + canShare, + isUploaded, + pinFile, + unpinFile, + ]); const handleCardClick = () => { if (!isSupported) return; @@ -497,6 +535,9 @@ const FileEditorThumbnail = ({ )} + {/* Policy enforcement overlay — shown while any policy is in-flight */} + + {/* Thumbnail image or loading state */}

- {truncateCenter(file.name, 40)} + + {truncateCenter(file.name, 40)} + +

{metaLine}

diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index 4d18fccc80..69c30f98a3 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -4,7 +4,7 @@ import { Checkbox, Menu, Tooltip } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; import MoreVertIcon from "@mui/icons-material/MoreVert"; -import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; +import { PolicyBadges as PolicyBadgeRow } from "@app/components/shared/PolicyBadges"; import FolderIcon from "@mui/icons-material/Folder"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; @@ -581,26 +581,7 @@ function FolderCard({ /** Shield badges for the policies that have run on a file. */ function PolicyBadges({ fileId }: { fileId: string }) { const badges = usePolicyFileBadges().get(fileId) ?? []; - if (badges.length === 0) return null; - return ( - - {badges.slice(0, 3).map((policy) => ( - - - - - - ))} - - ); + return ; } interface FileCardProps { diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css index dcc11bd0f3..86266b99bc 100644 --- a/frontend/editor/src/core/components/filesPage/FilesPage.css +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -484,24 +484,6 @@ gap: 0.4rem; } -/* Policy activity badges (a shield per policy that has run on the file). */ -.files-page-policy-badges { - display: inline-flex; - align-items: center; - gap: 3px; - flex-shrink: 0; -} -.files-page-policy-badge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 15px; - height: 15px; - border-radius: 4px; - /* `color` set inline to the policy accent; tint follows it. */ - background: color-mix(in srgb, currentColor 16%, transparent); -} - /* Parent-folder breadcrumb shown on cards/rows during recursive search so the user can tell which folder each hit lives in without navigating. */ .files-page-card-path { diff --git a/frontend/editor/src/core/components/policies/policyRunStore.ts b/frontend/editor/src/core/components/policies/policyRunStore.ts new file mode 100644 index 0000000000..798149a911 --- /dev/null +++ b/frontend/editor/src/core/components/policies/policyRunStore.ts @@ -0,0 +1,27 @@ +/** + * Core stub — the real implementation lives in the proprietary overlay. + * Returns empty state so core-build consumers compile without a policyRunStore module. + */ + +export interface PolicyRunRecord { + runId: string; + categoryId: string; + fileId: string; + fileName: string; + status: string; + currentStep?: number; + stepCount?: number; + error: string | null; + retrying?: boolean; + startedAt: number; +} + +export const POLICY_IN_FLIGHT_STATUSES = [ + "PENDING", + "RUNNING", + "WAITING_FOR_INPUT", +] as const; + +export function usePolicyRuns(): PolicyRunRecord[] { + return []; +} diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css index af6d9e06cc..5763ab2648 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css @@ -36,32 +36,6 @@ background-color: rgba(59, 130, 246, 0.06); } -/* A policy-enforced file glows in that policy's accent colour so it's obvious - the file has had a policy applied: it pulses a few times to catch the eye and - then fades out (no lingering glow). */ -.file-sidebar-file-item.policy-enforced { - animation: policy-glow-fade 4.5s ease-in-out forwards; -} -@keyframes policy-glow-fade { - 0%, - 24%, - 48% { - box-shadow: - inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 30%, transparent), - 0 0 6px -2px var(--policy-glow); - } - 12%, - 36%, - 60% { - box-shadow: - inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 75%, transparent), - 0 0 16px 0 var(--policy-glow); - } - 100% { - box-shadow: 0 0 0 0 transparent; - } -} - .file-sidebar-file-item.selected { background-color: rgba(59, 130, 246, 0.12); } @@ -160,25 +134,6 @@ text-overflow: ellipsis; } -/* ---- Policy activity badges (a shield per policy that has run on the file) ---- */ -.file-sidebar-policy-badges { - display: inline-flex; - align-items: center; - gap: 3px; - flex-shrink: 0; -} -.file-sidebar-policy-badge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 15px; - height: 15px; - border-radius: 4px; - /* `color` is set inline to the policy's accent; the tint follows it. */ - color: var(--text-secondary); - background: color-mix(in srgb, currentColor 16%, transparent); -} - /* ---- Cloud badge (file saved to the server) ---- */ .file-sidebar-cloud-badge { display: inline-flex; diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx index ff48a9704d..3fb26afa5d 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx @@ -1,6 +1,6 @@ -import { useState, useCallback, useRef } from "react"; +import React, { useState, useCallback, useRef } from "react"; import { createPortal } from "react-dom"; -import { Menu, Tooltip } from "@mantine/core"; +import { Group, Loader, Menu, Stack, Text, Tooltip } from "@mantine/core"; import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; @@ -13,6 +13,10 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; import HistoryIcon from "@mui/icons-material/History"; import type { FileId } from "@app/types/file"; import { FileDocIcon } from "@app/components/shared/FileDocIcon"; +import { + PolicyBadges, + type FileItemPolicyRef, +} from "@app/components/shared/PolicyBadges"; import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon"; import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail"; import { IMAGE_EXTENSIONS } from "@app/utils/fileUtils"; @@ -132,17 +136,6 @@ export interface FileItemFolderRef { accentColor: string; } -/** A policy that has run on this file, used for the activity badges. */ -export interface FileItemPolicyRef { - id: string; - name: string; - /** CSS colour for the badge (matches the policy's accent). */ - accentColor: string; - /** True only just after the policy was applied — drives the one-off glow, so - * it doesn't replay on every reload of an already-enforced file. */ - recent: boolean; -} - export interface FileItemProps { fileId: FileId; name: string; @@ -178,7 +171,6 @@ export interface FileItemProps { } const MAX_VISIBLE_FOLDER_TAGS = 2; -const MAX_VISIBLE_POLICY_BADGES = 3; export function FileItem({ fileId, @@ -208,6 +200,23 @@ export function FileItem({ const dateLabel = lastModified ? formatFileDate(lastModified) : ""; const typeLabel = ext ? ext.toUpperCase() : "File"; + const policyEnforcing = policies.some((p) => p.enforcing); + const enforcingTooltip = (action: string): React.ReactNode => ( + + + + + {t( + "policy.blockingAction", + "{{action}} blocked while enforcing policy, please wait...", + { action }, + )} + + + + + ); + const visibleFolders = folders.slice(0, MAX_VISIBLE_FOLDER_TAGS); const overflowFolders = folders.slice(MAX_VISIBLE_FOLDER_TAGS); @@ -228,9 +237,6 @@ export function FileItem({ const handleMouseLeave = useCallback(() => setHoverRect(null), []); - // A just-applied policy (recent run) drives the one-off row glow. - const recentPolicy = policies.find((p) => p.recent); - // Reactive: tooltip appears as soon as both hover rect and thumbnail are ready const thumbPos = hoverRect && resolvedThumbnail @@ -244,14 +250,7 @@ export function FileItem({ <>
onClick(fileId)} draggable={draggable} onDragStart={ @@ -301,25 +300,7 @@ export function FileItem({ )} - {policies.length > 0 && ( - - {policies.slice(0, MAX_VISIBLE_POLICY_BADGES).map((policy) => ( - - - - - - ))} - - )} + {folders.length > 0 && ( @@ -395,7 +376,7 @@ export function FileItem({ /> {(onDelete || - onSaveToCloud || + (canSaveToCloud && onSaveToCloud) || (hasVersionHistory && onVersionHistory)) && ( @@ -425,17 +406,10 @@ export function FileItem({ {t("fileSidebar.fileItem.versionHistory", "Version history")} )} - {canSaveToCloud && onSaveToCloud && ( - - } - onClick={(e) => { - e.stopPropagation(); - onSaveToCloud(fileId); - }} - > - {isUploadedToCloud + {canSaveToCloud && + onSaveToCloud && + (() => { + const uploadLabel = isUploadedToCloud ? t( "fileSidebar.fileItem.updateOnServer", "Update on server", @@ -443,21 +417,64 @@ export function FileItem({ : t( "fileSidebar.fileItem.uploadToServer", "Upload to server", - )} - - )} - {onDelete && ( - } - onClick={(e) => { - e.stopPropagation(); - onDelete(fileId); - }} - > - {t("fileSidebar.fileItem.delete", "Delete")} - - )} + ); + return ( + +
+ + } + onClick={(e) => { + e.stopPropagation(); + onSaveToCloud(fileId); + }} + > + {uploadLabel} + +
+
+ ); + })()} + {onDelete && + (() => { + const deleteLabel = t( + "fileSidebar.fileItem.delete", + "Delete", + ); + return ( + +
+ + } + onClick={(e) => { + e.stopPropagation(); + onDelete(fileId); + }} + > + {deleteLabel} + +
+
+ ); + })()}
)} diff --git a/frontend/editor/src/core/components/shared/HoverActionMenu.tsx b/frontend/editor/src/core/components/shared/HoverActionMenu.tsx index a6b827bb1f..d912aace48 100644 --- a/frontend/editor/src/core/components/shared/HoverActionMenu.tsx +++ b/frontend/editor/src/core/components/shared/HoverActionMenu.tsx @@ -10,6 +10,8 @@ export interface HoverAction { label: string; onClick: (e: React.MouseEvent) => void; disabled?: boolean; + /** Overrides label in the tooltip — use for rich ReactNode content (e.g. enforcement messages). */ + tooltip?: React.ReactNode; color?: string; hidden?: boolean; dataTour?: string; @@ -58,18 +60,22 @@ const HoverActionMenu: React.FC = ({ onClick={(e) => e.stopPropagation()} > {visibleActions.map((action) => ( - - - {action.icon} - + + {/* Wrapper keeps the tooltip working when the button is disabled + (disabled buttons don't emit the pointer events Tooltip needs). */} +
+ + {action.icon} + +
))}
diff --git a/frontend/editor/src/core/components/shared/PolicyBadges.css b/frontend/editor/src/core/components/shared/PolicyBadges.css new file mode 100644 index 0000000000..d193cc752e --- /dev/null +++ b/frontend/editor/src/core/components/shared/PolicyBadges.css @@ -0,0 +1,62 @@ +/* Canonical policy badge styling — every per-file policy badge in the app + * (sidebar, thumbnails, files page, viewer indicator) uses these classes so + * colour and shape stay consistent. `color` is set inline to the policy's + * accent; the background tint follows it. */ + +.policy-badges { + display: inline-flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} + +.policy-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 15px; + height: 15px; + border-radius: 4px; + color: var(--text-secondary); + background: color-mix(in srgb, currentColor 16%, transparent); + pointer-events: auto; +} + +/* Larger variant for standalone indicators (e.g. the minimised viewer overlay). */ +.policy-badge--lg { + width: 28px; + height: 28px; + border-radius: 8px; + box-shadow: var(--shadow-md); +} + +.policy-badge--enforcing svg { + animation: policy-badge-spin 1s linear infinite; +} +@keyframes policy-badge-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.policy-badge--recent { + animation: policy-badge-pulse 4.5s ease-in-out forwards; +} +@keyframes policy-badge-pulse { + 0%, + 24%, + 48% { + box-shadow: 0 0 0 0 transparent; + } + 12%, + 36% { + box-shadow: 0 0 5px 2px currentColor; + } + 60%, + 100% { + box-shadow: 0 0 0 0 transparent; + } +} diff --git a/frontend/editor/src/core/components/shared/PolicyBadges.tsx b/frontend/editor/src/core/components/shared/PolicyBadges.tsx new file mode 100644 index 0000000000..f8661b9176 --- /dev/null +++ b/frontend/editor/src/core/components/shared/PolicyBadges.tsx @@ -0,0 +1,72 @@ +import { Tooltip } from "@mantine/core"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; +import AutorenewIcon from "@mui/icons-material/Autorenew"; +import { useTranslation } from "react-i18next"; +import "@app/components/shared/PolicyBadges.css"; + +/** A policy that has run on this file, used for the activity badges. */ +export interface FileItemPolicyRef { + id: string; + name: string; + /** CSS colour for the badge (matches the policy's accent). */ + accentColor: string; + /** True only just after the policy was applied — drives the one-off glow, so + * it doesn't replay on every reload of an already-enforced file. */ + recent: boolean; + /** True while the policy run is actively in-flight on this file. */ + enforcing?: boolean; +} + +const MAX_VISIBLE = 3; + +/** + * The canonical policy badge row: one accent-tinted shield per policy that has + * run on a file, spinning while a run is in flight, glowing briefly after it + * lands. Every surface that shows per-file policy badges (file sidebar, file + * editor thumbnails, files page) renders this so they stay identical. + */ +export function PolicyBadges({ + policies, + className, +}: { + policies: FileItemPolicyRef[]; + /** Appended to the row for surface-specific layout (spacing only). */ + className?: string; +}) { + const { t } = useTranslation(); + if (policies.length === 0) return null; + return ( + + {policies.slice(0, MAX_VISIBLE).map((policy) => ( + + + {policy.enforcing ? ( + + ) : ( + + )} + + + ))} + + ); +} diff --git a/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx b/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx new file mode 100644 index 0000000000..5778d323aa --- /dev/null +++ b/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx @@ -0,0 +1,7 @@ +export function PolicyEnforcingOverlay(_props: { + enforcing: boolean; + progress?: number; + zIndex?: number; +}) { + return null; +} diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx index 87e80a16c5..5cc1e6cabd 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx @@ -5,6 +5,7 @@ import React, { useRef, useSyncExternalStore, } from "react"; +import { Group, Loader, Progress, Stack, Text } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; import { SegmentedControl } from "@app/ui/SegmentedControl"; @@ -33,6 +34,11 @@ import { Tooltip } from "@app/components/shared/Tooltip"; import LocalIcon from "@app/components/shared/LocalIcon"; import ViewerShareButton from "@app/components/viewer/ViewerShareButton"; import { useSharingEnabled } from "@app/hooks/useSharingEnabled"; +import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges"; +import { + POLICY_IN_FLIGHT_STATUSES, + usePolicyRuns, +} from "@app/components/policies/policyRunStore"; import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; import { enforceExportPolicies } from "@app/services/policyExport"; import { downloadFile as downloadRaw } from "@app/services/downloadService"; @@ -46,6 +52,7 @@ import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutl import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined"; import CloseIcon from "@mui/icons-material/Close"; import PrintIcon from "@mui/icons-material/Print"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import "@app/components/shared/WorkbenchBar.css"; const SECTION_ORDER: WorkbenchBarSection[] = ["top", "middle", "bottom"]; @@ -119,6 +126,60 @@ export default function WorkbenchBar({ const { actions: fileActions } = useFileActions(); const activeFiles = selectors.getFiles(); const { activeFileId, setActiveFileId } = useViewer(); + const policyFileBadges = usePolicyFileBadges(); + // Block print/export while any file the export would touch is under active + // policy enforcement: the viewer exports its active file, every other view + // exports the selection (or all files when nothing is selected). + const exportTargetIds: string[] = + currentView === "viewer" + ? activeFileId + ? [activeFileId] + : [] + : selectedFileIds.length > 0 + ? selectedFileIds + : activeFiles.filter(isStirlingFile).map((f) => f.fileId); + const enforcingFileId = exportTargetIds.find((id) => + (policyFileBadges.get(id) ?? []).some((p) => p.enforcing), + ); + const policyEnforcing = enforcingFileId != null; + const policyRuns = usePolicyRuns(); + const enforcingRun = policyEnforcing + ? policyRuns.find( + (r) => + r.fileId === enforcingFileId && + (POLICY_IN_FLIGHT_STATUSES as readonly string[]).includes(r.status), + ) + : undefined; + const enforcingProgress = + enforcingRun?.currentStep != null && enforcingRun.stepCount + ? Math.round((enforcingRun.currentStep / enforcingRun.stepCount) * 100) + : undefined; + const makeEnforcingTooltip = (action: string): React.ReactNode => ( + + + + + {t( + "policy.blockingAction", + "{{action}} blocked while enforcing policy, please wait", + { action }, + )} + + + {enforcingProgress != null ? ( + + ) : ( + + )} + + ); const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0; const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0; @@ -504,13 +565,18 @@ export default function WorkbenchBar({ className="workbench-bar-action-icon" onClick={handlePrint} disabled={ - totalItems === 0 || allButtonsDisabled || disableForFullscreen + totalItems === 0 || + allButtonsDisabled || + disableForFullscreen || + policyEnforcing } aria-label={t("workbenchBar.print", "Print PDF")} > , - t("workbenchBar.print", "Print PDF"), + policyEnforcing + ? makeEnforcingTooltip(t("workbenchBar.print", "Print PDF")) + : t("workbenchBar.print", "Print PDF"), )} {/* Download (file-level action — not relevant in custom views) */} @@ -522,7 +588,10 @@ export default function WorkbenchBar({ className="workbench-bar-action-icon" onClick={() => handleExportAll()} disabled={ - disableForFullscreen || totalItems === 0 || allButtonsDisabled + disableForFullscreen || + totalItems === 0 || + allButtonsDisabled || + policyEnforcing } aria-label={downloadTooltip} > @@ -532,7 +601,9 @@ export default function WorkbenchBar({ height="1rem" /> , - downloadTooltip, + policyEnforcing + ? makeEnforcingTooltip(downloadTooltip) + : downloadTooltip, )} {/* Save As */} @@ -545,7 +616,10 @@ export default function WorkbenchBar({ className="workbench-bar-action-icon" onClick={() => handleExportAll(true)} disabled={ - disableForFullscreen || totalItems === 0 || allButtonsDisabled + disableForFullscreen || + totalItems === 0 || + allButtonsDisabled || + policyEnforcing } aria-label={t("workbenchBar.saveAs", "Save As")} > @@ -555,7 +629,9 @@ export default function WorkbenchBar({ height="1rem" /> , - t("workbenchBar.saveAs", "Save As"), + policyEnforcing + ? makeEnforcingTooltip(t("workbenchBar.saveAs", "Save As")) + : t("workbenchBar.saveAs", "Save As"), )} {/* Separator: export group | close */} diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index 555f93df9d..1e21637849 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -47,6 +47,7 @@ import { useWheelZoom } from "@app/hooks/useWheelZoom"; import { useFormFill } from "@app/tools/formFill/FormFillContext"; import { FormSaveBar } from "@app/tools/formFill/FormSaveBar"; import { useViewerKeyCommand } from "@app/hooks/useViewerKeyCommand"; +import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges"; import { alert } from "@app/components/toast"; // ─── Measure dictionary extraction ──────────────────────────────────────────── @@ -443,6 +444,15 @@ const EmbedPdfViewerContent = ({ const viewerKeyCommand = useViewerKeyCommand(); + const policyFileBadges = usePolicyFileBadges(); + const policyEnforcing = + !!activeFileId && + (policyFileBadges.get(activeFileId) ?? []).some((p) => p.enforcing); + // Use a ref so the keydown handler always reads the latest value without + // needing to be in the effect's dependency array. + const policyEnforcingRef = useRef(false); + policyEnforcingRef.current = policyEnforcing; + // Handle keyboard shortcuts useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -468,7 +478,9 @@ const EmbedPdfViewerContent = ({ case "p": case "P": event.preventDefault(); - printActions.print(); + if (!policyEnforcingRef.current) { + printActions.print(); + } return; case "a": case "A": @@ -1333,6 +1345,7 @@ const EmbedPdfViewerContent = ({ file={currentFile ?? null} isFormFillToolActive={isFormFillToolActive} onApply={handleFormApply} + policyEnforcing={policyEnforcing} /> s.id === activeFileId) : undefined; + const policyFileBadges = usePolicyFileBadges(); + const runs = usePolicyRuns(); + const enforcing = + !!activeFileId && + (policyFileBadges.get(activeFileId) ?? []).some((p) => p.enforcing); + const enforcingRun = enforcing + ? runs.find( + (r) => + r.fileId === activeFileId && + (POLICY_IN_FLIGHT_STATUSES as readonly string[]).includes(r.status), + ) + : undefined; + const enforcingProgress = + enforcingRun?.currentStep != null && enforcingRun.stepCount + ? Math.round((enforcingRun.currentStep / enforcingRun.stepCount) * 100) + : undefined; + const label = t("workbenchBar.share", "Share"); - const isDisabled = Boolean(disabled) || !stub; + const isDisabled = Boolean(disabled) || !stub || enforcing; + + const tooltipContent = enforcing ? ( + + + + + {t( + "policy.blockingAction", + "{{action}} blocked while enforcing policy, please wait", + { action: label }, + )} + + + {enforcingProgress != null ? ( + + ) : ( + + )} + + ) : ( + label + ); const openShare = (target: StirlingFileStub) => { setShareStub(target); @@ -125,7 +177,7 @@ export default function ViewerShareButton({ return ( <> => { const stirlingFiles = await addFiles( diff --git a/frontend/editor/src/core/contexts/file/fileActions.ts b/frontend/editor/src/core/contexts/file/fileActions.ts index 749e820e41..86483baf32 100644 --- a/frontend/editor/src/core/contexts/file/fileActions.ts +++ b/frontend/editor/src/core/contexts/file/fileActions.ts @@ -254,6 +254,9 @@ interface AddFileOptions { ) => Promise; // Optional callback to confirm extraction of large ZIP files allowDuplicates?: boolean; skipUploadTracking?: boolean; + /** When true, marks every added stub as derivedFromTool so the policy + * auto-run skips it — used for policy outputs imported via addFiles. */ + derivedFromTool?: boolean; } /** @@ -368,6 +371,7 @@ export async function addFiles( // Create new filestub with minimal metadata; hydrate thumbnails/processedFile asynchronously const fileStub = createNewStirlingFileStub(file, fileId); + if (options.derivedFromTool) fileStub.derivedFromTool = true; // Early encryption detection for PDFs — set the flag before dispatch so the // viewer gate and modal queue pick it up immediately instead of after hydration diff --git a/frontend/editor/src/core/hooks/usePolicyFileBadges.ts b/frontend/editor/src/core/hooks/usePolicyFileBadges.ts index e3297f9bd0..8013edf0b9 100644 --- a/frontend/editor/src/core/hooks/usePolicyFileBadges.ts +++ b/frontend/editor/src/core/hooks/usePolicyFileBadges.ts @@ -1,4 +1,4 @@ -import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem"; +import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges"; /** * Policies that have run on each file, keyed by fileId — drives the shield diff --git a/frontend/editor/src/core/tools/formFill/FormSaveBar.tsx b/frontend/editor/src/core/tools/formFill/FormSaveBar.tsx index da8cfc785a..48ded3045f 100644 --- a/frontend/editor/src/core/tools/formFill/FormSaveBar.tsx +++ b/frontend/editor/src/core/tools/formFill/FormSaveBar.tsx @@ -28,12 +28,15 @@ interface FormSaveBarProps { isFormFillToolActive: boolean; /** Callback when form changes are applied (should reload PDF with filled values) */ onApply?: (filledBlob: Blob) => Promise; + /** Disable download while an ingestion-time policy run is in flight. */ + policyEnforcing?: boolean; } export function FormSaveBar({ file, isFormFillToolActive, onApply, + policyEnforcing = false, }: FormSaveBarProps) { const { t } = useTranslation(); const { state, submitForm } = useFormFill(); @@ -181,7 +184,7 @@ export function FormSaveBar({ size="sm" leftSection={} loading={saving} - disabled={applying} + disabled={applying || policyEnforcing} onClick={handleDownload} style={{ flex: 1 }} > diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts index 45a8066bf0..9485df3a9e 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts @@ -49,6 +49,13 @@ export interface PolicyRunRecord { startedAt: number; } +/** Statuses of a run that is still executing (not yet settled). */ +export const POLICY_IN_FLIGHT_STATUSES: readonly PolicyRunStatus[] = [ + "PENDING", + "RUNNING", + "WAITING_FOR_INPUT", +]; + interface RunState { runs: PolicyRunRecord[]; dispatched: string[]; @@ -92,6 +99,10 @@ function read(): RunState { let state: RunState = read(); const listeners = new Set<() => void>(); +function notifyListeners() { + for (const l of listeners) l(); +} + function emit() { try { if (typeof localStorage !== "undefined") { @@ -100,7 +111,18 @@ function emit() { } catch { // Best-effort persistence. } - for (const l of listeners) l(); + notifyListeners(); +} + +// Sync in-memory state when another tab (or a test via page.evaluate + dispatchEvent) +// writes to the same localStorage key. +if (typeof window !== "undefined") { + window.addEventListener("storage", (e) => { + if (e.key === STORAGE_KEY) { + state = read(); + notifyListeners(); + } + }); } function subscribe(listener: () => void) { diff --git a/frontend/editor/src/proprietary/components/policies/policyStatus.ts b/frontend/editor/src/proprietary/components/policies/policyStatus.ts index 1cf9e5eeb1..e45d280654 100644 --- a/frontend/editor/src/proprietary/components/policies/policyStatus.ts +++ b/frontend/editor/src/proprietary/components/policies/policyStatus.ts @@ -27,3 +27,17 @@ export const ROW_ACCENT: Record = { routing: "amber", retention: "red", }; + +/** Accent name → the CSS colour var the policy badges tint with. */ +const ACCENT_VAR: Record = { + blue: "var(--color-blue)", + purple: "var(--color-purple)", + green: "var(--color-green)", + amber: "var(--color-amber)", + red: "var(--color-red)", +}; + +/** CSS colour var for a policy category's accent (blue for unknown categories). */ +export function policyAccentVar(categoryId: string): string { + return ACCENT_VAR[ROW_ACCENT[categoryId] ?? "blue"]; +} diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx index 6090557c70..249b56ea33 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx @@ -155,7 +155,13 @@ describe("auto-run import: new-version output delivery", () => { recordCompletedRun(); await runImport(); - expect(mocks.addFiles).toHaveBeenCalled(); + // derivedFromTool must ride along so the auto-run never re-enforces this + // output, even after the dispatched list is wiped (fresh device / storage + // clear) — without it the output re-triggers the policy indefinitely. + expect(mocks.addFiles).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ derivedFromTool: true }), + ); expect(mocks.persistVersionedOutputs).not.toHaveBeenCalled(); expect(mocks.consumeFiles).not.toHaveBeenCalled(); }); @@ -182,7 +188,10 @@ describe("auto-run import: new-version output delivery", () => { }); expect(getRun("srv-1")?.startedAt).toBe(1000); - expect(mocks.addFiles).toHaveBeenCalled(); + expect(mocks.addFiles).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ derivedFromTool: true }), + ); expect(mocks.persistVersionedOutputs).not.toHaveBeenCalled(); }); }); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 188b404d23..9ecdf62525 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -309,7 +309,7 @@ export function usePolicyAutoRun(): void { interface ImportContext { addFiles: ( files: File[], - options?: { skipUploadTracking?: boolean }, + options?: { skipUploadTracking?: boolean; derivedFromTool?: boolean }, ) => Promise; consumeFiles: ( inputFileIds: FileId[], @@ -498,9 +498,14 @@ async function importOutputs( parentStub, "automate", ); - // Mark the outputs handled BEFORE adding them, so the auto-run never enforces - // the policy on its own output — that would version endlessly in a loop. - for (const s of stubs) markDispatched(run.categoryId, s.id); + // derivedFromTool is the durable cross-session guard; markDispatched is the + // belt-and-suspenders session guard. Both are needed: dispatched lives only + // in localStorage (wiped on clear / absent on a different device), while + // derivedFromTool is stamped on the stub itself. + for (const s of stubs) { + s.derivedFromTool = true; + markDispatched(run.categoryId, s.id); + } deliveredIds = stubs.map((s) => s.id as string); if (ctx.parentStub) { // Input is in the active workspace: version it there (workspace + storage). @@ -516,9 +521,13 @@ async function importOutputs( ctx.bumpRevision(); } } else { - const added = await ctx.addFiles(files, { skipUploadTracking: true }); - // Same loop-guard for new-file output: the produced file is a new workspace - // file the auto-run would otherwise re-enforce indefinitely. + // derivedFromTool prevents the auto-run from ever re-enforcing this output, + // even if the dispatched list is cleared (localStorage wipe / different device). + const added = await ctx.addFiles(files, { + skipUploadTracking: true, + derivedFromTool: true, + }); + // Belt-and-suspenders session guard on top of derivedFromTool. for (const f of added) markDispatched(run.categoryId, f.fileId); deliveredIds = added.map((f) => f.fileId as string); } diff --git a/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx b/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx new file mode 100644 index 0000000000..973410aef3 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx @@ -0,0 +1,91 @@ +import { + Center, + Loader, + Overlay, + Progress, + Stack, + Text, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; +import CloseIcon from "@mui/icons-material/Close"; +import { useTranslation } from "react-i18next"; + +interface PolicyEnforcingOverlayProps { + enforcing: boolean; + /** 0-100 progress when the run reports step counts; omit for indeterminate. */ + progress?: number; + zIndex?: number; + /** When provided, an × button is shown and called on click. */ + onDismiss?: () => void; +} + +/** + * Frosted-glass enforcement overlay. Renders into its nearest positioned ancestor + * (position: relative) — works for both the full-screen viewer and thumbnail cards. + */ +export function PolicyEnforcingOverlay({ + enforcing, + progress, + zIndex = 200, + onDismiss, +}: PolicyEnforcingOverlayProps) { + const { t } = useTranslation(); + if (!enforcing) return null; + return ( + + {onDismiss && ( + + + + + + )} +
+ + + + + + {t("policy.enforcingTitle", "Enforcing policy…")} + + {progress != null ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx b/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx new file mode 100644 index 0000000000..53fb1a4733 --- /dev/null +++ b/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx @@ -0,0 +1,75 @@ +import { useState, useEffect, useRef } from "react"; +import { Tooltip } from "@mantine/core"; +import AutorenewIcon from "@mui/icons-material/Autorenew"; +import { useTranslation } from "react-i18next"; +import { + POLICY_IN_FLIGHT_STATUSES, + type PolicyRunRecord, +} from "@app/components/policies/policyRunStore"; +import { policyAccentVar } from "@app/components/policies/policyStatus"; +import { PolicyEnforcingOverlay } from "@app/components/shared/PolicyEnforcingOverlay"; +import "@app/components/shared/PolicyBadges.css"; + +interface Props { + runs: PolicyRunRecord[]; +} + +export function PolicyEnforcementOverlay({ runs }: Props) { + const { t } = useTranslation(); + const [dismissed, setDismissed] = useState(false); + const prevRunId = useRef(undefined); + + const inFlight = runs.find( + (r) => POLICY_IN_FLIGHT_STATUSES.includes(r.status) || r.retrying, + ); + + // Reset dismissed when a new run starts (including retries, which replace the + // run record with a new runId even while inFlight stays truthy throughout). + useEffect(() => { + if (inFlight && inFlight.runId !== prevRunId.current) setDismissed(false); + prevRunId.current = inFlight?.runId; + }, [inFlight]); + + if (!inFlight) return null; + + const progress = + inFlight.currentStep != null && inFlight.stepCount + ? Math.round((inFlight.currentStep / inFlight.stepCount) * 100) + : undefined; + + if (dismissed) { + // Overlay dismissed — collapsed to a corner badge (same design as the + // per-file policy badges, larger) so the user can read the PDF. + return ( + + + + + + ); + } + + return ( + setDismissed(true)} + /> + ); +} diff --git a/frontend/editor/src/proprietary/components/viewer/Viewer.tsx b/frontend/editor/src/proprietary/components/viewer/Viewer.tsx new file mode 100644 index 0000000000..e1d0cd96ab --- /dev/null +++ b/frontend/editor/src/proprietary/components/viewer/Viewer.tsx @@ -0,0 +1,59 @@ +import { Box } from "@mantine/core"; +import CoreViewer from "@core/components/viewer/Viewer"; +import type { ViewerProps } from "@core/components/viewer/Viewer"; +import type { EmbedPdfViewerProps } from "@core/components/viewer/EmbedPdfViewer"; +import { useViewer } from "@app/contexts/ViewerContext"; +import { + POLICY_IN_FLIGHT_STATUSES, + usePolicyRuns, + type PolicyRunRecord, +} from "@app/components/policies/policyRunStore"; +import { PolicyEnforcementOverlay } from "@app/components/viewer/PolicyEnforcementOverlay"; + +type SignatureOverlayPassThrough = Pick< + EmbedPdfViewerProps, + | "signaturePreviews" + | "signaturePreviewsReadOnly" + | "signaturePlacementMode" + | "signaturePlacementData" + | "signaturePlacementType" + | "onSignaturePreviewsChange" + | "signatureOverlayApiRef" +>; + +const Viewer = (props: ViewerProps & SignatureOverlayPassThrough) => { + const { activeFileId } = useViewer(); + const allRuns = usePolicyRuns(); + + const activeFileRuns = activeFileId + ? allRuns.filter( + (r: PolicyRunRecord) => + r.fileId === activeFileId && + (POLICY_IN_FLIGHT_STATUSES.includes(r.status) || r.retrying === true), + ) + : []; + + return ( + // isolation: "isolate" keeps the overlay's z-index self-contained so it + // sits above EmbedPdfViewer's internal toolbar/sidebars regardless of + // their own z-index values. + + + {/* key resets dismissed state when the active file changes */} + + + ); +}; + +export default Viewer; diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts index db69bb77af..c5d8b9952b 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts @@ -112,3 +112,72 @@ describe("buildPolicyBadgeMap — badge follows the document onto derived files" expect((map.get("part") ?? [])[0].recent).toBe(false); }); }); + +describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", () => { + const enforcingOn = ( + map: Map, + id: string, + ) => (map.get(id) ?? []).some((b) => b.enforcing); + + it("marks the input file enforcing while the run is RUNNING", () => { + const map = buildPolicyBadgeMap( + [run({ status: "RUNNING", outputFileIds: [] })], + [{ id: "in" }], + labels, + NOW, + ); + expect(enforcingOn(map, "in")).toBe(true); + }); + + it("keeps enforcing after COMPLETED until the outputs are imported", () => { + // Status reaches COMPLETED before the async import lands — the spinner + // must survive that gap, then clear once imported. + const before = buildPolicyBadgeMap( + [run({ status: "COMPLETED" })], + [{ id: "in" }], + labels, + NOW, + ); + expect(enforcingOn(before, "in")).toBe(true); + + const after = buildPolicyBadgeMap( + [run({ status: "COMPLETED", imported: true })], + [{ id: "in" }], + labels, + NOW, + ); + expect(enforcingOn(after, "in")).toBe(false); + }); + + it("clears enforcing when the run settles as FAILED or CANCELLED", () => { + for (const status of ["FAILED", "CANCELLED"] as const) { + const map = buildPolicyBadgeMap( + [run({ status, outputFileIds: [] })], + [{ id: "in" }], + labels, + NOW, + ); + expect(enforcingOn(map, "in")).toBe(false); + } + }); + + it("keeps enforcing on a settled run that is auto-retrying", () => { + const map = buildPolicyBadgeMap( + [run({ status: "FAILED", retrying: true, outputFileIds: [] })], + [{ id: "in" }], + labels, + NOW, + ); + expect(enforcingOn(map, "in")).toBe(true); + }); + + it("skips runs with no input fileId (server-reconciled orphans)", () => { + const map = buildPolicyBadgeMap( + [run({ status: "RUNNING", fileId: "", outputFileIds: [] })], + [{ id: "in" }], + labels, + NOW, + ); + expect(enforcingOn(map, "in")).toBe(false); + }); +}); diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts index ad4c016895..40fcd0399e 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts @@ -3,22 +3,14 @@ import { usePolicyRuns } from "@app/components/policies/policyRunStore"; import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; import { useAllFiles } from "@app/contexts/FileContext"; import { loadPolicyCatalog } from "@app/services/policyCatalog"; -import { ROW_ACCENT } from "@app/components/policies/policyStatus"; -import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem"; +import { policyAccentVar } from "@app/components/policies/policyStatus"; +import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges"; /** How long after a run a badge counts as "recent" (drives the one-off glow). - * Covers the run + import delay; old/reloaded runs fall outside it, so the glow - * fires only just after a policy is applied, not on every page reload. */ -const RECENT_MS = 60_000; - -/** Policy accent name (ROW_ACCENT) → the CSS colour var the badge uses. */ -const ACCENT_VAR: Record = { - blue: "var(--color-blue)", - purple: "var(--color-purple)", - green: "var(--color-green)", - amber: "var(--color-amber)", - red: "var(--color-red)", -}; + * Measured from run start — must exceed the longest realistic policy wall-clock + * time so the glow still fires after a slow run completes and imports. Old or + * reloaded runs fall outside this window, suppressing the glow on page reload. */ +const RECENT_MS = 5 * 60 * 1000; /** Minimal provenance shape needed to resolve a file's inherited badges. */ type LineageStub = { @@ -71,7 +63,7 @@ export function buildPolicyBadgeMap( list.push({ id: run.categoryId, name, - accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"], + accentColor: policyAccentVar(run.categoryId), recent, }); directByFile.set(fileId, list); @@ -107,6 +99,35 @@ export function buildPolicyBadgeMap( } } + // In-flight pass: add (or upgrade) a badge on the input file for any run that + // is currently being processed, so the sidebar shows a spinning indicator + // while the policy is actively enforcing — not just after it completes. + // Keep the spinner until `imported` is true: the status reaches COMPLETED + // before the output files are imported into the workspace, so gating on + // status alone would drop the badge during that async gap. + for (const run of runs) { + if (!run.fileId) continue; + const settled = + run.imported || run.status === "FAILED" || run.status === "CANCELLED"; + if (settled && !run.retrying) continue; + const name = labelById.get(run.categoryId); + if (!name) continue; + const list = result.get(run.fileId) ?? []; + const existing = list.find((p) => p.id === run.categoryId); + if (existing) { + existing.enforcing = true; + } else { + list.push({ + id: run.categoryId, + name, + accentColor: policyAccentVar(run.categoryId), + recent: false, + enforcing: true, + }); + result.set(run.fileId, list); + } + } + return result; }