merge main
This commit is contained in:
@@ -5854,6 +5854,7 @@ routing = "Routing"
|
||||
security = "Security"
|
||||
|
||||
[policies.detail]
|
||||
close = "Close"
|
||||
editSettings = "Edit Settings"
|
||||
enforces = "Enforces"
|
||||
managedByOrg = "Managed by your organization. Contact a team leader to change this policy."
|
||||
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
|
||||
back = "Back"
|
||||
builderDesc = "Build the sequence of tools this policy runs on each document."
|
||||
clear = "Clear"
|
||||
close = "Close"
|
||||
continue = "Continue"
|
||||
docTypesLabel = "Document types"
|
||||
edit = "Edit"
|
||||
|
||||
@@ -14,6 +14,11 @@ export function usePoliciesEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether the Policies list should appear for the current user. False in core. */
|
||||
export function usePoliciesVisible(): 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.
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
PoliciesSection,
|
||||
PolicyDetailTakeover,
|
||||
usePoliciesEnabled,
|
||||
usePoliciesVisible,
|
||||
usePolicyDetailActive,
|
||||
} from "@app/components/policies/PoliciesSidebar";
|
||||
import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController";
|
||||
@@ -18,6 +19,7 @@ import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
|
||||
import { useToolSections } from "@app/hooks/useToolSections";
|
||||
import type { SubcategoryGroup } from "@app/hooks/useToolSections";
|
||||
import { ToolIcon } from "@app/components/shared/ToolIcon";
|
||||
import { PanelHeader } from "@shared/components/PanelHeader";
|
||||
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
|
||||
import { withViewTransition } from "@app/utils/viewTransition";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
@@ -64,6 +66,7 @@ export default function RightSidebar() {
|
||||
} = useToolWorkflow();
|
||||
|
||||
const policiesEnabled = usePoliciesEnabled();
|
||||
const policiesVisible = usePoliciesVisible();
|
||||
const rawPolicyDetailActive = usePolicyDetailActive();
|
||||
const fullscreenExpanded = useIsFullscreenExpanded();
|
||||
const fullscreenGeometry = useToolPanelGeometry({
|
||||
@@ -115,9 +118,14 @@ export default function RightSidebar() {
|
||||
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.
|
||||
// Policies sit above the tool list in the default tool-picker view — but only
|
||||
// when the current user actually has policies to see (see usePoliciesVisible),
|
||||
// so regular users with none get the plain tool picker with no empty block.
|
||||
const showPolicies =
|
||||
policiesEnabled && !allToolsView && leftPanelView === "toolPicker";
|
||||
policiesEnabled &&
|
||||
policiesVisible &&
|
||||
!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
|
||||
@@ -281,65 +289,67 @@ export default function RightSidebar() {
|
||||
</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}
|
||||
{!showPolicies &&
|
||||
(activeTool ? (
|
||||
<PanelHeader
|
||||
icon={
|
||||
<ToolIcon
|
||||
icon={activeTool.icon}
|
||||
marginRight="0"
|
||||
color="currentColor"
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
}
|
||||
title={activeTool.name}
|
||||
onClose={handleHeaderBack}
|
||||
closeLabel={
|
||||
inToolView
|
||||
? t("toolPanel.backToAllTools", "Back to all tools")
|
||||
: t("toolPanel.goBack", "Go back")
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="tool-panel__compact-header">
|
||||
{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
|
||||
|
||||
@@ -294,67 +294,6 @@
|
||||
background-color: var(--bg-muted);
|
||||
}
|
||||
|
||||
.tool-panel__active-tool-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.35rem 0.75rem 0.35rem 0.4rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 9999px;
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.tool-panel__active-tool-pill-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--mantine-color-blue-light);
|
||||
color: var(--mantine-color-blue-filled);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .tool-panel__active-tool-pill-icon {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-filled) 18%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
|
||||
}
|
||||
|
||||
/* The inner .tool-button-icon wrapper carries its own transform/margin; reset
|
||||
them so the icon glyph sits dead-centre in the circular background. */
|
||||
.tool-panel__active-tool-pill-icon .tool-button-icon {
|
||||
margin: 0 !important;
|
||||
transform: none !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tool-panel__active-tool-pill-icon svg {
|
||||
font-size: 1rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.tool-panel__active-tool-pill-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
::view-transition-old(tool-rail) {
|
||||
animation: vt-rail-fade-out 180ms ease-out forwards;
|
||||
}
|
||||
|
||||
@@ -17,16 +17,16 @@ import CloseIcon from "@mui/icons-material/Close";
|
||||
import WarningIcon from "@mui/icons-material/Warning";
|
||||
import { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { getAvailableToExtensions } from "@app/utils/convertUtils";
|
||||
import { ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
interface ToolConfigurationModalProps {
|
||||
opened: boolean;
|
||||
tool: {
|
||||
id: string;
|
||||
operation: string;
|
||||
name: string;
|
||||
parameters?: any;
|
||||
parameters?: ErasedToolParams;
|
||||
};
|
||||
onSave: (parameters: any) => void;
|
||||
onSave: (parameters: ErasedToolParams) => void;
|
||||
onCancel: () => void;
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default function ToolConfigurationModal({
|
||||
}: ToolConfigurationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [parameters, setParameters] = useState<any>({});
|
||||
const [parameters, setParameters] = useState<ErasedToolParams>({});
|
||||
|
||||
// Get tool info from registry
|
||||
const toolInfo = toolRegistry[tool.operation as ToolId];
|
||||
@@ -74,26 +74,11 @@ export default function ToolConfigurationModal({
|
||||
);
|
||||
}
|
||||
|
||||
// Special handling for ConvertSettings which needs additional props
|
||||
if (tool.operation === "convert") {
|
||||
return (
|
||||
<SettingsComponent
|
||||
parameters={parameters}
|
||||
onParameterChange={(key: string, value: any) => {
|
||||
setParameters((prev: any) => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
getAvailableToExtensions={getAvailableToExtensions}
|
||||
selectedFiles={[]}
|
||||
disabled={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsComponent
|
||||
parameters={parameters}
|
||||
onParameterChange={(key: string, value: any) => {
|
||||
setParameters((prev: any) => ({ ...prev, [key]: value }));
|
||||
onParameterChange={(key, value) => {
|
||||
setParameters((prev) => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
disabled={false}
|
||||
/>
|
||||
|
||||
@@ -18,7 +18,7 @@ interface ToolListProps {
|
||||
onToolConfigure: (index: number) => void;
|
||||
onToolAdd: () => void;
|
||||
getToolName: (operation: string) => string;
|
||||
getToolDefaultParameters: (operation: string) => Record<string, any>;
|
||||
getToolDefaultParameters: (operation: string) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
export default function ToolList({
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { isImageFormat, isWebFormat } from "@app/utils/convertUtils";
|
||||
import {
|
||||
isImageFormat,
|
||||
isWebFormat,
|
||||
getAvailableToExtensions as defaultGetAvailableToExtensions,
|
||||
} from "@app/utils/convertUtils";
|
||||
import { getConversionEndpoints } from "@app/data/toolsTaxonomy";
|
||||
import { useFileSelection } from "@app/contexts/FileContext";
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
@@ -47,18 +51,18 @@ interface ConvertSettingsProps {
|
||||
key: K,
|
||||
value: ConvertParameters[K],
|
||||
) => void;
|
||||
getAvailableToExtensions: (
|
||||
getAvailableToExtensions?: (
|
||||
fromExtension: string,
|
||||
) => Array<{ value: string; label: string; group: string }>;
|
||||
selectedFiles: StirlingFile[];
|
||||
selectedFiles?: StirlingFile[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ConvertSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
getAvailableToExtensions,
|
||||
selectedFiles,
|
||||
getAvailableToExtensions = defaultGetAvailableToExtensions,
|
||||
selectedFiles = [],
|
||||
disabled = false,
|
||||
}: ConvertSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -10,14 +10,20 @@ export interface FilesToolStepProps {
|
||||
minFiles?: number;
|
||||
}
|
||||
|
||||
export function createFilesToolStep(
|
||||
interface StepBaseProps {
|
||||
isVisible?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
onCollapsedClick?: () => void;
|
||||
}
|
||||
|
||||
export function createFilesToolStep<T>(
|
||||
createStep: (
|
||||
title: string,
|
||||
props: any,
|
||||
props: StepBaseProps,
|
||||
children?: React.ReactNode,
|
||||
) => React.ReactElement,
|
||||
) => T,
|
||||
props: FilesToolStepProps,
|
||||
): React.ReactElement {
|
||||
): T {
|
||||
return createStep(
|
||||
i18n.t("files.title", "Files"),
|
||||
{
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { type TFunction } from "i18next";
|
||||
import React from "react";
|
||||
import { ToolOperationConfig } from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import {
|
||||
type ErasedToolParams,
|
||||
type ToolAutomationSettingsProps,
|
||||
ToolOperationConfig,
|
||||
} from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import { BaseToolProps } from "@app/types/tool";
|
||||
import { WorkbenchType } from "@app/types/workbench";
|
||||
import {
|
||||
@@ -58,10 +62,13 @@ export type ToolRegistryEntry = {
|
||||
kind?: ToolKind;
|
||||
// Workbench type for navigation
|
||||
workbench?: WorkbenchType;
|
||||
// Operation configuration for automation
|
||||
operationConfig?: ToolOperationConfig<any>;
|
||||
// Settings component for automation configuration
|
||||
automationSettings: React.ComponentType<any> | null;
|
||||
// Operation configuration for automation. TParams is erased at the registry
|
||||
// boundary; tools are authored type-safely via defineToolAutomation.
|
||||
operationConfig?: ToolOperationConfig<ErasedToolParams>;
|
||||
// Settings component for automation configuration.
|
||||
automationSettings: React.ComponentType<
|
||||
ToolAutomationSettingsProps<ErasedToolParams>
|
||||
> | null;
|
||||
// Whether this tool supports automation (defaults to true)
|
||||
supportsAutomate?: boolean;
|
||||
// Synonyms for search (optional)
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
LinkToolRegistry,
|
||||
} from "@app/data/toolsTaxonomy";
|
||||
import { isSuperToolId, isLinkToolId } from "@app/types/toolId";
|
||||
import {
|
||||
asRegistryConfig,
|
||||
lazySettings,
|
||||
} from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import { adjustContrastOperationConfig } from "@app/hooks/tools/adjustContrast/useAdjustContrastOperation";
|
||||
import { getSynonyms } from "@app/utils/toolSynonyms";
|
||||
import { useProprietaryToolRegistry } from "@app/data/useProprietaryToolRegistry";
|
||||
@@ -143,8 +147,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["merge-pdfs"],
|
||||
operationConfig: mergeOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(mergeOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() => import("@app/components/tools/merge/MergeSettings"),
|
||||
),
|
||||
synonyms: getSynonyms(t, "merge"),
|
||||
@@ -169,8 +173,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
synonyms: getSynonyms(t, "certSign"),
|
||||
maxFiles: -1,
|
||||
endpoints: ["cert-sign"],
|
||||
operationConfig: certSignOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(certSignOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/certSign/CertSignAutomationSettings"),
|
||||
),
|
||||
@@ -193,7 +197,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.SIGNING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["timestamp-pdf"],
|
||||
operationConfig: timestampPdfOperationConfig,
|
||||
operationConfig: asRegistryConfig(timestampPdfOperationConfig),
|
||||
automationSettings: null,
|
||||
synonyms: getSynonyms(t, "timestampPdf"),
|
||||
},
|
||||
@@ -210,8 +214,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.SIGNING,
|
||||
endpoints: ["sign"],
|
||||
operationConfig: signOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(signOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() => import("@app/components/tools/sign/SignSettings"),
|
||||
), // TODO:: not all settings shown, suggested next tools shown
|
||||
synonyms: getSynonyms(t, "sign"),
|
||||
@@ -234,7 +238,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
endpoints: ["sign"],
|
||||
operationConfig: signOperationConfig,
|
||||
operationConfig: asRegistryConfig(signOperationConfig),
|
||||
automationSettings: null,
|
||||
synonyms: getSynonyms(t, "addText"),
|
||||
supportsAutomate: false,
|
||||
@@ -253,7 +257,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
endpoints: ["add-image"],
|
||||
operationConfig: signOperationConfig,
|
||||
operationConfig: asRegistryConfig(signOperationConfig),
|
||||
automationSettings: null,
|
||||
synonyms: getSynonyms(t, "addImage"),
|
||||
supportsAutomate: false,
|
||||
@@ -276,7 +280,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
workbench: "viewer",
|
||||
endpoints: ["view-pdf"],
|
||||
operationConfig: signOperationConfig,
|
||||
operationConfig: asRegistryConfig(signOperationConfig),
|
||||
automationSettings: null,
|
||||
synonyms: getSynonyms(t, "annotate"),
|
||||
supportsAutomate: false,
|
||||
@@ -298,8 +302,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
maxFiles: -1,
|
||||
endpoints: ["add-password"],
|
||||
operationConfig: addPasswordOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(addPasswordOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() => import("@app/components/tools/addPassword/AddPasswordSettings"),
|
||||
),
|
||||
synonyms: getSynonyms(t, "addPassword"),
|
||||
@@ -322,8 +326,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
endpoints: ["add-watermark"],
|
||||
operationConfig: addWatermarkOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(addWatermarkOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/addWatermark/AddWatermarkSingleStepSettings"),
|
||||
),
|
||||
@@ -348,8 +352,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
synonyms: getSynonyms(t, "addStamp"),
|
||||
maxFiles: -1,
|
||||
endpoints: ["add-stamp"],
|
||||
operationConfig: addStampOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(addStampOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/addStamp/AddStampAutomationSettings"),
|
||||
),
|
||||
@@ -372,8 +376,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
"Remove potentially harmful elements from PDF files",
|
||||
),
|
||||
endpoints: ["sanitize-pdf"],
|
||||
operationConfig: sanitizeOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(sanitizeOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() => import("@app/components/tools/sanitize/SanitizeSettings"),
|
||||
),
|
||||
synonyms: getSynonyms(t, "sanitize"),
|
||||
@@ -396,8 +400,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
maxFiles: -1,
|
||||
endpoints: ["flatten"],
|
||||
operationConfig: flattenOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(flattenOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() => import("@app/components/tools/flatten/FlattenSettings"),
|
||||
),
|
||||
synonyms: getSynonyms(t, "flatten"),
|
||||
@@ -420,7 +424,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
maxFiles: -1,
|
||||
endpoints: ["unlock-pdf-forms"],
|
||||
operationConfig: unlockPdfFormsOperationConfig,
|
||||
operationConfig: asRegistryConfig(unlockPdfFormsOperationConfig),
|
||||
synonyms: getSynonyms(t, "unlockPDFForms"),
|
||||
automationSettings: null,
|
||||
},
|
||||
@@ -458,8 +462,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
maxFiles: -1,
|
||||
endpoints: ["add-password"],
|
||||
operationConfig: changePermissionsOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(changePermissionsOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/changePermissions/ChangePermissionsSettings"),
|
||||
),
|
||||
@@ -551,8 +555,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
|
||||
maxFiles: -1,
|
||||
endpoints: ["update-metadata"],
|
||||
operationConfig: changeMetadataOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(changeMetadataOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/changeMetadata/ChangeMetadataSingleStep"),
|
||||
),
|
||||
@@ -570,7 +574,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
|
||||
maxFiles: 1,
|
||||
endpoints: ["edit-table-of-contents"],
|
||||
operationConfig: editTableOfContentsOperationConfig,
|
||||
operationConfig: asRegistryConfig(editTableOfContentsOperationConfig),
|
||||
automationSettings: null,
|
||||
supportsAutomate: false,
|
||||
synonyms: getSynonyms(t, "editTableOfContents"),
|
||||
@@ -589,8 +593,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["crop"],
|
||||
operationConfig: cropOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(cropOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() => import("@app/components/tools/crop/CropAutomationSettings"),
|
||||
),
|
||||
},
|
||||
@@ -609,8 +613,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["rotate-pdf"],
|
||||
operationConfig: rotateOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(rotateOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() => import("@app/components/tools/rotate/RotateAutomationSettings"),
|
||||
),
|
||||
synonyms: getSynonyms(t, "rotate"),
|
||||
@@ -629,8 +633,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
endpoints: Array.from(new Set(Object.values(SPLIT_ENDPOINT_NAMES))),
|
||||
operationConfig: splitOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(splitOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() => import("@app/components/tools/split/SplitAutomationSettings"),
|
||||
),
|
||||
synonyms: getSynonyms(t, "split"),
|
||||
@@ -648,7 +652,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
endpoints: ["rearrange-pages"],
|
||||
operationConfig: reorganizePagesOperationConfig,
|
||||
operationConfig: asRegistryConfig(reorganizePagesOperationConfig),
|
||||
synonyms: getSynonyms(t, "reorganizePages"),
|
||||
automationSettings: null,
|
||||
},
|
||||
@@ -666,8 +670,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["scale-pages"],
|
||||
operationConfig: adjustPageScaleOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(adjustPageScaleOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/adjustPageScale/AdjustPageScaleSettings"),
|
||||
),
|
||||
@@ -683,13 +687,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
automationSettings: lazy(
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/addPageNumbers/AddPageNumbersAutomationSettings"),
|
||||
),
|
||||
maxFiles: -1,
|
||||
endpoints: ["add-page-numbers"],
|
||||
operationConfig: addPageNumbersOperationConfig,
|
||||
operationConfig: asRegistryConfig(addPageNumbersOperationConfig),
|
||||
synonyms: getSynonyms(t, "addPageNumbers"),
|
||||
},
|
||||
pageLayout: {
|
||||
@@ -710,7 +714,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["multi-page-layout"],
|
||||
automationSettings: lazy(
|
||||
automationSettings: lazySettings(
|
||||
() => import("@app/components/tools/pageLayout/PageLayoutSettings"),
|
||||
),
|
||||
synonyms: getSynonyms(t, "pageLayout"),
|
||||
@@ -725,8 +729,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
),
|
||||
name: t("home.bookletImposition.title", "Booklet Imposition"),
|
||||
component: lazy(() => import("@app/tools/BookletImposition")),
|
||||
operationConfig: bookletImpositionOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(bookletImpositionOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/bookletImposition/BookletImpositionSettings"),
|
||||
),
|
||||
@@ -757,7 +761,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["pdf-to-single-page"],
|
||||
operationConfig: singleLargePageOperationConfig,
|
||||
operationConfig: asRegistryConfig(singleLargePageOperationConfig),
|
||||
synonyms: getSynonyms(t, "pdfToSinglePage"),
|
||||
automationSettings: null,
|
||||
},
|
||||
@@ -776,8 +780,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
synonyms: getSynonyms(t, "addAttachments"),
|
||||
maxFiles: 1,
|
||||
endpoints: ["add-attachments"],
|
||||
operationConfig: addAttachmentsOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(addAttachmentsOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/addAttachments/AddAttachmentsSettings"),
|
||||
),
|
||||
@@ -798,11 +802,11 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.EXTRACTION,
|
||||
synonyms: getSynonyms(t, "extractPages"),
|
||||
automationSettings: lazy(
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/extractPages/ExtractPagesSettings"),
|
||||
),
|
||||
operationConfig: extractPagesOperationConfig,
|
||||
operationConfig: asRegistryConfig(extractPagesOperationConfig),
|
||||
endpoints: ["rearrange-pages"],
|
||||
},
|
||||
extractImages: {
|
||||
@@ -823,8 +827,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.EXTRACTION,
|
||||
maxFiles: -1,
|
||||
endpoints: ["extract-images"],
|
||||
operationConfig: extractImagesOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(extractImagesOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/extractImages/ExtractImagesSettings"),
|
||||
),
|
||||
@@ -852,8 +856,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
maxFiles: 1,
|
||||
endpoints: ["remove-pages"],
|
||||
synonyms: getSynonyms(t, "removePages"),
|
||||
operationConfig: removePagesOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(removePagesOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() => import("@app/components/tools/removePages/RemovePagesSettings"),
|
||||
),
|
||||
},
|
||||
@@ -876,8 +880,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
maxFiles: 1,
|
||||
endpoints: ["remove-blanks"],
|
||||
synonyms: getSynonyms(t, "removeBlanks"),
|
||||
operationConfig: removeBlanksOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(removeBlanksOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/removeBlanks/RemoveBlanksSettings"),
|
||||
),
|
||||
@@ -900,7 +904,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-annotations"],
|
||||
operationConfig: removeAnnotationsOperationConfig,
|
||||
operationConfig: asRegistryConfig(removeAnnotationsOperationConfig),
|
||||
automationSettings: null,
|
||||
synonyms: getSynonyms(t, "removeAnnotations"),
|
||||
},
|
||||
@@ -944,8 +948,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
endpoints: ["remove-password"],
|
||||
maxFiles: -1,
|
||||
operationConfig: removePasswordOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(removePasswordOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/removePassword/RemovePasswordSettings"),
|
||||
),
|
||||
@@ -969,7 +973,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-cert-sign"],
|
||||
operationConfig: removeCertificateSignOperationConfig,
|
||||
operationConfig: asRegistryConfig(removeCertificateSignOperationConfig),
|
||||
synonyms: getSynonyms(t, "removeCertSign"),
|
||||
automationSettings: null,
|
||||
},
|
||||
@@ -1002,7 +1006,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
component: lazy(() => import("@app/tools/AutoRename")),
|
||||
maxFiles: -1,
|
||||
endpoints: ["auto-rename"],
|
||||
operationConfig: autoRenameOperationConfig,
|
||||
operationConfig: asRegistryConfig(autoRenameOperationConfig),
|
||||
description: t(
|
||||
"home.autoRename.desc",
|
||||
"Automatically rename PDF files based on their content",
|
||||
@@ -1028,8 +1032,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["adjust-contrast"],
|
||||
operationConfig: adjustContrastOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(adjustContrastOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/adjustContrast/AdjustContrastSingleStepSettings"),
|
||||
),
|
||||
@@ -1053,7 +1057,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["repair"],
|
||||
operationConfig: repairOperationConfig,
|
||||
operationConfig: asRegistryConfig(repairOperationConfig),
|
||||
synonyms: getSynonyms(t, "repair"),
|
||||
automationSettings: null,
|
||||
},
|
||||
@@ -1078,8 +1082,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["extract-image-scans"],
|
||||
operationConfig: scannerImageSplitOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(scannerImageSplitOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/scannerImageSplit/ScannerImageSplitSettings"),
|
||||
),
|
||||
@@ -1102,9 +1106,9 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
endpoints: ["overlay-pdf"],
|
||||
operationConfig: overlayPdfsOperationConfig,
|
||||
operationConfig: asRegistryConfig(overlayPdfsOperationConfig),
|
||||
synonyms: getSynonyms(t, "overlay-pdfs"),
|
||||
automationSettings: lazy(
|
||||
automationSettings: lazySettings(
|
||||
() => import("@app/components/tools/overlayPdfs/OverlayPdfsSettings"),
|
||||
),
|
||||
},
|
||||
@@ -1126,8 +1130,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["replace-invert-pdf"],
|
||||
operationConfig: replaceColorOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(replaceColorOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/replaceColor/ReplaceColorSettings"),
|
||||
),
|
||||
@@ -1299,8 +1303,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["compress-pdf"],
|
||||
operationConfig: compressOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(compressOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() => import("@app/components/tools/compress/CompressSettings"),
|
||||
),
|
||||
synonyms: getSynonyms(t, "compress"),
|
||||
@@ -1338,8 +1342,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
"pdf-to-epub",
|
||||
],
|
||||
|
||||
operationConfig: convertOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(convertOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() => import("@app/components/tools/convert/ConvertSettings"),
|
||||
),
|
||||
synonyms: getSynonyms(t, "convert"),
|
||||
@@ -1363,8 +1367,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["ocr-pdf"],
|
||||
operationConfig: ocrOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(ocrOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() => import("@app/components/tools/ocr/OCRSettings"),
|
||||
),
|
||||
synonyms: getSynonyms(t, "ocr"),
|
||||
@@ -1387,8 +1391,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["auto-redact"],
|
||||
operationConfig: redactOperationConfig,
|
||||
automationSettings: lazy(
|
||||
operationConfig: asRegistryConfig(redactOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() => import("@app/components/tools/redact/RedactSingleStepSettings"),
|
||||
),
|
||||
synonyms: getSynonyms(t, "redact"),
|
||||
|
||||
@@ -40,7 +40,7 @@ export function useAutomationForm({
|
||||
);
|
||||
|
||||
const getToolDefaultParameters = useCallback(
|
||||
(operation: string): Record<string, any> => {
|
||||
(operation: string): Record<string, unknown> => {
|
||||
const config = toolRegistry[operation as ToolId]?.operationConfig;
|
||||
if (config?.defaultParameters) {
|
||||
return { ...config.defaultParameters };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { lazy, type ComponentType } from "react";
|
||||
import { StirlingFile } from "@app/types/fileContext";
|
||||
import type { ResponseHandler } from "@app/utils/toolResponseProcessor";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
@@ -147,6 +148,56 @@ export type ToolOperationConfig<TParams = void> =
|
||||
| MultiFileToolOperationConfig<TParams>
|
||||
| CustomToolOperationConfig<TParams>;
|
||||
|
||||
/**
|
||||
* One generic source-of-truth for the props every automation settings component
|
||||
* accepts: the tool's parameters plus a typed change handler.
|
||||
*/
|
||||
export interface ToolAutomationSettingsProps<TParams> {
|
||||
parameters: TParams;
|
||||
onParameterChange: <K extends keyof TParams>(
|
||||
key: K,
|
||||
value: TParams[K],
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erased parameter shape stored in the registry. Spreadable and callable, so
|
||||
* consumers can merge defaults and invoke buildFormData/customProcessor/endpoint
|
||||
* without per-tool type knowledge.
|
||||
*/
|
||||
export type ErasedToolParams = Record<string, unknown>;
|
||||
|
||||
export type RegistryToolOperationConfig = ToolOperationConfig<ErasedToolParams>;
|
||||
export type RegistryAutomationSettings = ComponentType<
|
||||
ToolAutomationSettingsProps<ErasedToolParams>
|
||||
> | null;
|
||||
|
||||
/**
|
||||
* Store a tool's typed operationConfig in the registry. The input is validated as
|
||||
* a real ToolOperationConfig<TParams>, then TParams is erased here. TParams is
|
||||
* invariant in ToolOperationConfig, so the erasure cannot be a plain assignment;
|
||||
* the `as unknown as` is the localized existential boundary.
|
||||
*/
|
||||
export function asRegistryConfig<TParams>(
|
||||
config: ToolOperationConfig<TParams>,
|
||||
): RegistryToolOperationConfig {
|
||||
return config as unknown as RegistryToolOperationConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily load a tool's automation settings component for the registry. The loaded
|
||||
* component is validated against ToolAutomationSettingsProps<TParams> (inferred
|
||||
* from the module), then erased to the registry's shared props shape.
|
||||
*/
|
||||
export function lazySettings<TParams>(
|
||||
loader: () => Promise<{
|
||||
default: ComponentType<ToolAutomationSettingsProps<TParams>>;
|
||||
}>,
|
||||
): RegistryAutomationSettings {
|
||||
return lazy(loader) as unknown as RegistryAutomationSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete tool operation interface returned by useToolOperation.
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,10 @@ import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import {
|
||||
createToolFlow,
|
||||
type MiddleStepConfig,
|
||||
} from "@app/components/tools/shared/createToolFlow";
|
||||
import { createFilesToolStep } from "@app/components/tools/shared/FilesToolStep";
|
||||
import AutomationSelection from "@app/components/tools/automate/AutomationSelection";
|
||||
import AutomationCreation from "@app/components/tools/automate/AutomationCreation";
|
||||
@@ -200,7 +203,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
|
||||
const createStep = (
|
||||
title: string,
|
||||
props: any,
|
||||
props: Omit<MiddleStepConfig, "title" | "content">,
|
||||
content?: React.ReactNode,
|
||||
) => ({
|
||||
title,
|
||||
|
||||
@@ -4,6 +4,11 @@ import { ToolId } from "@app/types/toolId";
|
||||
import { AUTOMATION_CONSTANTS } from "@app/constants/automation";
|
||||
import { AutomationFileProcessor } from "@app/utils/automationFileProcessor";
|
||||
import { ToolType } from "@app/hooks/tools/shared/useToolOperation";
|
||||
import {
|
||||
type ErasedToolParams,
|
||||
type MultiFileToolOperationConfig,
|
||||
type SingleFileToolOperationConfig,
|
||||
} from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
import { processResponse } from "@app/utils/toolResponseProcessor";
|
||||
|
||||
@@ -76,8 +81,8 @@ const executeApiRequest = async (
|
||||
* Execute single-file tool operation (processes files one at a time)
|
||||
*/
|
||||
const executeSingleFileOperation = async (
|
||||
config: any,
|
||||
parameters: any,
|
||||
config: SingleFileToolOperationConfig<ErasedToolParams>,
|
||||
parameters: ErasedToolParams,
|
||||
files: File[],
|
||||
filePrefix: string,
|
||||
): Promise<File[]> => {
|
||||
@@ -89,9 +94,7 @@ const executeSingleFileOperation = async (
|
||||
? config.endpoint(parameters)
|
||||
: config.endpoint;
|
||||
|
||||
const formData = (
|
||||
config.buildFormData as (params: any, file: File) => FormData
|
||||
)(parameters, file);
|
||||
const formData = config.buildFormData(parameters, file);
|
||||
|
||||
const processedFiles = await executeApiRequest(
|
||||
endpoint,
|
||||
@@ -110,8 +113,8 @@ const executeSingleFileOperation = async (
|
||||
* Execute multi-file tool operation (processes all files in one request)
|
||||
*/
|
||||
const executeMultiFileOperation = async (
|
||||
config: any,
|
||||
parameters: any,
|
||||
config: MultiFileToolOperationConfig<ErasedToolParams>,
|
||||
parameters: ErasedToolParams,
|
||||
files: File[],
|
||||
filePrefix: string,
|
||||
): Promise<File[]> => {
|
||||
@@ -120,9 +123,7 @@ const executeMultiFileOperation = async (
|
||||
? config.endpoint(parameters)
|
||||
: config.endpoint;
|
||||
|
||||
const formData = (
|
||||
config.buildFormData as (params: any, files: File[]) => FormData
|
||||
)(parameters, files);
|
||||
const formData = config.buildFormData(parameters, files);
|
||||
|
||||
return await executeApiRequest(
|
||||
endpoint,
|
||||
@@ -138,7 +139,7 @@ const executeMultiFileOperation = async (
|
||||
*/
|
||||
export const executeToolOperation = async (
|
||||
operationName: string,
|
||||
parameters: any,
|
||||
parameters: ErasedToolParams,
|
||||
files: File[],
|
||||
toolRegistry: ToolRegistry,
|
||||
): Promise<File[]> => {
|
||||
@@ -156,7 +157,7 @@ export const executeToolOperation = async (
|
||||
*/
|
||||
export const executeToolOperationWithPrefix = async (
|
||||
operationName: string,
|
||||
parameters: any,
|
||||
parameters: ErasedToolParams,
|
||||
files: File[],
|
||||
toolRegistry: ToolRegistry,
|
||||
filePrefix: string = AUTOMATION_CONSTANTS.FILE_PREFIX,
|
||||
|
||||
@@ -25,20 +25,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-panel__agent-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.4rem 0.75rem 0.4rem 0.4rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 9999px;
|
||||
background: var(--mantine-color-body);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 120ms ease-out,
|
||||
border-color 120ms ease-out;
|
||||
.chat-panel__agent-pill-vt {
|
||||
view-transition-name: stirling-agent;
|
||||
}
|
||||
|
||||
@@ -50,89 +37,6 @@
|
||||
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.chat-panel__agent-pill:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.chat-panel__agent-pill-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--mantine-color-blue-light);
|
||||
color: var(--mantine-color-blue-filled);
|
||||
flex-shrink: 0;
|
||||
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,
|
||||
var(--mantine-color-blue-5) 60%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .chat-panel__agent-pill--loading {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-4) 55%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
}
|
||||
|
||||
/* Dark-mode: let the pill blend into the rail. Just a thin border, no fill —
|
||||
so it doesn't read as a clashing lighter card on the dark toolbar. */
|
||||
[data-mantine-color-scheme="dark"] .chat-panel__agent-pill {
|
||||
background: transparent;
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .chat-panel__agent-pill:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .chat-panel__agent-pill-icon {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-filled) 18%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
|
||||
}
|
||||
|
||||
/* Same treatment for the input pill and quick-action cards. */
|
||||
[data-mantine-color-scheme="dark"] .chat-panel-input {
|
||||
background: transparent;
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Box,
|
||||
Collapse,
|
||||
Group,
|
||||
Menu,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
@@ -25,14 +24,12 @@ import {
|
||||
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||
import ArticleOutlinedIcon from "@mui/icons-material/ArticleOutlined";
|
||||
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
|
||||
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,
|
||||
@@ -45,6 +42,7 @@ import { formatRelativeTime } from "@app/utils/timeUtils";
|
||||
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
|
||||
import { StirlingLogoAnimated } from "@app/components/agents/StirlingLogoAnimated";
|
||||
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
|
||||
import { PanelHeader } from "@shared/components/PanelHeader";
|
||||
import { ChatQuickActions } from "@app/components/chat/ChatQuickActions";
|
||||
import "@app/components/chat/ChatPanel.css";
|
||||
|
||||
@@ -470,47 +468,25 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
|
||||
|
||||
return (
|
||||
<Box className="chat-panel chat-panel--embedded">
|
||||
<div className="chat-panel__header">
|
||||
<Menu shadow="md" width={220} position="bottom-start" withinPortal>
|
||||
<Menu.Target>
|
||||
<button
|
||||
type="button"
|
||||
className={`chat-panel__agent-pill${isLoading ? " chat-panel__agent-pill--loading" : ""}`}
|
||||
aria-label={t("chat.header.agentMenu", "Stirling agent options")}
|
||||
>
|
||||
<span className="chat-panel__agent-pill-icon">
|
||||
<StirlingLogoOutline size={16} />
|
||||
{isLoading && <span className="agent-status-dot" />}
|
||||
</span>
|
||||
<span className="chat-panel__agent-pill-label">
|
||||
{t("agents.stirling_name", "Stirling")}
|
||||
</span>
|
||||
<KeyboardArrowDownIcon
|
||||
sx={{ fontSize: 18, color: "var(--text-muted)" }}
|
||||
/>
|
||||
</button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<DeleteSweepIcon sx={{ fontSize: 18 }} />}
|
||||
onClick={clearChat}
|
||||
disabled={messages.length === 0 && !isLoading}
|
||||
>
|
||||
{t("chat.header.clearChat", "Clear chat")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="md"
|
||||
radius="xl"
|
||||
onClick={onBack}
|
||||
aria-label={backLabel}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<PanelHeader
|
||||
icon={<StirlingLogoOutline size={16} />}
|
||||
title={t("agents.stirling_name", "Stirling")}
|
||||
loading={isLoading}
|
||||
className="chat-panel__header"
|
||||
barClassName="chat-panel__agent-pill-vt"
|
||||
menuLabel={t("chat.header.agentMenu", "Stirling agent options")}
|
||||
menuItems={[
|
||||
{
|
||||
key: "clear-chat",
|
||||
icon: <DeleteSweepIcon sx={{ fontSize: 18 }} />,
|
||||
label: t("chat.header.clearChat", "Clear chat"),
|
||||
onClick: clearChat,
|
||||
disabled: messages.length === 0 && !isLoading,
|
||||
},
|
||||
]}
|
||||
onClose={onBack}
|
||||
closeLabel={backLabel}
|
||||
/>
|
||||
|
||||
{showQuickActions && (
|
||||
<div className="chat-panel-disclaimer chat-panel-disclaimer--banner">
|
||||
|
||||
@@ -8,6 +8,27 @@
|
||||
/* scaffolding + the collapsed rail; spacing snaps to the SUI --space-* */
|
||||
/* scale and colour to the SUI token set so it reads as one product. */
|
||||
|
||||
/* Dark mode only: remap SUI surface/border tokens to the app's neutral-grey values so policy cards read as one product with the rail; accent tokens are left alone. */
|
||||
[data-theme="dark"] .pol-list,
|
||||
[data-theme="dark"] .pol-takeover,
|
||||
[data-theme="dark"] .pol-detail,
|
||||
[data-theme="dark"] .pol-crail {
|
||||
--color-bg: var(--bg-toolbar);
|
||||
--color-bg-alt: var(--bg-toolbar);
|
||||
--color-bg-subtle: var(--bg-toolbar);
|
||||
--color-surface: var(--bg-surface);
|
||||
--color-surface-alt: #323942;
|
||||
--color-bg-hover: #323942;
|
||||
--color-bg-muted: var(--bg-surface);
|
||||
--color-border: var(--border-default);
|
||||
--color-border-light: var(--border-subtle);
|
||||
--color-border-input: var(--border-strong);
|
||||
--color-border-hover: var(--border-strong);
|
||||
--color-divider: var(--border-subtle);
|
||||
--color-dropdown-bg: var(--bg-surface);
|
||||
--color-dropdown-border: var(--border-default);
|
||||
}
|
||||
|
||||
/* ---- List ---- */
|
||||
.pol-list {
|
||||
width: 100%;
|
||||
@@ -69,6 +90,19 @@
|
||||
outline: 2px solid var(--color-blue);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
/* Policy icons are colourless at rest; hovering or focusing the row reveals the
|
||||
category colour (blue/purple/green/amber/red — see ROW_ACCENT). The accent
|
||||
class sets --ib-base; we neutralise --ib-accent here and restore it on hover. */
|
||||
.pol-row .sui-iconbadge {
|
||||
--ib-accent: var(--color-text-3);
|
||||
transition:
|
||||
color var(--motion-fast),
|
||||
background var(--motion-fast);
|
||||
}
|
||||
.pol-row:hover .sui-iconbadge,
|
||||
.pol-row:focus-visible .sui-iconbadge {
|
||||
--ib-accent: var(--ib-base);
|
||||
}
|
||||
.pol-row-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -126,20 +160,28 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Locked "Coming soon" row — muted, not interactive. */
|
||||
/* Enterprise-only ("coming soon") row — shown to admins / team leaders but not
|
||||
available on the current plan, so the whole box is dimmed to read as disabled.
|
||||
The row itself isn't a button; its trailing "Upgrade to enterprise" link is. */
|
||||
.pol-row--soon {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.pol-row--soon:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.pol-row-soon {
|
||||
/* Trailing "Upgrade to enterprise" link → contact us. Greyed to match the
|
||||
disabled row; still clickable for admins who want to enquire. */
|
||||
.pol-row-upgrade {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-4);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pol-row-upgrade:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* In-progress activity icon spins gently. */
|
||||
.pol-spin {
|
||||
@@ -192,9 +234,17 @@
|
||||
|
||||
/* ---- Step indicator (wraps a SUI StepIndicator) ---- */
|
||||
.pol-steps {
|
||||
padding: var(--space-3) var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0 var(--space-5) var(--space-3);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.pol-step-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ---- Scroll body ---- */
|
||||
.pol-scroll {
|
||||
@@ -211,6 +261,7 @@
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-4);
|
||||
margin: 0;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.pol-section-label {
|
||||
font-size: 0.6875rem;
|
||||
|
||||
@@ -169,11 +169,11 @@ describe("Policies right-sidebar surface", () => {
|
||||
expect(await screen.findByText("No activity yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns to the list via the back button", () => {
|
||||
it("returns to the list via the close button", () => {
|
||||
renderHost();
|
||||
fireEvent.click(screen.getByText("Security"));
|
||||
expect(screen.getByText("Enforces")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText("Back"));
|
||||
fireEvent.click(screen.getByLabelText("Close"));
|
||||
expect(screen.getByText("Policies")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,16 @@ export function usePoliciesEnabled(): boolean {
|
||||
return POLICIES_ENABLED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the right rail should show the Policies section.
|
||||
*/
|
||||
export function usePoliciesVisible(): boolean {
|
||||
const pol = usePolicies();
|
||||
const { categories } = usePolicyCatalog();
|
||||
if (!POLICIES_ENABLED) return false;
|
||||
return pol.canConfigure || categories.some((c) => !c.comingSoon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current user is a guest who can't open or configure policies —
|
||||
* an anonymous user on a login-enabled deployment (i.e. a SaaS sign-up prompt
|
||||
@@ -122,6 +132,14 @@ export function PoliciesSection({
|
||||
|
||||
if (!POLICIES_ENABLED) return null;
|
||||
|
||||
// Admins / team leads see the full catalogue (coming-soon rows greyed as an
|
||||
// enterprise upsell); regular users only see the live policies — the
|
||||
// coming-soon "Upgrade to enterprise" rows are hidden from them.
|
||||
const visibleCategories = pol.canConfigure
|
||||
? categories
|
||||
: categories.filter((c) => !c.comingSoon);
|
||||
if (visibleCategories.length === 0) return null;
|
||||
|
||||
// The header tally counts every CONFIGURED policy (active + paused), not just
|
||||
// the active ones.
|
||||
const configuredCount = categories.filter(
|
||||
@@ -170,14 +188,10 @@ export function PoliciesSection({
|
||||
{expanded && (
|
||||
<>
|
||||
<div className="pol-list-rows">
|
||||
{categories.map((cat) => {
|
||||
{visibleCategories.map((cat) => {
|
||||
if (cat.comingSoon) {
|
||||
return (
|
||||
<div
|
||||
key={cat.id}
|
||||
className="pol-row pol-row--soon"
|
||||
aria-disabled="true"
|
||||
>
|
||||
<div key={cat.id} className="pol-row pol-row--soon">
|
||||
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
|
||||
{cat.icon}
|
||||
</IconBadge>
|
||||
@@ -185,12 +199,17 @@ export function PoliciesSection({
|
||||
{t(`policies.catalog.${cat.id}`, cat.label)}
|
||||
</span>
|
||||
<span className="pol-row-trail">
|
||||
<span className="pol-row-soon">
|
||||
<a
|
||||
className="pol-row-upgrade"
|
||||
href="https://stirling.com/contact"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t(
|
||||
"policies.sidebar.upgradeToEnterprise",
|
||||
"Upgrade to enterprise",
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -457,58 +476,64 @@ export function PoliciesCollapsedButton({
|
||||
|
||||
if (!POLICIES_ENABLED) return null;
|
||||
|
||||
// Coming-soon policies are excluded; admins see all real policies, others only see configured ones — renders nothing when empty.
|
||||
const railCategories = categories.filter((cat) => {
|
||||
if (cat.comingSoon) return false;
|
||||
if (pol.canConfigure) return true;
|
||||
return pol.policies[cat.id]?.configured;
|
||||
});
|
||||
if (railCategories.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pol-crail">
|
||||
{categories
|
||||
.filter((cat) => !cat.comingSoon)
|
||||
.map((cat) => {
|
||||
const status = deriveRowStatus(pol.policies[cat.id]);
|
||||
const label = t(`policies.catalog.${cat.id}`, cat.label);
|
||||
const statusLabel = t(
|
||||
`policies.status.${status}`,
|
||||
STATUS_LABEL[status],
|
||||
);
|
||||
const suffix =
|
||||
status === "active"
|
||||
? t("policies.sidebar.railSuffixActive", " (Active)")
|
||||
: status === "paused"
|
||||
? t("policies.sidebar.railSuffixPaused", " (Paused)")
|
||||
: "";
|
||||
return (
|
||||
<AppTooltip
|
||||
key={cat.id}
|
||||
content={`${label}${suffix}`}
|
||||
position="left"
|
||||
arrow
|
||||
delay={300}
|
||||
{railCategories.map((cat) => {
|
||||
const status = deriveRowStatus(pol.policies[cat.id]);
|
||||
const label = t(`policies.catalog.${cat.id}`, cat.label);
|
||||
const statusLabel = t(
|
||||
`policies.status.${status}`,
|
||||
STATUS_LABEL[status],
|
||||
);
|
||||
const suffix =
|
||||
status === "active"
|
||||
? t("policies.sidebar.railSuffixActive", " (Active)")
|
||||
: status === "paused"
|
||||
? t("policies.sidebar.railSuffixPaused", " (Paused)")
|
||||
: "";
|
||||
return (
|
||||
<AppTooltip
|
||||
key={cat.id}
|
||||
content={`${label}${suffix}`}
|
||||
position="left"
|
||||
arrow
|
||||
delay={300}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="pol-crail-btn"
|
||||
data-status={status}
|
||||
aria-label={t(
|
||||
"policies.sidebar.railAriaLabel",
|
||||
"{{label}} policy — {{status}}",
|
||||
{ label, status: statusLabel },
|
||||
)}
|
||||
onClick={() => {
|
||||
if (guestBlocked) {
|
||||
promptGuestSignup();
|
||||
return;
|
||||
}
|
||||
selectPolicy(cat.id);
|
||||
onExpand();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="pol-crail-btn"
|
||||
data-status={status}
|
||||
aria-label={t(
|
||||
"policies.sidebar.railAriaLabel",
|
||||
"{{label}} policy — {{status}}",
|
||||
{ label, status: statusLabel },
|
||||
)}
|
||||
onClick={() => {
|
||||
if (guestBlocked) {
|
||||
promptGuestSignup();
|
||||
return;
|
||||
}
|
||||
selectPolicy(cat.id);
|
||||
onExpand();
|
||||
}}
|
||||
>
|
||||
{cat.icon}
|
||||
{(status === "active" || status === "paused") && (
|
||||
<span className="pol-crail-dot" data-status={status} />
|
||||
)}
|
||||
</button>
|
||||
</AppTooltip>
|
||||
);
|
||||
})}
|
||||
{cat.icon}
|
||||
{(status === "active" || status === "paused") && (
|
||||
<span className="pol-crail-dot" data-status={status} />
|
||||
)}
|
||||
</button>
|
||||
</AppTooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="tool-panel__collapsed-divider" />
|
||||
</>
|
||||
|
||||
@@ -132,9 +132,10 @@ export function PolicyDetailPanel({
|
||||
<div className="pol-detail">
|
||||
<PanelHeader
|
||||
icon={category.icon}
|
||||
iconAccent={ROW_ACCENT[category.id] ?? "blue"}
|
||||
accent={ROW_ACCENT[category.id]}
|
||||
title={t(`policies.catalog.${category.id}`, category.label)}
|
||||
onBack={onBack}
|
||||
onClose={onBack}
|
||||
closeLabel={t("policies.detail.close", "Close")}
|
||||
actions={
|
||||
<StatusBadge
|
||||
tone={isPaused ? "warning" : "success"}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import { PanelHeader } from "@shared/components/PanelHeader";
|
||||
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
|
||||
@@ -159,7 +158,7 @@ export function PolicySetupWizard({
|
||||
<div className="pol-detail">
|
||||
<PanelHeader
|
||||
icon={category.icon}
|
||||
iconAccent={ROW_ACCENT[category.id] ?? "blue"}
|
||||
accent={ROW_ACCENT[category.id]}
|
||||
title={
|
||||
isEdit
|
||||
? t("policies.wizard.editTitle", "Edit {{label}} Policy", {
|
||||
@@ -169,7 +168,8 @@ export function PolicySetupWizard({
|
||||
label: t(`policies.catalog.${category.id}`, category.label),
|
||||
})
|
||||
}
|
||||
onBack={onCancel}
|
||||
onClose={onCancel}
|
||||
closeLabel={t("policies.wizard.close", "Close")}
|
||||
/>
|
||||
<div className="pol-scroll">
|
||||
<EmptyState
|
||||
@@ -299,7 +299,7 @@ export function PolicySetupWizard({
|
||||
<div className="pol-detail">
|
||||
<PanelHeader
|
||||
icon={category.icon}
|
||||
iconAccent={ROW_ACCENT[category.id] ?? "blue"}
|
||||
accent={ROW_ACCENT[category.id]}
|
||||
title={
|
||||
isEdit
|
||||
? t("policies.wizard.editTitle", "Edit {{label}} Policy", {
|
||||
@@ -309,23 +309,17 @@ export function PolicySetupWizard({
|
||||
label: t(`policies.catalog.${category.id}`, category.label),
|
||||
})
|
||||
}
|
||||
subtitle={t("policies.wizard.stepOf", "Step {{step}} of {{total}}", {
|
||||
step,
|
||||
total: TOTAL_STEPS,
|
||||
})}
|
||||
onBack={back}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={t("cancel", "Cancel")}
|
||||
onClick={onCancel}
|
||||
leadingIcon={<CloseIcon sx={{ fontSize: "1.1rem" }} />}
|
||||
/>
|
||||
}
|
||||
onClose={onCancel}
|
||||
closeLabel={t("cancel", "Cancel")}
|
||||
/>
|
||||
|
||||
<div className="pol-steps">
|
||||
<span className="pol-step-label">
|
||||
{t("policies.wizard.stepOf", "Step {{step}} of {{total}}", {
|
||||
step,
|
||||
total: TOTAL_STEPS,
|
||||
})}
|
||||
</span>
|
||||
<StepIndicator total={TOTAL_STEPS} current={step} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ export const STATUS_LABEL: Record<PolicyRowStatus, string> = {
|
||||
setup: "Set up",
|
||||
};
|
||||
|
||||
/** A soft tinted icon tile per category — gives each policy a calm identity colour. */
|
||||
/**
|
||||
* Per-category accent colour
|
||||
*/
|
||||
export const ROW_ACCENT: Record<string, IconBadgeAccent> = {
|
||||
ingestion: "blue",
|
||||
security: "purple",
|
||||
|
||||
+7
-3
@@ -55,6 +55,9 @@ export default function TeamDetailsSection({
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>("");
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string>("");
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const availableUsersForTeam = team
|
||||
? availableUsers.filter((user) => user.team?.id !== team.id)
|
||||
: [];
|
||||
|
||||
// License information
|
||||
const [licenseInfo, setLicenseInfo] = useState<{
|
||||
@@ -725,7 +728,7 @@ export default function TeamDetailsSection({
|
||||
placeholder={t(
|
||||
"workspace.teams.addMemberToTeam.selectUserPlaceholder",
|
||||
)}
|
||||
data={availableUsers.map((user) => ({
|
||||
data={availableUsersForTeam.map((user) => ({
|
||||
value: user.id.toString(),
|
||||
label: `${user.username}${user.team ? ` (${t("workspace.teams.addMemberToTeam.currentlyIn")} ${user.team.name})` : ""}`,
|
||||
}))}
|
||||
@@ -739,8 +742,9 @@ export default function TeamDetailsSection({
|
||||
/>
|
||||
|
||||
{selectedUserId &&
|
||||
availableUsers.find((u) => u.id.toString() === selectedUserId)
|
||||
?.team && (
|
||||
availableUsersForTeam.find(
|
||||
(u) => u.id.toString() === selectedUserId,
|
||||
)?.team && (
|
||||
<Text size="xs" c="orange">
|
||||
{t("workspace.teams.addMemberToTeam.willBeMoved")}
|
||||
</Text>
|
||||
|
||||
+7
-3
@@ -46,6 +46,9 @@ export default function TeamsSection() {
|
||||
const [newTeamName, setNewTeamName] = useState("");
|
||||
const [renameTeamName, setRenameTeamName] = useState("");
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>("");
|
||||
const availableUsersForSelectedTeam = selectedTeam
|
||||
? availableUsers.filter((user) => user.team?.id !== selectedTeam.id)
|
||||
: [];
|
||||
|
||||
useEffect(() => {
|
||||
fetchTeams();
|
||||
@@ -604,7 +607,7 @@ export default function TeamsSection() {
|
||||
placeholder={t(
|
||||
"workspace.teams.addMemberToTeam.selectUserPlaceholder",
|
||||
)}
|
||||
data={availableUsers.map((user) => ({
|
||||
data={availableUsersForSelectedTeam.map((user) => ({
|
||||
value: user.id.toString(),
|
||||
label: `${user.username}${user.team ? ` (${t("workspace.teams.addMemberToTeam.currentlyIn")} ${user.team.name})` : ""}`,
|
||||
}))}
|
||||
@@ -618,8 +621,9 @@ export default function TeamsSection() {
|
||||
/>
|
||||
|
||||
{selectedUserId &&
|
||||
availableUsers.find((u) => u.id.toString() === selectedUserId)
|
||||
?.team && (
|
||||
availableUsersForSelectedTeam.find(
|
||||
(u) => u.id.toString() === selectedUserId,
|
||||
)?.team && (
|
||||
<Text size="xs" c="orange">
|
||||
{t("workspace.teams.addMemberToTeam.willBeMoved")}
|
||||
</Text>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type PrototypeToolRegistry,
|
||||
} from "@app/data/toolsTaxonomy";
|
||||
import { pdfCommentAgentOperationConfig } from "@app/hooks/tools/pdfCommentAgent/pdfCommentAgentOperationConfig";
|
||||
import { asRegistryConfig } from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import PdfCommentAgent from "@app/tools/PdfCommentAgent";
|
||||
import { getSynonyms } from "@app/utils/toolSynonyms";
|
||||
|
||||
@@ -42,7 +43,7 @@ export function usePrototypeToolRegistry(): PrototypeToolRegistry {
|
||||
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
|
||||
maxFiles: 1,
|
||||
endpoints: ["pdf-comment-agent"],
|
||||
operationConfig: pdfCommentAgentOperationConfig,
|
||||
operationConfig: asRegistryConfig(pdfCommentAgentOperationConfig),
|
||||
automationSettings: null,
|
||||
synonyms: getSynonyms(t, "pdfCommentAgent"),
|
||||
versionStatus: "beta",
|
||||
|
||||
@@ -214,10 +214,8 @@ export default defineConfig(
|
||||
ignores: [
|
||||
"editor/src/core/components/**/*.{js,mjs,jsx,ts,tsx}",
|
||||
"editor/src/core/contexts/**/*.{js,mjs,jsx,ts,tsx}",
|
||||
"editor/src/core/data/**/*.{js,mjs,jsx,ts,tsx}",
|
||||
"editor/src/core/hooks/**/*.{js,mjs,jsx,ts,tsx}",
|
||||
"editor/src/core/services/**/*.{js,mjs,jsx,ts,tsx}",
|
||||
"editor/src/core/tools/Automate.tsx",
|
||||
"editor/src/core/tools/annotate/useAnnotationSelection.ts",
|
||||
"editor/src/core/types/**/*.{js,mjs,jsx,ts,tsx}",
|
||||
"editor/src/core/utils/**/*.{js,mjs,jsx,ts,tsx}",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
flex-shrink: 0;
|
||||
/* Resolved tint. Each accent class sets --ib-base; --ib-accent defaults to it
|
||||
but a consumer can override --ib-accent alone (e.g. to neutralise the badge
|
||||
until hover) without losing the per-accent base. */
|
||||
--ib-accent: var(--ib-base, var(--color-blue));
|
||||
color: var(--ib-accent);
|
||||
background: color-mix(in srgb, var(--ib-accent) 14%, transparent);
|
||||
}
|
||||
.sui-iconbadge--sm {
|
||||
width: 1.75rem;
|
||||
@@ -14,22 +20,17 @@
|
||||
height: 2rem;
|
||||
}
|
||||
.sui-iconbadge--blue {
|
||||
color: var(--color-blue);
|
||||
background: color-mix(in srgb, var(--color-blue) 14%, transparent);
|
||||
--ib-base: var(--color-blue);
|
||||
}
|
||||
.sui-iconbadge--purple {
|
||||
color: var(--color-purple);
|
||||
background: color-mix(in srgb, var(--color-purple) 14%, transparent);
|
||||
--ib-base: var(--color-purple);
|
||||
}
|
||||
.sui-iconbadge--green {
|
||||
color: var(--color-green);
|
||||
background: color-mix(in srgb, var(--color-green) 14%, transparent);
|
||||
--ib-base: var(--color-green);
|
||||
}
|
||||
.sui-iconbadge--amber {
|
||||
color: var(--color-amber);
|
||||
background: color-mix(in srgb, var(--color-amber) 14%, transparent);
|
||||
--ib-base: var(--color-amber);
|
||||
}
|
||||
.sui-iconbadge--red {
|
||||
color: var(--color-red);
|
||||
background: color-mix(in srgb, var(--color-red) 14%, transparent);
|
||||
--ib-base: var(--color-red);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,168 @@
|
||||
/* ===================== PanelHeader ===================== */
|
||||
/* The header shared by the active-tool panel, the AI chat panel and the */
|
||||
/* Policies detail/wizard. Mirrors the AI chat header treatment so it reads well */
|
||||
/* in both light and dark mode (thin border, no heavy fill). */
|
||||
|
||||
.sui-panelhdr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.sui-panelhdr__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.sui-panelhdr__back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1rem 0.75rem;
|
||||
flex-shrink: 0;
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-2);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast),
|
||||
border-color var(--motion-fast);
|
||||
}
|
||||
.sui-panelhdr__back:hover {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-text-4);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.sui-panelhdr__text {
|
||||
min-width: 0;
|
||||
}
|
||||
.sui-panelhdr__title {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.sui-panelhdr__sub {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-4);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.sui-panelhdr__actions {
|
||||
|
||||
.sui-panelhdr__bar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.4rem 0.75rem 0.4rem 0.4rem;
|
||||
border: 1px solid var(--border-subtle, var(--mantine-color-default-border));
|
||||
border-radius: 9999px;
|
||||
background: var(--mantine-color-body);
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Only the menu-trigger variant is interactive. */
|
||||
button.sui-panelhdr__bar {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 120ms ease-out,
|
||||
border-color 120ms ease-out;
|
||||
}
|
||||
|
||||
button.sui-panelhdr__bar:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.sui-panelhdr__icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--mantine-color-blue-light);
|
||||
color: var(--mantine-color-blue-filled);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sui-panelhdr__icon svg {
|
||||
font-size: 1rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/* ToolIcon wraps its glyph in .tool-button-icon with its own margin/transform;
|
||||
reset them so the glyph sits dead-centre in the circular badge. */
|
||||
.sui-panelhdr__icon .tool-button-icon {
|
||||
margin: 0 !important;
|
||||
transform: none !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sui-panelhdr__label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.sui-panelhdr__chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
/* Right-aligned content inside the bar (e.g. a status badge); the flexible
|
||||
label pushes it to the trailing edge of the pill. */
|
||||
.sui-panelhdr__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Running / in-progress status dot, anchored to the header icon. */
|
||||
.sui-panelhdr__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: sui-panelhdr-dot-pulse 2.4s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes sui-panelhdr-dot-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sui-panelhdr__dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sui-panelhdr__bar--loading {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-5) 60%,
|
||||
var(--border-subtle, var(--mantine-color-default-border))
|
||||
);
|
||||
}
|
||||
|
||||
/* Dark mode: let the header blend into the rail — just a thin border, no fill —
|
||||
so it doesn't read as a clashing lighter card on the dark toolbar. */
|
||||
[data-mantine-color-scheme="dark"] .sui-panelhdr__bar {
|
||||
background: transparent;
|
||||
border-color: var(--border-subtle, var(--mantine-color-default-border));
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] button.sui-panelhdr__bar:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .sui-panelhdr__bar--loading {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-4) 55%,
|
||||
var(--border-subtle, var(--mantine-color-default-border))
|
||||
);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .sui-panelhdr__icon {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-filled) 18%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
|
||||
}
|
||||
|
||||
/* Dark mode: the subtle gray close button is too dim against the dark rail —
|
||||
brighten it to a clearly-visible light grey (near-white on hover). */
|
||||
[data-mantine-color-scheme="dark"] .sui-panelhdr__close {
|
||||
color: var(--mantine-color-gray-4);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .sui-panelhdr__close:hover {
|
||||
color: var(--mantine-color-gray-2);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
|
||||
import { PanelHeader } from "@shared/components/PanelHeader";
|
||||
import { Button } from "@shared/components/Button";
|
||||
import { StatusBadge } from "@shared/components/StatusBadge";
|
||||
|
||||
const meta: Meta<typeof PanelHeader> = {
|
||||
@@ -9,53 +10,53 @@ const meta: Meta<typeof PanelHeader> = {
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
title: "Pipeline detail",
|
||||
subtitle: "COI Compliance · us-east-1",
|
||||
icon: <ShieldOutlinedIcon sx={{ fontSize: "1rem" }} />,
|
||||
title: "Security",
|
||||
closeLabel: "Close",
|
||||
},
|
||||
argTypes: { onBack: { action: "back" } },
|
||||
argTypes: { onClose: { action: "close" } },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PanelHeader>;
|
||||
|
||||
/** Toggle title / subtitle / onBack / actions in controls. */
|
||||
/** Plain header pill with a trailing close button. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const WithActions: Story = {
|
||||
/** Category-accented icon badge (blue / purple / green / amber / red). */
|
||||
export const Accented: Story = {
|
||||
args: { accent: "purple" },
|
||||
};
|
||||
|
||||
/** Dropdown trigger — a disclosure chevron appears and clicking the pill opens
|
||||
* the menu (e.g. the chat header's "Clear chat"). */
|
||||
export const WithMenu: Story = {
|
||||
args: {
|
||||
subtitle: "Last deploy 14m ago · golden set 48/48",
|
||||
actions: (
|
||||
<>
|
||||
<StatusBadge tone="success" pulse>
|
||||
Healthy
|
||||
</StatusBadge>
|
||||
<Button size="sm" variant="outline">
|
||||
Edit composition
|
||||
</Button>
|
||||
<Button size="sm" variant="gradient">
|
||||
View runs
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
title: "Stirling",
|
||||
menuLabel: "Stirling agent options",
|
||||
menuItems: [
|
||||
{
|
||||
key: "clear",
|
||||
icon: <DeleteSweepIcon sx={{ fontSize: 18 }} />,
|
||||
label: "Clear chat",
|
||||
onClick: () => {},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Everything: Story = {
|
||||
/** Loading state — pulsing status dot on the icon + a tinted border. */
|
||||
export const Loading: Story = {
|
||||
args: { title: "Stirling", loading: true },
|
||||
};
|
||||
|
||||
/** Right-aligned actions rendered before the close button. */
|
||||
export const WithActions: Story = {
|
||||
args: {
|
||||
title: "Pipeline detail — COI Compliance",
|
||||
subtitle: "Forked from Compliance Pack · 1,287 docs / 24h",
|
||||
onBack: () => {},
|
||||
accent: "purple",
|
||||
actions: (
|
||||
<>
|
||||
<StatusBadge tone="success" pulse>
|
||||
Healthy
|
||||
</StatusBadge>
|
||||
<Button size="sm" variant="outline">
|
||||
Edit composition
|
||||
</Button>
|
||||
<Button size="sm" variant="gradient">
|
||||
View runs
|
||||
</Button>
|
||||
</>
|
||||
<StatusBadge tone="success" showDot pulse>
|
||||
Active
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,73 +1,152 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import type { IconBadgeAccent } from "@shared/components/IconBadge";
|
||||
import "@shared/components/PanelHeader.css";
|
||||
import { IconBadge } from "@shared/components/IconBadge";
|
||||
|
||||
export interface PanelHeaderMenuItem {
|
||||
/** Stable key; falls back to the item index. */
|
||||
key?: string;
|
||||
/** Optional leading glyph. */
|
||||
icon?: ReactNode;
|
||||
label: ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PanelHeaderProps {
|
||||
/** Glyph rendered in the tinted circular badge at the header's leading edge. */
|
||||
icon: ReactNode;
|
||||
/** Header title. */
|
||||
title: ReactNode;
|
||||
/** Sub-heading below the title. */
|
||||
subtitle?: ReactNode;
|
||||
/** Show a back chevron and trigger this callback when clicked. */
|
||||
onBack?: () => void;
|
||||
/** Optional leading visual (e.g. a category glyph) shown in a tinted box. */
|
||||
icon?: ReactNode;
|
||||
/** Accent tint for the leading icon box. Defaults to blue. */
|
||||
iconAccent?: "blue" | "purple" | "green" | "amber" | "red";
|
||||
/** Right-aligned action buttons / chips. */
|
||||
/** Close (X) handler. The trailing close button renders only when supplied. */
|
||||
onClose?: () => void;
|
||||
/** aria-label for the close button. */
|
||||
closeLabel?: string;
|
||||
/**
|
||||
* When provided, the header becomes a dropdown trigger: a disclosure chevron is
|
||||
* shown and clicking it opens a menu of these items (e.g. "Clear chat").
|
||||
*/
|
||||
menuItems?: PanelHeaderMenuItem[];
|
||||
/** aria-label for the header when it acts as a menu trigger. */
|
||||
menuLabel?: string;
|
||||
/**
|
||||
* Tints the icon badge with a category colour (blue/purple/green/amber/red).
|
||||
* Defaults to the standard blue when omitted (tool + AI chat headers).
|
||||
*/
|
||||
accent?: IconBadgeAccent;
|
||||
/** Shows a pulsing status dot on the icon + a tinted border (e.g. AI running). */
|
||||
loading?: boolean;
|
||||
/** Right-aligned content rendered inside the header bar, after the title
|
||||
* (e.g. a status badge). */
|
||||
actions?: ReactNode;
|
||||
/** Applied to the inner header element — e.g. to set a view-transition-name. */
|
||||
barClassName?: string;
|
||||
/** Applied to the outer header container. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header strip used by drill-down panels (admin tabs, agent detail, settings
|
||||
* sub-pages). Back chevron renders only when `onBack` is supplied; an optional
|
||||
* leading `icon` renders in a tinted box before the title.
|
||||
* The header shared by the rail surfaces — the active tool panel, the AI chat
|
||||
* panel, and the Policies detail/wizard. A tinted icon badge + title sit in a
|
||||
* rounded bar, with an optional dropdown menu and a trailing close button. The
|
||||
* styling stays legible in dark mode (thin border, no heavy fill) across every
|
||||
* surface.
|
||||
*/
|
||||
export function PanelHeader({
|
||||
title,
|
||||
subtitle,
|
||||
onBack,
|
||||
icon,
|
||||
iconAccent = "blue",
|
||||
title,
|
||||
onClose,
|
||||
closeLabel,
|
||||
menuItems,
|
||||
menuLabel,
|
||||
accent,
|
||||
loading = false,
|
||||
actions,
|
||||
barClassName,
|
||||
className,
|
||||
}: PanelHeaderProps) {
|
||||
const hasMenu = menuItems != null && menuItems.length > 0;
|
||||
|
||||
// Tint the icon badge with the category colour when an accent is given. Inline
|
||||
// so it wins over the default blue treatment in both light and dark mode; the
|
||||
// --color-* tokens are theme-aware and match the badge tint used elsewhere.
|
||||
const iconStyle: CSSProperties | undefined = accent
|
||||
? {
|
||||
color: `var(--color-${accent})`,
|
||||
background: `color-mix(in srgb, var(--color-${accent}) 14%, transparent)`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const barClasses = [
|
||||
"sui-panelhdr__bar",
|
||||
loading ? "sui-panelhdr__bar--loading" : "",
|
||||
barClassName ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const barBody = (
|
||||
<>
|
||||
<span className="sui-panelhdr__icon" style={iconStyle}>
|
||||
{icon}
|
||||
{loading && <span className="sui-panelhdr__dot" />}
|
||||
</span>
|
||||
<span className="sui-panelhdr__label">{title}</span>
|
||||
{actions != null && (
|
||||
<span className="sui-panelhdr__actions">{actions}</span>
|
||||
)}
|
||||
{hasMenu && (
|
||||
<KeyboardArrowDownIcon
|
||||
className="sui-panelhdr__chevron"
|
||||
sx={{ fontSize: 18 }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={["sui-panelhdr", className ?? ""].filter(Boolean).join(" ")}
|
||||
>
|
||||
<div className="sui-panelhdr__left">
|
||||
{onBack && (
|
||||
<button
|
||||
type="button"
|
||||
className="sui-panelhdr__back"
|
||||
onClick={onBack}
|
||||
aria-label="Back"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={18}
|
||||
height={18}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{icon && (
|
||||
<IconBadge accent={iconAccent} size="md">
|
||||
{icon}
|
||||
</IconBadge>
|
||||
)}
|
||||
<div className="sui-panelhdr__text">
|
||||
<div className="sui-panelhdr__title">{title}</div>
|
||||
{subtitle && <div className="sui-panelhdr__sub">{subtitle}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="sui-panelhdr__actions">{actions}</div>}
|
||||
{hasMenu ? (
|
||||
<Menu shadow="md" width={220} position="bottom-start" withinPortal>
|
||||
<Menu.Target>
|
||||
<button type="button" className={barClasses} aria-label={menuLabel}>
|
||||
{barBody}
|
||||
</button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{(menuItems ?? []).map((item, i) => (
|
||||
<Menu.Item
|
||||
key={item.key ?? i}
|
||||
leftSection={item.icon}
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
) : (
|
||||
<div className={barClasses}>{barBody}</div>
|
||||
)}
|
||||
|
||||
{onClose && (
|
||||
<ActionIcon
|
||||
className="sui-panelhdr__close"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
aria-label={closeLabel}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user