From 8e485801c98050b4673dc8aa2f785ec1700df6bc Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:57:17 +0100 Subject: [PATCH] change policies ui (#6683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Removed colors from policies to make them look more professional. • upgraded to enterprise link to contact us. • Hid inactive policies from users (Kept for admin and team lead). • Closing policies had wrong arrow, made a standard component for chat, tools and policies header. --- .../public/locales/en-US/translation.toml | 2 + .../components/policies/PoliciesSidebar.tsx | 5 + .../core/components/tools/RightSidebar.tsx | 130 +++++------ .../src/core/components/tools/ToolPanel.css | 61 ------ .../proprietary/components/chat/ChatPanel.css | 98 +-------- .../proprietary/components/chat/ChatPanel.tsx | 64 ++---- .../components/policies/Policies.css | 61 +++++- .../policies/PoliciesSidebar.test.tsx | 4 +- .../components/policies/PoliciesSidebar.tsx | 137 +++++++----- .../components/policies/PolicyDetailPanel.tsx | 5 +- .../components/policies/PolicySetupWizard.tsx | 30 ++- .../components/policies/policyStatus.ts | 4 +- frontend/shared/components/IconBadge.css | 21 +- frontend/shared/components/PanelHeader.css | 204 ++++++++++++++---- .../shared/components/PanelHeader.stories.tsx | 71 +++--- frontend/shared/components/PanelHeader.tsx | 181 +++++++++++----- 16 files changed, 591 insertions(+), 487 deletions(-) diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 0bfb5749ce..84f9c26de5 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx index d787c6435f..9d9cbed2e2 100644 --- a/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx +++ b/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx @@ -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. diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx index 494553a690..bfd98d7b32 100644 --- a/frontend/editor/src/core/components/tools/RightSidebar.tsx +++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx @@ -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() { ) : ( <> - {!showPolicies && ( -
- {activeTool ? ( -
- - - - - {activeTool.name} - -
- ) : showHeaderSearch ? ( -
- -
- ) : null} - {showCloseButton ? ( - - - - ) : ( - - - - )} -
- )} + } + title={activeTool.name} + onClose={handleHeaderBack} + closeLabel={ + inToolView + ? t("toolPanel.backToAllTools", "Back to all tools") + : t("toolPanel.goBack", "Go back") + } + /> + ) : ( +
+ {showHeaderSearch ? ( +
+ +
+ ) : null} + {showCloseButton ? ( + + + + ) : ( + + + + )} +
+ ))} {showPolicies && ( -
- - - - - - } - onClick={clearChat} - disabled={messages.length === 0 && !isLoading} - > - {t("chat.header.clearChat", "Clear chat")} - - - - - - -
+ } + 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: , + label: t("chat.header.clearChat", "Clear chat"), + onClick: clearChat, + disabled: messages.length === 0 && !isLoading, + }, + ]} + onClose={onBack} + closeLabel={backLabel} + /> {showQuickActions && (
diff --git a/frontend/editor/src/proprietary/components/policies/Policies.css b/frontend/editor/src/proprietary/components/policies/Policies.css index 637e30d380..07fcb1865f 100644 --- a/frontend/editor/src/proprietary/components/policies/Policies.css +++ b/frontend/editor/src/proprietary/components/policies/Policies.css @@ -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; diff --git a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx index c05680cfb7..2ab524b495 100644 --- a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx @@ -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(); }); }); diff --git a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx index 8b6e551cde..0e87d9f982 100644 --- a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx +++ b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx @@ -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 && ( <>
- {categories.map((cat) => { + {visibleCategories.map((cat) => { if (cat.comingSoon) { return ( -
+
{cat.icon} @@ -185,12 +199,17 @@ export function PoliciesSection({ {t(`policies.catalog.${cat.id}`, cat.label)} - + {t( "policies.sidebar.upgradeToEnterprise", "Upgrade to enterprise", )} - +
); @@ -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 ( <>
- {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 ( - { + 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 ( + + - - ); - })} + {cat.icon} + {(status === "active" || status === "paused") && ( + + )} + + + ); + })}
diff --git a/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx b/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx index 114f1ac16b..aed4062869 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx @@ -132,9 +132,10 @@ export function PolicyDetailPanel({
} - /> - } + onClose={onCancel} + closeLabel={t("cancel", "Cancel")} />
+ + {t("policies.wizard.stepOf", "Step {{step}} of {{total}}", { + step, + total: TOTAL_STEPS, + })} +
diff --git a/frontend/editor/src/proprietary/components/policies/policyStatus.ts b/frontend/editor/src/proprietary/components/policies/policyStatus.ts index d3c3d8ea7b..fab615ab25 100644 --- a/frontend/editor/src/proprietary/components/policies/policyStatus.ts +++ b/frontend/editor/src/proprietary/components/policies/policyStatus.ts @@ -17,7 +17,9 @@ export const STATUS_LABEL: Record = { 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 = { ingestion: "blue", security: "purple", diff --git a/frontend/shared/components/IconBadge.css b/frontend/shared/components/IconBadge.css index 2a510b282f..10e2d28915 100644 --- a/frontend/shared/components/IconBadge.css +++ b/frontend/shared/components/IconBadge.css @@ -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); } diff --git a/frontend/shared/components/PanelHeader.css b/frontend/shared/components/PanelHeader.css index dabffb27d5..9869b56be3 100644 --- a/frontend/shared/components/PanelHeader.css +++ b/frontend/shared/components/PanelHeader.css @@ -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); } diff --git a/frontend/shared/components/PanelHeader.stories.tsx b/frontend/shared/components/PanelHeader.stories.tsx index 1aa37b9556..02cf26360a 100644 --- a/frontend/shared/components/PanelHeader.stories.tsx +++ b/frontend/shared/components/PanelHeader.stories.tsx @@ -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 = { @@ -9,53 +10,53 @@ const meta: Meta = { tags: ["autodocs"], parameters: { layout: "padded" }, args: { - title: "Pipeline detail", - subtitle: "COI Compliance · us-east-1", + icon: , + title: "Security", + closeLabel: "Close", }, - argTypes: { onBack: { action: "back" } }, + argTypes: { onClose: { action: "close" } }, }; export default meta; type Story = StoryObj; -/** 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: ( - <> - - Healthy - - - - - ), + title: "Stirling", + menuLabel: "Stirling agent options", + menuItems: [ + { + key: "clear", + icon: , + 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: ( - <> - - Healthy - - - - + + Active + ), }, }; diff --git a/frontend/shared/components/PanelHeader.tsx b/frontend/shared/components/PanelHeader.tsx index e65b21f376..1fcedd88ac 100644 --- a/frontend/shared/components/PanelHeader.tsx +++ b/frontend/shared/components/PanelHeader.tsx @@ -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 = ( + <> + + {icon} + {loading && } + + {title} + {actions != null && ( + {actions} + )} + {hasMenu && ( + + )} + + ); + return (
-
- {onBack && ( - - )} - {icon && ( - - {icon} - - )} -
-
{title}
- {subtitle &&
{subtitle}
} -
-
- {actions &&
{actions}
} + {hasMenu ? ( + + + + + + {(menuItems ?? []).map((item, i) => ( + + {item.label} + + ))} + + + ) : ( +
{barBody}
+ )} + + {onClose && ( + + + + )}
); }