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 && (
-
-
-
-
-
-
- {isLoading && }
-
-
- {t("agents.stirling_name", "Stirling")}
-
-
-
-
-
- }
- 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 (
-
+
);
@@ -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 (
+
+ {
+ if (guestBlocked) {
+ promptGuestSignup();
+ return;
+ }
+ selectPolicy(cat.id);
+ onExpand();
+ }}
>
- {
- if (guestBlocked) {
- promptGuestSignup();
- return;
- }
- selectPolicy(cat.id);
- onExpand();
- }}
- >
- {cat.icon}
- {(status === "active" || status === "paused") && (
-
- )}
-
-
- );
- })}
+ {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
-
-
- Edit composition
-
-
- View runs
-
- >
- ),
+ 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
-
-
- Edit composition
-
-
- View runs
-
- >
+
+ 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 ? (
+
+
+
+ {barBody}
+
+
+
+ {(menuItems ?? []).map((item, i) => (
+
+ {item.label}
+
+ ))}
+
+
+ ) : (
+
{barBody}
+ )}
+
+ {onClose && (
+
+
+
+ )}
);
}