Revert "SaaS fixes (#6578)"

This reverts commit ddf78d11ae.
This commit is contained in:
Anthony Stirling
2026-06-16 16:48:30 +01:00
committed by GitHub
parent ddf78d11ae
commit 5389e39cfc
415 changed files with 5857 additions and 29554 deletions
+4 -5
View File
@@ -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>
);
}
@@ -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);
@@ -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(),
-10
View File
@@ -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;
-24
View File
@@ -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
* inputoutput mapping. Unlike `parentFileId` (the version chain) this is a
* pure provenance link, so it covers independent artifacts split (1N),
* merge (N1), 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.
-2
View File
@@ -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);
});
});
+10 -17
View File
@@ -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;
}
@@ -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")}
+6 -7
View File
@@ -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