mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
@@ -17,9 +17,8 @@ import "@app/styles/index.css";
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "@app/utils/fileIdSafety";
|
||||
|
||||
// Minimal providers for the public, no-auth mobile-scanner page - no API
|
||||
// calls, no authentication
|
||||
function PublicRouteProviders({ children }: { children: React.ReactNode }) {
|
||||
// Minimal providers for mobile scanner - no API calls, no authentication
|
||||
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>{children}</RainbowThemeProvider>
|
||||
@@ -35,9 +34,9 @@ export default function App() {
|
||||
<Route
|
||||
path="/mobile-scanner"
|
||||
element={
|
||||
<PublicRouteProviders>
|
||||
<MobileScannerProviders>
|
||||
<MobileScannerPage />
|
||||
</PublicRouteProviders>
|
||||
</MobileScannerProviders>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -14,14 +14,6 @@ export interface AuthContextType {
|
||||
* should treat the resulting string as opaque display text.
|
||||
*/
|
||||
displayName: string | null;
|
||||
/**
|
||||
* Whether the current session is an anonymous / guest one. Each layer
|
||||
* derives this from its own native user shape (Supabase `is_anonymous` in
|
||||
* SaaS, the Spring anonymous flag in proprietary). Always `false` in core
|
||||
* OSS, which has no auth context. Consumers use it to gate account-only
|
||||
* actions (cloud folders, MCP) without reaching into a layer-specific user.
|
||||
*/
|
||||
isAnonymous: boolean;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
signOut: () => Promise<void>;
|
||||
@@ -37,7 +29,6 @@ export function useAuth(): AuthContextType {
|
||||
session: null,
|
||||
user: null,
|
||||
displayName: null,
|
||||
isAnonymous: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
signOut: async () => {},
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Core stubs for the right-rail Agents UI.
|
||||
*
|
||||
* The real implementations live in {@code proprietary/components/agents/AgentsPanel.tsx}
|
||||
* and shadow these stubs via the {@code @app/*} alias cascade when the proprietary
|
||||
* build is active. Core builds render nothing, so the right rail collapses to the
|
||||
* tool list unchanged.
|
||||
*/
|
||||
|
||||
/** Whether the right rail should reserve space for agents UI. False in core. */
|
||||
export function useAgentsEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agent chat panel is currently open. Core builds have no chat,
|
||||
* so this always returns false. Proprietary builds bridge to the ChatContext.
|
||||
*/
|
||||
export function useAgentChatOpen(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Inline "Agents" section rendered above the tool list in {@code ToolPicker}. */
|
||||
export function AgentsSection() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon-only agent button rendered in the collapsed (minimised) right rail.
|
||||
* Returns null in core; proprietary renders the Stirling agent shortcut.
|
||||
*/
|
||||
export function AgentsCollapsedButton(_props: { onExpand: () => void }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents card rendered inside the fullscreen tool picker. Matches the visual
|
||||
* language of the fullscreen category cards (gradient border, title, items).
|
||||
* Returns null in core; proprietary renders the Stirling agent.
|
||||
*/
|
||||
export function AgentsFullscreenSection() {
|
||||
return null;
|
||||
}
|
||||
@@ -7,9 +7,12 @@
|
||||
export function useChat() {
|
||||
return {
|
||||
messages: [] as never[],
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
progress: null,
|
||||
progressLog: [] as never[],
|
||||
toggleOpen: () => {},
|
||||
setOpen: (_open: boolean) => {},
|
||||
sendMessage: async (_content: string) => {},
|
||||
cancelMessage: () => {},
|
||||
clearChat: () => {},
|
||||
|
||||
@@ -17,7 +17,7 @@ import AddFileCard from "@app/components/fileEditor/AddFileCard";
|
||||
import FilePickerModal from "@app/components/shared/FilePickerModal";
|
||||
import { FileId, StirlingFile } from "@app/types/fileContext";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
|
||||
interface FileEditorProps {
|
||||
@@ -256,7 +256,6 @@ const FileEditor = ({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: record.localFilePath,
|
||||
fileId,
|
||||
});
|
||||
console.log("[FileEditor] Download complete, checking dirty state:", {
|
||||
localFilePath: record.localFilePath,
|
||||
|
||||
@@ -36,7 +36,7 @@ import ToolChain from "@app/components/shared/ToolChain";
|
||||
import HoverActionMenu, {
|
||||
HoverAction,
|
||||
} from "@app/components/shared/HoverActionMenu";
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
|
||||
import ShareFileModal from "@app/components/shared/ShareFileModal";
|
||||
@@ -248,7 +248,6 @@ const FileEditorThumbnail = ({
|
||||
data: fileToSave,
|
||||
filename: file.name,
|
||||
localPath: file.localFilePath,
|
||||
fileId: file.id,
|
||||
});
|
||||
if (!result.cancelled && result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(file.id, {
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, Button, Checkbox, Menu, Tooltip } from "@mantine/core";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||
@@ -18,7 +17,6 @@ import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
import { FileId } from "@app/types/file";
|
||||
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
|
||||
import { useFolders } from "@app/contexts/FolderContext";
|
||||
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { formatFileSize, getFileDate } from "@app/utils/fileUtils";
|
||||
import {
|
||||
@@ -574,31 +572,6 @@ 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 (
|
||||
<span className="files-page-policy-badges" data-no-select>
|
||||
{badges.slice(0, 3).map((policy) => (
|
||||
<Tooltip
|
||||
key={policy.id}
|
||||
label={`${policy.name} policy ran on this file`}
|
||||
withArrow
|
||||
position="top"
|
||||
>
|
||||
<span
|
||||
className="files-page-policy-badge"
|
||||
style={{ color: policy.accentColor }}
|
||||
>
|
||||
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileCardProps {
|
||||
file: StirlingFileStub;
|
||||
isSelected: boolean;
|
||||
@@ -758,7 +731,6 @@ function FileCard({
|
||||
<span>{fileSize}</span>
|
||||
<span>·</span>
|
||||
<span>{fileDate}</span>
|
||||
<PolicyBadges fileId={file.id as string} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="files-page-card-actions">
|
||||
@@ -1343,7 +1315,6 @@ function FileRow({
|
||||
)}
|
||||
</span>
|
||||
<FileOriginBadge origin={getFileOrigin(file)} compact />
|
||||
<PolicyBadges fileId={file.id as string} />
|
||||
{isInWorkspace && (
|
||||
<span className="files-page-row-open-pill">
|
||||
<span className="files-page-card-open-dot" />
|
||||
|
||||
@@ -34,8 +34,6 @@ import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
|
||||
import { stripBasePath } from "@app/constants/app";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
|
||||
import { useFolders } from "@app/contexts/FolderContext";
|
||||
import { useFileActions } from "@app/contexts/file/fileHooks";
|
||||
@@ -108,27 +106,17 @@ export default function FileManagerView() {
|
||||
const isMobile = useIsMobile();
|
||||
const isMobileUploadAvailable =
|
||||
Boolean(appConfig?.enableMobileScanner) && !isMobile;
|
||||
// Guests (anonymous sessions) have no server-side storage, so every cloud
|
||||
// action is account-only. Rather than let the click fire a guaranteed 401
|
||||
// (which surfaced as an error toast), we disable the control and explain why
|
||||
// on hover - the same affordance the storage-disabled / wrong-tab gates use.
|
||||
const { isAnonymous } = useAuth();
|
||||
const signInRequiredReason = isAnonymous
|
||||
? t("filesPage.signInRequired", "Sign in to use cloud storage.")
|
||||
: null;
|
||||
// Server storage gate; mirrors ConfigController's storageEnabled
|
||||
// (enableLogin && storage.isEnabled). When off, Save-to-server stays
|
||||
// visible but disabled with an explanatory tooltip (discoverability beats
|
||||
// hiding - mirrors the New folder / Manage sharing gates in this view).
|
||||
const uploadEnabled = appConfig?.storageEnabled === true;
|
||||
const saveToServerDisabledReason: string | null =
|
||||
signInRequiredReason ??
|
||||
(uploadEnabled
|
||||
? null
|
||||
: t(
|
||||
"filesPage.saveToServerDisabledHint",
|
||||
"Saving to the server isn't enabled on this server. Ask your admin to enable it.",
|
||||
));
|
||||
const saveToServerDisabledReason: string | null = uploadEnabled
|
||||
? null
|
||||
: t(
|
||||
"filesPage.saveToServerDisabledHint",
|
||||
"Saving to the server isn't enabled on this server. Ask your admin to enable it.",
|
||||
);
|
||||
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const { requestNavigation } = useNavigationGuard();
|
||||
@@ -202,11 +190,10 @@ export default function FileManagerView() {
|
||||
|
||||
// Push folder selection into the URL while still on /files.
|
||||
useEffect(() => {
|
||||
const stripped = stripBasePath(window.location.pathname);
|
||||
if (!stripped.startsWith("/files")) return;
|
||||
if (!window.location.pathname.startsWith("/files")) return;
|
||||
const target =
|
||||
currentFolderId === null ? "/files" : `/files/${currentFolderId}`;
|
||||
if (stripped !== target) {
|
||||
if (window.location.pathname !== target) {
|
||||
navigate(target, { replace: true });
|
||||
}
|
||||
}, [currentFolderId, navigate]);
|
||||
@@ -816,11 +803,6 @@ export default function FileManagerView() {
|
||||
|
||||
// null = New folder actionable; string = disabled tooltip reason.
|
||||
const newFolderDisabledReason: string | null = useMemo(() => {
|
||||
// Guests can't use cloud folders at all - say so before any tab/storage
|
||||
// hint, since switching tabs wouldn't help them.
|
||||
if (signInRequiredReason) {
|
||||
return signInRequiredReason;
|
||||
}
|
||||
if (currentTab === "local") {
|
||||
return t(
|
||||
"filesPage.localFoldersUnavailable",
|
||||
@@ -844,7 +826,7 @@ export default function FileManagerView() {
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [signInRequiredReason, currentTab, folders.serverReachable, t]);
|
||||
}, [currentTab, folders.serverReachable, t]);
|
||||
|
||||
return (
|
||||
<div className="files-page" ref={dropZoneRef}>
|
||||
@@ -911,17 +893,14 @@ export default function FileManagerView() {
|
||||
/>
|
||||
<div className="files-page-header-actions">
|
||||
<Tooltip
|
||||
label={
|
||||
signInRequiredReason ??
|
||||
t("filesPage.refresh", "Refresh from server")
|
||||
}
|
||||
label={t("filesPage.refresh", "Refresh from server")}
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="md"
|
||||
loading={refreshing}
|
||||
disabled={refreshing || Boolean(signInRequiredReason)}
|
||||
disabled={refreshing}
|
||||
aria-busy={refreshing}
|
||||
onClick={handleRefresh}
|
||||
aria-label={t("filesPage.refresh", "Refresh from server")}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
import { isBaseWorkbench } from "@app/types/workbench";
|
||||
import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useCookieConsent } from "@app/hooks/useCookieConsent";
|
||||
import styles from "@app/components/layout/Workbench.module.css";
|
||||
|
||||
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
|
||||
import LandingPage from "@app/components/shared/LandingPage";
|
||||
import Footer from "@app/components/shared/Footer";
|
||||
import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton";
|
||||
import { ChatFAB } from "@app/components/chat/ChatFAB";
|
||||
|
||||
@@ -37,10 +37,6 @@ export default function Workbench() {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { config } = useAppConfig();
|
||||
|
||||
// The consent banner used to be initialised by the footer; the legal links
|
||||
// now live in Settings → Legal, so the workbench owns the banner lifecycle.
|
||||
useCookieConsent({ analyticsEnabled: config?.enableAnalytics === true });
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { selectors } = useFileState();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
@@ -256,6 +252,15 @@ export default function Workbench() {
|
||||
{renderMainContent()}
|
||||
</Suspense>
|
||||
</Box>
|
||||
|
||||
<Footer
|
||||
analyticsEnabled={config?.enableAnalytics === true}
|
||||
termsAndConditions={config?.termsAndConditions}
|
||||
privacyPolicy={config?.privacyPolicy}
|
||||
cookiePolicy={config?.cookiePolicy}
|
||||
impressum={config?.impressum}
|
||||
accessibilityStatement={config?.accessibilityStatement}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-5
@@ -34,8 +34,8 @@
|
||||
}
|
||||
|
||||
.standaloneIcon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: contain;
|
||||
animation: heroLogoScale 0.25s ease forwards;
|
||||
}
|
||||
@@ -124,6 +124,8 @@
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
animation: heroLogoEnter 0.25s ease forwards;
|
||||
position: relative;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
@@ -173,8 +175,8 @@
|
||||
}
|
||||
|
||||
.downloadIcon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: contain;
|
||||
animation: heroLogoScale 0.25s ease forwards;
|
||||
}
|
||||
@@ -203,7 +205,6 @@
|
||||
}
|
||||
|
||||
.bodyCopy {
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
animation: bodySlideIn 0.25s ease forwards;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
import { UNIFIED_LIGHT_BACKGROUND } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||
import {
|
||||
DesktopInstallTitle,
|
||||
type OSOption,
|
||||
@@ -47,6 +47,9 @@ export default function DesktopInstallSlide({
|
||||
),
|
||||
body: <DesktopInstallBody />,
|
||||
downloadUrl: osUrl,
|
||||
background: UNIFIED_LIGHT_BACKGROUND,
|
||||
background: {
|
||||
gradientStops: ["#2563EB", "#0EA5E9"],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
AnimatedCircleConfig,
|
||||
AnimatedSlideBackgroundProps,
|
||||
} from "@app/types/types";
|
||||
import { AnimatedCircleConfig } from "@app/types/types";
|
||||
|
||||
/**
|
||||
* Unified circle background configuration used across all onboarding slides.
|
||||
@@ -30,36 +27,3 @@ export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
|
||||
offsetY: 18,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Build a light slide background in a slide-specific accent colour: a white
|
||||
* hero fading into a pale horizon tint, with the unified sphere geometry
|
||||
* glowing in the accent on each sphere's inner edge.
|
||||
*/
|
||||
export function createLightSlideBackground(
|
||||
accentRgb: [number, number, number],
|
||||
horizon: string,
|
||||
): AnimatedSlideBackgroundProps {
|
||||
const [r, g, b] = accentRgb;
|
||||
const sphereColors = [
|
||||
`linear-gradient(45deg, rgba(${r}, ${g}, ${b}, 0.04), rgba(${r}, ${g}, ${b}, 0.3))`,
|
||||
`linear-gradient(225deg, rgba(${r}, ${g}, ${b}, 0.04), rgba(${r}, ${g}, ${b}, 0.26))`,
|
||||
];
|
||||
return {
|
||||
gradientStops: ["#FFFFFF", horizon],
|
||||
circles: UNIFIED_CIRCLE_CONFIG.map((circle, index) => ({
|
||||
...circle,
|
||||
color: sphereColors[index],
|
||||
})),
|
||||
tone: "light",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default light slide background: white with a light blue horizon and
|
||||
* blue-glow spheres.
|
||||
*/
|
||||
export const UNIFIED_LIGHT_BACKGROUND = createLightSlideBackground(
|
||||
[37, 99, 235],
|
||||
"#DBEAFE",
|
||||
);
|
||||
|
||||
@@ -23,7 +23,7 @@ import { FileId } from "@app/types/file";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
|
||||
interface FileItem {
|
||||
id: FileId;
|
||||
@@ -98,7 +98,6 @@ const FileThumbnail = ({
|
||||
void downloadFile({
|
||||
data: maybeFile,
|
||||
filename: maybeFile.name || file.name || "download",
|
||||
fileId: file.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Core stubs for the right-rail Policies UI.
|
||||
*
|
||||
* The real implementations live in {@code proprietary/components/policies/PoliciesSidebar.tsx}
|
||||
* and shadow these stubs via the {@code @app/*} alias cascade when the proprietary
|
||||
* build is active. Core builds render nothing, so the right rail shows only the
|
||||
* tool list unchanged.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Whether the right rail should host the Policies section. False in core. */
|
||||
export function usePoliciesEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a policy is open (its detail should take over the rail). Always false
|
||||
* in core; proprietary bridges to the policy-selection store.
|
||||
*/
|
||||
export function usePolicyDetailActive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Collapsible policy list rendered above the Tools section. Null in core. */
|
||||
export function PoliciesSection(_props: { leadingControl?: ReactNode } = {}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Open-policy detail/wizard/settings that replaces the tool area. Null in core. */
|
||||
export function PolicyDetailTakeover() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Collapsed-rail policy icons. Null in core; proprietary renders the rail. */
|
||||
export function PoliciesCollapsedButton(_props: { onExpand: () => void }) {
|
||||
return null;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Core stub for the policy auto-run controller.
|
||||
*
|
||||
* The real implementation lives in
|
||||
* {@code proprietary/components/policies/PolicyAutoRunController.tsx} and shadows
|
||||
* this stub via the {@code @app/*} alias cascade in the proprietary build. Core
|
||||
* builds have no Policies feature, so this renders nothing and runs no policies.
|
||||
*/
|
||||
export function PolicyAutoRunController() {
|
||||
return null;
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useConfigNavSections } from "@app/components/shared/config/configNavSections";
|
||||
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { COOKIE_CONSENT_SCROLL_SHARD } from "@app/hooks/useCookieConsent";
|
||||
import "@app/components/shared/AppConfigModal.css";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import {
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
useUnsavedChanges,
|
||||
} from "@app/contexts/UnsavedChangesContext";
|
||||
import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar";
|
||||
import { stripBasePath, withBasePath } from "@app/constants/app";
|
||||
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
@@ -98,14 +96,13 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
const switchSection = useCallback(
|
||||
(key: NavKey) => {
|
||||
setActive(key);
|
||||
const alreadyInSettings = stripBasePath(
|
||||
window.location.pathname,
|
||||
).startsWith("/settings");
|
||||
const alreadyInSettings =
|
||||
window.location.pathname.startsWith("/settings");
|
||||
if (alreadyInSettings) {
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
withBasePath(`/settings/${key}`),
|
||||
`/settings/${key}`,
|
||||
);
|
||||
} else {
|
||||
navigate(`/settings/${key}`);
|
||||
@@ -217,7 +214,6 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
padding={0}
|
||||
fullScreen={isMobile}
|
||||
styles={{ content: { overflowY: "hidden", overscrollBehavior: "none" } }}
|
||||
removeScrollProps={{ shards: [COOKIE_CONSENT_SCROLL_SHARD] }}
|
||||
>
|
||||
<div className="modal-container">
|
||||
{/* Left navigation */}
|
||||
|
||||
@@ -396,19 +396,6 @@
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* No colored disc behind an actual photo; keep it for the initials fallback. */
|
||||
.file-sidebar-bottom-avatar--picture {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-name {
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl";
|
||||
import {
|
||||
useIndexedDB,
|
||||
useIndexedDBRevision,
|
||||
@@ -42,7 +41,6 @@ import type { FileId } from "@app/types/file";
|
||||
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
|
||||
import { useFolderMembership } from "@app/hooks/useFolderMembership";
|
||||
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
|
||||
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
||||
import {
|
||||
setWatchedFolderDraggedFileIds,
|
||||
clearWatchedFolderDraggedFileIds,
|
||||
@@ -132,7 +130,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
// the workbench). The same map drives the per-file membership dots.
|
||||
const folderMembership = useFolderMembership();
|
||||
const allFolders = useAllWatchedFolders();
|
||||
const policyFileBadges = usePolicyFileBadges();
|
||||
const folderById = useMemo(
|
||||
() => new Map(allFolders.map((f) => [f.id, f])),
|
||||
[allFolders],
|
||||
@@ -187,11 +184,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
const displayName =
|
||||
authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User");
|
||||
|
||||
const profilePictureUrl = useProfilePictureUrl();
|
||||
const [pictureFailed, setPictureFailed] = useState(false);
|
||||
useEffect(() => setPictureFailed(false), [profilePictureUrl]);
|
||||
const showProfilePicture = !!profilePictureUrl && !pictureFailed;
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.enableLogin) {
|
||||
setAccountUsername(null);
|
||||
@@ -894,9 +886,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
onDragStart={handleWatchedFolderDragStart}
|
||||
folders={memberFolders}
|
||||
onFolderClick={openWatchedFolder}
|
||||
policies={
|
||||
policyFileBadges.get(stub.id as string) ?? []
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -949,21 +938,10 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
style={onOpenSettings ? { cursor: "pointer" } : undefined}
|
||||
>
|
||||
<div
|
||||
className={`file-sidebar-bottom-avatar${
|
||||
showProfilePicture ? " file-sidebar-bottom-avatar--picture" : ""
|
||||
}`}
|
||||
className="file-sidebar-bottom-avatar"
|
||||
aria-label={displayName}
|
||||
>
|
||||
{showProfilePicture ? (
|
||||
<img
|
||||
src={profilePictureUrl}
|
||||
alt=""
|
||||
className="file-sidebar-bottom-avatar-img"
|
||||
onError={() => setPictureFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
displayName.charAt(0).toUpperCase()
|
||||
)}
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-bottom-name sidebar-content-fade">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* ---- Folder membership tags ---- */
|
||||
.file-sidebar-folder-tags {
|
||||
display: flex;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
|
||||
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
|
||||
@@ -126,17 +125,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;
|
||||
@@ -155,12 +143,9 @@ export interface FileItemProps {
|
||||
folders?: FileItemFolderRef[];
|
||||
/** Clicking a membership dot opens that folder. */
|
||||
onFolderClick?: (folderId: string) => void;
|
||||
/** Policies that have run on this file — rendered as small shield badges. */
|
||||
policies?: FileItemPolicyRef[];
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_FOLDER_TAGS = 2;
|
||||
const MAX_VISIBLE_POLICY_BADGES = 3;
|
||||
|
||||
export function FileItem({
|
||||
fileId,
|
||||
@@ -177,7 +162,6 @@ export function FileItem({
|
||||
onDragStart,
|
||||
folders = [],
|
||||
onFolderClick,
|
||||
policies = [],
|
||||
}: FileItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const ext = getFileExtension(name);
|
||||
@@ -204,9 +188,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
|
||||
@@ -220,14 +201,7 @@ export function FileItem({
|
||||
<>
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}${recentPolicy ? " policy-enforced" : ""}`}
|
||||
style={
|
||||
recentPolicy
|
||||
? ({
|
||||
"--policy-glow": recentPolicy.accentColor,
|
||||
} as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}`}
|
||||
onClick={() => onClick(fileId)}
|
||||
draggable={draggable}
|
||||
onDragStart={
|
||||
@@ -263,25 +237,6 @@ export function FileItem({
|
||||
{dateLabel && typeLabel ? " · " : ""}
|
||||
{typeLabel}
|
||||
</span>
|
||||
{policies.length > 0 && (
|
||||
<span className="file-sidebar-policy-badges" data-no-select>
|
||||
{policies.slice(0, MAX_VISIBLE_POLICY_BADGES).map((policy) => (
|
||||
<Tooltip
|
||||
key={policy.id}
|
||||
label={`${policy.name} policy ran on this file`}
|
||||
withArrow
|
||||
position="top"
|
||||
>
|
||||
<span
|
||||
className="file-sidebar-policy-badge"
|
||||
style={{ color: policy.accentColor }}
|
||||
>
|
||||
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{folders.length > 0 && (
|
||||
<span className="file-sidebar-folder-tags" data-no-select>
|
||||
|
||||
@@ -77,6 +77,15 @@ export default function Footer({
|
||||
color: forceLightMode ? "#495057" : undefined,
|
||||
}}
|
||||
>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
id="survey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://stirlingpdf.info/s/cm28y3niq000o56dv7liv8wsu"
|
||||
>
|
||||
{t("survey.nav", "Survey")}
|
||||
</a>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
|
||||
@@ -8,8 +8,7 @@ import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded";
|
||||
import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
|
||||
import WarningRoundedIcon from "@mui/icons-material/WarningRounded";
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import { convertImageToPdf, isImageFile } from "@app/utils/imageToPdfUtils";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
@@ -84,16 +83,11 @@ export default function MobileUploadModal({
|
||||
const timerIntervalRef = useRef<number | null>(null);
|
||||
const processedFiles = useRef<Set<string>>(new Set());
|
||||
|
||||
// Build the QR-code URL the phone opens. It must land on the public
|
||||
// /mobile-scanner route under the app's base path, otherwise the phone hits
|
||||
// the auth-gated catch-all route and is bounced to the login page.
|
||||
const mobileUrl = buildMobileScannerUrl({
|
||||
configuredUrl:
|
||||
localStorage.getItem("server_url") || config?.frontendUrl || "",
|
||||
sessionId,
|
||||
origin: window.location.origin,
|
||||
basePath: BASE_PATH,
|
||||
});
|
||||
// Use configured frontendUrl if set, otherwise use current origin
|
||||
// Combine with base path and mobile-scanner route
|
||||
const baseUrl = localStorage.getItem("server_url") || "";
|
||||
const frontendUrl = baseUrl || config?.frontendUrl || window.location.origin;
|
||||
const mobileUrl = `${frontendUrl}${withBasePath("/mobile-scanner")}?session=${sessionId}`;
|
||||
|
||||
// Create session on backend
|
||||
const createSession = useCallback(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useEffect, ReactNode } from "react";
|
||||
import { createContext, useContext, ReactNode } from "react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { useRainbowTheme } from "@app/hooks/useRainbowTheme";
|
||||
import { mantineTheme } from "@app/theme/mantineTheme";
|
||||
@@ -7,10 +7,6 @@ import { ToastProvider } from "@app/components/toast";
|
||||
import ToastRenderer from "@app/components/toast/ToastRenderer";
|
||||
import { ToastPortalBinder } from "@app/components/toast";
|
||||
import type { ThemeMode } from "@app/constants/theme";
|
||||
// SUI shared design-system tokens (used by @shared/components). Additive — its
|
||||
// var names don't clash with the editor's own theme.css. The effect below
|
||||
// bridges Mantine's color scheme to the `data-theme` attribute SUI keys on.
|
||||
import "@shared/tokens/tokens.css";
|
||||
|
||||
interface RainbowThemeContextType {
|
||||
themeMode: ThemeMode;
|
||||
@@ -44,16 +40,6 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
|
||||
const mantineColorScheme =
|
||||
rainbowTheme.themeMode === "rainbow" ? "dark" : rainbowTheme.themeMode;
|
||||
|
||||
// Bridge the resolved scheme to the `data-theme` attribute SUI's tokens.css
|
||||
// keys its dark palette on (the editor otherwise only sets Mantine's
|
||||
// data-mantine-color-scheme), so @shared/components theme correctly.
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute(
|
||||
"data-theme",
|
||||
mantineColorScheme === "dark" ? "dark" : "light",
|
||||
);
|
||||
}, [mantineColorScheme]);
|
||||
|
||||
return (
|
||||
<RainbowThemeContext.Provider value={rainbowTheme}>
|
||||
<MantineProvider
|
||||
|
||||
@@ -29,7 +29,7 @@ import { ViewerContext, useViewer } from "@app/contexts/ViewerContext";
|
||||
import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import {
|
||||
WorkbenchBarButtonConfig,
|
||||
WorkbenchBarRenderContext,
|
||||
@@ -150,7 +150,6 @@ export default function WorkbenchBar({
|
||||
data: new Blob([buffer], { type: "application/pdf" }),
|
||||
filename: fileToExport.name,
|
||||
localPath: forceNewFile ? undefined : stub?.localFilePath,
|
||||
fileId: stub?.id,
|
||||
});
|
||||
if (!forceNewFile && !result.cancelled && stub && result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(stub.id, {
|
||||
@@ -180,7 +179,6 @@ export default function WorkbenchBar({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: forceNewFile ? undefined : stub?.localFilePath,
|
||||
fileId: stub?.id,
|
||||
});
|
||||
if (result.cancelled) continue;
|
||||
if (!forceNewFile && stub && result.savedPath) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { NavKey } from "@app/components/shared/config/types";
|
||||
import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection";
|
||||
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
|
||||
import HelpSection from "@app/components/shared/config/configSections/HelpSection";
|
||||
import LegalSection from "@app/components/shared/config/configSections/LegalSection";
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
@@ -71,17 +70,6 @@ export const useConfigNavSections = (
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("settings.legal.title", "Legal"),
|
||||
items: [
|
||||
{
|
||||
key: "legal",
|
||||
label: t("settings.legal.label", "Legal"),
|
||||
icon: "gavel-rounded",
|
||||
component: <LegalSection />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return sections;
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import React from "react";
|
||||
import { Anchor, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useFooterInfo } from "@app/hooks/useFooterInfo";
|
||||
import { useCookieConsent } from "@app/hooks/useCookieConsent";
|
||||
|
||||
interface LegalLink {
|
||||
key: string;
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const LegalSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const { footerInfo } = useFooterInfo();
|
||||
|
||||
const analyticsEnabled =
|
||||
config?.enableAnalytics ?? footerInfo?.analyticsEnabled ?? false;
|
||||
const privacyPolicy = config?.privacyPolicy ?? footerInfo?.privacyPolicy;
|
||||
const termsAndConditions =
|
||||
config?.termsAndConditions ?? footerInfo?.termsAndConditions;
|
||||
const accessibilityStatement =
|
||||
config?.accessibilityStatement ?? footerInfo?.accessibilityStatement;
|
||||
const cookiePolicy = config?.cookiePolicy ?? footerInfo?.cookiePolicy;
|
||||
const impressum = config?.impressum ?? footerInfo?.impressum;
|
||||
|
||||
const { showCookiePreferences } = useCookieConsent({
|
||||
analyticsEnabled: analyticsEnabled === true,
|
||||
});
|
||||
|
||||
const isValidLink = (link?: string) => link && link.trim().length > 0;
|
||||
|
||||
const legalLinks: LegalLink[] = [
|
||||
{
|
||||
key: "privacy",
|
||||
label: t("legal.privacy", "Privacy Policy"),
|
||||
href: isValidLink(privacyPolicy)
|
||||
? privacyPolicy!
|
||||
: "https://www.stirling.com/privacy",
|
||||
},
|
||||
{
|
||||
key: "terms",
|
||||
label: t("legal.terms", "Terms and Conditions"),
|
||||
href: isValidLink(termsAndConditions)
|
||||
? termsAndConditions!
|
||||
: "https://www.stirling.com/terms",
|
||||
},
|
||||
...(isValidLink(accessibilityStatement)
|
||||
? [
|
||||
{
|
||||
key: "accessibility",
|
||||
label: t("legal.accessibility", "Accessibility"),
|
||||
href: accessibilityStatement!,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isValidLink(cookiePolicy)
|
||||
? [
|
||||
{
|
||||
key: "cookie",
|
||||
label: t("legal.cookie", "Cookie Policy"),
|
||||
href: cookiePolicy!,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isValidLink(impressum)
|
||||
? [
|
||||
{
|
||||
key: "impressum",
|
||||
label: t("legal.impressum", "Impressum"),
|
||||
href: impressum!,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const renderLink = (link: LegalLink) => (
|
||||
<Anchor
|
||||
key={link.key}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{link.label}
|
||||
<LocalIcon icon="open-in-new-rounded" width="0.9rem" height="0.9rem" />
|
||||
</Group>
|
||||
</Anchor>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t("settings.legal.documents.title", "Legal Documents")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"settings.legal.documents.description",
|
||||
"Policies and legal information for this service.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Stack gap="sm">{legalLinks.map(renderLink)}</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{analyticsEnabled === true && (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t("legal.showCookieBanner", "Cookie Preferences")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"settings.legal.cookiePreferences.description",
|
||||
"Review or change your cookie consent choices.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
id="cookieBanner"
|
||||
onClick={showCookiePreferences}
|
||||
>
|
||||
{t("settings.legal.cookiePreferences.manage", "Manage")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default LegalSection;
|
||||
@@ -31,8 +31,6 @@ export const VALID_NAV_KEYS = [
|
||||
"adminStorageSharing",
|
||||
"adminMcp",
|
||||
"help",
|
||||
"legal",
|
||||
"payg",
|
||||
] as const;
|
||||
|
||||
// Derive the type from the array
|
||||
|
||||
@@ -51,27 +51,6 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* A toast tied to a policy pulses a glow in that policy's accent (var set
|
||||
inline as --toast-glow), so it's obvious which policy is being applied. */
|
||||
.toast-item--glow {
|
||||
animation: toast-glow-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes toast-glow-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
var(--shadow-lg),
|
||||
0 0 0 1px color-mix(in srgb, var(--toast-glow) 35%, transparent),
|
||||
0 0 10px -2px var(--toast-glow);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
var(--shadow-lg),
|
||||
0 0 0 1px color-mix(in srgb, var(--toast-glow) 80%, transparent),
|
||||
0 0 20px 0 var(--toast-glow);
|
||||
}
|
||||
}
|
||||
|
||||
/* Toast Alert Type Colors */
|
||||
.toast-item--success {
|
||||
background: var(--color-green-100);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@app/components/toast/ToastContext";
|
||||
import { ToastInstance, ToastLocation } from "@app/components/toast/types";
|
||||
@@ -65,16 +64,7 @@ export default function ToastRenderer() {
|
||||
<div key={loc} className={`toast-container ${locationToClass[loc]}`}>
|
||||
{grouped[loc].map((t) => {
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
role="status"
|
||||
className={`${getToastItemClass(t)}${t.glowColor ? " toast-item--glow" : ""}`}
|
||||
style={
|
||||
t.glowColor
|
||||
? ({ "--toast-glow": t.glowColor } as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div key={t.id} role="status" className={getToastItemClass(t)}>
|
||||
{/* Top row: Icon + Title + Controls */}
|
||||
<div className="toast-header">
|
||||
{/* Icon */}
|
||||
|
||||
@@ -25,10 +25,6 @@ export interface ToastOptions {
|
||||
id?: string;
|
||||
/** If true, show chevron and collapse/expand animation. Defaults to true. */
|
||||
expandable?: boolean;
|
||||
/** Optional accent colour (any CSS colour, e.g. a `var(--color-…)`). When set,
|
||||
* the toast pulses a glow in this colour — used to tie a toast to a policy's
|
||||
* accent. */
|
||||
glowColor?: string;
|
||||
}
|
||||
|
||||
export interface ToastInstance extends Omit<
|
||||
|
||||
@@ -3,6 +3,11 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
|
||||
import {
|
||||
AgentsFullscreenSection,
|
||||
useAgentChatOpen,
|
||||
useAgentsEnabled,
|
||||
} from "@app/components/agents/AgentsPanel";
|
||||
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import type { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
@@ -11,11 +16,13 @@ import type { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
export function useIsFullscreenExpanded(): boolean {
|
||||
const { toolPanelMode, leftPanelView, readerMode } = useToolWorkflow();
|
||||
const isMobile = useIsMobile();
|
||||
const agentChatOpen = useAgentChatOpen();
|
||||
return (
|
||||
toolPanelMode === "fullscreen" &&
|
||||
leftPanelView === "toolPicker" &&
|
||||
!isMobile &&
|
||||
!readerMode
|
||||
!readerMode &&
|
||||
!agentChatOpen
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +50,8 @@ export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
|
||||
handleToolSelect,
|
||||
} = useToolWorkflow();
|
||||
const isMobile = useIsMobile();
|
||||
const agentsEnabled = useAgentsEnabled();
|
||||
const agentChatOpen = useAgentChatOpen();
|
||||
const { setAllButtonsDisabled } = useWorkbenchBar();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
|
||||
@@ -50,7 +59,8 @@ export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
|
||||
toolPanelMode === "fullscreen" &&
|
||||
leftPanelView === "toolPicker" &&
|
||||
!isMobile &&
|
||||
!readerMode;
|
||||
!readerMode &&
|
||||
!agentChatOpen;
|
||||
|
||||
useEffect(() => {
|
||||
setAllButtonsDisabled(fullscreenExpanded);
|
||||
@@ -84,6 +94,9 @@ export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
|
||||
}
|
||||
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
|
||||
geometry={geometry}
|
||||
agentsSlot={
|
||||
agentsEnabled && !searchQuery ? <AgentsFullscreenSection /> : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ interface FullscreenToolSurfaceProps {
|
||||
onToggleDescriptions: () => void;
|
||||
onExitFullscreenMode: () => void;
|
||||
geometry: ToolPanelGeometry | null;
|
||||
/** Optional agents block rendered above the tool list. */
|
||||
agentsSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
const FullscreenToolSurface = ({
|
||||
@@ -39,6 +41,7 @@ const FullscreenToolSurface = ({
|
||||
onToggleDescriptions,
|
||||
onExitFullscreenMode: _onExitFullscreenMode,
|
||||
geometry,
|
||||
agentsSlot,
|
||||
}: FullscreenToolSurfaceProps) => {
|
||||
const { t } = useTranslation();
|
||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||
@@ -89,6 +92,9 @@ const FullscreenToolSurface = ({
|
||||
className="tool-panel__fullscreen-scroll"
|
||||
offsetScrollbars
|
||||
>
|
||||
{agentsSlot && (
|
||||
<div className="tool-panel__fullscreen-agents">{agentsSlot}</div>
|
||||
)}
|
||||
<FullscreenToolList
|
||||
filteredTools={filteredTools}
|
||||
searchQuery={searchQuery}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, useRef, useCallback } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
@@ -9,13 +9,12 @@ import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import ToolPanel from "@app/components/tools/ToolPanel";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
import {
|
||||
PoliciesCollapsedButton,
|
||||
PoliciesSection,
|
||||
PolicyDetailTakeover,
|
||||
usePoliciesEnabled,
|
||||
usePolicyDetailActive,
|
||||
} from "@app/components/policies/PoliciesSidebar";
|
||||
import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController";
|
||||
AgentsCollapsedButton,
|
||||
AgentsSection,
|
||||
useAgentsEnabled,
|
||||
} from "@app/components/agents/AgentsPanel";
|
||||
import { useChat } from "@app/components/chat/ChatContext";
|
||||
import { ChatPanel } from "@app/components/chat/ChatPanel";
|
||||
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
|
||||
import { useToolSections } from "@app/hooks/useToolSections";
|
||||
import type { SubcategoryGroup } from "@app/hooks/useToolSections";
|
||||
@@ -34,12 +33,16 @@ import {
|
||||
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
import "@app/components/tools/ToolPanel.css";
|
||||
|
||||
const DEFAULT_CHAT_WIDTH_PX = 18.5 * 16; // 18.5rem in px
|
||||
const MIN_CHAT_WIDTH_PX = 240;
|
||||
const MAX_CHAT_WIDTH_PX = 720;
|
||||
|
||||
/**
|
||||
* Right-side rail wrapping the tool panel.
|
||||
*
|
||||
* Owns the rail-level concerns: collapse/expand chrome and the collapsed strip
|
||||
* (favourite/recommended icon shortcuts). Fullscreen takeover lives in
|
||||
* FullscreenToolPanel.
|
||||
* Owns the rail-level concerns: collapse/expand chrome, the AGENTS top label,
|
||||
* the collapsed strip (agent button + favourite/recommended icon shortcuts),
|
||||
* and the agents chat overlay. Fullscreen takeover lives in FullscreenToolPanel.
|
||||
*/
|
||||
export default function RightSidebar() {
|
||||
const { t } = useTranslation();
|
||||
@@ -66,8 +69,7 @@ export default function RightSidebar() {
|
||||
favoriteTools,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const policiesEnabled = usePoliciesEnabled();
|
||||
const rawPolicyDetailActive = usePolicyDetailActive();
|
||||
const agentsEnabled = useAgentsEnabled();
|
||||
const fullscreenExpanded = useIsFullscreenExpanded();
|
||||
const fullscreenGeometry = useToolPanelGeometry({
|
||||
enabled: fullscreenExpanded,
|
||||
@@ -89,6 +91,57 @@ export default function RightSidebar() {
|
||||
|
||||
const [allToolsView, setAllToolsView] = useState(false);
|
||||
|
||||
const { isOpen: isChatOpen, setOpen: setChatOpen } = useChat();
|
||||
const [chatWidthPx, setChatWidthPx] = useState(DEFAULT_CHAT_WIDTH_PX);
|
||||
const [isChatDragging, setIsChatDragging] = useState(false);
|
||||
const chatDragState = useRef<{ startX: number; startWidth: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleChatClose = useCallback(() => {
|
||||
withViewTransition(() => setChatOpen(false));
|
||||
setChatWidthPx(DEFAULT_CHAT_WIDTH_PX);
|
||||
}, [setChatOpen]);
|
||||
|
||||
const handleResizeChatPointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
chatDragState.current = { startX: e.clientX, startWidth: chatWidthPx };
|
||||
setIsChatDragging(true);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (!chatDragState.current) return;
|
||||
const delta = chatDragState.current.startX - ev.clientX;
|
||||
setChatWidthPx(
|
||||
Math.max(
|
||||
MIN_CHAT_WIDTH_PX,
|
||||
Math.min(
|
||||
MAX_CHAT_WIDTH_PX,
|
||||
chatDragState.current.startWidth + delta,
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
chatDragState.current = null;
|
||||
setIsChatDragging(false);
|
||||
document.body.style.removeProperty("cursor");
|
||||
document.body.style.removeProperty("user-select");
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", cleanup);
|
||||
window.removeEventListener("pointercancel", cleanup);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", cleanup);
|
||||
window.addEventListener("pointercancel", cleanup);
|
||||
},
|
||||
[chatWidthPx],
|
||||
);
|
||||
|
||||
const handleShowAllTools = () => {
|
||||
withViewTransition(() => setAllToolsView(true));
|
||||
};
|
||||
@@ -100,34 +153,15 @@ export default function RightSidebar() {
|
||||
});
|
||||
};
|
||||
|
||||
// Opening a policy (e.g. from the collapsed rail) lands the rail in the clean
|
||||
// default tool-picker view — the only view the policy takeover renders in — so
|
||||
// it never collides with an open tool or the all-tools/search view.
|
||||
const handleOpenPolicy = () => {
|
||||
withViewTransition(() => {
|
||||
if (readerMode) setReaderMode(false);
|
||||
setLeftPanelView("toolPicker");
|
||||
if (!sidebarsVisible) setSidebarsVisible(true);
|
||||
setAllToolsView(false);
|
||||
setSearchQuery("");
|
||||
});
|
||||
};
|
||||
|
||||
// The header shows [back] [search] when we have somewhere to go back to —
|
||||
// i.e. the user is in a specific tool, or already in the all-tools/search view.
|
||||
const inToolView = leftPanelView !== "toolPicker";
|
||||
// Show X (close) button only when there's somewhere to go back to.
|
||||
const showCloseButton = inToolView || allToolsView;
|
||||
// Policies sit above the tool list in the default tool-picker view.
|
||||
const showPolicies =
|
||||
policiesEnabled && !allToolsView && leftPanelView === "toolPicker";
|
||||
// When Policies are shown, the search moves OUT of the header to sit between
|
||||
// the Policies and Tools sections (separating them); otherwise it stays in the
|
||||
// header. Show the header search when there's a close button, or in the
|
||||
// default tool-picker view.
|
||||
const showInlineSearch = showPolicies && !showCloseButton;
|
||||
// Show search input whenever there's a close button, or when agents are off and
|
||||
// we're in the default tool-picker view (search filters the full list inline).
|
||||
const showHeaderSearch =
|
||||
!showInlineSearch && (showCloseButton || leftPanelView === "toolPicker");
|
||||
showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker");
|
||||
|
||||
const handleHeaderBack = () => {
|
||||
if (inToolView) {
|
||||
@@ -160,20 +194,18 @@ export default function RightSidebar() {
|
||||
? (toolRegistry[selectedToolKey as ToolId] ?? null)
|
||||
: null;
|
||||
|
||||
// The detail takeover replaces the tool list ONLY in the same default view —
|
||||
// never over an open tool or the all-tools view (which must keep priority).
|
||||
// A lingering selection is harmless: it stays hidden behind a tool and the
|
||||
// list/takeover reappears on return to the picker (as in the prototype).
|
||||
const policyDetailActive = rawPolicyDetailActive && showPolicies;
|
||||
|
||||
// The rail widens when a policy detail takes it over — the tool list is fine
|
||||
// at 18.5rem, but the policy detail/wizard/settings need more breathing room.
|
||||
const expandedWidth = policyDetailActive ? "25rem" : "18.5rem";
|
||||
// Agents header + section is hidden when:
|
||||
// - the AI engine is off, or
|
||||
// - the user is in the all-tools view, or
|
||||
// - a specific tool is being rendered (leftPanelView ≠ "toolPicker").
|
||||
const showAgents =
|
||||
agentsEnabled && !allToolsView && leftPanelView === "toolPicker";
|
||||
|
||||
const computedWidth = () => {
|
||||
if (isMobile) return "100%";
|
||||
if (isChatOpen) return `${chatWidthPx}px`;
|
||||
if (!isPanelVisible) return "3.5rem";
|
||||
return expandedWidth;
|
||||
return "18.5rem";
|
||||
};
|
||||
|
||||
// Collapsed rail: show favourites + recommended tools as icons.
|
||||
@@ -207,17 +239,39 @@ export default function RightSidebar() {
|
||||
ref={toolPanelRef}
|
||||
data-sidebar="tool-panel"
|
||||
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
|
||||
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
|
||||
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : isChatOpen ? "" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
|
||||
isRainbowMode ? rainbowStyles.rainbowPaper : ""
|
||||
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
|
||||
style={{
|
||||
width: computedWidth(),
|
||||
padding: "0",
|
||||
...(isChatDragging ? { transition: "none" } : {}),
|
||||
}}
|
||||
>
|
||||
{/* Headless: enforces enabled policies on every uploaded file. */}
|
||||
{policiesEnabled && <PolicyAutoRunController />}
|
||||
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
|
||||
{!fullscreenExpanded && isChatOpen && (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="agents-takeover__resize-handle"
|
||||
onPointerDown={handleResizeChatPointerDown}
|
||||
role="separator"
|
||||
aria-label={t("chat.resize", "Resize chat panel")}
|
||||
aria-orientation="vertical"
|
||||
/>
|
||||
<ChatPanel
|
||||
onBack={handleChatClose}
|
||||
backLabel={t("agents.back_to_tools", "Back to tools")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fullscreenExpanded && !isChatOpen && !isPanelVisible && !isMobile && (
|
||||
<div className="tool-panel__collapsed-strip">
|
||||
<div className="tool-panel__collapsed-top">
|
||||
<ActionIcon
|
||||
@@ -225,15 +279,15 @@ export default function RightSidebar() {
|
||||
color="gray.4"
|
||||
radius="xl"
|
||||
size="md"
|
||||
className="tool-panel__expand-btn tool-panel__toggle-vt"
|
||||
className="tool-panel__expand-btn"
|
||||
onClick={handleExpand}
|
||||
aria-label={t("toolPanel.expand", "Expand panel")}
|
||||
>
|
||||
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
<AgentsCollapsedButton onExpand={handleExpand} />
|
||||
</div>
|
||||
<div className="tool-panel__collapsed-divider" />
|
||||
<PoliciesCollapsedButton onExpand={handleOpenPolicy} />
|
||||
<div className="tool-panel__collapsed-tools">
|
||||
{collapsedRailItems.map(({ id, tool }) => (
|
||||
<AppTooltip
|
||||
@@ -261,7 +315,7 @@ export default function RightSidebar() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fullscreenExpanded && isPanelVisible && (
|
||||
{!fullscreenExpanded && !isChatOpen && isPanelVisible && (
|
||||
<div
|
||||
/* Fixed width matches the expanded panel width so the inner content is
|
||||
laid out at its final size from the moment it mounts. The outer
|
||||
@@ -272,114 +326,84 @@ export default function RightSidebar() {
|
||||
opacity: 1,
|
||||
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||
height: "100%",
|
||||
width: isMobile ? "100%" : expandedWidth,
|
||||
width: isMobile ? "100%" : "18.5rem",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{policyDetailActive ? (
|
||||
<div className="pol-takeover">
|
||||
<PolicyDetailTakeover />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!showPolicies && (
|
||||
<div className="tool-panel__compact-header">
|
||||
{activeTool ? (
|
||||
<div
|
||||
className="tool-panel__active-tool-pill"
|
||||
aria-label={activeTool.name}
|
||||
>
|
||||
<span className="tool-panel__active-tool-pill-icon">
|
||||
<ToolIcon
|
||||
icon={activeTool.icon}
|
||||
marginRight="0"
|
||||
color="var(--mantine-color-blue-filled)"
|
||||
/>
|
||||
</span>
|
||||
<span className="tool-panel__active-tool-pill-label">
|
||||
{activeTool.name}
|
||||
</span>
|
||||
</div>
|
||||
) : showHeaderSearch ? (
|
||||
<div className="tool-panel__compact-header-search">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={handleHeaderSearchChange}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
autoFocus={allToolsView && !inToolView}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showCloseButton ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleHeaderBack}
|
||||
aria-label={
|
||||
inToolView
|
||||
? t("toolPanel.backToAllTools", "Back to all tools")
|
||||
: t("toolPanel.goBack", "Go back")
|
||||
}
|
||||
className="tool-panel__expand-btn"
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleCollapse}
|
||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||
className="tool-panel__expand-btn tool-panel__toggle-vt"
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPolicies && (
|
||||
<PoliciesSection
|
||||
leadingControl={
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleCollapse}
|
||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||
className="tool-panel__expand-btn tool-panel__toggle-vt"
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showInlineSearch && (
|
||||
<div className="tool-panel__between-search">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={handleHeaderSearchChange}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
<div className="tool-panel__compact-header">
|
||||
{activeTool ? (
|
||||
<div
|
||||
className="tool-panel__active-tool-pill"
|
||||
aria-label={activeTool.name}
|
||||
>
|
||||
<span className="tool-panel__active-tool-pill-icon">
|
||||
<ToolIcon
|
||||
icon={activeTool.icon}
|
||||
marginRight="0"
|
||||
color="var(--mantine-color-blue-filled)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
<span className="tool-panel__active-tool-pill-label">
|
||||
{activeTool.name}
|
||||
</span>
|
||||
</div>
|
||||
) : showHeaderSearch ? (
|
||||
<div className="tool-panel__compact-header-search">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={handleHeaderSearchChange}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
autoFocus={allToolsView && !inToolView}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
showAgents && (
|
||||
<span className="tool-panel__section-label">
|
||||
{t("agents.section_title", "Agents")}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{showCloseButton ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleHeaderBack}
|
||||
aria-label={
|
||||
inToolView
|
||||
? t("toolPanel.backToAllTools", "Back to all tools")
|
||||
: t("toolPanel.goBack", "Go back")
|
||||
}
|
||||
className="tool-panel__expand-btn"
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleCollapse}
|
||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||
className="tool-panel__expand-btn"
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ToolPanel
|
||||
allToolsView={allToolsView}
|
||||
onShowAllTools={handleShowAllTools}
|
||||
onToolSelect={handleToolSelectWithTransition}
|
||||
compact={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{showAgents && <AgentsSection />}
|
||||
|
||||
<ToolPanel
|
||||
allToolsView={allToolsView}
|
||||
onShowAllTools={handleShowAllTools}
|
||||
onToolSelect={handleToolSelectWithTransition}
|
||||
compact={agentsEnabled && !allToolsView}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -201,13 +201,6 @@
|
||||
border-color: var(--border-subtle) !important;
|
||||
}
|
||||
|
||||
/* The collapse/expand toggle keeps a stable identity across the collapsed strip
|
||||
and the expanded header, so a view transition translates it between the two
|
||||
(same size) instead of cross-fading/scaling it ("big then small"). */
|
||||
.tool-panel__toggle-vt {
|
||||
view-transition-name: sidebar-toggle;
|
||||
}
|
||||
|
||||
.tool-panel__expand-btn svg {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
@@ -278,22 +271,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Search that separates the Policies section from the Tools list below it. */
|
||||
.tool-panel__between-search {
|
||||
padding: 0.5rem 0.75rem 0.25rem;
|
||||
border-top: 1px solid var(--border-subtle, var(--color-border));
|
||||
}
|
||||
|
||||
.tool-panel__between-search .search-input-container {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Slightly recessed search field so it reads as distinct from the panel. */
|
||||
.tool-panel__between-search input {
|
||||
background-color: var(--bg-muted);
|
||||
}
|
||||
|
||||
.tool-panel__active-tool-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -385,6 +362,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tool-panel__section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tool-panel__mode-toggle {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
@@ -846,6 +832,33 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Agents card lives in its own column above the tool grid. It uses the same
|
||||
.tool-panel__fullscreen-group base styles but with a colourful gradient
|
||||
border so it stands out as a distinct surface (and doesn't blend into the
|
||||
neighbouring category cards). */
|
||||
.tool-panel__fullscreen-agents {
|
||||
padding: 1.5rem 1.75rem 0;
|
||||
}
|
||||
|
||||
.tool-panel__fullscreen-group--agents {
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(var(--fullscreen-bg-group), var(--fullscreen-bg-group))
|
||||
padding-box,
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--mantine-color-blue-6) 0%,
|
||||
var(--mantine-color-violet-6) 45%,
|
||||
var(--mantine-color-pink-6) 100%
|
||||
)
|
||||
border-box;
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
|
||||
.tool-panel__fullscreen-section-icon--agents {
|
||||
color: var(--mantine-color-blue-6);
|
||||
}
|
||||
|
||||
@keyframes tool-panel-fullscreen-slide-in {
|
||||
from {
|
||||
transform: translateX(6%) scaleX(0.85);
|
||||
|
||||
-4
@@ -21,15 +21,12 @@ interface AddWatermarkSingleStepSettingsProps {
|
||||
value: AddWatermarkParameters[K],
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
/** When false, hide the "Flatten PDF pages to images" option (e.g. in policies). */
|
||||
showFlatten?: boolean;
|
||||
}
|
||||
|
||||
const AddWatermarkSingleStepSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false,
|
||||
showFlatten = true,
|
||||
}: AddWatermarkSingleStepSettingsProps) => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
@@ -72,7 +69,6 @@ const AddWatermarkSingleStepSettings = ({
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
showFlatten={showFlatten}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -10,15 +10,12 @@ interface WatermarkFormattingProps {
|
||||
value: AddWatermarkParameters[K],
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
/** When false, hide the "Flatten PDF pages to images" option (e.g. in policies). */
|
||||
showFlatten?: boolean;
|
||||
}
|
||||
|
||||
const WatermarkFormatting = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false,
|
||||
showFlatten = true,
|
||||
}: WatermarkFormattingProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -98,19 +95,17 @@ const WatermarkFormatting = ({
|
||||
</Group>
|
||||
|
||||
{/* Advanced Options */}
|
||||
{showFlatten && (
|
||||
<Checkbox
|
||||
label={t(
|
||||
"watermark.settings.convertToImage",
|
||||
"Flatten PDF pages to images",
|
||||
)}
|
||||
checked={parameters.convertPDFToImage}
|
||||
onChange={(event) =>
|
||||
onParameterChange("convertPDFToImage", event.currentTarget.checked)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
<Checkbox
|
||||
label={t(
|
||||
"watermark.settings.convertToImage",
|
||||
"Flatten PDF pages to images",
|
||||
)}
|
||||
checked={parameters.convertPDFToImage}
|
||||
onChange={(event) =>
|
||||
onParameterChange("convertPDFToImage", event.currentTarget.checked)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
useNavigationState,
|
||||
useNavigationGuard,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { stripBasePath, withBasePath } from "@app/constants/app";
|
||||
import { BASE_PATH, withBasePath } from "@app/constants/app";
|
||||
import { useRedaction, useRedactionMode } from "@app/contexts/RedactionContext";
|
||||
import TextFieldsIcon from "@mui/icons-material/TextFields";
|
||||
import StraightenIcon from "@mui/icons-material/Straighten";
|
||||
@@ -73,10 +73,17 @@ export function useViewerWorkbenchBarButtons(
|
||||
});
|
||||
}, [registerImmediatePanUpdate]);
|
||||
|
||||
const stripBasePath = useCallback((path: string) => {
|
||||
if (BASE_PATH && path.startsWith(BASE_PATH)) {
|
||||
return path.slice(BASE_PATH.length) || "/";
|
||||
}
|
||||
return path;
|
||||
}, []);
|
||||
|
||||
const isAnnotationsPath = useCallback(() => {
|
||||
const cleanPath = stripBasePath(window.location.pathname).toLowerCase();
|
||||
return cleanPath === "/annotations" || cleanPath.endsWith("/annotations");
|
||||
}, []);
|
||||
}, [stripBasePath]);
|
||||
|
||||
const [isAnnotationsActive, setIsAnnotationsActive] = useState<boolean>(() =>
|
||||
isAnnotationsPath(),
|
||||
|
||||
@@ -38,13 +38,3 @@ export const absoluteWithBasePath = (path: string): string => {
|
||||
const clean = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${window.location.origin}${BASE_PATH}${clean}`;
|
||||
};
|
||||
|
||||
/** Strip BASE_PATH prefix so route comparisons work under any subpath deploy. */
|
||||
export const stripBasePath = (pathname: string): string => {
|
||||
if (!BASE_PATH) return pathname;
|
||||
if (pathname === BASE_PATH) return "/";
|
||||
if (pathname.startsWith(`${BASE_PATH}/`)) {
|
||||
return pathname.slice(BASE_PATH.length);
|
||||
}
|
||||
return pathname;
|
||||
};
|
||||
|
||||
@@ -4,18 +4,11 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Watched Folders. Disabled for now — gates both the custom workbench
|
||||
* registration (seeding, URL sync, view) and the sidebar entry point, so the
|
||||
* feature is unreachable from the UI. Flip to `true` to restore access.
|
||||
* Watched Folders (a.k.a. Watched Folders). Disabled for now — gates both the
|
||||
* custom workbench registration (seeding, URL sync, view) and the sidebar
|
||||
* entry point, so the feature is unreachable from the UI. Flip to `true` to
|
||||
* restore access.
|
||||
*/
|
||||
// Annotated as `boolean` (not the literal `false`) so call sites aren't treated
|
||||
// as constant/unreachable conditions by the type checker and linter.
|
||||
export const WATCHED_FOLDERS_ENABLED: boolean = false;
|
||||
|
||||
/**
|
||||
* Policies — a proprietary, automation-backed feature (like Watched Folders but
|
||||
* backend-driven, with non-folder triggers). The implementation lives under
|
||||
* `proprietary/`; this core value stays `false` so the shared sidebar entry
|
||||
* never appears in the open-source build.
|
||||
*/
|
||||
export const POLICIES_ENABLED: boolean = false;
|
||||
|
||||
@@ -14,7 +14,6 @@ export const AUTH_ROUTES = [
|
||||
"/invite",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
"/oauth/consent",
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import { useFileActions } from "@app/contexts/file/fileHooks";
|
||||
import { useFolders } from "@app/contexts/FolderContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
|
||||
/** View-toggle modes; tuple keeps the union and iterator in sync. */
|
||||
export const FILES_PAGE_VIEW_MODES = ["grid", "list"] as const;
|
||||
@@ -141,7 +140,6 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
const folders = useFolders();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { config: appConfig } = useAppConfig();
|
||||
const { isAnonymous } = useAuth();
|
||||
|
||||
const [allFiles, setAllFiles] = useState<StirlingFileStub[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -168,7 +166,6 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
const merged = await reconcileServerFiles(localLeaf, {
|
||||
storageEnabled,
|
||||
shareLinksEnabled,
|
||||
isAnonymous,
|
||||
});
|
||||
// Drop the merged result if a newer refresh has already started -
|
||||
// otherwise its stale snapshot will clobber the newer one's state.
|
||||
@@ -184,7 +181,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
// Only the latest refresh should clear the loading state.
|
||||
if (gen === refreshGenRef.current) setLoading(false);
|
||||
}
|
||||
}, [setFoldersError, storageEnabled, shareLinksEnabled, isAnonymous]);
|
||||
}, [setFoldersError, storageEnabled, shareLinksEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
import { FolderProvider, useFolders } from "@app/contexts/FolderContext";
|
||||
import { createFolderId, FolderId, FolderRecord } from "@app/types/folder";
|
||||
@@ -39,31 +38,6 @@ vi.mock("@app/services/folderSyncService", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// FolderProvider only pulls from the server for a confirmed, non-anonymous
|
||||
// user (guests have no cloud storage). Mock useAuth as a signed-in user so the
|
||||
// pull runs; the guest-skip path is covered by its own test below.
|
||||
const { mockAuth } = vi.hoisted(() => ({
|
||||
mockAuth: {
|
||||
user: { id: "test-user", is_anonymous: false } as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
isAnonymous: false,
|
||||
},
|
||||
}));
|
||||
vi.mock("@app/auth/UseSession", () => ({
|
||||
useAuth: () => ({
|
||||
user: mockAuth.user,
|
||||
isAnonymous: mockAuth.isAnonymous,
|
||||
session: null,
|
||||
displayName: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
signOut: vi.fn(),
|
||||
refreshSession: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Stateful IDB mock - the revision-driven refresh re-reads getAllFolders
|
||||
// after every state change, so a stateless [] mock would clobber pull results.
|
||||
const { mockIdb } = vi.hoisted(() => ({
|
||||
@@ -136,11 +110,9 @@ function axiosError(status: number, message = "rejected"): Error {
|
||||
|
||||
async function renderAndWaitForPull(): Promise<void> {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<FolderProvider>
|
||||
<Probe />
|
||||
</FolderProvider>
|
||||
</MemoryRouter>,
|
||||
<FolderProvider>
|
||||
<Probe />
|
||||
</FolderProvider>,
|
||||
);
|
||||
// The pull is fired from a mount effect; wait until it has resolved by
|
||||
// observing that `mockList` was called at least once and a tick has
|
||||
@@ -158,9 +130,6 @@ describe("FolderContext sync-banner gating", () => {
|
||||
mockList.mockReset();
|
||||
mockUpdate.mockReset();
|
||||
mockDelete.mockReset();
|
||||
// Default each test to a signed-in, non-anonymous user so the pull runs.
|
||||
mockAuth.user = { id: "test-user", is_anonymous: false };
|
||||
mockAuth.isAnonymous = false;
|
||||
});
|
||||
|
||||
test("401 (unauthorized) does NOT surface a banner", async () => {
|
||||
@@ -212,28 +181,6 @@ describe("FolderContext sync-banner gating", () => {
|
||||
expect(screen.getByTestId("error").textContent).toBe("<null>");
|
||||
expect(screen.getByTestId("reachable").textContent).toBe("false");
|
||||
});
|
||||
|
||||
test("guest (anonymous) session does NOT pull from the server", async () => {
|
||||
// Guests have no cloud storage; pulling would 401 and (historically)
|
||||
// surface a toast. The provider must make no folder request at all.
|
||||
mockAuth.user = { id: "guest", is_anonymous: true };
|
||||
mockAuth.isAnonymous = true;
|
||||
mockList.mockResolvedValue([]);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<FolderProvider>
|
||||
<Probe />
|
||||
</FolderProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
// Let mount effects run; the pull must never fire.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mockList).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("reachable").textContent).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
/** Per-folder mutation 404 = silent cleanup (drop subtree, no banner, pull). */
|
||||
@@ -244,9 +191,6 @@ describe("FolderContext stale-folder 404 cleanup", () => {
|
||||
mockDelete.mockReset();
|
||||
// Reset the stateful IDB mock so each test starts with an empty cache.
|
||||
mockIdb.folders = [];
|
||||
// Signed-in, non-anonymous user so pullFromServer runs.
|
||||
mockAuth.user = { id: "test-user", is_anonymous: false };
|
||||
mockAuth.isAnonymous = false;
|
||||
});
|
||||
|
||||
function makeFolder(name: string, parentFolderId: FolderId | null = null) {
|
||||
@@ -297,11 +241,9 @@ describe("FolderContext stale-folder 404 cleanup", () => {
|
||||
mockList.mockResolvedValueOnce(initial);
|
||||
const apiRef: { current: ProbeApi | null } = { current: null };
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<FolderProvider>
|
||||
<ApiProbe onReady={(api) => (apiRef.current = api)} />
|
||||
</FolderProvider>
|
||||
</MemoryRouter>,
|
||||
<FolderProvider>
|
||||
<ApiProbe onReady={(api) => (apiRef.current = api)} />
|
||||
</FolderProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("count").textContent).toBe(
|
||||
|
||||
@@ -38,9 +38,6 @@ import {
|
||||
} from "@app/types/folder";
|
||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { isAuthRoute } from "@app/constants/routes";
|
||||
|
||||
interface FolderContextValue {
|
||||
folders: FolderRecord[];
|
||||
@@ -366,28 +363,10 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
// guaranteed-to-403 round-trip per session.
|
||||
const { config: appConfig } = useAppConfig();
|
||||
const storageBackedByServer = appConfig?.storageEnabled === true;
|
||||
// Skip server pulls on auth routes: FolderProvider is mounted globally and
|
||||
// /login has no session yet, so the pull would be a guaranteed 401.
|
||||
const location = useLocation();
|
||||
const onAuthRoute = isAuthRoute(location.pathname);
|
||||
// Guests (anonymous sessions) have no server-side storage, so a pull is a
|
||||
// guaranteed 401 - skip it once we know the session is anonymous. This is
|
||||
// what keeps the "must sign in" affordance toast-free: with no folder request
|
||||
// fired, there's no error to surface. We gate on `isAnonymous` (which is
|
||||
// false for a real signed-in user) rather than the auth `loading` flag, which
|
||||
// can stay true for an authenticated session and would otherwise block the
|
||||
// pull for legitimate users.
|
||||
const { user, isAnonymous } = useAuth();
|
||||
useEffect(() => {
|
||||
// Only pull once we have a confirmed, non-anonymous user. Skipping while
|
||||
// `user` is still null avoids a stray 401 in the brief window before an
|
||||
// anonymous session resolves (at which point `isAnonymous` flips true and
|
||||
// keeps us out). `isAnonymous` alone isn't enough because it defaults false
|
||||
// before the session loads; the auth `loading` flag is unreliable (it can
|
||||
// stay true for a valid signed-in session), so we key off the user object.
|
||||
if (!storageBackedByServer || onAuthRoute || !user || isAnonymous) return;
|
||||
if (!storageBackedByServer) return;
|
||||
void pullFromServer();
|
||||
}, [pullFromServer, storageBackedByServer, onAuthRoute, user, isAnonymous]);
|
||||
}, [pullFromServer, storageBackedByServer]);
|
||||
|
||||
const foldersById = useMemo(() => {
|
||||
const map = new Map<FolderId, FolderRecord>();
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
fileContextReducer,
|
||||
initialFileContextState,
|
||||
} from "@app/contexts/file/FileReducer";
|
||||
import type {
|
||||
FileContextState,
|
||||
StirlingFileStub,
|
||||
} from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
function stub(
|
||||
id: string,
|
||||
overrides: Partial<StirlingFileStub> = {},
|
||||
): StirlingFileStub {
|
||||
return {
|
||||
id: id as FileId,
|
||||
name: `${id}.pdf`,
|
||||
type: "application/pdf",
|
||||
size: 1,
|
||||
lastModified: 0,
|
||||
isLeaf: true,
|
||||
originalFileId: id,
|
||||
versionNumber: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stateWith(stubs: StirlingFileStub[]): FileContextState {
|
||||
return {
|
||||
...initialFileContextState,
|
||||
files: {
|
||||
ids: stubs.map((s) => s.id),
|
||||
byId: Object.fromEntries(stubs.map((s) => [s.id, s])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("fileContextReducer — derivedFromTool provenance", () => {
|
||||
it("ADD_FILES leaves uploads unmarked (a genuine upload is not tool-derived)", () => {
|
||||
const next = fileContextReducer(initialFileContextState, {
|
||||
type: "ADD_FILES",
|
||||
payload: { stirlingFileStubs: [stub("a")] },
|
||||
});
|
||||
expect(next.files.byId["a" as FileId].derivedFromTool).toBeUndefined();
|
||||
});
|
||||
|
||||
it("CONSUME_FILES marks every output as tool-derived", () => {
|
||||
// An upload "a" is consumed by a tool producing an independent artifact "b"
|
||||
// (version metadata identical to an upload — only the flag distinguishes it).
|
||||
const next = fileContextReducer(stateWith([stub("a")]), {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["a" as FileId],
|
||||
outputStirlingFileStubs: [stub("b")],
|
||||
},
|
||||
});
|
||||
expect(next.files.byId["b" as FileId].derivedFromTool).toBe(true);
|
||||
// Provenance: "b" records the input it derived from.
|
||||
expect(next.files.byId["b" as FileId].sourceFileIds).toEqual(["a"]);
|
||||
expect(next.files.byId["a" as FileId]).toBeUndefined(); // input consumed
|
||||
});
|
||||
|
||||
it("CONSUME_FILES accumulates sourceFileIds transitively", () => {
|
||||
// "b" already derived from "a"; consuming "b" → "c" carries both, so the
|
||||
// badge still resolves after the intermediate "b" is gone (e.g. split).
|
||||
const start = stateWith([stub("b", { sourceFileIds: ["a" as FileId] })]);
|
||||
const next = fileContextReducer(start, {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["b" as FileId],
|
||||
outputStirlingFileStubs: [stub("c")],
|
||||
},
|
||||
});
|
||||
expect(next.files.byId["c" as FileId].sourceFileIds).toEqual(["b", "a"]);
|
||||
});
|
||||
|
||||
it("CONSUME_FILES with multiple inputs (merge) records all sources", () => {
|
||||
const start = stateWith([stub("a"), stub("b")]);
|
||||
const next = fileContextReducer(start, {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["a" as FileId, "b" as FileId],
|
||||
outputStirlingFileStubs: [stub("merged")],
|
||||
},
|
||||
});
|
||||
expect(next.files.byId["merged" as FileId].sourceFileIds).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("UNDO_CONSUME_FILES restores the original upload without mislabelling it", () => {
|
||||
// Reverses the swap above: the original upload "a" comes back through the
|
||||
// same helper, but must NOT be flagged tool-derived.
|
||||
const consumed = fileContextReducer(stateWith([stub("a")]), {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["a" as FileId],
|
||||
outputStirlingFileStubs: [stub("b")],
|
||||
},
|
||||
});
|
||||
const undone = fileContextReducer(consumed, {
|
||||
type: "UNDO_CONSUME_FILES",
|
||||
payload: {
|
||||
inputStirlingFileStubs: [stub("a")],
|
||||
outputFileIds: ["b" as FileId],
|
||||
},
|
||||
});
|
||||
expect(undone.files.byId["a" as FileId].derivedFromTool).toBeUndefined();
|
||||
expect(undone.files.byId["b" as FileId]).toBeUndefined(); // output removed
|
||||
});
|
||||
});
|
||||
@@ -288,33 +288,7 @@ export function fileContextReducer(
|
||||
case "CONSUME_FILES": {
|
||||
const { inputFileIds, outputStirlingFileStubs } = action.payload;
|
||||
|
||||
// Transitive provenance: the outputs derive from these inputs AND from
|
||||
// whatever those inputs themselves derived from. Accumulating the closure
|
||||
// (rather than just the immediate inputs) means a policy badge still
|
||||
// resolves after an intermediate edit has been consumed and removed —
|
||||
// e.g. redact → edit → split still tags each split part. Captured before
|
||||
// the inputs are swapped out below.
|
||||
const sourceFileIds = Array.from(
|
||||
new Set(
|
||||
inputFileIds.flatMap((id) => [
|
||||
id,
|
||||
...(state.files.byId[id]?.sourceFileIds ?? []),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
// Mark every consume output as tool-produced (the single chokepoint for
|
||||
// both versioned edits and independent artifacts like convert/split/merge)
|
||||
// and stamp its provenance. Tag here, not in processFileSwap, so
|
||||
// UNDO_CONSUME (which restores the original inputs through the same helper)
|
||||
// doesn't mislabel real uploads.
|
||||
const taggedOutputs = outputStirlingFileStubs.map((stub) => ({
|
||||
...stub,
|
||||
derivedFromTool: true,
|
||||
sourceFileIds,
|
||||
}));
|
||||
|
||||
return processFileSwap(state, inputFileIds, taggedOutputs);
|
||||
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
|
||||
}
|
||||
|
||||
case "UNDO_CONSUME_FILES": {
|
||||
|
||||
@@ -17,9 +17,7 @@ export function useAllWatchedFolders(): WatchedFolder[] {
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
// Policy-owned folders are managed by Policies, not shown here.
|
||||
const all = await watchedFolderStorage.getAllFolders();
|
||||
setFolders(all.filter((f) => !f.policyCategoryId));
|
||||
setFolders(await watchedFolderStorage.getAllFolders());
|
||||
} catch (err) {
|
||||
console.error("Failed to load smart folders:", err);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,6 @@ import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import {
|
||||
Z_INDEX_COOKIE_CONSENT_BANNER,
|
||||
Z_INDEX_COOKIE_PREFERENCES_MODAL,
|
||||
} from "@app/styles/zIndex";
|
||||
import { TOUR_STATE_EVENT, type TourStatePayload } from "@app/constants/events";
|
||||
import { getCookieConsentOverrides } from "@app/extensions/cookieConsentConfig";
|
||||
|
||||
@@ -15,8 +11,6 @@ declare global {
|
||||
run: (config: any) => void;
|
||||
show: (show?: boolean) => void;
|
||||
hide: () => void;
|
||||
showPreferences: () => void;
|
||||
hidePreferences: () => void;
|
||||
getCookie: (name?: string) => any;
|
||||
acceptedCategory: (category: string) => boolean;
|
||||
acceptedService: (serviceName: string, category: string) => boolean;
|
||||
@@ -29,16 +23,6 @@ interface CookieConsentConfig {
|
||||
forceLightMode?: boolean;
|
||||
}
|
||||
|
||||
// Shard so Mantine's scroll-lock doesn't swallow events on the consent dialog;
|
||||
// lazy because #cc-main only exists post-load.
|
||||
export const COOKIE_CONSENT_SCROLL_SHARD = {
|
||||
get current(): HTMLElement | null {
|
||||
return typeof document === "undefined"
|
||||
? null
|
||||
: document.getElementById("cc-main");
|
||||
},
|
||||
};
|
||||
|
||||
export const useCookieConsent = ({
|
||||
analyticsEnabled = false,
|
||||
forceLightMode = false,
|
||||
@@ -50,26 +34,16 @@ export const useCookieConsent = ({
|
||||
useEffect(() => {
|
||||
if (!analyticsEnabled) return;
|
||||
|
||||
// Bridge the layering constants to the static cookie-consent stylesheets
|
||||
document.documentElement.style.setProperty(
|
||||
"--z-index-cookie-consent",
|
||||
String(Z_INDEX_COOKIE_CONSENT_BANNER),
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--z-index-cookie-preferences",
|
||||
String(Z_INDEX_COOKIE_PREFERENCES_MODAL),
|
||||
);
|
||||
|
||||
const mainCSS = document.createElement("link");
|
||||
mainCSS.rel = "stylesheet";
|
||||
mainCSS.href = `${BASE_PATH}/css/cookieconsent.css`;
|
||||
mainCSS.href = `${BASE_PATH}css/cookieconsent.css`;
|
||||
if (!document.querySelector(`link[href="${mainCSS.href}"]`)) {
|
||||
document.head.appendChild(mainCSS);
|
||||
}
|
||||
|
||||
const customCSS = document.createElement("link");
|
||||
customCSS.rel = "stylesheet";
|
||||
customCSS.href = `${BASE_PATH}/css/cookieconsentCustomisation.css`;
|
||||
customCSS.href = `${BASE_PATH}css/cookieconsentCustomisation.css`;
|
||||
if (!document.querySelector(`link[href="${customCSS.href}"]`)) {
|
||||
document.head.appendChild(customCSS);
|
||||
}
|
||||
@@ -83,7 +57,7 @@ export const useCookieConsent = ({
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = `${BASE_PATH}/js/thirdParty/cookieconsent.umd.js`;
|
||||
script.src = `${BASE_PATH}js/thirdParty/cookieconsent.umd.js`;
|
||||
script.onload = () => {
|
||||
setTimeout(() => {
|
||||
const detectTheme = () => {
|
||||
@@ -417,10 +391,7 @@ export const useCookieConsent = ({
|
||||
|
||||
const showCookiePreferences = useCallback(() => {
|
||||
if (isInitialized && window.CookieConsent) {
|
||||
// Open the detailed preferences dialog directly (not the consent
|
||||
// banner) — it gets the `.show--preferences` class, which the CSS
|
||||
// raises above the settings modal.
|
||||
window.CookieConsent?.showPreferences();
|
||||
window.CookieConsent?.show(true);
|
||||
}
|
||||
}, [isInitialized]);
|
||||
|
||||
|
||||
@@ -128,14 +128,10 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
"[useEndpointConfig] Fetching all endpoint statuses from server",
|
||||
);
|
||||
|
||||
// Fetch all endpoints at once; auto-fires on app load, so a 401 must
|
||||
// fail silently instead of triggering the global login redirect.
|
||||
// Fetch all endpoints at once - no query params needed
|
||||
const response = await apiClient.get<
|
||||
Record<string, EndpointAvailabilityDetails>
|
||||
>(`/api/v1/config/endpoints-availability`, {
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
>(`/api/v1/config/endpoints-availability`);
|
||||
|
||||
// Populate global cache with all results
|
||||
Object.entries(response.data).forEach(([endpoint, details]) => {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem";
|
||||
|
||||
/**
|
||||
* Policies that have run on each file, keyed by fileId — drives the shield
|
||||
* badges in the file sidebar. Empty in core; the proprietary build shadows this
|
||||
* with an implementation backed by the policy run store.
|
||||
*/
|
||||
export function usePolicyFileBadges(): Map<string, FileItemPolicyRef[]> {
|
||||
return new Map();
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Core stub — no profile picture source; auth-aware layers override this.
|
||||
*/
|
||||
export function useProfilePictureUrl(): string | null {
|
||||
return null;
|
||||
}
|
||||
@@ -21,10 +21,6 @@ import UploadRoundedIcon from "@mui/icons-material/UploadRounded";
|
||||
import AddPhotoAlternateRoundedIcon from "@mui/icons-material/AddPhotoAlternateRounded";
|
||||
import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded";
|
||||
import { loadJscanify } from "@app/utils/loadJscanify";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
// Use the configured API base (e.g. api.stirling.com), not the page origin.
|
||||
const API_BASE = (apiClient.defaults.baseURL ?? "").replace(/\/+$/, "");
|
||||
|
||||
/**
|
||||
* MobileScannerPage
|
||||
@@ -87,7 +83,7 @@ export default function MobileScannerPage() {
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/mobile-scanner/validate-session/${sessionId}`,
|
||||
`/api/v1/mobile-scanner/validate-session/${sessionId}`,
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
@@ -845,7 +841,7 @@ export default function MobileScannerPage() {
|
||||
});
|
||||
|
||||
const uploadResponse = await fetch(
|
||||
`${API_BASE}/api/v1/mobile-scanner/upload/${sessionId}`,
|
||||
`/api/v1/mobile-scanner/upload/${sessionId}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
|
||||
@@ -28,11 +28,8 @@ export const accountService = {
|
||||
* This is a public endpoint - doesn't require authentication
|
||||
*/
|
||||
async getLoginPageData(): Promise<LoginPageData> {
|
||||
// Also auto-called by onboarding when a stale stirling_jwt exists; a 401
|
||||
// must never trigger the global login redirect.
|
||||
const response = await apiClient.get<LoginPageData>(
|
||||
"/api/v1/proprietary/ui-data/login",
|
||||
{ suppressErrorToast: true, skipAuthRedirect: true },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
);
|
||||
}
|
||||
|
||||
/** Auth headers for raw fetch() calls — empty in core; proprietary/SaaS override. */
|
||||
export async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||
/** Auth headers for raw fetch() calls (SSE streams, etc.). Proprietary overrides with JWT + XSRF. */
|
||||
export function getAuthHeaders(): Record<string, string> {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -2,9 +2,6 @@ export interface DownloadRequest {
|
||||
data: Blob | File;
|
||||
filename: string;
|
||||
localPath?: string;
|
||||
/** Workspace fileId of the file being exported, when known. Lets export-time
|
||||
* policy enforcement version the in-editor file (not just the download). */
|
||||
fileId?: string;
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Export entry points call {@link downloadFileWithPolicy} instead of
|
||||
* {@link downloadFile} so any "export"-triggered policy enforces on the file
|
||||
* before it's downloaded. The enforcement itself is proprietary (a no-op in the
|
||||
* core build via the `@app/services/policyExport` stub), and never hard-blocks:
|
||||
* on failure the original file is downloaded.
|
||||
*/
|
||||
|
||||
import {
|
||||
downloadFile,
|
||||
type DownloadRequest,
|
||||
type DownloadResult,
|
||||
} from "@app/services/downloadService";
|
||||
import { enforceExportPolicies } from "@app/services/policyExport";
|
||||
|
||||
export async function downloadFileWithPolicy(
|
||||
request: DownloadRequest,
|
||||
): Promise<DownloadResult> {
|
||||
// enforceExportPolicies only touches PDFs and is a no-op without an active
|
||||
// export policy, so non-PDF / non-policy downloads pass straight through.
|
||||
const input =
|
||||
request.data instanceof File
|
||||
? request.data
|
||||
: new File([request.data], request.filename, {
|
||||
type: request.data.type,
|
||||
});
|
||||
const [enforced] = await enforceExportPolicies([input], [request.fileId]);
|
||||
return downloadFile({ ...request, data: enforced ?? request.data });
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
legacyDerivedFromTool,
|
||||
type StoredStirlingFileRecord,
|
||||
} from "@app/services/fileStorage";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
/**
|
||||
* Backfill of `derivedFromTool` for files persisted before the field existed
|
||||
* (old users' IndexedDB). New records always carry an explicit flag, so this
|
||||
* helper only governs pre-upgrade records.
|
||||
*/
|
||||
function record(
|
||||
overrides: Partial<StoredStirlingFileRecord>,
|
||||
): StoredStirlingFileRecord {
|
||||
return {
|
||||
id: "f" as FileId,
|
||||
fileId: "f" as FileId,
|
||||
quickKey: "k",
|
||||
name: "f.pdf",
|
||||
type: "application/pdf",
|
||||
size: 1,
|
||||
lastModified: 0,
|
||||
isLeaf: true,
|
||||
originalFileId: "f",
|
||||
versionNumber: 1,
|
||||
data: new ArrayBuffer(0),
|
||||
...overrides,
|
||||
} as StoredStirlingFileRecord;
|
||||
}
|
||||
|
||||
describe("legacyDerivedFromTool — IndexedDB backfill for pre-upgrade files", () => {
|
||||
it("flags a legacy versioned edit (has tool history)", () => {
|
||||
expect(
|
||||
legacyDerivedFromTool(
|
||||
record({ toolHistory: [{ toolId: "compress" as any, timestamp: 0 }] }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a legacy file past its first version", () => {
|
||||
expect(legacyDerivedFromTool(record({ versionNumber: 2 }))).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a legacy file with a parent", () => {
|
||||
expect(legacyDerivedFromTool(record({ parentFileId: "p" as FileId }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a clean legacy root unflagged — treated as an upload (safe default for enforcement)", () => {
|
||||
// A genuine upload AND a legacy independent artifact (convert/split/merge)
|
||||
// both look like this; old data can't tell them apart, so we enforce.
|
||||
expect(legacyDerivedFromTool(record({}))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -38,25 +38,6 @@ export interface StorageStats {
|
||||
quota?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort provenance for records persisted before `derivedFromTool`
|
||||
* existed. A version chain (a tool history, a version past the first, or a
|
||||
* parent) is unambiguously a tool output, so flag it. Legacy independent
|
||||
* artifacts (convert/split/merge) recorded none of that and are
|
||||
* indistinguishable from uploads in old data — they stay unflagged, which for
|
||||
* an enforcement feature is the safe default (enforce rather than silently
|
||||
* skip). New records always carry an explicit flag, so this only fires for
|
||||
* pre-existing files on first read after upgrade.
|
||||
*/
|
||||
export function legacyDerivedFromTool(
|
||||
record: StoredStirlingFileRecord,
|
||||
): boolean | undefined {
|
||||
if ((record.toolHistory?.length ?? 0) > 0) return true;
|
||||
if ((record.versionNumber ?? 1) > 1) return true;
|
||||
if (record.parentFileId != null) return true;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
class FileStorageService {
|
||||
private readonly dbConfig = DATABASE_CONFIGS.FILES;
|
||||
private readonly storeName = "files";
|
||||
@@ -143,8 +124,6 @@ class FileStorageService {
|
||||
originalFileId: stub.originalFileId ?? stirlingFile.fileId,
|
||||
parentFileId: stub.parentFileId ?? undefined,
|
||||
toolHistory: stub.toolHistory ?? [],
|
||||
derivedFromTool: stub.derivedFromTool ?? false,
|
||||
sourceFileIds: stub.sourceFileIds,
|
||||
|
||||
// Folder organisation (root when null)
|
||||
folderId: stub.folderId ?? null,
|
||||
@@ -268,9 +247,6 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory,
|
||||
derivedFromTool:
|
||||
record.derivedFromTool ?? legacyDerivedFromTool(record),
|
||||
sourceFileIds: record.sourceFileIds,
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
};
|
||||
@@ -327,9 +303,6 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId || record.id,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory || [],
|
||||
derivedFromTool:
|
||||
record.derivedFromTool ?? legacyDerivedFromTool(record),
|
||||
sourceFileIds: record.sourceFileIds,
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
});
|
||||
@@ -418,9 +391,6 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId || record.id,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory || [],
|
||||
derivedFromTool:
|
||||
record.derivedFromTool ?? legacyDerivedFromTool(record),
|
||||
sourceFileIds: record.sourceFileIds,
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
});
|
||||
|
||||
@@ -63,12 +63,6 @@ interface AccessedShareLinkResponse {
|
||||
export interface ReconcileOptions {
|
||||
storageEnabled: boolean;
|
||||
shareLinksEnabled: boolean;
|
||||
/**
|
||||
* Guests (anonymous sessions) have no cloud library; pulling it just 401s and
|
||||
* surfaces a "sign in to load cloud files" toast. Skip the server pull for
|
||||
* them and fall back to locally-cached files only.
|
||||
*/
|
||||
isAnonymous?: boolean;
|
||||
}
|
||||
|
||||
function normalizeServerFileName(fileName: string | undefined | null): string {
|
||||
@@ -114,7 +108,7 @@ export async function reconcileServerFiles(
|
||||
localStubs: StirlingFileStub[],
|
||||
opts: ReconcileOptions,
|
||||
): Promise<StirlingFileStub[]> {
|
||||
if (!opts.storageEnabled || opts.isAnonymous) {
|
||||
if (!opts.storageEnabled) {
|
||||
return localStubs;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,14 +62,8 @@ function toFolderRecord(dto: ServerFolder): FolderRecord {
|
||||
|
||||
export const folderSyncService = {
|
||||
async list(): Promise<FolderRecord[]> {
|
||||
// Auto-fired by FolderProvider on every load; a persistent 401 must fail
|
||||
// silently here or the global handler redirects to /login and loops.
|
||||
const response = await apiClient.get<ServerFolder[]>(
|
||||
"/api/v1/storage/folders",
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
},
|
||||
);
|
||||
return (response.data ?? []).map(toFolderRecord);
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
clampText,
|
||||
extractAxiosErrorMessage,
|
||||
} from "@app/services/httpErrorUtils";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
|
||||
// Module-scoped state to reduce global variable usage
|
||||
const recentSpecialByEndpoint: Record<string, number> = {};
|
||||
@@ -46,51 +45,6 @@ function stashPostLoginRedirect(path: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Loop breaker: a second 401 redirect within this window means the login page
|
||||
// bounced us back with a live session — redirecting again would loop forever.
|
||||
const LOGIN_REDIRECT_THROTTLE_KEY = "stirling_last_401_redirect";
|
||||
const LOGIN_REDIRECT_THROTTLE_MS = 10_000;
|
||||
|
||||
function loginRedirectRecentlyFired(): boolean {
|
||||
try {
|
||||
const last = Number(
|
||||
window.sessionStorage.getItem(LOGIN_REDIRECT_THROTTLE_KEY),
|
||||
);
|
||||
return (
|
||||
Number.isFinite(last) && Date.now() - last < LOGIN_REDIRECT_THROTTLE_MS
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markLoginRedirectFired(): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
LOGIN_REDIRECT_THROTTLE_KEY,
|
||||
String(Date.now()),
|
||||
);
|
||||
} catch {
|
||||
// sessionStorage unavailable — fail open
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the throttle when the user establishes a fresh session via interactive
|
||||
// login. Otherwise a genuine expiry that happens within the throttle window of
|
||||
// the redirect that sent them to /login would be wrongly suppressed, leaving
|
||||
// them on a page with silently failing requests. Login dispatches
|
||||
// "jwt-available"; token refresh does not (it fires "TOKEN_REFRESHED"), so
|
||||
// refresh-driven redirect churn is still dampened by the throttle.
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("jwt-available", () => {
|
||||
try {
|
||||
window.sessionStorage.removeItem(LOGIN_REDIRECT_THROTTLE_KEY);
|
||||
} catch {
|
||||
// sessionStorage unavailable - nothing to clear
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles HTTP errors with toast notifications and file error broadcasting
|
||||
* Returns true if the error should be suppressed (deduplicated), false otherwise
|
||||
@@ -116,13 +70,6 @@ export async function handleHttpError(error: any): Promise<boolean> {
|
||||
|
||||
// If not on auth page, redirect to login with expired session message
|
||||
if (!isAuthPage && !skipAuthRedirect) {
|
||||
if (loginRedirectRecentlyFired()) {
|
||||
console.warn(
|
||||
"[httpErrorHandler] 401 redirect already fired moments ago — suppressing repeat to avoid a login loop:",
|
||||
error?.config?.url,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
console.debug("[httpErrorHandler] 401 detected, redirecting to login");
|
||||
// Spring 302-strips the ?from= query from /login, so stash the return
|
||||
// path in sessionStorage (AuthCallback reads it after SSO round-trip).
|
||||
@@ -135,8 +82,7 @@ export async function handleHttpError(error: any): Promise<boolean> {
|
||||
// ignore storage access failures
|
||||
}
|
||||
const expiredPrefix = hadStoredJwt ? "expired=true&" : "";
|
||||
markLoginRedirectFired();
|
||||
window.location.href = `${withBasePath("/login")}?${expiredPrefix}from=${encodeURIComponent(currentLocation)}`;
|
||||
window.location.href = `/login?${expiredPrefix}from=${encodeURIComponent(currentLocation)}`;
|
||||
return true; // Suppress toast since we're redirecting
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
setPageRotation,
|
||||
addNewPage,
|
||||
} from "@app/services/pdfiumService";
|
||||
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
|
||||
|
||||
// A4 dimensions in PDF points (72 dpi)
|
||||
@@ -259,10 +259,10 @@ export class PDFExportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single file, applying any export-triggered policy first.
|
||||
* Download a single file
|
||||
*/
|
||||
downloadFile(blob: Blob, filename: string): void {
|
||||
void downloadFileWithPolicy({ data: blob, filename });
|
||||
void downloadFile({ data: blob, filename });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Open-source stub for export-time policy enforcement. Policies are a proprietary
|
||||
* feature, so in the core build there's nothing to enforce — this returns the
|
||||
* files unchanged. The proprietary build shadows this module via the `@app/*`
|
||||
* alias with the real implementation.
|
||||
*/
|
||||
export async function enforceExportPolicies(
|
||||
files: File[],
|
||||
_fileIds?: (string | undefined)[],
|
||||
): Promise<File[]> {
|
||||
return files;
|
||||
}
|
||||
@@ -26,11 +26,6 @@ export const Z_INDEX_DRAG_BADGE = 1001;
|
||||
// Modal that appears on top of config modal (e.g., restart confirmation, update modal)
|
||||
export const Z_INDEX_OVER_CONFIG_MODAL = 2000;
|
||||
|
||||
// Cookie-consent banner — above the chat FAB (1050), below all modals and onboarding; reaches CSS via --z-index-cookie-consent
|
||||
export const Z_INDEX_COOKIE_CONSENT_BANNER = 1060;
|
||||
// Cookie-consent preferences dialog — above the config modal it opens from; reaches CSS via --z-index-cookie-preferences
|
||||
export const Z_INDEX_COOKIE_PREFERENCES_MODAL = 1450;
|
||||
|
||||
// Sign-in modal — must appear above all app UI including config and analytics modals
|
||||
export const Z_INDEX_SIGN_IN_MODAL = 9000;
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ export async function mockAppApis(
|
||||
|
||||
// Current user — anonymous by default, configurable for authenticated flows
|
||||
await page.route("**/api/v1/auth/me", (route: Route) =>
|
||||
route.fulfill({ json: { user } }),
|
||||
route.fulfill({ json: user }),
|
||||
);
|
||||
|
||||
// Tool availability — every tool enabled unless overridden
|
||||
@@ -248,14 +248,6 @@ export async function mockAppApis(
|
||||
await page.route("**/api/v1/info/wau", (route: Route) =>
|
||||
route.fulfill({ json: { count: 0 } }),
|
||||
);
|
||||
|
||||
// Policies (proprietary): the reconcile fires GET /api/v1/policies on app
|
||||
// load. Return an empty list so the stubbed (backend-free) env stays clean —
|
||||
// no failed request polluting the console, and the auto-run controller has
|
||||
// nothing to dispatch.
|
||||
await page.route("**/api/v1/policies", (route: Route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -95,23 +95,8 @@ test.describe("1. Authentication and Login", () => {
|
||||
await page.goto("/merge");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// Simulate a genuinely expired session by forcing the token-refresh
|
||||
// endpoint to fail. Clearing client state alone is not enough: the SPA's
|
||||
// auth client refreshes a fresh JWT from a surviving server-side
|
||||
// credential and re-authenticates, churning between /merge, / and /login
|
||||
// instead of settling on the login page. A 401 here is what a real
|
||||
// expired/revoked session looks like to the client.
|
||||
await page.route("**/api/v1/auth/refresh", (route) =>
|
||||
route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "session_expired" }),
|
||||
}),
|
||||
);
|
||||
|
||||
// Invalidate the client session: clear cookies + the stored JWT, then
|
||||
// re-add the cookie-consent cookie so the banner doesn't block the login
|
||||
// page after redirect.
|
||||
// Step 1-2: Invalidate session by clearing cookies and localStorage JWT,
|
||||
// then re-add the cookie consent cookie so the banner doesn't block after redirect
|
||||
await page.context().clearCookies();
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
@@ -131,28 +116,20 @@ test.describe("1. Authentication and Login", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
// Full page reload forces the SPA to re-check auth with the backend.
|
||||
// Full page reload forces the SPA to re-check auth with the backend
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
|
||||
// With refresh failing, the SPA settles on the login page. Wait for the
|
||||
// login form to render (a concrete signal the redirect landed) and then
|
||||
// confirm the URL, rather than racing a URL poll against the bootstrap.
|
||||
await page
|
||||
.locator("#email")
|
||||
.waitFor({ state: "visible", timeout: 30000 });
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
// Step 3: Verify the user is redirected to the login page
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15000 });
|
||||
|
||||
// Stop forcing refresh failures so the fresh login below behaves normally.
|
||||
await page.unroute("**/api/v1/auth/refresh");
|
||||
|
||||
// Log back in with valid credentials.
|
||||
// Step 5: Log in with valid credentials
|
||||
await page.locator("#email").fill("admin");
|
||||
await page.locator("#password").fill("adminadmin");
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
// Verify the user is redirected back off the login page. Any non-/login
|
||||
// URL is acceptable: the app may route to the original page (/merge) or
|
||||
// to the dashboard (/), both are valid post-login states.
|
||||
// Step 6: Verify the user is redirected back to /merge or home.
|
||||
// Any non-/login URL is acceptable — the app may route to the original
|
||||
// page (/merge) or to the dashboard (/), both are valid post-login states.
|
||||
await page.waitForURL((url) => !url.pathname.includes("/login"), {
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
@@ -1,107 +1,103 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import { openSettings } from "@app/tests/helpers/ui-helpers";
|
||||
|
||||
test.describe("18. Cookie Preferences", () => {
|
||||
test.describe("18.1 Cookie Banner", () => {
|
||||
test("should open and configure cookie preferences from Settings → Legal", async ({
|
||||
test("should open and configure cookie preferences from footer", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Analytics must be enabled for the Cookie Preferences button to render
|
||||
await page.route("**/api/v1/ui-data/footer-info", (route) =>
|
||||
route.fulfill({ json: { analyticsEnabled: true } }),
|
||||
);
|
||||
await page.goto("/");
|
||||
|
||||
// Step 1: The "Cookie Preferences" button lives in Settings → Legal
|
||||
await openSettings(page);
|
||||
const legalNav = page.locator('[data-tour="admin-legal-nav"]').first();
|
||||
await expect(legalNav).toBeVisible({ timeout: 5000 });
|
||||
await legalNav.click();
|
||||
|
||||
const cookieButton = page.locator("#cookieBanner").first();
|
||||
await expect(cookieButton).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The consent library lazy-loads when the Legal section mounts
|
||||
await page.waitForFunction(
|
||||
() => (window as unknown as { CookieConsent?: unknown }).CookieConsent,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Step 2: Click the button — it opens the detailed preferences dialog
|
||||
// directly. No force: the click must land with the settings modal open,
|
||||
// proving the dialog stacks above it.
|
||||
await cookieButton.click();
|
||||
|
||||
// Step 3: Verify the preferences dialog opens inside #cc-main
|
||||
const ccMain = page.locator("#cc-main");
|
||||
const prefsDialog = ccMain.locator(".pm").first();
|
||||
await expect(prefsDialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Step 4: Verify the dialog sits above the settings modal
|
||||
// (Z_INDEX_CONFIG_MODAL = 1400)
|
||||
const zIndex = await page.evaluate(() => {
|
||||
const el = document.querySelector("#cc-main");
|
||||
return el ? Number(getComputedStyle(el).zIndex) : -1;
|
||||
});
|
||||
expect(zIndex).toBeGreaterThan(1400);
|
||||
|
||||
// Step 5: Verify the preferences panel shows cookie categories
|
||||
const necessaryCategory = ccMain
|
||||
.locator("text=/Strictly Necessary|necessary/i")
|
||||
// Step 1: Locate the "Cookie Preferences" button in the footer
|
||||
const cookieButton = page
|
||||
.locator(
|
||||
'#cookieBanner, button:has-text("Preferensi Cookie"), button:has-text("Cookie Preferences")',
|
||||
)
|
||||
.first();
|
||||
const analyticsCategory = ccMain.locator("text=/Analytics/i").first();
|
||||
await expect(necessaryCategory.or(analyticsCategory).first()).toBeVisible(
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
// Step 6: Expand all sections and verify the dialog body wheel-scrolls
|
||||
// while the settings modal is open (regression: the modal's
|
||||
// react-remove-scroll lock swallowed wheel events on the dialog)
|
||||
const expanders = ccMain.locator(
|
||||
".pm__section--expandable .pm__section-title",
|
||||
);
|
||||
const expanderCount = await expanders.count();
|
||||
for (let i = 0; i < expanderCount; i++) {
|
||||
await expanders.nth(i).click();
|
||||
}
|
||||
const dialogBody = ccMain.locator(".pm__body").first();
|
||||
const canScroll = await dialogBody.evaluate(
|
||||
(el) => el.scrollHeight > el.clientHeight,
|
||||
);
|
||||
if (canScroll) {
|
||||
const box = await dialogBody.boundingBox();
|
||||
await page.mouse.move(
|
||||
box!.x + box!.width / 2,
|
||||
box!.y + box!.height / 2,
|
||||
await page.waitForTimeout(1000);
|
||||
const isVisible = await cookieButton.isVisible().catch(() => false);
|
||||
if (!isVisible) {
|
||||
test.skip(
|
||||
true,
|
||||
"Cookie Preferences button not visible — analytics may be disabled",
|
||||
);
|
||||
await page.mouse.wheel(0, 300);
|
||||
await expect
|
||||
.poll(() => dialogBody.evaluate((el) => el.scrollTop), {
|
||||
timeout: 3000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
await dialogBody.evaluate((el) => {
|
||||
el.scrollTop = 0;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 7: Accept all (or save) to close — again without force
|
||||
const acceptAllBtn = ccMain
|
||||
.locator('button:has-text("Accept all")')
|
||||
await expect(cookieButton).toBeVisible();
|
||||
|
||||
// Step 2: Click the Cookie Preferences button
|
||||
await cookieButton.click({ force: true });
|
||||
|
||||
// Step 3: Verify the cookie consent dialog opens
|
||||
// The CookieConsent library renders inside #cc-main
|
||||
const ccMain = page.locator("#cc-main");
|
||||
const consentDialog = ccMain.getByRole("dialog").first();
|
||||
await expect(consentDialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Step 4: Verify options are available
|
||||
// The initial consent view shows: "Oke", "Tidak, terima kasih", "Kelola preferensi"
|
||||
const okeBtn = ccMain
|
||||
.locator('button:has-text("Oke"), button:has-text("OK")')
|
||||
.first();
|
||||
const savePrefBtn = ccMain
|
||||
.locator('button:has-text("Save preferences")')
|
||||
const noThanksBtn = ccMain
|
||||
.locator(
|
||||
'button:has-text("Tidak, terima kasih"), button:has-text("No Thanks")',
|
||||
)
|
||||
.first();
|
||||
const manageBtn = ccMain
|
||||
.locator(
|
||||
'button:has-text("Kelola preferensi"), button:has-text("Manage preferences")',
|
||||
)
|
||||
.first();
|
||||
|
||||
if (await acceptAllBtn.isVisible().catch(() => false)) {
|
||||
await acceptAllBtn.click();
|
||||
} else {
|
||||
await savePrefBtn.click();
|
||||
const hasOke = await okeBtn.isVisible().catch(() => false);
|
||||
const hasNoThanks = await noThanksBtn.isVisible().catch(() => false);
|
||||
const hasManage = await manageBtn.isVisible().catch(() => false);
|
||||
|
||||
expect(hasOke || hasNoThanks || hasManage).toBe(true);
|
||||
|
||||
// Step 5: Click "Kelola preferensi" to open the detailed preferences panel
|
||||
if (hasManage) {
|
||||
await manageBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify the preferences panel shows cookie categories
|
||||
const necessaryCategory = ccMain
|
||||
.locator(
|
||||
"text=/Cookie yang Sangat Diperlukan|Strictly Necessary|necessary/i",
|
||||
)
|
||||
.first();
|
||||
const analyticsCategory = ccMain
|
||||
.locator("text=/Analitik|Analytics/i")
|
||||
.first();
|
||||
await expect(
|
||||
necessaryCategory.or(analyticsCategory).first(),
|
||||
).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Click "Terima semua" (Accept all) or "Simpan preferensi" (Save preferences) to close
|
||||
const acceptAllBtn = ccMain
|
||||
.locator(
|
||||
'button:has-text("Terima semua"), button:has-text("Accept all")',
|
||||
)
|
||||
.first();
|
||||
const savePrefBtn = ccMain
|
||||
.locator(
|
||||
'button:has-text("Simpan preferensi"), button:has-text("Save preferences")',
|
||||
)
|
||||
.first();
|
||||
|
||||
if (await acceptAllBtn.isVisible().catch(() => false)) {
|
||||
await acceptAllBtn.click();
|
||||
} else if (await savePrefBtn.isVisible().catch(() => false)) {
|
||||
await savePrefBtn.click();
|
||||
}
|
||||
} else if (hasOke) {
|
||||
await okeBtn.click();
|
||||
} else if (hasNoThanks) {
|
||||
await noThanksBtn.click();
|
||||
}
|
||||
|
||||
// Step 8: Verify the dialog is dismissed
|
||||
await expect(prefsDialog).toBeHidden({ timeout: 5000 });
|
||||
// Step 6: Verify the dialog is dismissed
|
||||
await expect(consentDialog).toBeHidden({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,14 +141,7 @@ async function settle(page: Page, ms = 350): Promise<void> {
|
||||
}
|
||||
|
||||
test.describe("Files page screenshots", () => {
|
||||
// Seed a logged-in session: the cloud-folder surfaces (move-dialog
|
||||
// create-folder, the seeded "Reports" folder) only render once a confirmed,
|
||||
// non-anonymous user triggers the folder pull (see FolderContext gating).
|
||||
test.use({
|
||||
autoGoto: false,
|
||||
viewport: { width: 1600, height: 900 },
|
||||
seedJwt: true,
|
||||
});
|
||||
test.use({ autoGoto: false, viewport: { width: 1600, height: 900 } });
|
||||
|
||||
test("01_empty_state_ctas", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
|
||||
@@ -539,10 +539,7 @@ test.describe("Files page", () => {
|
||||
});
|
||||
|
||||
test.describe("Move dialog inline create-folder", () => {
|
||||
// The inline create-folder affordance is gated on `serverReachable`, which
|
||||
// only flips true once a confirmed, non-anonymous user triggers the folder
|
||||
// pull (see FolderContext). Seed a JWT so the stubbed session is logged-in.
|
||||
test.use({ autoGoto: false, seedJwt: true });
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Move dialog shows Create new folder affordance", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import { mockAppApis, seedCookieConsent } from "@app/tests/helpers/api-stubs";
|
||||
import { openSettings } from "@app/tests/helpers/ui-helpers";
|
||||
|
||||
test.describe("2. Main Dashboard / Home Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -88,29 +87,33 @@ test.describe("2. Main Dashboard / Home Page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("2.4 Dashboard - Legal Links", () => {
|
||||
test("legal links live in Settings → Legal, not in a footer", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(
|
||||
page.locator('[data-testid="config-button"]').first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The dashboard footer (survey + legal links) was removed
|
||||
await expect(page.locator(".footer-link")).toHaveCount(0);
|
||||
await expect(page.getByText("Survey")).toHaveCount(0);
|
||||
|
||||
await openSettings(page);
|
||||
const legalNav = page.locator('[data-tour="admin-legal-nav"]').first();
|
||||
await expect(legalNav).toBeVisible({ timeout: 10000 });
|
||||
await legalNav.click();
|
||||
|
||||
test.describe("2.4 Dashboard - Footer Links", () => {
|
||||
test("should display footer links with correct URLs", async ({ page }) => {
|
||||
await expect(page.getByText("Survey").first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText("Privacy Policy").first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText(/Terms/i).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText("Discord").first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText("GitHub").first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const githubLink = page
|
||||
.locator('a[href*="github.com/Stirling-Tools/Stirling-PDF"]')
|
||||
.first();
|
||||
await expect(githubLink).toBeVisible();
|
||||
|
||||
const discordLink = page
|
||||
.locator('a[href*="discord.gg/Cn8pWhQRxZ"]')
|
||||
.first();
|
||||
await expect(discordLink).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
|
||||
/**
|
||||
* Policy editing is admin-only (mirrors the backend's enforcement on the
|
||||
* save/delete endpoints). The frontend gate is `canConfigure = !enableLogin ||
|
||||
* isAdmin`, surfaced through `usePolicies()`:
|
||||
* - login enabled + non-admin → read-only: the setup wizard shows the
|
||||
* "Managed by your organization" locked state instead of the steps.
|
||||
* - login enabled + admin → full setup flow ("Step 1 of 2").
|
||||
* - login disabled (single-user/desktop) → open to the local operator.
|
||||
*
|
||||
* Backend-free: the app-config (`enableLogin`/`isAdmin`) and the empty policy
|
||||
* list are stubbed via `mockAppApis`, so this asserts the gating wiring without
|
||||
* a live Spring Boot server. "Security" is the only non-coming-soon policy, so
|
||||
* it's the one we open.
|
||||
*/
|
||||
|
||||
const LOCKED_TITLE = "Managed by your organization";
|
||||
const LOCKED_DESC = "Contact a team leader to change this policy.";
|
||||
|
||||
/** Open the Security policy from the right-sidebar Policies list. */
|
||||
async function openSecurityPolicy(page: import("@playwright/test").Page) {
|
||||
const row = page.locator("button.pol-row").filter({ hasText: "Security" });
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await row.click();
|
||||
// The wizard header confirms we opened the right policy in either state.
|
||||
await expect(page.getByText("Set up Security Policy")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Policy editing gate — non-admin (login on)", () => {
|
||||
test.use({
|
||||
stubOptions: { enableLogin: true, isAdmin: false },
|
||||
seedJwt: true,
|
||||
});
|
||||
|
||||
test("non-admin gets the read-only locked state", async ({ page }) => {
|
||||
await openSecurityPolicy(page);
|
||||
await expect(page.getByText(LOCKED_TITLE)).toBeVisible();
|
||||
await expect(page.getByText(LOCKED_DESC)).toBeVisible();
|
||||
// The editable flow must NOT be reachable.
|
||||
await expect(page.getByText(/Step \d+ of \d+/)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Policy editing gate — admin (login on)", () => {
|
||||
test.use({
|
||||
stubOptions: { enableLogin: true, isAdmin: true },
|
||||
seedJwt: true,
|
||||
});
|
||||
|
||||
test("admin can reach the setup wizard", async ({ page }) => {
|
||||
await openSecurityPolicy(page);
|
||||
await expect(page.getByText(/Step \d+ of \d+/)).toBeVisible();
|
||||
await expect(page.getByText(LOCKED_TITLE)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Policy editing gate — single-user (login off)", () => {
|
||||
test.use({ stubOptions: { enableLogin: false, isAdmin: false } });
|
||||
|
||||
test("local operator can reach the setup wizard with no admin role", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openSecurityPolicy(page);
|
||||
await expect(page.getByText(/Step \d+ of \d+/)).toBeVisible();
|
||||
await expect(page.getByText(LOCKED_TITLE)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,9 @@ import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
* Verifies that the Stripe SDK (@stripe/stripe-js, @stripe/react-stripe-js,
|
||||
* and the js.stripe.com remote script) is NOT fetched on cold page loads —
|
||||
* only when the checkout modal actually mounts. The proprietary
|
||||
* CheckoutProvider and the SaaS Plan settings page gate the modal behind
|
||||
* React.lazy + a conditional render so the Stripe chunk lives in its own
|
||||
* async bundle.
|
||||
* CheckoutProvider, the SaaS TrialStatusBanner, and the SaaS Plan settings
|
||||
* page all gate the modal behind React.lazy + a conditional render so the
|
||||
* Stripe chunk lives in its own async bundle.
|
||||
*/
|
||||
|
||||
const STRIPE_URL_FRAGMENTS = [
|
||||
|
||||
@@ -150,16 +150,6 @@ export const mantineTheme = createTheme({
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Code: {
|
||||
styles: {
|
||||
root: {
|
||||
backgroundColor: "var(--color-gray-100)",
|
||||
color: "var(--text-primary)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Textarea: {
|
||||
styles: (_theme: MantineTheme) => ({
|
||||
input: {
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface AppConfig {
|
||||
enableDesktopInstallSlide?: boolean;
|
||||
premiumEnabled?: boolean;
|
||||
premiumKey?: string;
|
||||
paygEnabled?: boolean;
|
||||
termsAndConditions?: string;
|
||||
privacyPolicy?: string;
|
||||
cookiePolicy?: string;
|
||||
|
||||
@@ -37,30 +37,6 @@ export interface BaseFileMetadata {
|
||||
parentFileId?: FileId; // Immediate parent file ID
|
||||
toolHistory?: ToolOperation[]; // Tool chain for history tracking
|
||||
|
||||
/**
|
||||
* True if this file was produced by a tool/automation in-app (any
|
||||
* `consumeFiles` output — a versioned edit OR an independent artifact like a
|
||||
* convert/split/merge result) rather than entering the system as a genuine
|
||||
* upload. Set at the consume chokepoint so it covers both kinds, including
|
||||
* independent artifacts whose version metadata is otherwise indistinguishable
|
||||
* from a fresh upload. Persisted so the distinction survives a reload.
|
||||
* Used by input-mode (upload) policy auto-run to enforce only on real uploads.
|
||||
*/
|
||||
derivedFromTool?: boolean;
|
||||
|
||||
/**
|
||||
* Transitive set of fileIds this file was derived from — the inputs of the
|
||||
* tool operation that produced it, plus those inputs' own `sourceFileIds`.
|
||||
* Recorded at the `consumeFiles` boundary, the only place that knows the
|
||||
* input→output mapping. Unlike `parentFileId` (the version chain) this is a
|
||||
* pure provenance link, so it covers independent artifacts — split (1→N),
|
||||
* merge (N→1), convert — that intentionally have no parent. Being transitive,
|
||||
* it survives an intermediate edit being consumed/removed. Persisted; used so
|
||||
* a policy badge follows a document onto everything derived from it. Legacy
|
||||
* files predate it (the link was never recorded) and stay empty.
|
||||
*/
|
||||
sourceFileIds?: FileId[];
|
||||
|
||||
/**
|
||||
* The cloud folder this file lives in. Semantics:
|
||||
* - `remoteStorageId == null` → file is local-only; folderId MUST be null.
|
||||
|
||||
@@ -16,8 +16,6 @@ export interface AnimatedCircleConfig {
|
||||
export interface AnimatedSlideBackgroundProps {
|
||||
gradientStops: [string, string];
|
||||
circles: AnimatedCircleConfig[];
|
||||
/** Overall background tone; controls on top of the hero (e.g. close button) adapt to stay visible. Defaults to "dark". */
|
||||
tone?: "light" | "dark";
|
||||
}
|
||||
|
||||
export interface SlideConfig {
|
||||
|
||||
@@ -25,9 +25,6 @@ export interface WatchedFolder {
|
||||
hasOutputDirectory?: boolean; // true when a local FS output folder is configured
|
||||
/** Where input files come from. Default: 'idb' (dropped/sidebar files stay in browser). */
|
||||
inputSource?: "idb" | "local-folder";
|
||||
/** Set when this folder is the backing store for a Policy (its category id).
|
||||
* Policy-owned folders are filtered out of the Watched Folders UI. */
|
||||
policyCategoryId?: string;
|
||||
}
|
||||
|
||||
export interface FolderFileMetadata {
|
||||
|
||||
@@ -2,8 +2,6 @@ import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
|
||||
import { enforceExportPolicies } from "@app/services/policyExport";
|
||||
|
||||
/**
|
||||
* Downloads a blob as a file using browser download API
|
||||
@@ -29,11 +27,10 @@ export async function downloadFileFromStorage(
|
||||
throw new Error(`File "${file.name}" not found in storage`);
|
||||
}
|
||||
|
||||
await downloadFileWithPolicy({
|
||||
await downloadFile({
|
||||
data: stirlingFile,
|
||||
filename: stirlingFile.name,
|
||||
localPath: file.localFilePath,
|
||||
fileId: file.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,15 +59,15 @@ export async function downloadFilesAsZip(
|
||||
throw new Error("No files provided for ZIP download");
|
||||
}
|
||||
|
||||
// Convert stored files to File objects (tracking ids so export policies can
|
||||
// version the in-editor file).
|
||||
// Convert stored files to File objects
|
||||
const filesToZip: File[] = [];
|
||||
const fileIds: (string | undefined)[] = [];
|
||||
for (const fileWithUrl of files) {
|
||||
const stirlingFile = await fileStorage.getStirlingFile(fileWithUrl.id);
|
||||
const lookupKey = fileWithUrl.id;
|
||||
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
|
||||
|
||||
if (stirlingFile) {
|
||||
// StirlingFile is already a File object!
|
||||
filesToZip.push(stirlingFile);
|
||||
fileIds.push(fileWithUrl.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,9 +75,6 @@ export async function downloadFilesAsZip(
|
||||
throw new Error("No valid files found in storage for ZIP download");
|
||||
}
|
||||
|
||||
// Enforce any export-triggered policy on each PDF before they're zipped.
|
||||
const enforced = await enforceExportPolicies(filesToZip, fileIds);
|
||||
|
||||
// Generate default filename if not provided
|
||||
const finalZipFilename =
|
||||
zipFilename ||
|
||||
@@ -88,7 +82,7 @@ export async function downloadFilesAsZip(
|
||||
|
||||
// Create and download ZIP
|
||||
const { zipFile } = await zipFileService.createZipFromFiles(
|
||||
enforced,
|
||||
filesToZip,
|
||||
finalZipFilename,
|
||||
);
|
||||
await downloadFile({ data: zipFile, filename: finalZipFilename });
|
||||
@@ -131,7 +125,7 @@ export async function downloadFiles(
|
||||
* @param filename - Optional custom filename
|
||||
*/
|
||||
export function downloadFileObject(file: File, filename?: string): void {
|
||||
void downloadFileWithPolicy({ data: file, filename: filename || file.name });
|
||||
void downloadFile({ data: file, filename: filename || file.name });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
scoreMatch,
|
||||
minScoreForQuery,
|
||||
isFuzzyMatch,
|
||||
} from "@app/utils/fuzzySearch";
|
||||
|
||||
describe("scoreMatch", () => {
|
||||
it("scores substring matches highest", () => {
|
||||
expect(scoreMatch("rota", "Rotate")).toBeGreaterThanOrEqual(90);
|
||||
expect(scoreMatch("priv", "private")).toBeGreaterThanOrEqual(
|
||||
minScoreForQuery("priv"),
|
||||
);
|
||||
});
|
||||
|
||||
it("tolerates small typos", () => {
|
||||
expect(isFuzzyMatch("rotaet", "rotate")).toBe(true);
|
||||
expect(isFuzzyMatch("comprss", "compress")).toBe(true);
|
||||
// Typo inside one token of a multi-word target
|
||||
expect(isFuzzyMatch("watermrk", "Add Watermark")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unrelated words that share half their letters", () => {
|
||||
// Unrelated words can share many letters (rotate vs update is edit
|
||||
// distance 3 of 6) but must not be treated as a fuzzy match.
|
||||
expect(isFuzzyMatch("rotate", "update")).toBe(false);
|
||||
expect(isFuzzyMatch("rotate", "create")).toBe(false);
|
||||
expect(isFuzzyMatch("rotate", "private")).toBe(false);
|
||||
expect(isFuzzyMatch("rotate", "isolated")).toBe(false);
|
||||
expect(isFuzzyMatch("rotate", "automate")).toBe(false);
|
||||
expect(isFuzzyMatch("rotate", "annotate")).toBe(false);
|
||||
expect(isFuzzyMatch("rotat", "protect")).toBe(false);
|
||||
expect(isFuzzyMatch("rotat", "redact")).toBe(false);
|
||||
expect(isFuzzyMatch("rotat", "contrast")).toBe(false);
|
||||
expect(isFuzzyMatch("rotat", "annotate")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("minScoreForQuery", () => {
|
||||
it("does not loosen the threshold for long queries", () => {
|
||||
expect(minScoreForQuery("rot")).toBe(40);
|
||||
expect(minScoreForQuery("rotate")).toBe(30);
|
||||
expect(minScoreForQuery("orientation")).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -63,27 +63,20 @@ export function scoreMatch(queryRaw: string, targetRaw: string): number {
|
||||
}
|
||||
}
|
||||
|
||||
// Levenshtein fallback is for typos only: allow a small absolute number of
|
||||
// edits against the whole target or any single token. Relative similarity is
|
||||
// not enough here; half the letters of an unrelated word can match (e.g.
|
||||
// "rotate" vs "update" is edit distance 3 of 6).
|
||||
const maxEdits = query.length <= 4 ? 1 : 2;
|
||||
let best = 0;
|
||||
for (const candidate of [target, ...tokens]) {
|
||||
if (Math.abs(query.length - candidate.length) > maxEdits) continue;
|
||||
const distance = levenshtein(query, candidate);
|
||||
if (distance > maxEdits) continue;
|
||||
const maxLen = Math.max(query.length, candidate.length, 1);
|
||||
const similarity = 1 - distance / maxLen; // 0..1
|
||||
const score = Math.floor(similarity * 60); // scale below substring scores
|
||||
if (score > best) best = score;
|
||||
}
|
||||
return best;
|
||||
const distance = levenshtein(
|
||||
query,
|
||||
target.length > 64 ? target.slice(0, 64) : target,
|
||||
);
|
||||
const maxLen = Math.max(query.length, target.length, 1);
|
||||
const similarity = 1 - distance / maxLen; // 0..1
|
||||
return Math.floor(similarity * 60); // scale below substring scores
|
||||
}
|
||||
|
||||
export function minScoreForQuery(query: string): number {
|
||||
const len = normalizeText(query).length;
|
||||
return len <= 3 ? 40 : 30;
|
||||
if (len <= 3) return 40;
|
||||
if (len <= 6) return 30;
|
||||
return 25;
|
||||
}
|
||||
|
||||
// Decide if a target matches a query based on a threshold
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
cv?: any;
|
||||
@@ -7,9 +5,8 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
// Served under the app's base path (handles sub-path deploys like /app).
|
||||
const OPENCV_SRC = withBasePath("/vendor/jscanify/opencv.js");
|
||||
const JSCANIFY_SRC = withBasePath("/vendor/jscanify/jscanify.js");
|
||||
const OPENCV_SRC = "/vendor/jscanify/opencv.js";
|
||||
const JSCANIFY_SRC = "/vendor/jscanify/jscanify.js";
|
||||
|
||||
let loadPromise: Promise<void> | null = null;
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* Unit tests for buildMobileScannerUrl.
|
||||
*
|
||||
* Regression guard: the SaaS frontend is served under a base path (e.g.
|
||||
* `/app`), and `/mobile-scanner` is a public route that only matches under that
|
||||
* base path. The backend advertises `frontendUrl` as a bare origin (no
|
||||
* subpath), so the generated QR URL must still carry the base path. Dropping it
|
||||
* sent phones to the auth-gated catch-all route / login page.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl";
|
||||
|
||||
const sessionId = "abc-123";
|
||||
|
||||
describe("buildMobileScannerUrl", () => {
|
||||
test("origin-only frontendUrl keeps the app base path (SaaS web regression)", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: "https://app.stirlingpdf.com",
|
||||
sessionId,
|
||||
origin: "https://app.stirlingpdf.com",
|
||||
basePath: "/app",
|
||||
}),
|
||||
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||
});
|
||||
|
||||
test("origin-only frontendUrl with a trailing slash keeps the app base path", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: "https://app.stirlingpdf.com/",
|
||||
sessionId,
|
||||
origin: "https://app.stirlingpdf.com",
|
||||
basePath: "/app",
|
||||
}),
|
||||
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||
});
|
||||
|
||||
test("configured URL that already carries the subpath is used verbatim (no doubled base)", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: "https://app.stirlingpdf.com/app",
|
||||
sessionId,
|
||||
origin: "https://elsewhere.example",
|
||||
basePath: "/app",
|
||||
}),
|
||||
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||
});
|
||||
|
||||
test("configured URL subpath with trailing slash is normalized", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: "https://host.example/app/",
|
||||
sessionId,
|
||||
origin: "https://host.example",
|
||||
basePath: "",
|
||||
}),
|
||||
).toBe("https://host.example/app/mobile-scanner?session=abc-123");
|
||||
});
|
||||
|
||||
test("no base path and no configured URL uses the current origin", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: "",
|
||||
sessionId,
|
||||
origin: "http://localhost:5173",
|
||||
basePath: "",
|
||||
}),
|
||||
).toBe("http://localhost:5173/mobile-scanner?session=abc-123");
|
||||
});
|
||||
|
||||
test("empty configured URL falls back to origin + base path", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: " ",
|
||||
sessionId,
|
||||
origin: "https://app.stirlingpdf.com",
|
||||
basePath: "/app",
|
||||
}),
|
||||
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||
});
|
||||
|
||||
test("custom port (LAN/desktop) is preserved", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: "http://192.168.1.50:8080",
|
||||
sessionId,
|
||||
origin: "http://localhost:8080",
|
||||
basePath: "",
|
||||
}),
|
||||
).toBe("http://192.168.1.50:8080/mobile-scanner?session=abc-123");
|
||||
});
|
||||
|
||||
test("invalid configured URL falls back to the current origin + base path", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: "not a url",
|
||||
sessionId,
|
||||
origin: "https://app.stirlingpdf.com",
|
||||
basePath: "/app",
|
||||
}),
|
||||
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||
});
|
||||
|
||||
test("non-http(s) configured URL is ignored (no javascript: injection)", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: "javascript:alert(1)",
|
||||
sessionId,
|
||||
origin: "https://app.stirlingpdf.com",
|
||||
basePath: "/app",
|
||||
}),
|
||||
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Build the URL a phone opens (via the QR code) to reach the SPA's
|
||||
* `/mobile-scanner` route.
|
||||
*
|
||||
* That route is a public, top-level route. It lives under the app's base path,
|
||||
* which is the router's `basename`. If the generated URL omits the base path,
|
||||
* the phone loads a path the router can't match, falls through to the
|
||||
* auth-gated catch-all route, and gets bounced to the login page. So the base
|
||||
* path must always be present.
|
||||
*
|
||||
* A configured `server_url`/`frontendUrl` supplies the host the phone should
|
||||
* reach (desktop / LAN / reverse proxy):
|
||||
* - origin only (no subpath): apply the app's base path. The backend's
|
||||
* `resolveFrontendUrl` advertises a bare origin with no subpath, so this is
|
||||
* the common SaaS web case (frontend served under e.g. `/app`).
|
||||
* - already carries a subpath: it points at the target SPA's base directly,
|
||||
* so use it verbatim and do not add the base path again (no doubled base).
|
||||
*
|
||||
* With no usable configured URL, fall back to the current origin + base path.
|
||||
*/
|
||||
export function buildMobileScannerUrl(params: {
|
||||
configuredUrl: string;
|
||||
sessionId: string;
|
||||
origin: string;
|
||||
basePath: string;
|
||||
}): string {
|
||||
const { configuredUrl, sessionId, origin, basePath } = params;
|
||||
const query = `?session=${sessionId}`;
|
||||
const route = `${basePath}/mobile-scanner`;
|
||||
|
||||
const trimmed = configuredUrl.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
||||
const subpath = parsed.pathname.replace(/\/+$/, "");
|
||||
return subpath
|
||||
? `${parsed.origin}${subpath}/mobile-scanner${query}`
|
||||
: `${parsed.origin}${route}${query}`;
|
||||
}
|
||||
} catch {
|
||||
// invalid configured URL — fall through to the current-origin default
|
||||
}
|
||||
}
|
||||
|
||||
return `${origin}${route}${query}`;
|
||||
}
|
||||
@@ -1,21 +1,53 @@
|
||||
import { NavKey } from "@app/components/shared/config/types";
|
||||
import { stripBasePath, withBasePath } from "@app/constants/app";
|
||||
|
||||
/** Push the URL for a settings section and notify listeners. */
|
||||
/**
|
||||
* Navigate to a specific settings section
|
||||
*
|
||||
* @param section - The settings section key to navigate to
|
||||
*
|
||||
* @example
|
||||
* // Navigate to People section
|
||||
* navigateToSettings('people');
|
||||
*
|
||||
* // Navigate to Admin Premium section
|
||||
* navigateToSettings('adminPremium');
|
||||
*/
|
||||
export function navigateToSettings(section: NavKey) {
|
||||
const newPath = withBasePath(`/settings/${section}`);
|
||||
const basePath = window.location.pathname.split("/settings")[0] || "";
|
||||
const newPath = `${basePath}/settings/${section}`;
|
||||
window.history.pushState({}, "", newPath);
|
||||
|
||||
// Trigger a popstate event to notify components
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
|
||||
/** URL for a settings section (subpath-aware). */
|
||||
/**
|
||||
* Get the URL path for a settings section
|
||||
* Useful for creating links
|
||||
*
|
||||
* @param section - The settings section key
|
||||
* @returns The URL path for the settings section
|
||||
*
|
||||
* @example
|
||||
* <a href={getSettingsUrl('people')}>Go to People Settings</a>
|
||||
* // Returns: "/settings/people"
|
||||
*/
|
||||
export function getSettingsUrl(section: NavKey): string {
|
||||
return withBasePath(`/settings/${section}`);
|
||||
return `/settings/${section}`;
|
||||
}
|
||||
|
||||
/** Whether the current URL is in /settings (optionally a specific section). */
|
||||
/**
|
||||
* Check if currently viewing a settings section
|
||||
*
|
||||
* @param section - Optional section key to check for specific section
|
||||
* @returns True if in settings (and matching specific section if provided)
|
||||
*/
|
||||
export function isInSettings(section?: NavKey): boolean {
|
||||
const pathname = stripBasePath(window.location.pathname);
|
||||
if (!section) return pathname.startsWith("/settings");
|
||||
const pathname = window.location.pathname;
|
||||
|
||||
if (!section) {
|
||||
return pathname.startsWith("/settings");
|
||||
}
|
||||
|
||||
return pathname === `/settings/${section}`;
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { filterToolRegistryByQuery } from "@app/utils/toolSearch";
|
||||
import {
|
||||
ToolCategoryId,
|
||||
SubcategoryId,
|
||||
ToolRegistry,
|
||||
ToolRegistryEntry,
|
||||
} from "@app/data/toolsTaxonomy";
|
||||
|
||||
function makeEntry(name: string, tags: string): ToolRegistryEntry {
|
||||
return {
|
||||
icon: null,
|
||||
name,
|
||||
component: null,
|
||||
description: "",
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
automationSettings: null,
|
||||
synonyms: tags.split(","),
|
||||
};
|
||||
}
|
||||
|
||||
// Real names and tags from the en-GB translations, chosen because they share
|
||||
// letters with "rotat"/"rotate" and stress the partial-query filtering.
|
||||
const registry: Partial<ToolRegistry> = {
|
||||
rotate: makeEntry(
|
||||
"Rotate",
|
||||
"turn,flip,orient,rotate,orientation,landscape,portrait,90 degrees,180 degrees,clockwise,anticlockwise,counter-clockwise,fix orientation",
|
||||
),
|
||||
addPassword: makeEntry(
|
||||
"Add Password",
|
||||
"encrypt,password,lock,secure,protect,security,encryption,safeguard,confidential,private,restrict access",
|
||||
),
|
||||
changeMetadata: makeEntry(
|
||||
"Change Metadata",
|
||||
"edit,modify,update,metadata,properties,document properties,author,title,subject,keywords,creator,producer,info,document info,file properties",
|
||||
),
|
||||
scannerEffect: makeEntry(
|
||||
"Scanner Effect",
|
||||
"scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan",
|
||||
),
|
||||
adjustContrast: makeEntry(
|
||||
"Adjust Colours/Contrast",
|
||||
"contrast,brightness,saturation,adjust colors,color correction,enhance,lighten,darken,improve quality,color balance,hue,vibrance",
|
||||
),
|
||||
annotate: makeEntry(
|
||||
"Annotate",
|
||||
"annotate,highlight,draw,markup,comment,notes,review,redline,feedback,markup tools,sticky notes,shapes,arrows,text box,freehand",
|
||||
),
|
||||
redact: makeEntry(
|
||||
"Redact",
|
||||
"censor,blackout,hide,redact,redaction,black out,block out,remove sensitive,hide text,privacy,confidential,GDPR,PII,sensitive data,permanently remove,cover up,legal redaction",
|
||||
),
|
||||
compare: makeEntry(
|
||||
"Compare",
|
||||
"difference,compare,diff,compare PDFs,compare documents,find differences,show differences,changes,what changed,track changes,revisions,version compare,side by side,contrast,delta",
|
||||
),
|
||||
};
|
||||
|
||||
function idsFor(query: string): string[] {
|
||||
return filterToolRegistryByQuery(registry, query).map(({ item: [id] }) => id);
|
||||
}
|
||||
|
||||
describe("filterToolRegistryByQuery", () => {
|
||||
it("returns everything for an empty query", () => {
|
||||
expect(idsFor("")).toHaveLength(Object.keys(registry).length);
|
||||
});
|
||||
|
||||
it("matches tools by tag substring", () => {
|
||||
expect(idsFor("protect")).toEqual(["addPassword"]);
|
||||
expect(idsFor("orientation")).toEqual(["rotate"]);
|
||||
});
|
||||
|
||||
it("only returns Rotate for every prefix of 'rotate'", () => {
|
||||
// Prefixes of "rotate" must not leak tools that merely share letters
|
||||
// (Redact, Annotate, Compare, Adjust Colours/Contrast, etc.).
|
||||
expect(idsFor("rota")).toEqual(["rotate"]);
|
||||
expect(idsFor("rotat")).toEqual(["rotate"]);
|
||||
expect(idsFor("rotate")).toEqual(["rotate"]);
|
||||
});
|
||||
});
|
||||
@@ -390,11 +390,12 @@ self.addEventListener(
|
||||
let compDoc: PDFDocumentProxy | null = null;
|
||||
|
||||
try {
|
||||
// Resolve pdfjs CMap/standard-font URLs against the worker's origin + subpath.
|
||||
const assetsBase = new URL(
|
||||
`${import.meta.env.BASE_URL}pdfjs/`,
|
||||
self.location.origin,
|
||||
).toString();
|
||||
// `CanvasFactory`/`FilterFactory` are supported at runtime but not declared on legacy types.
|
||||
// pdfjs-dist 5.x renamed the option to `CanvasFactory` (capital C); without a FilterFactory
|
||||
// the default DOMFilterFactory crashes in a worker calling document.createElementNS.
|
||||
// Resolve CMap/standard-font URLs against the worker's own origin. Vite copies these
|
||||
// directories from pdfjs-dist at build time via viteStaticCopy (see vite.config.ts).
|
||||
const assetsBase = new URL("/pdfjs/", self.location.origin).toString();
|
||||
const loaderOpts = (data: ArrayBuffer) =>
|
||||
({
|
||||
data,
|
||||
|
||||
@@ -68,16 +68,12 @@ export const useConfigNavSections = (
|
||||
],
|
||||
};
|
||||
|
||||
// In local mode only show Preferences + Connection Mode + Legal — everything
|
||||
// else requires a server and will 500 or show irrelevant admin UI.
|
||||
// In local mode only show Preferences + Connection Mode — everything else
|
||||
// requires a server and will 500 or show irrelevant admin UI.
|
||||
if (isLocalMode) {
|
||||
const result: ConfigNavSection[] = [];
|
||||
if (sections.length > 0) result.push(sections[0]);
|
||||
result.push(connectionModeSection);
|
||||
const legalSection = sections.find((section) =>
|
||||
section.items.some((item) => item.key === "legal"),
|
||||
);
|
||||
if (legalSection) result.push(legalSection);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
+169
-2
@@ -10,9 +10,15 @@ import {
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Menu,
|
||||
List,
|
||||
ThemeIcon,
|
||||
Modal,
|
||||
CloseButton,
|
||||
Anchor,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
|
||||
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
@@ -37,10 +43,15 @@ export function SaaSTeamsSection() {
|
||||
refreshTeams,
|
||||
} = useSaaSTeam();
|
||||
|
||||
// Check Pro status via billing context
|
||||
const { tier } = useSaaSBilling();
|
||||
const isPro = tier !== "free";
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviting, setInviting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [featuresModalOpened, setFeaturesModalOpened] = useState(false);
|
||||
|
||||
// Team rename state
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
@@ -60,6 +71,12 @@ export function SaaSTeamsSection() {
|
||||
return () => clearInterval(interval);
|
||||
}, []); // Only run on mount/unmount
|
||||
|
||||
const navigateToPlan = () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("appConfig:navigate", { detail: { key: "planBilling" } }),
|
||||
);
|
||||
};
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inviteEmail.trim()) return;
|
||||
@@ -304,6 +321,156 @@ export function SaaSTeamsSection() {
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* Upgrade Banner for Free Users */}
|
||||
{isPersonalTeam && !isPro && (
|
||||
<Alert
|
||||
color="blue"
|
||||
icon={<LocalIcon icon="info" width={16} height={16} />}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"team.upgrade.title",
|
||||
"Upgrade to Pro to unlock team features",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{t(
|
||||
"team.upgrade.description",
|
||||
"Invite members, share credits, and more.",
|
||||
)}{" "}
|
||||
<Anchor
|
||||
size="xs"
|
||||
onClick={() => setFeaturesModalOpened(true)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{t("common.learnMore", "Learn more")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
</div>
|
||||
<Button size="sm" variant="light" onClick={navigateToPlan}>
|
||||
{t("team.upgrade.button", "Upgrade to Pro")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Team Features Modal */}
|
||||
<Modal
|
||||
opened={featuresModalOpened}
|
||||
onClose={() => setFeaturesModalOpened(false)}
|
||||
size="md"
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<CloseButton
|
||||
onClick={() => setFeaturesModalOpened(false)}
|
||||
size="lg"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" pt="md">
|
||||
{/* Header */}
|
||||
<Stack gap="md" align="center">
|
||||
<Badge size="lg" color="violet" variant="filled">
|
||||
{t("team.features.badge", "PRO FEATURE")}
|
||||
</Badge>
|
||||
<Text size="xl" fw={700} ta="center">
|
||||
{t("team.features.title", "Team Collaboration")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
"team.features.subtitle",
|
||||
"Upgrade to Pro and unlock powerful team features",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Features List */}
|
||||
<List
|
||||
spacing="md"
|
||||
size="sm"
|
||||
icon={
|
||||
<ThemeIcon color="violet" size={24} radius="xl" variant="light">
|
||||
<LocalIcon icon="check" width={14} height={14} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
<List.Item>
|
||||
<Text fw={500}>
|
||||
{t("team.features.invite.title", "Invite team members")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"team.features.invite.description",
|
||||
"Add unlimited users with additional seat purchases",
|
||||
)}
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<Text fw={500}>
|
||||
{t(
|
||||
"team.features.credits.title",
|
||||
"Share credits across your team",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"team.features.credits.description",
|
||||
"Pool resources for collaborative work",
|
||||
)}
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<Text fw={500}>
|
||||
{t(
|
||||
"team.features.dashboard.title",
|
||||
"Team management dashboard",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"team.features.dashboard.description",
|
||||
"Control permissions, monitor usage, and manage members",
|
||||
)}
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<Text fw={500}>
|
||||
{t("team.features.billing.title", "Centralized billing")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"team.features.billing.description",
|
||||
"One invoice for all team seats and usage",
|
||||
)}
|
||||
</Text>
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Button
|
||||
size="md"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
setFeaturesModalOpened(false);
|
||||
navigateToPlan();
|
||||
}}
|
||||
>
|
||||
{t("team.features.viewPlans", "View Pro Plans")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Error/Success Messages */}
|
||||
{error && (
|
||||
<Alert color="red" onClose={() => setError(null)} withCloseButton>
|
||||
@@ -317,8 +484,8 @@ export function SaaSTeamsSection() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Invite Members */}
|
||||
{isTeamLeader && (
|
||||
{/* Invite Members (Pro Users) */}
|
||||
{isTeamLeader && isPro && (
|
||||
<div>
|
||||
<Text fw={600} size="md" mb="sm">
|
||||
{t("team.invite.title", "Invite Team Member")}
|
||||
|
||||
@@ -26,9 +26,8 @@ import "@app/styles/auth-theme.css";
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "@app/utils/fileIdSafety";
|
||||
|
||||
// Minimal providers for public, no-auth pages (mobile scanner, participant
|
||||
// signing) - no API calls, no authentication
|
||||
function PublicRouteProviders({ children }: { children: React.ReactNode }) {
|
||||
// Minimal providers for mobile scanner - no API calls, no authentication
|
||||
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>{children}</RainbowThemeProvider>
|
||||
@@ -51,9 +50,9 @@ export default function App() {
|
||||
<Route
|
||||
path="/mobile-scanner"
|
||||
element={
|
||||
<PublicRouteProviders>
|
||||
<MobileScannerProviders>
|
||||
<MobileScannerPage />
|
||||
</PublicRouteProviders>
|
||||
</MobileScannerProviders>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -61,9 +60,9 @@ export default function App() {
|
||||
<Route
|
||||
path="/workflow/sign/:token"
|
||||
element={
|
||||
<PublicRouteProviders>
|
||||
<MobileScannerProviders>
|
||||
<ParticipantViewPage />
|
||||
</PublicRouteProviders>
|
||||
</MobileScannerProviders>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { springAuth } from "@app/auth/springAuthClient";
|
||||
import { clearPlatformAuthOnLoginInit } from "@app/extensions/authSessionCleanup";
|
||||
import { stripBasePath } from "@app/constants/app";
|
||||
import type {
|
||||
Session,
|
||||
User,
|
||||
@@ -34,8 +33,6 @@ interface AuthContextType {
|
||||
* consumers can fall back to whatever makes sense.
|
||||
*/
|
||||
displayName: string | null;
|
||||
/** Whether the current session is an anonymous (login-disabled) one. */
|
||||
isAnonymous: boolean;
|
||||
loading: boolean;
|
||||
error: AuthError | null;
|
||||
signOut: () => Promise<void>;
|
||||
@@ -63,7 +60,6 @@ const AuthContext = createContext<AuthContextType>({
|
||||
session: null,
|
||||
user: null,
|
||||
displayName: null,
|
||||
isAnonymous: false,
|
||||
loading: true,
|
||||
error: null,
|
||||
signOut: async () => {},
|
||||
@@ -171,7 +167,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// Clear any platform-specific cached auth on login page init.
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
stripBasePath(window.location.pathname).startsWith("/login")
|
||||
window.location.pathname.startsWith("/login")
|
||||
) {
|
||||
await clearPlatformAuthOnLoginInit();
|
||||
}
|
||||
@@ -295,7 +291,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
session,
|
||||
user,
|
||||
displayName: deriveDisplayName(user, t),
|
||||
isAnonymous: user?.is_anonymous === true,
|
||||
loading,
|
||||
error,
|
||||
signOut,
|
||||
|
||||
@@ -16,11 +16,11 @@ export function AppProviders({
|
||||
appConfigProviderProps,
|
||||
}: AppProvidersProps) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<CoreAppProviders
|
||||
appConfigRetryOptions={appConfigRetryOptions}
|
||||
appConfigProviderProps={appConfigProviderProps}
|
||||
>
|
||||
<CoreAppProviders
|
||||
appConfigRetryOptions={appConfigRetryOptions}
|
||||
appConfigProviderProps={appConfigProviderProps}
|
||||
>
|
||||
<AuthProvider>
|
||||
<LicenseProvider>
|
||||
<UpdateSeatsProvider>
|
||||
<ServerExperienceProvider>
|
||||
@@ -31,7 +31,7 @@ export function AppProviders({
|
||||
</ServerExperienceProvider>
|
||||
</UpdateSeatsProvider>
|
||||
</LicenseProvider>
|
||||
</CoreAppProviders>
|
||||
</AuthProvider>
|
||||
</AuthProvider>
|
||||
</CoreAppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
/* ─── Chat takeover ─────────────────────────────────────────────────────── */
|
||||
|
||||
.agents-takeover {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-toolbar);
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
view-transition-name: agents-rail;
|
||||
}
|
||||
|
||||
.agents-takeover__resize-handle {
|
||||
position: absolute;
|
||||
left: -3px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 7px;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.agents-takeover__resize-handle::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: transparent;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
width 150ms ease,
|
||||
left 150ms ease;
|
||||
}
|
||||
|
||||
.agents-takeover__resize-handle:hover::after {
|
||||
left: 2px;
|
||||
width: 3px;
|
||||
background: var(--mantine-color-blue-4);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ─── Sidebar agents section ─────────────────────────────────────────────── */
|
||||
|
||||
.agents-section {
|
||||
padding: 0.5rem 0.75rem 0.875rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-toolbar);
|
||||
flex-shrink: 0;
|
||||
view-transition-name: agents-rail;
|
||||
}
|
||||
|
||||
/* Hero button — real Stirling agent */
|
||||
.agent-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--mantine-color-blue-5) 40%, var(--border-subtle));
|
||||
border-radius: 0.625rem;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-6) 6%,
|
||||
var(--mantine-color-body)
|
||||
)
|
||||
0%,
|
||||
var(--mantine-color-body) 100%
|
||||
);
|
||||
transition:
|
||||
background 150ms ease-out,
|
||||
border-color 150ms ease-out;
|
||||
}
|
||||
|
||||
.agent-button:hover {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-5) 65%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-6) 12%,
|
||||
var(--mantine-color-default-hover)
|
||||
)
|
||||
0%,
|
||||
var(--mantine-color-default-hover) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.agent-button__logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--mantine-color-blue-filled);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Coming-soon agent rows */
|
||||
.agents-sidebar-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.agent-button--coming-soon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 0.625rem;
|
||||
background: var(--mantine-color-body);
|
||||
cursor: not-allowed !important;
|
||||
opacity: 0.65;
|
||||
transition:
|
||||
opacity 180ms ease-out,
|
||||
transform 180ms ease-out,
|
||||
border-color 120ms ease-out;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
.agent-button--coming-soon {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
}
|
||||
|
||||
.agent-button--coming-soon:hover {
|
||||
opacity: 0.85;
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-gray-4) 60%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.agent-button__icon-plain {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
color: var(--mantine-color-dimmed);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agents-view-all {
|
||||
all: unset;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-dimmed);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
border-radius: 0.375rem;
|
||||
transition: opacity 120ms ease-out;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.agents-view-all:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Dark mode — sidebar */
|
||||
[data-mantine-color-scheme="dark"] .agent-button--coming-soon {
|
||||
background: transparent;
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-dark-4) 60%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button--coming-soon:hover {
|
||||
border-color: var(--mantine-color-dark-3);
|
||||
background: var(--mantine-color-dark-6);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-7) 55%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--mantine-color-blue-9) 35%, transparent) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button:hover {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-5) 60%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-8) 45%,
|
||||
rgba(255, 255, 255, 0.04)
|
||||
)
|
||||
0%,
|
||||
rgba(255, 255, 255, 0.04) 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button__logo {
|
||||
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
|
||||
}
|
||||
|
||||
/* ─── Collapsed-rail agent button ────────────────────────────────────────── */
|
||||
|
||||
.agents-collapsed-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: var(--mantine-color-blue-filled);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease-out;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.agents-collapsed-btn:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
/* ─── Running / in-progress status dot ──────────────────────────────────── */
|
||||
|
||||
.agent-status-dot {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--mantine-color-blue-5);
|
||||
border: 1.5px solid var(--mantine-color-body);
|
||||
animation: agent-dot-pulse 2.4s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes agent-dot-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
/* Blue outline on hero Stirling button when agent is running */
|
||||
.agent-button.agent-button--running {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-5) 60%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button.agent-button--running {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-4) 55%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-status-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Fullscreen hero card ───────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* .tool-panel__fullscreen-group--agents gives the gradient border via the
|
||||
* padding-box / border-box trick. We only override the padding-box with a
|
||||
* very light tint so the card doesn't read as a colourful surface.
|
||||
*/
|
||||
.agents-hero {
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
border-radius: 1rem;
|
||||
background:
|
||||
linear-gradient(
|
||||
125deg,
|
||||
color-mix(in srgb, var(--mantine-color-blue-1) 35%, white) 0%,
|
||||
color-mix(in srgb, var(--mantine-color-violet-1) 45%, white) 50%,
|
||||
color-mix(in srgb, var(--mantine-color-pink-0) 35%, white) 100%
|
||||
)
|
||||
padding-box,
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--mantine-color-blue-6) 0%,
|
||||
var(--mantine-color-violet-6) 45%,
|
||||
var(--mantine-color-pink-6) 100%
|
||||
)
|
||||
border-box;
|
||||
}
|
||||
|
||||
/* Body: left CTA + right grid */
|
||||
.agents-hero__body {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ── Left CTA — plain content, only the button inside is interactive ── */
|
||||
.agents-hero__cta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
flex: 0 0 18rem;
|
||||
box-sizing: border-box;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.agents-hero__cta-logo {
|
||||
color: var(--mantine-color-blue-filled);
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.agents-hero__cta-headline {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
.agents-hero__cta-btn {
|
||||
all: unset;
|
||||
margin-top: 1.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.4375rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--mantine-color-blue-6);
|
||||
color: white;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.agents-hero__cta-btn:hover {
|
||||
background: var(--mantine-color-blue-7);
|
||||
}
|
||||
|
||||
.agents-hero__cta-btn:focus-visible {
|
||||
outline: 2px solid var(--mantine-color-blue-5);
|
||||
outline-offset: 3px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Right 2×3 grid — sizes to content, pushed to the right ── */
|
||||
.agents-hero__grid {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
display: grid;
|
||||
/* auto columns: each column = widest item in that column */
|
||||
grid-template-columns: repeat(2, auto);
|
||||
gap: 0.625rem;
|
||||
align-content: start;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
/* Solid white cards — no opacity so the white is opaque */
|
||||
.agents-hero__grid-item {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.07);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--mantine-color-body);
|
||||
cursor: not-allowed;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.agents-hero__grid-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--mantine-color-blue-5);
|
||||
}
|
||||
|
||||
.agents-hero__grid-body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Dark mode — fullscreen ── */
|
||||
[data-mantine-color-scheme="dark"] .agents-hero {
|
||||
background:
|
||||
linear-gradient(
|
||||
125deg,
|
||||
color-mix(in srgb, var(--mantine-color-blue-9) 55%, #1a1b2e) 0%,
|
||||
color-mix(in srgb, var(--mantine-color-violet-9) 45%, #1a1b2e) 50%,
|
||||
color-mix(in srgb, var(--mantine-color-pink-9) 40%, #1a1b2e) 100%
|
||||
)
|
||||
padding-box,
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--mantine-color-blue-6) 0%,
|
||||
var(--mantine-color-violet-6) 45%,
|
||||
var(--mantine-color-pink-6) 100%
|
||||
)
|
||||
border-box;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agents-hero__cta-logo {
|
||||
color: var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agents-hero__grid-item {
|
||||
background: var(--fullscreen-bg-item);
|
||||
border-color: var(--fullscreen-border-subtle-70);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agents-hero__grid-icon {
|
||||
color: var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
/* ─── agents-rail view transition — slide up ─────────────────────────────── */
|
||||
|
||||
::view-transition-old(agents-rail) {
|
||||
animation: vt-agents-out 150ms ease-out forwards;
|
||||
}
|
||||
|
||||
::view-transition-new(agents-rail) {
|
||||
animation: vt-agents-in 260ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes vt-agents-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes vt-agents-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-old(agents-rail),
|
||||
::view-transition-new(agents-rail) {
|
||||
animation-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useState } from "react";
|
||||
import type { ElementType } from "react";
|
||||
import { Box, Group, Text, UnstyledButton } from "@mantine/core";
|
||||
import TableChartRoundedIcon from "@mui/icons-material/TableChartRounded";
|
||||
import SummarizeRoundedIcon from "@mui/icons-material/SummarizeRounded";
|
||||
import VisibilityOffRoundedIcon from "@mui/icons-material/VisibilityOffRounded";
|
||||
import GavelRoundedIcon from "@mui/icons-material/GavelRounded";
|
||||
import AssignmentRoundedIcon from "@mui/icons-material/AssignmentRounded";
|
||||
import CodeRoundedIcon from "@mui/icons-material/CodeRounded";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useChat } from "@app/components/chat/ChatContext";
|
||||
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
|
||||
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
|
||||
import { withViewTransition } from "@app/utils/viewTransition";
|
||||
import "@app/components/agents/AgentsPanel.css";
|
||||
|
||||
interface ComingSoonAgent {
|
||||
id: string;
|
||||
nameKey: string;
|
||||
descriptionKey: string;
|
||||
Icon: ElementType;
|
||||
}
|
||||
|
||||
const COMING_SOON_AGENTS: ComingSoonAgent[] = [
|
||||
{
|
||||
id: "data-extraction",
|
||||
nameKey: "agents.data_extraction_name",
|
||||
descriptionKey: "agents.data_extraction_description",
|
||||
Icon: TableChartRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "doc-summary",
|
||||
nameKey: "agents.doc_summary_name",
|
||||
descriptionKey: "agents.doc_summary_description",
|
||||
Icon: SummarizeRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "auto-redaction",
|
||||
nameKey: "agents.auto_redaction_name",
|
||||
descriptionKey: "agents.auto_redaction_description",
|
||||
Icon: VisibilityOffRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "compliance",
|
||||
nameKey: "agents.compliance_name",
|
||||
descriptionKey: "agents.compliance_description",
|
||||
Icon: GavelRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "form-filler",
|
||||
nameKey: "agents.form_filler_name",
|
||||
descriptionKey: "agents.form_filler_description",
|
||||
Icon: AssignmentRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "pdf-to-markdown",
|
||||
nameKey: "agents.pdf_to_markdown_name",
|
||||
descriptionKey: "agents.pdf_to_markdown_description",
|
||||
Icon: CodeRoundedIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export function useAgentsEnabled(): boolean {
|
||||
const { config } = useAppConfig();
|
||||
return Boolean(config?.aiEngineEnabled);
|
||||
}
|
||||
|
||||
export function useAgentChatOpen(): boolean {
|
||||
const { isOpen } = useChat();
|
||||
return isOpen;
|
||||
}
|
||||
|
||||
const PREVIEW_COUNT = 3;
|
||||
|
||||
/** Sidebar agents section — Stirling as hero CTA, coming-soon agents below. */
|
||||
export function AgentsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, setOpen, isLoading } = useChat();
|
||||
const enabled = useAgentsEnabled();
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
if (!enabled || isOpen) return null;
|
||||
|
||||
const comingSoonLabel = t("agents.coming_soon", "Coming soon");
|
||||
const visibleAgents = showAll
|
||||
? COMING_SOON_AGENTS
|
||||
: COMING_SOON_AGENTS.slice(0, PREVIEW_COUNT);
|
||||
|
||||
return (
|
||||
<Box className="agents-section" w="100%">
|
||||
{/* Main Stirling agent — real and clickable */}
|
||||
<UnstyledButton
|
||||
className={`agent-button agent-button--hero${isLoading ? " agent-button--running" : ""}`}
|
||||
onClick={() => withViewTransition(() => setOpen(true))}
|
||||
aria-label={t("agents.stirling_name", "Stirling")}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<Box className="agent-button__logo">
|
||||
<StirlingLogoOutline size={28} />
|
||||
{isLoading && <span className="agent-status-dot" />}
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{t("agents.stirling_name", "Stirling")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{isLoading
|
||||
? t("agents.stirling_running", "Running...")
|
||||
: t(
|
||||
"agents.stirling_description",
|
||||
"Your general-purpose PDF assistant",
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
{/* Coming-soon agents */}
|
||||
<div className="agents-sidebar-list">
|
||||
{visibleAgents.map(({ id, nameKey, descriptionKey, Icon }) => (
|
||||
<AppTooltip
|
||||
key={id}
|
||||
content={comingSoonLabel}
|
||||
position="left"
|
||||
arrow
|
||||
delay={0}
|
||||
>
|
||||
<UnstyledButton
|
||||
className="agent-button agent-button--coming-soon"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<Box className="agent-button__icon-plain">
|
||||
<Icon sx={{ fontSize: "1.1rem" }} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{t(nameKey)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{t(descriptionKey)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</AppTooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!showAll ? (
|
||||
<button
|
||||
type="button"
|
||||
className="agents-view-all"
|
||||
onClick={() => withViewTransition(() => setShowAll(true))}
|
||||
>
|
||||
{t("agents.view_all", "View all agents")} →
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="agents-view-all"
|
||||
onClick={() => withViewTransition(() => setShowAll(false))}
|
||||
>
|
||||
{t("agents.show_less", "Show less")} ↑
|
||||
</button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Icon-only agent button in the collapsed (minimised) right rail. */
|
||||
export function AgentsCollapsedButton({ onExpand }: { onExpand: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { setOpen, isLoading } = useChat();
|
||||
const enabled = useAgentsEnabled();
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const label = t("agents.stirling_tooltip", "Stirling agent");
|
||||
|
||||
return (
|
||||
<AppTooltip content={label} position="left" arrow delay={300}>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
onExpand();
|
||||
setOpen(true);
|
||||
}}
|
||||
aria-label={label}
|
||||
className="agents-collapsed-btn"
|
||||
>
|
||||
<StirlingLogoOutline size={22} />
|
||||
{isLoading && <span className="agent-status-dot" />}
|
||||
</UnstyledButton>
|
||||
</AppTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen hero card — Stirling CTA on the left, 2×3 coming-soon grid on
|
||||
* the right. Matches the gradient border of the other fullscreen category cards.
|
||||
*/
|
||||
export function AgentsFullscreenSection() {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, setOpen } = useChat();
|
||||
const enabled = useAgentsEnabled();
|
||||
|
||||
if (!enabled || isOpen) return null;
|
||||
|
||||
const comingSoonLabel = t("agents.coming_soon", "Coming soon");
|
||||
|
||||
return (
|
||||
<section
|
||||
className="agents-hero tool-panel__fullscreen-group--agents"
|
||||
aria-label={t("agents.section_title", "Agents")}
|
||||
>
|
||||
<div className="agents-hero__body">
|
||||
{/* Left: Stirling content — only the button is interactive */}
|
||||
<div className="agents-hero__cta">
|
||||
<div className="agents-hero__cta-logo">
|
||||
<StirlingLogoOutline size={36} />
|
||||
</div>
|
||||
<Text className="agents-hero__cta-headline" fw={700} lh={1.2} mt="xs">
|
||||
{t("agents.stirling_full_name", "Stirling General Agent")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={8} lh={1.55}>
|
||||
{t(
|
||||
"agents.stirling_long_description",
|
||||
"General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents.",
|
||||
)}
|
||||
</Text>
|
||||
<button
|
||||
type="button"
|
||||
className="agents-hero__cta-btn"
|
||||
onClick={() => withViewTransition(() => setOpen(true))}
|
||||
>
|
||||
{t("agents.start_chat", "Start chatting")} →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right: 2×3 grid of coming-soon agents */}
|
||||
<div className="agents-hero__grid">
|
||||
{COMING_SOON_AGENTS.map(({ id, nameKey, descriptionKey, Icon }) => (
|
||||
<AppTooltip
|
||||
key={id}
|
||||
content={comingSoonLabel}
|
||||
position="top"
|
||||
arrow
|
||||
delay={0}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="agents-hero__grid-item"
|
||||
aria-disabled="true"
|
||||
>
|
||||
<span className="agents-hero__grid-icon">
|
||||
<Icon sx={{ fontSize: "1rem" }} />
|
||||
</span>
|
||||
<div className="agents-hero__grid-body">
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{t(nameKey)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{t(descriptionKey)}
|
||||
</Text>
|
||||
</div>
|
||||
</button>
|
||||
</AppTooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { generateId } from "@app/utils/generateId";
|
||||
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { getApiBaseUrl } from "@app/services/apiClientConfig";
|
||||
import { getAuthHeaders } from "@app/services/apiClientSetup";
|
||||
import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge";
|
||||
import { createChildStub } from "@app/contexts/file/fileActions";
|
||||
import {
|
||||
createNewStirlingFileStub,
|
||||
@@ -180,29 +178,11 @@ interface AiWorkflowResponse {
|
||||
fileId?: string;
|
||||
fileName?: string;
|
||||
contentType?: string;
|
||||
/**
|
||||
* Structured error code when a tool call inside the workflow was blocked (e.g.
|
||||
* PAYG_LIMIT_REACHED / FEATURE_DEGRADED). Present instead of a raw failure reason so the
|
||||
* client can pop the usage-limit modal. See {@link isPaygLimitCode}.
|
||||
*/
|
||||
errorCode?: string;
|
||||
/** From the blocking 402: true → over spending cap, false/absent → free allowance spent. */
|
||||
errorSubscribed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage-limit sentinels the agent can surface (matching the saas EntitlementGuard / apiClient
|
||||
* interceptor). When one of these is the result's errorCode, we open the usage-limit modal rather
|
||||
* than render the failure as chat text.
|
||||
*/
|
||||
const PAYG_LIMIT_CODES = new Set(["PAYG_LIMIT_REACHED", "FEATURE_DEGRADED"]);
|
||||
|
||||
function isPaygLimitCode(code: string | null | undefined): boolean {
|
||||
return code != null && PAYG_LIMIT_CODES.has(code);
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
messages: ChatMessage[];
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
progress: AiWorkflowProgress | null;
|
||||
/** Ordered log of every progress event in the current request. UI shows the last N entries. */
|
||||
@@ -219,6 +199,8 @@ type ChatAction =
|
||||
| { type: "SET_LOADING"; loading: boolean }
|
||||
| { type: "SET_PROGRESS"; progress: AiWorkflowProgress | null }
|
||||
| { type: "APPEND_PROGRESS"; progress: AiWorkflowProgress }
|
||||
| { type: "TOGGLE_OPEN" }
|
||||
| { type: "SET_OPEN"; open: boolean }
|
||||
| { type: "CLEAR" };
|
||||
|
||||
function chatReducer(state: ChatState, action: ChatAction): ChatState {
|
||||
@@ -248,6 +230,10 @@ function chatReducer(state: ChatState, action: ChatAction): ChatState {
|
||||
action.progress,
|
||||
],
|
||||
};
|
||||
case "TOGGLE_OPEN":
|
||||
return { ...state, isOpen: !state.isOpen };
|
||||
case "SET_OPEN":
|
||||
return { ...state, isOpen: action.open };
|
||||
case "CLEAR":
|
||||
return {
|
||||
...state,
|
||||
@@ -376,10 +362,13 @@ async function consumeSSEStream(
|
||||
|
||||
interface ChatContextValue {
|
||||
messages: ChatMessage[];
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
progress: AiWorkflowProgress | null;
|
||||
/** Ordered log of every progress event for the current in-flight request. */
|
||||
progressLog: AiWorkflowProgress[];
|
||||
toggleOpen: () => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
cancelMessage: () => void;
|
||||
/** Abort any in-flight request and reset the chat to an empty conversation. */
|
||||
@@ -390,6 +379,7 @@ const ChatContext = createContext<ChatContextValue | null>(null);
|
||||
|
||||
const initialState: ChatState = {
|
||||
messages: [],
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
progress: null,
|
||||
progressLog: [],
|
||||
@@ -475,6 +465,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
[fileActions, downloadFile],
|
||||
);
|
||||
|
||||
const toggleOpen = useCallback(() => dispatch({ type: "TOGGLE_OPEN" }), []);
|
||||
const setOpen = useCallback(
|
||||
(open: boolean) => dispatch({ type: "SET_OPEN", open }),
|
||||
[],
|
||||
);
|
||||
const cancelMessage = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
}, []);
|
||||
@@ -519,59 +514,28 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
formData.append(`conversationHistory[${i}].role`, message.role);
|
||||
formData.append(`conversationHistory[${i}].content`, message.content);
|
||||
});
|
||||
const response = await fetch(
|
||||
`${getApiBaseUrl()}/api/v1/ai/orchestrate/stream`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: await getAuthHeaders(),
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
const response = await fetch("/api/v1/ai/orchestrate/stream", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: getAuthHeaders(),
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail: string | undefined;
|
||||
let limitHandled = false;
|
||||
try {
|
||||
const body = await response.json();
|
||||
const code = typeof body?.error === "string" ? body.error : null;
|
||||
// A 402 carrying a usage-limit sentinel means the agent call itself was gated.
|
||||
// Fire the usage-limit modal (free → subscribe, subscribed → raise cap) and show a
|
||||
// brief line below — not a generic "engine failed" error.
|
||||
if (response.status === 402 && isPaygLimitCode(code)) {
|
||||
dispatchPaygLimitReached(
|
||||
typeof body?.subscribed === "boolean" ? body.subscribed : null,
|
||||
);
|
||||
limitHandled = true;
|
||||
} else {
|
||||
detail =
|
||||
body?.message ??
|
||||
body?.detail ??
|
||||
body?.error ??
|
||||
(Array.isArray(body?.errors)
|
||||
? body.errors[0]?.message
|
||||
: undefined);
|
||||
}
|
||||
detail =
|
||||
body?.message ??
|
||||
body?.detail ??
|
||||
body?.error ??
|
||||
(Array.isArray(body?.errors)
|
||||
? body.errors[0]?.message
|
||||
: undefined);
|
||||
} catch {
|
||||
// non-JSON body — ignore
|
||||
}
|
||||
if (limitHandled) {
|
||||
dispatch({ type: "SET_PROGRESS", progress: null });
|
||||
dispatch({
|
||||
type: "ADD_MESSAGE",
|
||||
message: {
|
||||
id: generateId(),
|
||||
role: ChatRole.ASSISTANT,
|
||||
content: t(
|
||||
"chat.responses.usage_limit_reached",
|
||||
"You've reached your usage limit. Check your plan options to keep going.",
|
||||
),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
detail ?? `AI engine request failed: ${response.status}`,
|
||||
);
|
||||
@@ -601,20 +565,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
onResult: (data) => {
|
||||
receivedResult = true;
|
||||
dispatch({ type: "SET_PROGRESS", progress: null });
|
||||
// The agent's tool calls run server-side, so a usage-limit 402 surfaces here on the
|
||||
// result (not via the apiClient interceptor that pops the modal for direct calls).
|
||||
// Fire the matching modal and replace the raw "tool failed: 402…" reason with a
|
||||
// brief, non-alarming line.
|
||||
const isLimit = isPaygLimitCode(data.errorCode);
|
||||
if (isLimit) {
|
||||
dispatchPaygLimitReached(data.errorSubscribed ?? null);
|
||||
}
|
||||
const replyContent = isLimit
|
||||
? t(
|
||||
"chat.responses.usage_limit_reached",
|
||||
"You've reached your usage limit. Check your plan options to keep going.",
|
||||
)
|
||||
: formatWorkflowResponse(data, t);
|
||||
const replyContent = formatWorkflowResponse(data, t);
|
||||
dispatch({
|
||||
type: "ADD_MESSAGE",
|
||||
message: {
|
||||
@@ -698,9 +649,12 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
<ChatContext.Provider
|
||||
value={{
|
||||
messages: state.messages,
|
||||
isOpen: state.isOpen,
|
||||
isLoading: state.isLoading,
|
||||
progress: state.progress,
|
||||
progressLog: state.progressLog,
|
||||
toggleOpen,
|
||||
setOpen,
|
||||
sendMessage,
|
||||
cancelMessage,
|
||||
clearChat,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
.chat-fab-trigger {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
bottom: calc(var(--footer-height, 2rem) + 16px);
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
/* Include box-shadow so the ChatFABButton hover shadow still animates */
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ChatFABButton } from "@shared/components/ChatFABButton";
|
||||
import { ChatFABWindow } from "@shared/components/ChatFABWindow";
|
||||
import { ChatPanel } from "@app/components/chat/ChatPanel";
|
||||
import { useChat } from "@app/components/chat/ChatContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useAgentsEnabled } from "@app/components/agents/AgentsPanel";
|
||||
import { Z_INDEX_CHAT_FAB_OVERLAY } from "@app/styles/zIndex";
|
||||
import "@app/components/chat/ChatFAB.css";
|
||||
|
||||
@@ -15,7 +15,8 @@ const PANEL_HEIGHT_PX = 520;
|
||||
const PANEL_MIN_WIDTH_PX = 300;
|
||||
const PANEL_MIN_HEIGHT_PX = 380;
|
||||
const FAB_GAP_PX = 16;
|
||||
const FAB_BOTTOM_OFFSET_PX = FAB_GAP_PX;
|
||||
// footer-height (2rem = 32px) + gap so the panel clears the footer
|
||||
const FAB_BOTTOM_OFFSET_PX = 32 + FAB_GAP_PX;
|
||||
|
||||
const RESET_MS = 380;
|
||||
const RESET_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
|
||||
@@ -53,8 +54,7 @@ export function ChatFAB() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasUnviewedResult, setHasUnviewedResult] = useState(false);
|
||||
const { isLoading } = useChat();
|
||||
const { config } = useAppConfig();
|
||||
const enabled = Boolean(config?.aiEngineEnabled);
|
||||
const enabled = useAgentsEnabled();
|
||||
|
||||
// Detect loading → done transition. If the FAB is closed when the agent
|
||||
// finishes, show the tick badge until the user opens the panel.
|
||||
@@ -90,13 +90,9 @@ export function ChatFAB() {
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// The overlay only mounts once the AI engine is enabled (config can load
|
||||
// after first render), so re-measure when that flips true rather than only
|
||||
// on initial mount, otherwise the default position never gets computed.
|
||||
if (!enabled) return;
|
||||
const pos = getDefaultPos();
|
||||
if (pos) setRndPos(pos);
|
||||
}, [enabled]);
|
||||
}, []);
|
||||
|
||||
// Clear the reset timer on unmount to avoid state updates on dead components.
|
||||
// Also ensure body user-select is restored if we unmount mid-resize.
|
||||
@@ -144,12 +140,6 @@ export function ChatFAB() {
|
||||
<ChatFABButton
|
||||
className={`chat-fab-trigger${isOpen ? " chat-fab-trigger--hidden" : ""}`}
|
||||
onClick={() => {
|
||||
// Fallback: ensure a position exists before opening, in case the
|
||||
// layout effect measured before the overlay was laid out.
|
||||
if (rndPos === null) {
|
||||
const pos = getDefaultPos();
|
||||
if (pos) setRndPos(pos);
|
||||
}
|
||||
setIsOpen(true);
|
||||
setHasUnviewedResult(false);
|
||||
}}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Clip + allow shrink so tall welcome content squeezes the scrollable
|
||||
quick-actions block instead of pushing the composer off-screen. */
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
/* Match the right-rail toolbar background so dark-mode doesn't show a
|
||||
lighter card behind the chat, and so the agent-card → pill morph
|
||||
doesn't flash a different colour mid-transition. */
|
||||
@@ -39,6 +35,7 @@
|
||||
transition:
|
||||
background 120ms ease-out,
|
||||
border-color 120ms ease-out;
|
||||
/* Pair with .agent-button to morph between the card and the pill. */
|
||||
view-transition-name: stirling-agent;
|
||||
}
|
||||
|
||||
@@ -67,36 +64,6 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Running / in-progress status dot, anchored to the agent pill icon. */
|
||||
.agent-status-dot {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--mantine-color-blue-5);
|
||||
border: 1.5px solid var(--mantine-color-body);
|
||||
animation: agent-dot-pulse 2.4s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes agent-dot-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-status-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-panel__agent-pill--loading {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
@@ -169,16 +136,6 @@
|
||||
.chat-panel-messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
/* Establish a size container so the message list's bottom padding can be
|
||||
expressed relative to the scroll viewport height (cqh) below. */
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
/* Extra room beneath the last message so that, scrolled to the bottom, the most
|
||||
recent turn rests near the vertical centre of the conversation rather than
|
||||
jammed against the composer. cqh is a fraction of the scroll viewport height. */
|
||||
.chat-panel-messages__content {
|
||||
padding-bottom: 15cqh;
|
||||
}
|
||||
|
||||
/* Quick action cards above the input. */
|
||||
@@ -187,12 +144,7 @@
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 0.75rem 0.25rem;
|
||||
/* When the panel is short, this block (file pills + actions) shrinks and
|
||||
scrolls internally instead of pushing the composer off-screen — the input
|
||||
must always stay pinned and visible at the bottom. */
|
||||
flex-shrink: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-panel__quick-actions-label {
|
||||
@@ -301,14 +253,12 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Input area: send button on the left, textarea fills the rest — one compact
|
||||
row so the composer stays short when the welcome screen is fully populated. */
|
||||
/* Input area: textarea on top row, action icons on bottom row. */
|
||||
.chat-panel-input {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
padding: 0.85rem 1rem 0.75rem;
|
||||
margin: 0.75rem 0.75rem 1rem;
|
||||
border: none;
|
||||
border-radius: 1.1rem;
|
||||
@@ -330,7 +280,7 @@
|
||||
.chat-panel-input__field,
|
||||
.chat-panel-input__field:focus,
|
||||
.chat-panel-input__field:focus-visible {
|
||||
font-size: 1.05rem;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.15rem 0 !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
@@ -338,49 +288,10 @@
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Textarea grows to fill the row; the send button keeps its size on the left. */
|
||||
.chat-panel-input__textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-panel-input__send {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* AI disclaimer. Two presentations share one base:
|
||||
- --banner: a soft grey card below the header on the welcome screen.
|
||||
- --inline: a compact line pinned above the composer once a chat is underway. */
|
||||
.chat-panel-disclaimer {
|
||||
.chat-panel-input__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chat-panel-disclaimer__icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Welcome-screen banner: a little grey box just under the header. */
|
||||
.chat-panel-disclaimer--banner {
|
||||
margin: 0.35rem 0.75rem 0;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--mantine-color-gray-light);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Compact line above the composer once the conversation has started. */
|
||||
.chat-panel-disclaimer--inline {
|
||||
justify-content: center;
|
||||
padding: 0.1rem 1.25rem 0;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.3;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Message layout */
|
||||
@@ -485,88 +396,109 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ─── Live progress indicator ──────────────────────────────────────────── */
|
||||
/* ─── Progress step log ─────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Shown while the AI works — one step at a time, no growing list. Our animated
|
||||
* logo sits on the left, the current step's label shimmers in the middle, and
|
||||
* the phase-specific icon (what it's doing right now) sits on the right.
|
||||
* Shown while the AI is working. Each step slides in from below as events
|
||||
* arrive; the active (last) step is full-opacity with a pulsing icon, and
|
||||
* earlier completed steps are dimmed. Only the most-recent N steps are
|
||||
* rendered — there is no scrollable list.
|
||||
*/
|
||||
.chat-progress-live {
|
||||
|
||||
.chat-progress-log {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
padding: 0.2rem 0.5rem 0.35rem;
|
||||
flex-direction: column;
|
||||
padding: 0.15rem 0.5rem 0.35rem;
|
||||
/* No gap — the connector line div provides the inter-step spacing. */
|
||||
}
|
||||
|
||||
.chat-progress-live__logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
.chat-progress-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
/*
|
||||
* Use color rather than opacity for past-step dimming so the connector
|
||||
* line (a sibling element inside __left, not a text node) is NOT affected
|
||||
* and stays clearly visible. currentColor flows into SVG icon fills;
|
||||
* color inherits into the label span automatically.
|
||||
*/
|
||||
color: var(--text-muted);
|
||||
transition: color 250ms ease-out;
|
||||
animation: chat-step-enter 300ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
/*
|
||||
* Active step: white text on dark mode so currentColor feeds directly into
|
||||
* the StirlingLogoAnimated SVG paths and the label at the same time.
|
||||
* Light-mode falls back to the default text colour so it's not invisible.
|
||||
*/
|
||||
/* Active step: icon and label are coloured independently so the logo can be
|
||||
blue while the text uses the normal text colour. */
|
||||
.chat-progress-step--active .chat-progress-step__icon {
|
||||
color: var(--mantine-color-blue-filled);
|
||||
}
|
||||
|
||||
/* Shimmer: a soft highlight sweeps left-to-right across the muted label. */
|
||||
.chat-progress-live__label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.35;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--text-muted) 0%,
|
||||
var(--text-muted) 40%,
|
||||
var(--mantine-color-text) 50%,
|
||||
var(--text-muted) 60%,
|
||||
var(--text-muted) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: transparent;
|
||||
animation: chat-shimmer 1.8s linear infinite;
|
||||
.chat-progress-step--active .chat-progress-step__label {
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.chat-progress-live__phase-icon {
|
||||
display: inline-flex;
|
||||
[data-mantine-color-scheme="dark"]
|
||||
.chat-progress-step--active
|
||||
.chat-progress-step__label {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.chat-progress-step__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
/* Icon cell — fixed height so the connector line aligns cleanly. */
|
||||
.chat-progress-step__icon {
|
||||
width: 20px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
animation: chat-phase-pulse 1.6s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
/* Inherits color from .chat-progress-step so currentColor flows into icons. */
|
||||
}
|
||||
|
||||
@keyframes chat-shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
/*
|
||||
* Connector line between steps. Not a child of the label so it is unaffected
|
||||
* by the step's color; it sits on its own and reads clearly against the
|
||||
* toolbar background.
|
||||
*/
|
||||
.chat-progress-step__line {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@keyframes chat-phase-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Active step label — slightly larger, inherits the active color. */
|
||||
.chat-progress-step__label {
|
||||
font-size: 0.875rem;
|
||||
padding-top: 3px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* Past step labels — a touch smaller so the active row reads as the primary. */
|
||||
.chat-progress-step:not(.chat-progress-step--active)
|
||||
.chat-progress-step__label {
|
||||
font-size: 0.8rem;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Wrapper that scales a registry tool icon (LocalIcon, typically 1.5rem/24px)
|
||||
* down to fit the compact icon slots used by the live indicator and the
|
||||
* completed tool list. Transform does not affect layout, so the parent's
|
||||
* overflow:hidden clips the un-scaled footprint cleanly.
|
||||
* down to fit the 20px icon column. Transform does not affect layout, so the
|
||||
* parent overflow:hidden clips the un-scaled footprint cleanly.
|
||||
*/
|
||||
.chat-step-icon-scaled {
|
||||
display: inline-flex;
|
||||
@@ -577,12 +509,22 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Completed tool breakdown ─────────────────────────────────────────── */
|
||||
/* Entry: steps slide up from a few pixels below their final position. */
|
||||
@keyframes chat-step-enter {
|
||||
from {
|
||||
transform: translateY(7px);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Completed progress log dropdown ──────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Shown above each completed assistant turn. The toggle displays a small
|
||||
* "Ran for X seconds" label; expanding reveals a numbered list of just the
|
||||
* tools that ran. Turns with no tools show the duration as a plain label.
|
||||
* "Ran for X seconds" label; expanding reveals the full ordered step list
|
||||
* for that turn in a compact, read-only format.
|
||||
*/
|
||||
|
||||
.chat-completed-log {
|
||||
@@ -602,56 +544,31 @@
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.chat-completed-log__static {
|
||||
display: inline-block;
|
||||
padding: 0.1rem;
|
||||
}
|
||||
|
||||
/* Numbered list of the tools that ran — icon + name, no connector lines. */
|
||||
.chat-completed-log__tools {
|
||||
list-style: none;
|
||||
margin: 0.25rem 0 0;
|
||||
padding: 0;
|
||||
.chat-completed-log__steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
padding: 0.35rem 0.4rem 0.2rem 0.25rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.chat-completed-log__tool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.2rem 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--mantine-color-text);
|
||||
/* In the completed log every step is a past step — no active highlighting,
|
||||
no entry animation (the content is static once opened). */
|
||||
.chat-completed-log__steps .chat-progress-step {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.chat-completed-log__tool-num {
|
||||
flex-shrink: 0;
|
||||
min-width: 1.1rem;
|
||||
text-align: right;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* Tighten the icon and connector dimensions for the denser historical view. */
|
||||
.chat-completed-log__steps .chat-progress-step__icon {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.chat-completed-log__tool-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
.chat-completed-log__steps .chat-progress-step__line {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.chat-completed-log__tool-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.chat-completed-log__steps .chat-progress-step__label {
|
||||
font-size: 0.775rem;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
/* Legacy thinking indicator (kept in case any other code references it). */
|
||||
@@ -664,15 +581,9 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-progress-live__label {
|
||||
.chat-progress-step {
|
||||
animation: none;
|
||||
background: none;
|
||||
-webkit-text-fill-color: var(--text-muted);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.chat-progress-live__phase-icon {
|
||||
animation: none;
|
||||
opacity: 0.85;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,12 +31,12 @@ import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
|
||||
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import {
|
||||
useChat,
|
||||
AiWorkflowPhase,
|
||||
ChatRole,
|
||||
PROGRESS_LOG_MAX,
|
||||
isKnownEngineProgressDetail,
|
||||
type AiWorkflowProgress,
|
||||
type AnyEngineProgressDetail,
|
||||
@@ -151,15 +151,20 @@ function formatEngineProgress(
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase-specific icon for a progress step: the tool's registry icon while a
|
||||
* tool runs, or a generic glyph for the read/extract/think phases. Used for the
|
||||
* right-hand "what it's doing" icon in the live indicator and for each row of
|
||||
* the completed tool breakdown.
|
||||
* Choose an icon for a progress step.
|
||||
*
|
||||
* The active (current) step always shows the animated Stirling logo so it reads
|
||||
* as the "live" indicator. Past steps get a phase-specific icon so the trail
|
||||
* is scannable at a glance.
|
||||
*/
|
||||
function progressStepIcon(
|
||||
progress: AiWorkflowProgress,
|
||||
resolveToolIcon: ToolIconResolver,
|
||||
isActive: boolean,
|
||||
): ReactNode {
|
||||
if (isActive) {
|
||||
return <StirlingLogoAnimated size={18} />;
|
||||
}
|
||||
if (progress.phase === AiWorkflowPhase.EXECUTING_TOOL) {
|
||||
const registryIcon = progress.tool ? resolveToolIcon(progress.tool) : null;
|
||||
if (registryIcon) {
|
||||
@@ -177,10 +182,9 @@ function progressStepIcon(
|
||||
}
|
||||
|
||||
/**
|
||||
* Live progress indicator shown while the AI is working. One step at a time:
|
||||
* our animated logo on the left, the current step's label shimmering in the
|
||||
* middle, and the phase-specific icon (what it's doing right now) on the right.
|
||||
* The latest event replaces the previous one in place — no growing list.
|
||||
* Animated step-by-step progress log shown while the AI is working.
|
||||
* Displays the last {@link PROGRESS_LOG_VISIBLE} steps from the live event stream,
|
||||
* with the active (most recent) step highlighted and older steps dimmed.
|
||||
*/
|
||||
function ProgressLogDisplay({
|
||||
progressLog,
|
||||
@@ -193,23 +197,55 @@ function ProgressLogDisplay({
|
||||
resolveToolName: ToolNameResolver;
|
||||
resolveToolIcon: ToolIconResolver;
|
||||
}) {
|
||||
const current =
|
||||
progressLog.length > 0 ? progressLog[progressLog.length - 1] : null;
|
||||
const label = current
|
||||
? formatProgress(current, t, resolveToolName)
|
||||
: t("chat.progress.thinking");
|
||||
// Placeholder shown before the first SSE event arrives.
|
||||
if (progressLog.length === 0) {
|
||||
return (
|
||||
<div className="chat-progress-log">
|
||||
<div className="chat-progress-step chat-progress-step--active">
|
||||
<div className="chat-progress-step__left">
|
||||
<div className="chat-progress-step__icon">
|
||||
<StirlingLogoAnimated size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="chat-progress-step__label">
|
||||
{t("chat.progress.thinking")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Chronological order: oldest at top, newest (active) at bottom.
|
||||
// The reducer already caps progressLog at PROGRESS_LOG_MAX entries, so this
|
||||
// slice is effectively a no-op but kept for defensive correctness.
|
||||
const visibleSteps = progressLog.slice(-PROGRESS_LOG_MAX);
|
||||
const startIndex = progressLog.length - visibleSteps.length;
|
||||
|
||||
return (
|
||||
<div className="chat-progress-live">
|
||||
<span className="chat-progress-live__logo">
|
||||
<StirlingLogoAnimated size={18} />
|
||||
</span>
|
||||
<span className="chat-progress-live__label">{label}</span>
|
||||
{current && (
|
||||
<span className="chat-progress-live__phase-icon">
|
||||
{progressStepIcon(current, resolveToolIcon)}
|
||||
</span>
|
||||
)}
|
||||
<div className="chat-progress-log">
|
||||
{visibleSteps.map((step, i) => {
|
||||
// Stable key based on absolute position in the full log — React reuses
|
||||
// existing DOM elements and only mounts (and animates) new ones.
|
||||
const globalIndex = startIndex + i;
|
||||
const isActive = i === visibleSteps.length - 1; // last = newest = bottom
|
||||
// Connector runs below every step except the active one at the bottom.
|
||||
const showConnector = i < visibleSteps.length - 1;
|
||||
const label = formatProgress(step, t, resolveToolName);
|
||||
return (
|
||||
<div
|
||||
key={globalIndex}
|
||||
className={`chat-progress-step${isActive ? " chat-progress-step--active" : ""}`}
|
||||
>
|
||||
<div className="chat-progress-step__left">
|
||||
<div className="chat-progress-step__icon">
|
||||
{progressStepIcon(step, resolveToolIcon, isActive)}
|
||||
</div>
|
||||
{showConnector && <div className="chat-progress-step__line" />}
|
||||
</div>
|
||||
<span className="chat-progress-step__label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -229,10 +265,8 @@ function formatDuration(ms: number, t: TranslateFn): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsed "Ran for X seconds" control above each completed assistant turn.
|
||||
* Expands to a numbered list of just the tools that actually ran — generic
|
||||
* progress phases (analysing, thinking, reading the document, …) are omitted.
|
||||
* When no tool ran, the duration shows as a plain label with nothing to expand.
|
||||
* Collapsed "Ran for X seconds" dropdown that appears above each completed
|
||||
* assistant turn. Expands to show the full ordered progress log for that turn.
|
||||
*/
|
||||
function CompletedProgressLogDropdown({
|
||||
progressLog,
|
||||
@@ -250,22 +284,6 @@ function CompletedProgressLogDropdown({
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const label = formatDuration(durationMs, t);
|
||||
|
||||
const toolSteps = progressLog.filter(
|
||||
(step) => step.phase === AiWorkflowPhase.EXECUTING_TOOL && step.tool,
|
||||
);
|
||||
|
||||
// A purely conversational turn (no tools): just show the duration, nothing
|
||||
// to expand.
|
||||
if (toolSteps.length === 0) {
|
||||
return (
|
||||
<div className="chat-completed-log">
|
||||
<Text size="xs" c="dimmed" className="chat-completed-log__static">
|
||||
{label}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-completed-log">
|
||||
<UnstyledButton
|
||||
@@ -285,21 +303,28 @@ function CompletedProgressLogDropdown({
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Collapse in={expanded}>
|
||||
<ol className="chat-completed-log__tools">
|
||||
{toolSteps.map((step, i) => {
|
||||
const endpoint = step.tool ?? "";
|
||||
const name = resolveToolName(endpoint) ?? endpoint;
|
||||
<div className="chat-completed-log__steps">
|
||||
{progressLog.map((step, i) => {
|
||||
const showConnector = i < progressLog.length - 1;
|
||||
const stepLabel = formatProgress(step, t, resolveToolName);
|
||||
return (
|
||||
<li key={i} className="chat-completed-log__tool">
|
||||
<span className="chat-completed-log__tool-num">{i + 1}</span>
|
||||
<span className="chat-completed-log__tool-icon">
|
||||
{progressStepIcon(step, resolveToolIcon)}
|
||||
</span>
|
||||
<span className="chat-completed-log__tool-name">{name}</span>
|
||||
</li>
|
||||
<div
|
||||
key={i}
|
||||
className="chat-progress-step chat-progress-step--done"
|
||||
>
|
||||
<div className="chat-progress-step__left">
|
||||
<div className="chat-progress-step__icon">
|
||||
{progressStepIcon(step, resolveToolIcon, false)}
|
||||
</div>
|
||||
{showConnector && (
|
||||
<div className="chat-progress-step__line" />
|
||||
)}
|
||||
</div>
|
||||
<span className="chat-progress-step__label">{stepLabel}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
@@ -463,10 +488,6 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
|
||||
};
|
||||
|
||||
const showQuickActions = messages.length === 0 && !isLoading;
|
||||
const disclaimerText = t(
|
||||
"chat.input.disclaimer",
|
||||
"AI can make mistakes. Be sure to verify the output before sharing.",
|
||||
);
|
||||
|
||||
return (
|
||||
<Box className="chat-panel chat-panel--embedded">
|
||||
@@ -512,23 +533,8 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
{showQuickActions && (
|
||||
<div className="chat-panel-disclaimer chat-panel-disclaimer--banner">
|
||||
<InfoOutlinedIcon
|
||||
className="chat-panel-disclaimer__icon"
|
||||
sx={{ fontSize: 18 }}
|
||||
/>
|
||||
<span>{disclaimerText}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className="chat-panel-messages" viewportRef={scrollRef}>
|
||||
<Stack
|
||||
gap="sm"
|
||||
px="md"
|
||||
pt="sm"
|
||||
className="chat-panel-messages__content"
|
||||
>
|
||||
<Stack gap="sm" px="md" py="sm">
|
||||
{messages.map((msg) => (
|
||||
<ChatMessageBubble
|
||||
key={msg.id}
|
||||
@@ -562,29 +568,7 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showQuickActions && (
|
||||
<div className="chat-panel-disclaimer chat-panel-disclaimer--inline">
|
||||
<InfoOutlinedIcon
|
||||
className="chat-panel-disclaimer__icon"
|
||||
sx={{ fontSize: 13 }}
|
||||
/>
|
||||
<span>{disclaimerText}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="chat-panel-input">
|
||||
<ActionIcon
|
||||
className="chat-panel-input__send"
|
||||
variant="filled"
|
||||
color="blue"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
onClick={() => handleSend()}
|
||||
disabled={!input.trim() || isLoading}
|
||||
aria-label={t("chat.input.send", "Send message")}
|
||||
>
|
||||
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
placeholder={t("chat.input.placeholder", "What do you want to do?")}
|
||||
@@ -596,11 +580,21 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
variant="unstyled"
|
||||
classNames={{
|
||||
root: "chat-panel-input__textarea",
|
||||
input: "chat-panel-input__field",
|
||||
}}
|
||||
classNames={{ input: "chat-panel-input__field" }}
|
||||
/>
|
||||
<div className="chat-panel-input__actions">
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="blue"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={() => handleSend()}
|
||||
disabled={!input.trim() || isLoading}
|
||||
aria-label={t("chat.input.send", "Send message")}
|
||||
>
|
||||
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFileOutlined";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import LayersIcon from "@mui/icons-material/Layers";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
@@ -204,12 +205,22 @@ export function ChatQuickActions({ heading, onAction }: ChatQuickActionsProps) {
|
||||
"chat.quickActions.compressOne",
|
||||
"Compress this document",
|
||||
);
|
||||
const rotateText = t(
|
||||
"chat.quickActions.rotateOne",
|
||||
"Rotate this document",
|
||||
);
|
||||
result.push({
|
||||
key: "compress",
|
||||
icon: <CompressIcon sx={{ fontSize: 18 }} />,
|
||||
title: compressText,
|
||||
onClick: send(compressText),
|
||||
});
|
||||
result.push({
|
||||
key: "rotate",
|
||||
icon: <RotateRightIcon sx={{ fontSize: 18 }} />,
|
||||
title: rotateText,
|
||||
onClick: send(rotateText),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -235,6 +246,10 @@ export function ChatQuickActions({ heading, onAction }: ChatQuickActionsProps) {
|
||||
"chat.quickActions.compressMany",
|
||||
"Compress these documents",
|
||||
);
|
||||
const rotateText = t(
|
||||
"chat.quickActions.rotateMany",
|
||||
"Rotate these documents",
|
||||
);
|
||||
result.push({
|
||||
key: "merge",
|
||||
icon: <LayersIcon sx={{ fontSize: 18 }} />,
|
||||
@@ -247,6 +262,12 @@ export function ChatQuickActions({ heading, onAction }: ChatQuickActionsProps) {
|
||||
title: compressText,
|
||||
onClick: send(compressText),
|
||||
});
|
||||
result.push({
|
||||
key: "rotate",
|
||||
icon: <RotateRightIcon sx={{ fontSize: 18 }} />,
|
||||
title: rotateText,
|
||||
onClick: send(rotateText),
|
||||
});
|
||||
return result;
|
||||
}, [summary, t, onAction, openFilesModal]);
|
||||
|
||||
|
||||
@@ -1,471 +0,0 @@
|
||||
/* ============================ Policies ============================ */
|
||||
/* The Policies surface is docked in the right tool sidebar: a list section */
|
||||
/* above Tools, a detail takeover that replaces Tools when a policy is open, */
|
||||
/* and a collapsed-rail of policy icons. */
|
||||
/* */
|
||||
/* Chrome (headers, cards, buttons, badges, chips, lists, steps…) uses SUI */
|
||||
/* (@shared/components). The bespoke .pol-* bits below are thin layout */
|
||||
/* scaffolding + the collapsed rail; spacing snaps to the SUI --space-* */
|
||||
/* scale and colour to the SUI token set so it reads as one product. */
|
||||
|
||||
/* ---- List ---- */
|
||||
.pol-list {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Header row: optional leading control (sidebar collapse) + the SectionHeader,
|
||||
mirroring the back-button + title layout inside an open policy. */
|
||||
.pol-list-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
.pol-list-head .sui-sectionhdr {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Small "what is a policy?" info button on the header. */
|
||||
/* Bare info icon matching the tool-step affordance (LocalIcon, no chrome). */
|
||||
.pol-info-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--motion-fast);
|
||||
}
|
||||
.pol-info-btn:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.pol-list-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-1) var(--space-1_5);
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
/* A policy row: tinted icon tile + label + trailing status/CTA. */
|
||||
.pol-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-1_5) var(--space-2);
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.pol-row:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
.pol-row:focus-visible {
|
||||
outline: 2px solid var(--color-blue);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.pol-row-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.pol-row-trail {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin-left: auto;
|
||||
}
|
||||
/* Unconfigured rows: a quiet blue "Set up" call-to-action instead of a status pill. */
|
||||
.pol-row-setup {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-blue);
|
||||
}
|
||||
/* Trailing drill-in chevron on each policy row (after the status / CTA). */
|
||||
.pol-row-chevron {
|
||||
color: var(--color-text-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Retry button on a failed activity row. */
|
||||
/* Expandable error text in the activity feed — long backend errors are clamped
|
||||
and collapsed by default so they don't blow up the row. */
|
||||
.pol-activity-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.pol-activity-error__text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.pol-activity-error__text--clamped {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pol-activity-error__toggle {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--color-blue);
|
||||
cursor: pointer;
|
||||
}
|
||||
.pol-activity-error__toggle:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Locked "Coming soon" row — muted, not interactive. */
|
||||
.pol-row--soon {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
.pol-row--soon:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.pol-row-soon {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* In-progress activity icon spins gently. */
|
||||
.pol-spin {
|
||||
animation: pol-spin 1.2s linear infinite;
|
||||
}
|
||||
@keyframes pol-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Locked, per-tool config (PolicyToolConfig): one section per tool ---- */
|
||||
.pol-tool-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.pol-tool-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.pol-tool-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.pol-tool-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin-right: auto;
|
||||
}
|
||||
.pol-tool-body {
|
||||
padding: 0 var(--space-3) var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
/* ---- Detail container (wizard / narrative / settings share this) ---- */
|
||||
.pol-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-width: 32rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---- Step indicator (wraps a SUI StepIndicator) ---- */
|
||||
.pol-steps {
|
||||
padding: var(--space-3) var(--space-5);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ---- Scroll body ---- */
|
||||
.pol-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3) var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.pol-desc {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-4);
|
||||
margin: 0;
|
||||
}
|
||||
.pol-section-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-4);
|
||||
/* Bottom gap so the label doesn't hug its card/chips. */
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
/* Sub-section header inside a settings card (e.g. "Output filename"). The field
|
||||
directly below it carries data-first so the borders don't double up. */
|
||||
.pol-subhead {
|
||||
padding: 0.55rem 0.875rem 0.4rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-4);
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Position dropdown + custom-text input sitting on one row under the
|
||||
"Output filename" subhead — the dropdown sizes to content, text fills. */
|
||||
.pol-name-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
.pol-name-row > :first-child {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.pol-name-row > :last-child {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---- Fields (PolicyFieldRow) — row inset matches SUI ListRow ---- */
|
||||
.pol-field {
|
||||
padding: 0.7rem 0.875rem;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.pol-field:not([data-first]) {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.pol-field-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.pol-field-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
.pol-field-chips-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.pol-field-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1_5);
|
||||
}
|
||||
|
||||
/* ---- Sources ---- */
|
||||
.pol-source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0.7rem 0.875rem;
|
||||
cursor: pointer;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.pol-source:not([data-first]) {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ---- Doc types ---- */
|
||||
.pol-link {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-blue);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pol-doctypes-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.7rem 0.875rem;
|
||||
}
|
||||
.pol-doctypes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0.875rem var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ---- Summary ---- */
|
||||
.pol-summary-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.pol-summary-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.pol-summary-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1_5);
|
||||
}
|
||||
/* Muted placeholder for an unset summary value (e.g. no reviewer chosen). */
|
||||
.pol-muted {
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ---- Enforces rule flow (wraps a SUI ChipFlow) ---- */
|
||||
.pol-rule-flow {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* ---- Meta / note ---- */
|
||||
.pol-meta-row {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.pol-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1_5);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
.pol-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg-muted);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ---- Stats: one grouped card with three divided columns. ---- */
|
||||
.pol-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.pol-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-0_5);
|
||||
padding: var(--space-3) var(--space-2);
|
||||
text-align: center;
|
||||
}
|
||||
.pol-stat:not(:first-child) {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
.pol-stat-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.pol-stat-label {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ---- Footer (hosts SUI Buttons) ---- */
|
||||
.pol-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pol-footer-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ---- Detail takeover (fills the rail when a policy is open) ---- */
|
||||
.pol-takeover {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---- Collapsed rail policy icons ---- */
|
||||
.pol-crail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
/* The collapsed-rail dividers carry 8px of space below them only, so the
|
||||
divider above gives the row 8px of headroom while the one below (rendered
|
||||
straight after) hugs it. Match that 8px underneath so the row sits centred
|
||||
between the two dividers. */
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pol-crail-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.pol-crail-btn:hover {
|
||||
background: var(--color-blue-light);
|
||||
}
|
||||
.pol-crail-btn[data-status="active"] {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
.pol-crail-btn[data-status="paused"] {
|
||||
color: var(--color-amber);
|
||||
}
|
||||
.pol-crail-dot {
|
||||
position: absolute;
|
||||
top: 0.1rem;
|
||||
right: 0.1rem;
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg-toolbar, var(--color-surface));
|
||||
}
|
||||
.pol-crail-dot[data-status="active"] {
|
||||
background: var(--color-green);
|
||||
}
|
||||
.pol-crail-dot[data-status="paused"] {
|
||||
background: var(--color-amber);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user