diff --git a/tests/integrations/python/config.json b/tests/integrations/python/config.json index deb4296ed09..806097a61a2 100644 --- a/tests/integrations/python/config.json +++ b/tests/integrations/python/config.json @@ -1,18 +1,13 @@ { "$schema": "https://www.getbifrost.ai/schema", - "env_label": "Development", "mcp": { "client_configs": [ { "name": "sse_mcp", "connection_type": "sse", "connection_string": "http://localhost:3012/sse", - "tools_to_execute": [ - "*" - ], - "tools_to_auto_execute": [ - "*" - ] + "tools_to_execute": ["*"], + "tools_to_auto_execute": ["*"] } ] }, @@ -23,9 +18,7 @@ "name": "OpenAI API Key", "value": "env.OPENAI_API_KEY", "weight": 1, - "models": [ - "*" - ], + "models": ["*"], "use_for_batch_api": true } ], @@ -39,9 +32,7 @@ "name": "ElevenLabs API Key", "value": "env.ELEVENLABS_API_KEY", "weight": 1, - "models": [ - "*" - ], + "models": ["*"], "use_for_batch_api": false } ], @@ -55,9 +46,7 @@ "name": "Xai API Key", "value": "env.XAI_API_KEY", "weight": 1, - "models": [ - "*" - ], + "models": ["*"], "use_for_batch_api": false } ], @@ -71,9 +60,7 @@ "name": "Hugging Face API Key", "value": "env.HUGGING_FACE_API_KEY", "weight": 1, - "models": [ - "*" - ], + "models": ["*"], "use_for_batch_api": false } ], @@ -87,9 +74,7 @@ "name": "Anthropic API Key", "value": "env.ANTHROPIC_API_KEY", "weight": 1, - "models": [ - "*" - ], + "models": ["*"], "use_for_batch_api": true } ], @@ -103,9 +88,7 @@ "name": "Gemini API Key", "value": "env.GEMINI_API_KEY", "weight": 1, - "models": [ - "*" - ], + "models": ["*"], "use_for_batch_api": true } ], @@ -123,9 +106,7 @@ "auth_credentials": "env.VERTEX_CREDENTIALS" }, "weight": 1, - "models": [ - "*" - ], + "models": ["*"], "use_for_batch_api": true, "blacklisted_models": [ "claude-sonnet-4-6", @@ -160,9 +141,7 @@ "name": "Mistral API Key", "value": "env.MISTRAL_API_KEY", "weight": 1, - "models": [ - "*" - ] + "models": ["*"] } ], "network_config": { @@ -175,9 +154,7 @@ "name": "Cohere API Key", "value": "env.COHERE_API_KEY", "weight": 1, - "models": [ - "*" - ] + "models": ["*"] } ], "network_config": { @@ -190,9 +167,7 @@ "name": "Parasail API Key", "value": "env.PARASAIL_API_KEY", "weight": 1, - "models": [ - "*" - ] + "models": ["*"] } ], "network_config": { @@ -205,9 +180,7 @@ "name": "Groq API Key", "value": "env.GROQ_API_KEY", "weight": 1, - "models": [ - "*" - ] + "models": ["*"] } ], "network_config": { @@ -220,9 +193,7 @@ "name": "Perplexity API Key", "value": "env.PERPLEXITY_API_KEY", "weight": 1, - "models": [ - "*" - ] + "models": ["*"] } ], "network_config": { @@ -235,9 +206,7 @@ "name": "Cerebras API Key", "value": "env.CEREBRAS_API_KEY", "weight": 1, - "models": [ - "*" - ] + "models": ["*"] } ], "network_config": { @@ -250,9 +219,7 @@ "name": "OpenRouter API Key", "value": "env.OPENROUTER_API_KEY", "weight": 1, - "models": [ - "*" - ] + "models": ["*"] } ], "network_config": { @@ -287,9 +254,7 @@ } }, "weight": 1, - "models": [ - "*" - ], + "models": ["*"], "use_for_batch_api": true } ], @@ -307,9 +272,7 @@ "region": "env.AWS_REGION" }, "weight": 1, - "models": [ - "*" - ], + "models": ["*"], "use_for_batch_api": true } ], @@ -327,9 +290,7 @@ "region": "env.AWS_REGION" }, "weight": 1, - "models": [ - "*" - ], + "models": ["*"], "use_for_batch_api": true } ], @@ -379,9 +340,7 @@ "name": "DeepSeek API Key", "value": "env.DEEPSEEK_API_KEY", "weight": 1, - "models": [ - "*" - ], + "models": ["*"], "use_for_batch_api": false } ], @@ -421,182 +380,110 @@ "provider_configs": [ { "provider": "openai", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 }, { "provider": "elevenlabs", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 }, { "provider": "xai", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 }, { "provider": "huggingface", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 }, { "provider": "anthropic", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 }, { "provider": "gemini", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 }, { "provider": "vertex", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 }, { "provider": "mistral", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 }, { "provider": "cohere", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 }, { "provider": "parasail", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 }, { "provider": "groq", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 }, { "provider": "perplexity", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 }, { "provider": "cerebras", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 }, { "provider": "openrouter", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 }, { "provider": "azure", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 }, { "provider": "bedrock", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 }, { "provider": "bedrock_mantle", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 }, { "provider": "deepseek", - "allowed_models": [ - "*" - ], - "key_ids": [ - "*" - ], + "allowed_models": ["*"], + "key_ids": ["*"], "weight": 1.0 } ] @@ -609,11 +496,9 @@ "client": { "drop_excess_requests": false, "initial_pool_size": 300, - "allowed_origins": [ - "*" - ], + "allowed_origins": ["*"], "enable_logging": true, "enforce_auth_on_inference": false, "max_request_body_size_mb": 100 } -} +} \ No newline at end of file diff --git a/ui/app/_fallbacks/enterprise/components/mcp-tool-groups/mcpToolGroups.tsx b/ui/app/_fallbacks/enterprise/components/mcp-tool-groups/mcpToolGroups.tsx index 79eb0a4b261..0c89eb3e1d9 100644 --- a/ui/app/_fallbacks/enterprise/components/mcp-tool-groups/mcpToolGroups.tsx +++ b/ui/app/_fallbacks/enterprise/components/mcp-tool-groups/mcpToolGroups.tsx @@ -1,15 +1,13 @@ +import PageTitle from "@/components/pageTitle"; import { ToolCase } from "lucide-react"; import ContactUsView from "../views/contactUsView"; export default function MCPToolGroups() { return ( <> -
-
-

MCP Tool Groups

-

Configure tool groups for MCP servers to organize and govern tools.

-
-
+ {/* The name and description live in the topbar, like every other page — + an inline

here would just repeat the title shown above it. */} + Configure tool groups for MCP servers to organize and govern tools.
- } - title="Unlock SCIM based access management for user provisioning" - description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you." - readmeLink="https://docs.getbifrost.ai/enterprise/advanced-governance" - /> +
+
+ } + title="Unlock SCIM based access management for user provisioning" + description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you." + readmeLink="https://docs.getbifrost.ai/enterprise/advanced-governance" + /> +
); } \ No newline at end of file diff --git a/ui/app/clientLayout.tsx b/ui/app/clientLayout.tsx index 44951cb1943..962005e0548 100644 --- a/ui/app/clientLayout.tsx +++ b/ui/app/clientLayout.tsx @@ -4,10 +4,12 @@ import OnboardingWidget from "@/components/onboardingWidget"; import ProgressProvider from "@/components/progressBar"; import Sidebar from "@/components/sidebar"; import { ThemeProvider } from "@/components/themeProvider"; +import Topbar from "@/components/topbar"; import TrialExpiryBanner from "@/components/trialExpiryBanner"; import { Button } from "@/components/ui/button"; -import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; +import { SidebarProvider } from "@/components/ui/sidebar"; import { useStoreSync } from "@/hooks/useStoreSync"; +import { TopbarProvider } from "@/lib/contexts/topbarContext"; import { WebSocketProvider } from "@/hooks/useWebSocket"; import { getErrorMessage, ReduxProvider, useGetCoreConfigQuery, useIsAuthEnabledQuery } from "@/lib/store"; import { BifrostConfig } from "@/lib/types/config"; @@ -51,12 +53,6 @@ function AppContent({ children }: { children: React.ReactNode }) { // neither a fragment nor a cookie to drive the tempTokenScoped per-visitor // logic. const publicShell = matches.some((m) => (m.staticData as { publicShell?: boolean } | undefined)?.publicShell === true); - const pathname = useLocation({ select: (location) => location.pathname }); - const mobilePageTitle = (pathname.split("/").filter(Boolean).at(-1) ?? "Dashboard") - .split("-") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); - // Probe dashboard auth state on opted-in routes. is-auth-enabled is whitelisted // (no 401 risk) and returns whether the current cookie is a valid session. const { data: authState, isLoading: authLoading } = useIsAuthEnabledQuery(undefined, { skip: !tempTokenScoped }); @@ -118,28 +114,38 @@ function AppContent({ children }: { children: React.ReactNode }) { - - -
-
-
- - {mobilePageTitle} + + + + {/* Content column: a fixed-height flex stack so the topbar takes its + 48px and the content card absorbs the remainder. The topbar has no + background of its own, so it reads as the same surface as the + sidebar (both show the page body background). */} +
+ + {/* No w-full: in a column flex container the cross axis is width, and + an explicit 100% would sit *outside* the right margin, pushing it + off-screen. Default align-items:stretch already fills the column + minus margins. No top margin either: the card butts against the + 60px topbar so its top edge lands on the same line as the sidebar's + search input, and --app-content-viewport compensates so full-height + pages still measure to the card's inner height. */} +
+ +
+ {isLoading ? ( + + ) : ( + + {children} + + )} +
+ {bifrostConfig?.is_db_connected && }
- -
- {isLoading ? ( - - ) : ( - - {children} - - )} -
- {bifrostConfig?.is_db_connected && } -
- + + ); diff --git a/ui/app/globals.css b/ui/app/globals.css index 366a9df013a..ec8de98d94f 100644 --- a/ui/app/globals.css +++ b/ui/app/globals.css @@ -96,7 +96,20 @@ --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); - --height-base: calc(100vh - 130px); + /* Height of the app topbar, and the viewport height left underneath it for + page content. Anything rendered inside the content card must size against + --app-content-viewport rather than 100dvh, or it overflows the card by + exactly the topbar's height and gives the whole shell a second scrollbar. + Full-viewport surfaces that sit outside the card — dialogs, portalled + popovers, the sidebar, the auth-only MinimalShell — keep using 100dvh. */ + --app-topbar-height: 3.25rem; + /* Pages size themselves as `calc(var(--app-content-viewport) - 1rem)` — a + leftover from when the card carried my-2. It now has only mb-2, so add + that 0.5rem back here to keep their subtraction landing exactly on the + card's inner height. */ + --app-content-viewport: calc(100dvh - var(--app-topbar-height) + 0.5rem); + + --height-base: calc(var(--app-content-viewport) - 130px); /* Font size overrides - format: [size, { line-height: value }] */ --text-xs: 0.75rem; diff --git a/ui/app/workspace/audit-logs/page.tsx b/ui/app/workspace/audit-logs/page.tsx index 7d64035c497..1724b9ba202 100644 --- a/ui/app/workspace/audit-logs/page.tsx +++ b/ui/app/workspace/audit-logs/page.tsx @@ -2,7 +2,7 @@ import AuditLogsView from "@enterprise/components/audit-logs/auditLogsView"; export default function AuditLogsPage() { return ( -
+
); diff --git a/ui/app/workspace/cluster/page.tsx b/ui/app/workspace/cluster/page.tsx index 328266aaa5c..1fe380abb77 100644 --- a/ui/app/workspace/cluster/page.tsx +++ b/ui/app/workspace/cluster/page.tsx @@ -2,7 +2,7 @@ import ClusterView from "@enterprise/components/cluster/clusterView"; export default function ClusterPage() { return ( -
+
); diff --git a/ui/app/workspace/complexity-router/page.tsx b/ui/app/workspace/complexity-router/page.tsx index c38998572c0..f83b99b5d53 100644 --- a/ui/app/workspace/complexity-router/page.tsx +++ b/ui/app/workspace/complexity-router/page.tsx @@ -1,3 +1,4 @@ +import PageTitle from "@/components/pageTitle"; import FullPageLoader from "@/components/fullPageLoader"; import { AlertDialog, @@ -335,7 +336,7 @@ export default function ComplexityRouterPage() { const hasErrors = Boolean(boundaryErrors || keywordErrors); return ( -
+
{/* ── Page header ── */} -
-
-

Complexity Router

-

- Tune how incoming requests are classified into four tiers. Thresholds and keyword lists feed the{" "} - complexity_tier field that routing rules can - target. -

-
+
+ + Tune how incoming requests are classified into four tiers. Thresholds and keyword lists feed the{" "} + complexity_tier field that routing rules can + target. + -
- - {/* Search and filters */} + {/* Search, filters and actions */}
+ {pageTitle}
)} + + {/* The label is hidden below sm, leaving an icon with no accessible + name, so the name is carried on the button itself. */} +
diff --git a/ui/app/workspace/dashboard/components/charts/chartCard.tsx b/ui/app/workspace/dashboard/components/charts/chartCard.tsx index 6dab70c8e1b..c778ad71b95 100644 --- a/ui/app/workspace/dashboard/components/charts/chartCard.tsx +++ b/ui/app/workspace/dashboard/components/charts/chartCard.tsx @@ -12,6 +12,12 @@ interface ChartCardProps { loading?: boolean; testId?: string; className?: string; + // Let the card grow to its content instead of the fixed chart height. Needed + // by cards that render a ranked legend under the plot. This has to be a prop + // rather than an `h-full` in `className`: cn() merges per variant, so a + // caller class only cancels the unprefixed height and leaves `sm:h-[330px]` + // in place, which clips the legend at the sm breakpoint and up. + autoHeight?: boolean; total?: ReactNode; totalLabel?: string; totalTooltip?: ReactNode; @@ -132,10 +138,16 @@ export function ChartCard({ secondaryTotal, secondaryTotalLabel, secondaryTotalTooltip, + autoHeight, }: ChartCardProps) { + const heightClass = autoHeight ? "h-auto" : "h-[260px] sm:h-[330px]"; + // An auto-height card has no height for the skeleton's `h-full` to resolve + // against, so the loading state keeps the fixed height as a floor. + const loadingHeightClass = autoHeight ? "h-auto min-h-[260px] sm:min-h-[330px]" : heightClass; + if (loading) { return ( - +
+
- - - + + {/* The span carries the tooltip, not the Button: a disabled button + emits no pointer or focus events, so anchoring the trigger on it + would hide the "Exporting..." tooltip exactly while it is the one + worth reading. DropdownMenuTrigger stays on the Button so the + disabled state still blocks a second export. */} + + + + {/* Icon-only: the label would crowd the tab strip it now shares a + row with. State still reads from the spinner + tooltip. */} + {/* size="icon" is size-9; the date range trigger next to it uses the + default h-7.5, so pin the square to that height instead. */} + + + + + {exporting ? "Exporting..." : "Export"} + diff --git a/ui/app/workspace/dashboard/components/modelRankingsTab.tsx b/ui/app/workspace/dashboard/components/modelRankingsTab.tsx index 84036259348..3736f85ab17 100644 --- a/ui/app/workspace/dashboard/components/modelRankingsTab.tsx +++ b/ui/app/workspace/dashboard/components/modelRankingsTab.tsx @@ -156,7 +156,8 @@ function TopModelsChart({ title="Top Models" loading={loadingModels} testId="dashboard-rankings-top-models" - className="z-[1] h-full" + className="z-[1]" + autoHeight totalLabel="Total" total={grandTotal !== null ? : undefined} totalTooltip={grandTotal !== null ? grandTotal.toLocaleString("en-US") : undefined} diff --git a/ui/app/workspace/dashboard/page.tsx b/ui/app/workspace/dashboard/page.tsx index 7083c413222..9f5509130be 100644 --- a/ui/app/workspace/dashboard/page.tsx +++ b/ui/app/workspace/dashboard/page.tsx @@ -467,110 +467,113 @@ export default function DashboardPage() { const activeTab = (urlState.tab || "overview") as DashboardTab; return ( -
+
{/* Sidebar Filters */} {/* Main Content */} - {/* Header */} -
-
-

Dashboard

-
-
- - {activeTab === "mcp" && mcpFilterData && ( -
- {(mcpFilterData.tool_names?.length ?? 0) > 0 && ( - { - if (value === "all") { - setUrlState({ mcp_tool_names: "" }); - } else { - setUrlState({ mcp_tool_names: value }); - } - }} - placeholder="All Tools" - data-testid="dashboard-mcp-tool-filter" - /> - )} - {(mcpFilterData.server_labels?.length ?? 0) > 0 && ( - { - if (value === "all") { - setUrlState({ mcp_server_labels: "" }); - } else { - setUrlState({ mcp_server_labels: value }); - } - }} - placeholder="All Servers" - data-testid="dashboard-mcp-server-filter" - /> - )} -
- )} - -
-
-
{/* Tabs */} -
- - - Overview - - - Provider Usage - - - Model Rankings - - - MCP usage - - - Team Rankings - - - User Rankings - - - Virtual Key Rankings - - - Customer Rankings - - - BU Rankings - - - App Rankings - - +
+ {/* min-w-0 keeps the tab strip from pushing the filters off the row — + there are eleven tabs. TabsList collapses whatever does not fit + into its own dropdown, so no horizontal scrolling is needed. */} +
+ {/* Stays w-max: TabsTrigger is flex-1, so a full-width list would + stretch every tab across the row. */} + + + Overview + + + Provider Usage + + + Model Rankings + + + MCP usage + + + Team Rankings + + + User Rankings + + + Virtual Key Rankings + + + Customer Rankings + + + BU Rankings + + + App Rankings + + +
+
+ + {activeTab === "mcp" && mcpFilterData && ( +
+ {(mcpFilterData.tool_names?.length ?? 0) > 0 && ( + { + if (value === "all") { + setUrlState({ mcp_tool_names: "" }); + } else { + setUrlState({ mcp_tool_names: value }); + } + }} + placeholder="All Tools" + data-testid="dashboard-mcp-tool-filter" + /> + )} + {(mcpFilterData.server_labels?.length ?? 0) > 0 && ( + { + if (value === "all") { + setUrlState({ mcp_server_labels: "" }); + } else { + setUrlState({ mcp_server_labels: value }); + } + }} + placeholder="All Servers" + data-testid="dashboard-mcp-server-filter" + /> + )} +
+ )} + +
{/* Overview Tab */} diff --git a/ui/app/workspace/edge-control/devices/page.tsx b/ui/app/workspace/edge-control/devices/page.tsx index ccf4a54e1a4..cf4f524aa71 100644 --- a/ui/app/workspace/edge-control/devices/page.tsx +++ b/ui/app/workspace/edge-control/devices/page.tsx @@ -2,7 +2,7 @@ import DevicesView from "@enterprise/components/edge-control/devicesView"; export default function EdgeDevicesPage() { return ( -
+
); diff --git a/ui/app/workspace/edge-control/inventory/page.tsx b/ui/app/workspace/edge-control/inventory/page.tsx index f8fab2068f7..fc38cd4dc09 100644 --- a/ui/app/workspace/edge-control/inventory/page.tsx +++ b/ui/app/workspace/edge-control/inventory/page.tsx @@ -2,7 +2,7 @@ import InventoryView from "@enterprise/components/edge-control/inventoryView"; export default function EdgeInventoryPage() { return ( -
+
); diff --git a/ui/app/workspace/governance/access-profiles/page.tsx b/ui/app/workspace/governance/access-profiles/page.tsx index f47e0caf91b..f76afb1308d 100644 --- a/ui/app/workspace/governance/access-profiles/page.tsx +++ b/ui/app/workspace/governance/access-profiles/page.tsx @@ -10,7 +10,7 @@ export default function AccessProfilesPage() { } return ( -
+
); diff --git a/ui/app/workspace/governance/business-units/page.tsx b/ui/app/workspace/governance/business-units/page.tsx index 2ce7aa6528d..f7971d24a63 100644 --- a/ui/app/workspace/governance/business-units/page.tsx +++ b/ui/app/workspace/governance/business-units/page.tsx @@ -2,7 +2,7 @@ import { BusinessUnitsView } from "@enterprise/components/user-groups/businessUn export default function GovernanceBusinessUnitsPage() { return ( -
+
); diff --git a/ui/app/workspace/governance/customers/page.tsx b/ui/app/workspace/governance/customers/page.tsx index c733c70bff3..73e51d1e86a 100644 --- a/ui/app/workspace/governance/customers/page.tsx +++ b/ui/app/workspace/governance/customers/page.tsx @@ -79,7 +79,7 @@ export default function GovernanceCustomersPage() { } return ( -
+
+
); diff --git a/ui/app/workspace/governance/teams/page.tsx b/ui/app/workspace/governance/teams/page.tsx index b9433aa1f4d..cf39b24f5a7 100644 --- a/ui/app/workspace/governance/teams/page.tsx +++ b/ui/app/workspace/governance/teams/page.tsx @@ -2,7 +2,7 @@ import { TeamsView } from "@enterprise/components/user-groups/teamsView"; export default function GovernanceTeamsPage() { return ( -
+
); diff --git a/ui/app/workspace/governance/users/page.tsx b/ui/app/workspace/governance/users/page.tsx index 7f13cfdabe1..3575bd7b359 100644 --- a/ui/app/workspace/governance/users/page.tsx +++ b/ui/app/workspace/governance/users/page.tsx @@ -2,7 +2,7 @@ import UsersView from "@enterprise/components/user-groups/usersView"; export default function GovernanceUsersPage() { return ( -
+
); diff --git a/ui/app/workspace/governance/views/customerTable.tsx b/ui/app/workspace/governance/views/customerTable.tsx index 50370752fbb..8e4bc05f970 100644 --- a/ui/app/workspace/governance/views/customerTable.tsx +++ b/ui/app/workspace/governance/views/customerTable.tsx @@ -1,3 +1,4 @@ +import PageTitle from "@/components/pageTitle"; import { PIN_SHADOW_RIGHT } from "@/components/table/columnPinning"; import { AlertDialog, @@ -181,10 +182,16 @@ export default function CustomersTable({ const hasActiveFilters = debouncedSearch; + // Rendered on the empty branch too, not just the populated one: PageTitle + // draws nothing inline, and leaving it out drops the topbar to the + // route-derived fallback. + const pageTitle = Manage customer accounts with their own teams, budgets, and access controls.; + // True empty state: no customers at all (not just filtered to zero) if (totalCount === 0 && !hasActiveFilters && !isFetching) { return ( <> + {pageTitle}
-
-
-

Customers

-

Manage customer accounts with their own teams, budgets, and access controls.

-
- -
- -
+
+ {pageTitle}
+
diff --git a/ui/app/workspace/governance/views/teamsTable.tsx b/ui/app/workspace/governance/views/teamsTable.tsx index 3267ef83dc8..7cfd23e48f4 100644 --- a/ui/app/workspace/governance/views/teamsTable.tsx +++ b/ui/app/workspace/governance/views/teamsTable.tsx @@ -1,3 +1,4 @@ +import PageTitle from "@/components/pageTitle"; import { PIN_SHADOW_RIGHT } from "@/components/table/columnPinning"; import { AlertDialog, @@ -202,10 +203,16 @@ export default function TeamsTable({ const hasActiveFilters = debouncedSearch; + // Rendered on the empty branch too, not just the populated one: PageTitle + // draws nothing inline, and leaving it out drops the topbar to the + // route-derived fallback. + const pageTitle = Organize users into teams with shared budgets and access controls.; + // True empty state: no teams at all (not just filtered to zero) if (totalCount === 0 && !hasActiveFilters && !isLoading) { return ( <> + {pageTitle} {showTeamSheet && } @@ -220,18 +227,8 @@ export default function TeamsTable({ {showTeamSheet && }
-
-
-

Teams

-

Organize users into teams with shared budgets and access controls.

-
- -
- -
+
+ {pageTitle}
+
diff --git a/ui/app/workspace/governance/virtual-keys/page.tsx b/ui/app/workspace/governance/virtual-keys/page.tsx index 24c90d887c2..eb60d99620e 100644 --- a/ui/app/workspace/governance/virtual-keys/page.tsx +++ b/ui/app/workspace/governance/virtual-keys/page.tsx @@ -118,7 +118,7 @@ export default function GovernanceVirtualKeysPage() { }; return ( -
+
+
{showEmptyState ? ( [0]) : null)} /> ) : ( diff --git a/ui/app/workspace/logs/views/emptyState.tsx b/ui/app/workspace/logs/views/emptyState.tsx index 77e1addb16a..97e6f8e4e54 100644 --- a/ui/app/workspace/logs/views/emptyState.tsx +++ b/ui/app/workspace/logs/views/emptyState.tsx @@ -259,7 +259,7 @@ const result = await chain.invoke({ input: "What is LangChain?" });`,
- + cURL OpenAI SDK Anthropic SDK diff --git a/ui/app/workspace/mcp-logs/page.tsx b/ui/app/workspace/mcp-logs/page.tsx index 68133eb54c6..9fa678e9f77 100644 --- a/ui/app/workspace/mcp-logs/page.tsx +++ b/ui/app/workspace/mcp-logs/page.tsx @@ -437,7 +437,7 @@ export default function MCPLogsPage() { ) : showEmptyState ? ( ) : ( -
+
{/* Sidebar Filters */} diff --git a/ui/app/workspace/mcp-logs/views/emptyState.tsx b/ui/app/workspace/mcp-logs/views/emptyState.tsx index 2948372b0ff..a255dbfff19 100644 --- a/ui/app/workspace/mcp-logs/views/emptyState.tsx +++ b/ui/app/workspace/mcp-logs/views/emptyState.tsx @@ -264,7 +264,7 @@ if (response.choices[0].message.tool_calls) {
- + Manual Tool Execution Agent Mode (Auto-Execute) diff --git a/ui/app/workspace/mcp-registry/library/page.tsx b/ui/app/workspace/mcp-registry/library/page.tsx index 53571a5df56..69d23c44604 100644 --- a/ui/app/workspace/mcp-registry/library/page.tsx +++ b/ui/app/workspace/mcp-registry/library/page.tsx @@ -1,3 +1,4 @@ +import PageTitle from "@/components/pageTitle"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scrollArea"; @@ -154,7 +155,7 @@ export default function MCPLibraryPage() { const isCatalogEmpty = !isFetching && totalCount === 0 && !debouncedSearch && !hasActiveFilters; return ( -
+
{/* Sidebar Filters */} @@ -162,13 +163,62 @@ export default function MCPLibraryPage() { {/* Main Content */}
- {/* Header */} -
-
-

MCP Server Library

-

Browse and install MCP servers from the synced catalog.

-
-
+ {/* Search + Actions */} +
+ Browse and install MCP servers from the synced catalog. + {!isCatalogEmpty && ( + <> +
+ + setUrlState({ search: e.target.value, offset: 0 })} + placeholder="Search servers..." + className="h-9 pl-9" + data-testid="mcp-library-search-input" + /> +
+
+ + +
+ + )} +
{hasCreateMCPClientAccess && (
- {/* Search */} - {!isCatalogEmpty && ( -
-
- - setUrlState({ search: e.target.value, offset: 0 })} - placeholder="Search servers..." - className="h-9 pl-9" - data-testid="mcp-library-search-input" - /> -
-
- - -
-
- )}
{/* Loading skeletons */} {isFetching && servers.length === 0 ? ( diff --git a/ui/app/workspace/mcp-registry/page.tsx b/ui/app/workspace/mcp-registry/page.tsx index aa538360020..a1b15a24822 100644 --- a/ui/app/workspace/mcp-registry/page.tsx +++ b/ui/app/workspace/mcp-registry/page.tsx @@ -156,11 +156,11 @@ export default function MCPServersPage() { // Onboarding empty state: no servers at all and no active filters/search. // Render full-width without the filter sidebar (the table renders the CTA). if (totalCount === 0 && !filtersActive && !debouncedSearch && !urlState.server) { - return
{table}
; + return
{table}
; } return ( -
+
diff --git a/ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx b/ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx index a66048e6f8c..f99c5d4d215 100644 --- a/ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx +++ b/ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx @@ -1,3 +1,4 @@ +import PageTitle from "@/components/pageTitle"; import ClientForm from "@/app/workspace/mcp-registry/views/mcpClientForm"; import { PIN_SHADOW_RIGHT } from "@/components/table/columnPinning"; import { @@ -563,10 +564,16 @@ export default function MCPClientsTable({ const hasActiveFilters = Boolean(debouncedSearch) || Boolean(server) || filtersActive; + // Rendered on the empty branch too, not just the populated one: PageTitle + // draws nothing inline, and leaving it out drops the topbar to the + // route-derived fallback, which for this route reads "MCP Registry". + const pageTitle = Manage servers that can connect to the MCP Tools endpoint.; + // True empty state: no servers at all (not just filtered to zero) if (totalCount === 0 && !hasActiveFilters) { return ( <> + {pageTitle} {formOpen && setFormOpen(false)} onSaved={handleSaved} />} @@ -862,34 +869,10 @@ export default function MCPClientsTable({ -
-
-

MCP Server Catalog

-

Manage servers that can connect to the MCP Tools endpoint.

-
-
- - - -
-
+ {/* Toolbar: Search + Actions */} +
+ {pageTitle} - {/* Toolbar: Search */} -
)} + +
+ + + +
diff --git a/ui/app/workspace/mcp-registry/views/mcpUsageGuide/mcpUsageGuideSheet.tsx b/ui/app/workspace/mcp-registry/views/mcpUsageGuide/mcpUsageGuideSheet.tsx index 496ced240c3..9bc87e7f750 100644 --- a/ui/app/workspace/mcp-registry/views/mcpUsageGuide/mcpUsageGuideSheet.tsx +++ b/ui/app/workspace/mcp-registry/views/mcpUsageGuide/mcpUsageGuideSheet.tsx @@ -147,7 +147,9 @@ export function MCPUsageGuideSheet() { Harness
setUrlState({ harness: value as HarnessID })}> - + {/* No overflow-x-auto: TabsList now collapses whatever does not fit + into its own trailing dropdown, so the strip never clips. */} + {HARNESSES.map((h) => (
diff --git a/ui/app/workspace/mcp-sessions/page.tsx b/ui/app/workspace/mcp-sessions/page.tsx index 72a7b351dc3..98b771fcbd1 100644 --- a/ui/app/workspace/mcp-sessions/page.tsx +++ b/ui/app/workspace/mcp-sessions/page.tsx @@ -122,11 +122,11 @@ export default function MCPSessionsPage() { // No sessions at all and no active filters/search: render full-width // without the filter sidebar, mirroring the MCP clients onboarding state. if (totalCount === 0 && !hasActiveFilters) { - return
{table}
; + return
{table}
; } return ( -
+
diff --git a/ui/app/workspace/mcp-sessions/views/sessionsTable.tsx b/ui/app/workspace/mcp-sessions/views/sessionsTable.tsx index 6ebb642af5f..f203e84ac6d 100644 --- a/ui/app/workspace/mcp-sessions/views/sessionsTable.tsx +++ b/ui/app/workspace/mcp-sessions/views/sessionsTable.tsx @@ -17,6 +17,7 @@ // Caller must resubmit values. // pending: flow row, user must complete OAuth authentication. +import PageTitle from "@/components/pageTitle"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -134,14 +135,9 @@ export default function SessionsTable({ -
-
-

MCP Auth Sessions

-

- Per-user credentials stored for MCP servers (OAuth tokens and submitted headers), plus any pending authentication flows. -

-
-
+ + Per-user credentials stored for MCP servers (OAuth tokens and submitted headers), plus any pending authentication flows. +
diff --git a/ui/app/workspace/model-catalog/views/modelCatalogTable.tsx b/ui/app/workspace/model-catalog/views/modelCatalogTable.tsx index 62bcac17a74..7820bdfac4d 100644 --- a/ui/app/workspace/model-catalog/views/modelCatalogTable.tsx +++ b/ui/app/workspace/model-catalog/views/modelCatalogTable.tsx @@ -1,3 +1,4 @@ +import PageTitle from "@/components/pageTitle"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -66,11 +67,8 @@ export default function ModelCatalogTable({
{/* Header + Filter */} -
-
-

Model Catalog

-

Overview of all configured providers, models, and usage.

-
+
+ Overview of all configured providers, models, and usage. )} + +
diff --git a/ui/app/workspace/oauth-grants/page.tsx b/ui/app/workspace/oauth-grants/page.tsx index 37b4596e8ee..304e0e27c71 100644 --- a/ui/app/workspace/oauth-grants/page.tsx +++ b/ui/app/workspace/oauth-grants/page.tsx @@ -1,3 +1,4 @@ +import PageTitle from "@/components/pageTitle"; import { Input } from "@/components/ui/input"; import { useDebouncedValue } from "@/hooks/useDebounce"; import { getErrorMessage, useGetOAuth2GrantsQuery, useRevokeOAuth2GrantMutation } from "@/lib/store"; @@ -93,14 +94,9 @@ export default function OAuthGrantsPage() {
!open && setPendingDelete(null)} onConfirm={confirmRevoke} /> -
-
-

OAuth Grants

-

- Active downstream OAuth grants issued to MCP clients that connected via the OAuth consent flow. -

-
-
+ + Active downstream OAuth grants issued to MCP clients that connected via the OAuth consent flow. +
@@ -144,11 +140,11 @@ export default function OAuthGrantsPage() { // No grants at all and no active filters/search: render full-width without // the filter sidebar, mirroring the MCP clients/sessions onboarding state. if (!isLoading && totalCount === 0 && !hasActiveFilters) { - return
{content}
; + return
{content}
; } return ( -
+
diff --git a/ui/app/workspace/observability/fragments/otelFormFragment.tsx b/ui/app/workspace/observability/fragments/otelFormFragment.tsx index 864952630eb..17da5e83458 100644 --- a/ui/app/workspace/observability/fragments/otelFormFragment.tsx +++ b/ui/app/workspace/observability/fragments/otelFormFragment.tsx @@ -184,6 +184,11 @@ export function OtelFormFragment({ setProfileOpenState((prev) => ({ ...prev, [index]: open })); }; + const handleAddProfile = () => { + append(emptyProfile()); + setProfileOpenState((prev) => ({ ...prev, [fields.length]: true })); + }; + const handleRemoveProfile = (index: number) => { remove(index); setProfileOpenState((prev) => { @@ -216,7 +221,7 @@ export function OtelFormFragment({ index={index} hasOtelAccess={hasOtelAccess} canRemove={fields.length > 1} - open={profileOpenState[index] ?? true} + open={profileOpenState[index] ?? false} onOpenChange={(open) => handleProfileOpenChange(index, open)} onRemove={() => handleRemoveProfile(index)} /> @@ -227,7 +232,7 @@ export function OtelFormFragment({ type="button" variant="outline" size="sm" - onClick={() => append(emptyProfile())} + onClick={handleAddProfile} disabled={!hasOtelAccess} data-testid="otel-add-profile-btn" > diff --git a/ui/app/workspace/providers/dialogs/providerConfigSheet.tsx b/ui/app/workspace/providers/dialogs/providerConfigSheet.tsx index 1e68bfd3c39..18e337fe2ce 100644 --- a/ui/app/workspace/providers/dialogs/providerConfigSheet.tsx +++ b/ui/app/workspace/providers/dialogs/providerConfigSheet.tsx @@ -110,7 +110,9 @@ export default function ProviderConfigSheet({ show, onCancel, provider }: Props)
-
+ {/* TabsList collapses overflowing tabs into its own dropdown, so this + no longer needs to scroll horizontally. */} +
{tabs.map((tab) => ( - + Default Credential @@ -646,7 +646,7 @@ export function ApiKeyFormFragment({ control, providerName, baseProviderType, fo } }} > - + Service Account (Attached) @@ -908,7 +908,7 @@ export function ApiKeyFormFragment({ control, providerName, baseProviderType, fo } }} > - + IAM Role (Inherited) @@ -1157,7 +1157,7 @@ export function ApiKeyFormFragment({ control, providerName, baseProviderType, fo } }} > - + IAM Role (Inherited) diff --git a/ui/app/workspace/providers/page.tsx b/ui/app/workspace/providers/page.tsx index c1bbf850e1f..8fda7b75699 100644 --- a/ui/app/workspace/providers/page.tsx +++ b/ui/app/workspace/providers/page.tsx @@ -202,7 +202,7 @@ export default function Providers() { setShowCustomProviderSheet(false); }} /> -
+
@@ -274,12 +274,12 @@ export default function Providers() { Providers {isLoadingProvider && ( -
+
)} {!selectedProvider && ( -
+
Select a provider
)} diff --git a/ui/app/workspace/routing-rules/page.tsx b/ui/app/workspace/routing-rules/page.tsx index 784779cb606..7f91bc0af46 100644 --- a/ui/app/workspace/routing-rules/page.tsx +++ b/ui/app/workspace/routing-rules/page.tsx @@ -7,7 +7,7 @@ import { RoutingRulesView } from "./views/routingRulesView"; export default function RoutingRulesPage() { return ( -
+
); diff --git a/ui/app/workspace/routing-rules/tree/page.tsx b/ui/app/workspace/routing-rules/tree/page.tsx index 53e7943f8e7..1c066c7f83a 100644 --- a/ui/app/workspace/routing-rules/tree/page.tsx +++ b/ui/app/workspace/routing-rules/tree/page.tsx @@ -12,7 +12,7 @@ export const metadata = { export default function RoutingTreePage() { return ( -
+
); diff --git a/ui/app/workspace/routing-rules/views/routingRulesTable.tsx b/ui/app/workspace/routing-rules/views/routingRulesTable.tsx index 3a64e3bb51a..ccace65b4be 100644 --- a/ui/app/workspace/routing-rules/views/routingRulesTable.tsx +++ b/ui/app/workspace/routing-rules/views/routingRulesTable.tsx @@ -107,6 +107,8 @@ interface RoutingRulesTableProps { offset: number; limit: number; onOffsetChange: (offset: number) => void; + /** Page-level actions rendered at the right end of the same row as the search. */ + actions?: React.ReactNode; } export function RoutingRulesTable({ @@ -122,6 +124,7 @@ export function RoutingRulesTable({ offset, limit, onOffsetChange, + actions, }: RoutingRulesTableProps) { const [deleteRuleId, setDeleteRuleId] = useState(null); const [deleteRoutingRule, { isLoading: isDeleting }] = useDeleteRoutingRuleMutation(); @@ -173,8 +176,8 @@ export function RoutingRulesTable({ return ( <> - {/* Toolbar: Search */} -
+ {/* Toolbar: Search + Actions */} +
+ {actions &&
{actions}
}
diff --git a/ui/app/workspace/routing-rules/views/routingRulesView.tsx b/ui/app/workspace/routing-rules/views/routingRulesView.tsx index 4297a929324..5037a946f8d 100644 --- a/ui/app/workspace/routing-rules/views/routingRulesView.tsx +++ b/ui/app/workspace/routing-rules/views/routingRulesView.tsx @@ -3,6 +3,7 @@ * Main orchestrator component for routing rules management */ +import PageTitle from "@/components/pageTitle"; import { Button } from "@/components/ui/button"; import { useDebouncedValue } from "@/hooks/useDebounce"; import { useGetRoutingRulesQuery } from "@/lib/store/apis/routingRulesApi"; @@ -114,27 +115,7 @@ export function RoutingRulesView() { return (
- {/* Header */} -
-
-

Routing Rules

-

Manage CEL-based routing rules for intelligent request routing across providers

-
-
- - {canCreate && ( - - )} -
-
+ Manage CEL-based routing rules for intelligent request routing across providers + {/* The labels are hidden below sm, leaving an icon with no + accessible name, so the name is carried on the control itself. */} + + {canCreate && ( + + )} + + } /> diff --git a/ui/app/workspace/scim/page.tsx b/ui/app/workspace/scim/page.tsx index 1a76cf498e5..431e7372940 100644 --- a/ui/app/workspace/scim/page.tsx +++ b/ui/app/workspace/scim/page.tsx @@ -1,11 +1,12 @@ import SCIMView from "@enterprise/components/scim/scimView"; export default function SCIMPage() { + // No no-padding-parent / no-border-parent / bg-background wrapper here: those opt + // out of the white content card in clientLayout, which left this page reading grey + // while every sibling placeholder sat on the card. Matches mcp-tool-groups. return ( -
-
- -
+
+
); } \ No newline at end of file diff --git a/ui/app/workspace/skills-repo/components/skillListView.tsx b/ui/app/workspace/skills-repo/components/skillListView.tsx index 9a0abb54dbe..1a6d1d15f7b 100644 --- a/ui/app/workspace/skills-repo/components/skillListView.tsx +++ b/ui/app/workspace/skills-repo/components/skillListView.tsx @@ -1,5 +1,6 @@ "use client"; +import PageTitle from "@/components/pageTitle"; import FullPageLoader from "@/components/fullPageLoader"; import { PIN_SHADOW_RIGHT } from "@/components/table/columnPinning"; import { @@ -411,77 +412,9 @@ export function SkillsListView({ return (
{/* Header */} -
-
-
-

Skills Repository

- Beta -
-

Manage Agent Skills for distribution to AI coding assistants

-
-
- {isGitAvailable ? ( - - ) : ( - - - - - - - -

- Git is not available on the server. Install git and restart Bifrost to enable marketplace registration for Claude Code and - Codex. -

-
-
- )} - - {hasCreateAccess && ( - - )} -
-
- - {/* Search + All-skills version */} -
+ {/* Search + All-skills version + Actions */} +
+ Manage Agent Skills for distribution to AI coding assistants
)}
+
+ {/* The title now lives in the topbar; the beta marker stays on the + page so it reads next to the actions it qualifies. */} + Beta +
+ {isGitAvailable ? ( + + ) : ( + + + + + + + +

+ Git is not available on the server. Install git and restart Bifrost to enable marketplace registration for Claude Code + and Codex. +

+
+
+ )} + + {hasCreateAccess && ( + + )} +
+
{/* Table */} diff --git a/ui/app/workspace/skills-repo/page.tsx b/ui/app/workspace/skills-repo/page.tsx index 7e077123bc1..b187aa5f4d0 100644 --- a/ui/app/workspace/skills-repo/page.tsx +++ b/ui/app/workspace/skills-repo/page.tsx @@ -56,7 +56,7 @@ export default function SkillsRepoPage() { // List view return ( -
+
setUrlState({ create: true, skillId: null, edit: false })} />
); diff --git a/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx b/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx index 573d836fd93..25075b7d902 100644 --- a/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx +++ b/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx @@ -1,3 +1,4 @@ +import PageTitle from "@/components/pageTitle"; import { BudgetDisplay } from "@/components/budgetDisplay"; import { CustomerSelector } from "@/components/entitySelectors/customerSelector"; import { TeamSelector } from "@/components/entitySelectors/teamSelector"; @@ -785,36 +786,9 @@ export default function VirtualKeysTable({
-
-
-

Virtual Keys

-

Manage virtual keys, their permissions, budgets, and rate limits.

-
-
- {selectedCount > 0 && ( - - )} - - -
-
- - {/* Toolbar: Search + Filters */} + {/* Toolbar: Search + Filters + Actions */}
+ Manage virtual keys, their permissions, budgets, and rate limits.
)} + +
+ {selectedCount > 0 && ( + + )} + + +
diff --git a/ui/app/workspace/webhooks/views/webhooksFilterBar.tsx b/ui/app/workspace/webhooks/views/webhooksFilterBar.tsx index e952f1968c0..aa20aa24f42 100644 --- a/ui/app/workspace/webhooks/views/webhooksFilterBar.tsx +++ b/ui/app/workspace/webhooks/views/webhooksFilterBar.tsx @@ -24,6 +24,8 @@ export interface WebhooksFilterBarProps { onStatusFilterChange: (value: string[]) => void; hasActiveFilters: boolean; onClearFilters: () => void; + /** Page-level actions rendered at the right end of the same row as the search. */ + actions?: React.ReactNode; } export default function WebhooksFilterBar(props: WebhooksFilterBarProps) { @@ -68,6 +70,7 @@ export default function WebhooksFilterBar(props: WebhooksFilterBarProps) { Clear filters )} + {props.actions &&
{props.actions}
}
); } \ No newline at end of file diff --git a/ui/app/workspace/webhooks/views/webhooksView.tsx b/ui/app/workspace/webhooks/views/webhooksView.tsx index a67572ec6d1..3ded4167833 100644 --- a/ui/app/workspace/webhooks/views/webhooksView.tsx +++ b/ui/app/workspace/webhooks/views/webhooksView.tsx @@ -1,3 +1,4 @@ +import PageTitle from "@/components/pageTitle"; import FullPageLoader from "@/components/fullPageLoader"; import { PIN_SHADOW_RIGHT } from "@/components/table/columnPinning"; import { @@ -324,19 +325,10 @@ export default function WebhooksView() { ) : ( <> -
-
-

Webhooks

-

- Register endpoints to receive signed notifications when async inference jobs complete or fail. Pass the endpoint's name in - the x-bf-async-webhook header when submitting a job. -

-
- -
+ + Register endpoints to receive signed notifications when async inference jobs complete or fail. Pass the endpoint's name in the{" "} + x-bf-async-webhook header when submitting a job. + setUrlState({ status: value.length ? value : null, offset: 0 })} hasActiveFilters={hasActiveFilters} onClearFilters={handleClearFilters} + actions={ + + } />
diff --git a/ui/components/noPermissionView.tsx b/ui/components/noPermissionView.tsx index c6ae3a395bd..053247a4bf1 100644 --- a/ui/components/noPermissionView.tsx +++ b/ui/components/noPermissionView.tsx @@ -11,7 +11,7 @@ export function NoPermissionView({ entity, className, align = "middle" }: NoPerm return (
, and the description becomes + * an info icon beside it that opens on hover. + * + * Drop it where the old heading lived so the page still reads as its own + * source of truth: + * + * + * Register endpoints to receive signed notifications. Pass the endpoint's + * name in the x-bf-async-webhook header. + * + * + * The description is portalled rather than lifted through context state, so it + * can be arbitrary JSX — links, , conditional spans — without its + * unstable identity re-rendering the whole shell on every parent render. + * + * `title` is only needed when the route slug wouldn't produce the right label + * (tab views, "&"-joined names). Without it the topbar falls back to the last + * path segment. + */ +export default function PageTitle({ title, children }: { title?: string; children?: React.ReactNode }) { + useSetTopbarTitle(title); + const slot = useDescriptionSlot(); + + if (!children || !slot) return null; + + return createPortal( + + + + + + {children} + + , + slot, + ); +} \ No newline at end of file diff --git a/ui/components/prompts/promptsView.tsx b/ui/components/prompts/promptsView.tsx index e822b28b20b..bac7f90748a 100644 --- a/ui/components/prompts/promptsView.tsx +++ b/ui/components/prompts/promptsView.tsx @@ -34,7 +34,7 @@ export default function PromptsView() { if (folders.length === 0 && prompts.length === 0) { return ( -
+
@@ -43,7 +43,7 @@ export default function PromptsView() { if (isMobile) { return ( -
+
@@ -78,7 +78,7 @@ export default function PromptsView() { } return ( -
+
diff --git a/ui/components/sidebar.tsx b/ui/components/sidebar.tsx index f4c0a1a3fcc..1cdd7178ead 100644 --- a/ui/components/sidebar.tsx +++ b/ui/components/sidebar.tsx @@ -6,7 +6,6 @@ import { BookUser, Boxes, BoxIcon, - BugIcon, Building, Building2, ChartColumnBig, @@ -26,7 +25,6 @@ import { Landmark, LaptopMinimalCheck, LayoutGrid, - LogOut, Logs, Megaphone, Network, @@ -47,7 +45,6 @@ import { Telescope, ToolCase, TrendingUp, - User, UserRoundCheck, Users, Wallet, @@ -56,7 +53,6 @@ import { } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Separator } from "@/components/ui/separator"; import { Sidebar, SidebarContent, @@ -76,17 +72,13 @@ import { HIDDEN_UNTIL_NAV_COOKIE, REMIND_LATER_COOKIE, useOnboardingChecklist } import { useWebSocket } from "@/hooks/useWebSocket"; import { IS_ENTERPRISE } from "@/lib/constants/config"; import { useBranding } from "@/lib/hooks/useBranding"; -import { useGetCoreConfigQuery, useGetLatestReleaseQuery, useGetVersionQuery, useLogoutMutation } from "@/lib/store"; +import { useGetCoreConfigQuery, useGetLatestReleaseQuery, useGetVersionQuery } from "@/lib/store"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; -import type { UserInfo } from "@enterprise/lib/store/utils/tokenManager"; -import { getUserInfo } from "@enterprise/lib/store/utils/tokenManager"; -import { BooksIcon, DiscordLogoIcon, GithubLogoIcon } from "@phosphor-icons/react"; import { Link, useLocation, useNavigate } from "@tanstack/react-router"; import { ChevronRight } from "lucide-react"; import { useTheme } from "next-themes"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCookies } from "react-cookie"; -import { ThemeToggle } from "./themeToggle"; import { Badge } from "./ui/badge"; import { PromoCardStack } from "./ui/promoCardStack"; @@ -118,32 +110,6 @@ const MCPIcon = ({ className }: { className?: string }) => ( // Main navigation items -// External links -const externalLinks = [ - { - title: "Discord Server", - url: "https://discord.gg/exN5KAydbU", - icon: DiscordLogoIcon, - }, - { - title: "GitHub Repository", - url: "https://github.com/maximhq/bifrost", - icon: GithubLogoIcon, - }, - { - title: "Report a bug", - url: "https://github.com/maximhq/bifrost/issues/new?title=[Bug Report]&labels=bug&type=bug&projects=maximhq/1", - icon: BugIcon, - strokeWidth: 1.5, - }, - { - title: "Full Documentation", - url: "https://docs.getbifrost.ai", - icon: BooksIcon, - strokeWidth: 1, - }, -]; - // Base promotional card (memoized outside component to prevent recreation) const productionSetupHelpCard = { id: "production-setup", @@ -544,8 +510,6 @@ export default function AppSidebar() { const { state: sidebarState, isMobile, toggleSidebar } = useSidebar(); const [mounted, setMounted] = useState(false); const [expandedItems, setExpandedItems] = useState>(new Set()); - const [areCardsEmpty, setAreCardsEmpty] = useState(false); - const [userPopoverOpen, setUserPopoverOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [focusedIndex, setFocusedIndex] = useState(-1); const searchInputRef = useRef(null); @@ -1185,18 +1149,6 @@ export default function AppSidebar() { const { data: version } = useGetVersionQuery(); const { resolvedTheme } = useTheme(); - const [logout] = useLogoutMutation(); - - // Get user info from localStorage (for enterprise SCIM OAuth) - const [userInfo, setUserInfo] = useState(null); - - useEffect(() => { - if (IS_ENTERPRISE) { - const info = getUserInfo(); - setUserInfo(info); - } - }, []); - const showNewReleaseBanner = useMemo(() => { if (IS_ENTERPRISE) return false; if (latestRelease && version) { @@ -1204,7 +1156,6 @@ export default function AppSidebar() { } return false; }, [latestRelease, version]); - const isAuthEnabled = coreConfig?.auth_config?.is_enabled || false; useEffect(() => { setMounted(true); @@ -1457,25 +1408,6 @@ export default function AppSidebar() { handleResumeOnboarding, ]); - // Reset areCardsEmpty when promoCards changes - useEffect(() => { - if (promoCards.length > 0) { - setAreCardsEmpty(false); - } - }, [promoCards]); - - // The promo card stack is hidden via CSS when collapsed (icon rail), so it - // shouldn't reserve vertical space there — otherwise the nav icon list - // gets squeezed into a shorter scroll area for a card nobody can see. - const hasPromoCards = promoCards.length > 0 && !areCardsEmpty && (isMobile || sidebarState !== "collapsed"); - // When cards are present: 13rem (header 3rem + bottom section ~10rem) - // When no cards: 8rem (header 3rem + bottom section without cards ~5rem) - const sidebarGroupHeight = hasPromoCards ? "h-[calc(100vh-13rem)]" : "h-[calc(100vh-8rem)]"; - - const handleCardsEmpty = () => { - setAreCardsEmpty(true); - }; - const handlePromoDismiss = useCallback( (cardId: string) => { if (cardId === "production-setup") { @@ -1507,22 +1439,11 @@ export default function AppSidebar() { [setCookie, cookies], ); - const handleLogout = async () => { - try { - setUserPopoverOpen(false); - await logout().unwrap(); - navigate("/login"); - } catch { - // Even if logout fails on server, redirect to login - navigate("/login"); - } - }; - return ( {/* Expanded state: horizontal layout */} -
+
{/* max-w caps an unusually wide uploaded logo so it cannot push the collapse button out of the header; object-contain preserves its @@ -1541,10 +1462,10 @@ export default function AppSidebar() {
{/* Collapsed state: vertical layout */}
- {logoAlt} + {logoAlt}
{envLabel && ( @@ -1586,8 +1507,8 @@ export default function AppSidebar() {
- - + + {filteredItems.map((item) => { @@ -1614,85 +1535,23 @@ export default function AppSidebar() { -
+
- +
-
-
- {sidebarState !== "collapsed" && - externalLinks.map((item, index) => ( - -
- -
-
- ))} - - {IS_ENTERPRISE && userInfo ? ( - - - - - -
-
-

{userInfo.name || userInfo.email || userInfo.preferred_username || "User"}

-
- - -
-
-
- ) : isAuthEnabled ? ( -
- -
- ) : null} -
- -
-
+ {/* Socials, theme toggle and the user/logout menu moved to . + All that remains here is the expand affordance for the collapsed + rail, since the collapsed header doubles as the collapse target. */} +
+
{version ?? ""}
diff --git a/ui/components/themeToggle.tsx b/ui/components/themeToggle.tsx index 102a8ad0364..917be4308b9 100644 --- a/ui/components/themeToggle.tsx +++ b/ui/components/themeToggle.tsx @@ -1,12 +1,36 @@ -import { Moon, Sun } from "lucide-react"; +import { Check, Laptop, Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdownMenu"; -export function ThemeToggle() { - const { setTheme } = useTheme(); +const THEMES = [ + { value: "light", label: "Light", icon: Sun }, + { value: "dark", label: "Dark", icon: Moon }, + { value: "system", label: "System", icon: Laptop }, +] as const; + +/** + * The theme choices as bare dropdown items, so they can be embedded in a larger + * menu (e.g. the topbar account menu) rather than only in their own popover. + */ +export function ThemeToggleItems() { + const { theme, setTheme } = useTheme(); + + return ( + <> + {THEMES.map(({ value, label, icon: Icon }) => ( + setTheme(value)} className="cursor-pointer"> + + {label} + {theme === value && } + + ))} + + ); +} +export function ThemeToggle() { return ( @@ -21,9 +45,7 @@ export function ThemeToggle() { - setTheme("light")}>Light - setTheme("dark")}>Dark - setTheme("system")}>System + ); diff --git a/ui/components/topbar.tsx b/ui/components/topbar.tsx new file mode 100644 index 00000000000..75357e78487 --- /dev/null +++ b/ui/components/topbar.tsx @@ -0,0 +1,198 @@ +import { ThemeToggle } from "@/components/themeToggle"; +import { deriveTitleFromPathname } from "@/components/topbar.utils"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdownMenu"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { IS_ENTERPRISE } from "@/lib/constants/config"; +import { useDescriptionSlotRef, useTopbarTitle } from "@/lib/contexts/topbarContext"; +import { useGetCoreConfigQuery, useLogoutMutation } from "@/lib/store"; +import type { UserInfo } from "@enterprise/lib/store/utils/tokenManager"; +import { getUserInfo } from "@enterprise/lib/store/utils/tokenManager"; +import { BooksIcon, DiscordLogoIcon, GithubLogoIcon } from "@phosphor-icons/react"; +import { useLocation, useNavigate } from "@tanstack/react-router"; +import { BugIcon, ChevronDown, LogOut, Menu, User } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +// External links that used to live in the sidebar footer icon row. They now +// render as labelled rows inside the topbar menu, which is both more legible +// than a row of bare glyphs and frees the sidebar footer for the promo card. +const externalLinks: { + title: string; + url: string; + // Mixed lucide + phosphor icons, which disagree on their prop surface + // (`weight` vs `strokeWidth`), so the slot is intentionally loose. + icon: React.ComponentType>; + strokeWidth?: number; +}[] = [ + { + title: "Discord Server", + url: "https://discord.gg/exN5KAydbU", + icon: DiscordLogoIcon, + }, + { + title: "GitHub Repository", + url: "https://github.com/maximhq/bifrost", + icon: GithubLogoIcon, + }, + { + title: "Report a bug", + url: "https://github.com/maximhq/bifrost/issues/new?title=[Bug Report]&labels=bug&type=bug&projects=maximhq/1", + icon: BugIcon, + strokeWidth: 1.5, + }, + { + title: "Full Documentation", + url: "https://docs.getbifrost.ai", + icon: BooksIcon, + strokeWidth: 1, + }, +]; + +/** + * Resolves the topbar title. A page can name itself via useSetTopbarTitle(); + * otherwise we derive it from the last non-empty path segment, e.g. + * "/workspace/mcp-registry" -> "MCP Registry". + */ +function usePageTitle() { + const pathname = useLocation({ select: (location) => location.pathname }); + const override = useTopbarTitle(); + const derived = useMemo(() => deriveTitleFromPathname(pathname), [pathname]); + return override ?? derived; +} + +/** + * Topbar renders the strip above the inset content card. It deliberately + * carries no background of its own so it reads as the same surface as the + * sidebar — both simply show the page body background, since is + * rendered with `bg-transparent border-none`. + * + * It is 52px tall (h-13) with pt-1, putting its centred row at y=28 — the same + * axis as the sidebar logo, which SidebarHeader's `mt-1 + p-2` around an h-8 row + * centres at 28. 52px is also where the sidebar's search input starts, so the + * content card below lines up with it. Keep this in sync with + * --app-topbar-height in globals.css — every full-height page measures itself + * against that variable. + */ +export default function Topbar() { + const title = usePageTitle(); + const setDescriptionSlot = useDescriptionSlotRef(); + const navigate = useNavigate(); + const [logout] = useLogoutMutation(); + const { data: coreConfig } = useGetCoreConfigQuery({}); + + // Enterprise SCIM/OAuth stashes the profile in localStorage. Read it after + // mount so SSR/first paint doesn't diverge from the hydrated tree. + const [userInfo, setUserInfo] = useState(null); + useEffect(() => { + if (IS_ENTERPRISE) { + setUserInfo(getUserInfo()); + } + }, []); + + const isAuthEnabled = coreConfig?.auth_config?.is_enabled || false; + const showUserPill = IS_ENTERPRISE && !!userInfo; + const canLogout = showUserPill || isAuthEnabled; + + const displayName = userInfo?.name || userInfo?.email || userInfo?.preferred_username || "User"; + // Only show the email as a second line when it isn't already the headline. + const secondaryLine = userInfo?.email && userInfo.email !== displayName ? userInfo.email : undefined; + + const handleLogout = async () => { + try { + await logout().unwrap(); + } finally { + // Redirect regardless — a failed server-side logout still means the + // user intended to end the session locally. + navigate({ to: "/login" }); + } + }; + + return ( +
+
+ + {/* text-lg font-semibold is the existing in-page

scale, so hoisting + the title here doesn't visually demote it. */} +

{title}

+ {/* Anchor for 's description popover. Pages portal into + this node, so the topbar never has to know their content. */} + +
+ + {/* Theme stays a first-class topbar control rather than a menu entry — + it's a display preference, not an account action. */} + + + + + {showUserPill ? ( + + ) : ( + + )} + + + + {showUserPill && ( + <> + + {displayName} + {secondaryLine && {secondaryLine}} + + + + )} + + + {externalLinks.map((item) => ( + + + + {item.title} + + + ))} + + + {canLogout && ( + <> + + + + Sign out + + + )} + + +
+ ); +} \ No newline at end of file diff --git a/ui/components/topbar.utils.test.ts b/ui/components/topbar.utils.test.ts new file mode 100644 index 00000000000..6a5d56b71f9 --- /dev/null +++ b/ui/components/topbar.utils.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { deriveTitleFromPathname } from "./topbar.utils"; + +describe("deriveTitleFromPathname", () => { + it("title-cases the last path segment", () => { + expect(deriveTitleFromPathname("/workspace/governance")).toBe("Governance"); + }); + + it("splits hyphenated segments into words", () => { + expect(deriveTitleFromPathname("/workspace/routing-rules")).toBe("Routing Rules"); + }); + + it("shouts known acronyms", () => { + expect(deriveTitleFromPathname("/workspace/mcp-registry")).toBe("MCP Registry"); + }); + + it("falls back to Dashboard at the root", () => { + expect(deriveTitleFromPathname("/")).toBe("Dashboard"); + expect(deriveTitleFromPathname("")).toBe("Dashboard"); + }); + + it("ignores a trailing slash", () => { + expect(deriveTitleFromPathname("/workspace/governance/")).toBe("Governance"); + }); + + // These are why has to be rendered on a page's loading and empty + // branches too: on these routes the derived fallback is not a blank title, + // it is a *different* one, so dropping to it is a silent mislabel. + it("cannot reproduce titles the slug does not contain", () => { + expect(deriveTitleFromPathname("/workspace/custom-pricing/overrides")).toBe("Overrides"); + expect(deriveTitleFromPathname("/workspace/model-limits")).toBe("Model Limits"); + }); +}); \ No newline at end of file diff --git a/ui/components/topbar.utils.ts b/ui/components/topbar.utils.ts new file mode 100644 index 00000000000..f58efe447c3 --- /dev/null +++ b/ui/components/topbar.utils.ts @@ -0,0 +1,30 @@ +/** Path segments that are shouted rather than capitalised. */ +const titleAcronyms: Record = { + ai: "AI", + api: "API", + llm: "LLM", + mcp: "MCP", + oauth: "OAuth", + rbac: "RBAC", + scim: "SCIM", + sdk: "SDK", + sso: "SSO", +}; + +function formatTitlePart(part: string) { + return titleAcronyms[part.toLowerCase()] ?? part.charAt(0).toUpperCase() + part.slice(1); +} + +/** + * Fallback title for a route that does not name itself, derived from the last + * non-empty path segment: "/workspace/mcp-registry" -> "MCP Registry". + * + * This is only ever a guess. Several routes carry a name the slug cannot + * produce ("/workspace/model-limits" is "Budgets & Limits"), which is why a + * page that renders must render it on every branch — including its + * loading and empty states. Falling back there does not blank the topbar, it + * silently shows a different title. + */ +export function deriveTitleFromPathname(pathname: string): string { + return (pathname.split("/").filter(Boolean).at(-1) ?? "Dashboard").split("-").map(formatTitlePart).join(" "); +} \ No newline at end of file diff --git a/ui/components/ui/tabs.tsx b/ui/components/ui/tabs.tsx index a3242ecd583..de578e2fa80 100644 --- a/ui/components/ui/tabs.tsx +++ b/ui/components/ui/tabs.tsx @@ -1,19 +1,159 @@ import * as TabsPrimitive from "@radix-ui/react-tabs"; +import { ChevronDown } from "lucide-react"; import * as React from "react"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdownMenu"; +import { partitionTabs } from "@/components/ui/tabs.utils"; import { cn } from "@/lib/utils"; -function Tabs({ className, ...props }: React.ComponentProps) { - return ; +/** + * The selected value plus a setter, so can decide which trigger must + * stay on screen and can still switch tabs from the overflow dropdown. Radix + * keeps this in a private context, so we mirror it on the way through. + */ +const TabsValueContext = React.createContext<{ value?: string; setValue: (next: string) => void }>({ setValue: () => {} }); + +/** Room reserved for the overflow dropdown trigger once it is shown. */ +const MORE_BUTTON_WIDTH = 40; + +function Tabs({ className, value, defaultValue, onValueChange, ...props }: React.ComponentProps) { + // Track the uncontrolled case ourselves; the controlled case just reads `value`. + const [uncontrolled, setUncontrolled] = React.useState(defaultValue); + const current = value ?? uncontrolled; + + const setValue = React.useCallback( + (next: string) => { + if (value === undefined) setUncontrolled(next); + onValueChange?.(next); + }, + [value, onValueChange], + ); + + const context = React.useMemo(() => ({ value: current, setValue }), [current, setValue]); + + return ( + + + + ); } -function TabsList({ className, ...props }: React.ComponentProps) { +type TabsTriggerElement = React.ReactElement<{ value?: string; children?: React.ReactNode }>; + +/** + * Renders the triggers that fit on one row and collapses the rest into a + * trailing dropdown. + * + * The alternative - a horizontally scrolling strip - hides tabs behind an + * affordance most people never find, and inside a sheet a trackpad's horizontal + * gesture fights the sheet's own scrolling. Strips that already fit are + * untouched: partitionTabs returns everything inline and no dropdown renders. + */ +function TabsList({ className, children, ...props }: React.ComponentProps) { + const items = React.useMemo( + () => React.Children.toArray(children).filter((child) => React.isValidElement(child)) as TabsTriggerElement[], + [children], + ); + const { value: activeValue, setValue } = React.useContext(TabsValueContext); + const activeIndex = items.findIndex((item) => item.props.value === activeValue); + + const listRef = React.useRef(null); + const [containerWidth, setContainerWidth] = React.useState(0); + // Widths are captured on the first pass, while every trigger is still inline; + // a trigger moved into the dropdown reports 0 and could not be re-measured. + // `count` invalidates the cache when the set of tabs changes. + const [metrics, setMetrics] = React.useState<{ count: number; widths: number[] }>({ count: -1, widths: [] }); + const measured = metrics.count === items.length; + + React.useLayoutEffect(() => { + const list = listRef.current; + if (measured || !list) return; + const widths = Array.from(list.children).map((child) => (child as HTMLElement).offsetWidth); + if (widths.length === items.length && widths.every((width) => width > 0)) { + setMetrics({ count: items.length, widths }); + } + }, [measured, items.length]); + + // Measure the PARENT, never the list. TabsList is `w-fit inline-flex` by + // default and consumers often set `w-max`, so the list's own width is just the + // sum of its children - comparing against it would always report "everything + // fits" and the dropdown would never appear. The parent is what actually + // constrains the row. Measuring the parent also avoids a feedback loop: + // hiding a tab shrinks the list, which would re-trigger a list-based observer. + React.useEffect(() => { + const list = listRef.current; + if (!list || typeof ResizeObserver === "undefined") return; + const parent = list.parentElement; + const target = parent ?? list; + + const measure = () => { + const horizontalPadding = (element: HTMLElement) => { + const style = window.getComputedStyle(element); + return (Number.parseFloat(style.paddingLeft) || 0) + (Number.parseFloat(style.paddingRight) || 0); + }; + // clientWidth excludes border and scrollbar but includes padding, and the + // list's own padding is not available to its children either. + const outer = parent ? parent.clientWidth - horizontalPadding(parent) : list.clientWidth; + setContainerWidth(Math.max(0, outer - horizontalPadding(list))); + }; + + measure(); + const observer = new ResizeObserver(measure); + observer.observe(target); + return () => observer.disconnect(); + }, []); + + const { visible, overflow } = measured + ? partitionTabs({ itemWidths: metrics.widths, containerWidth, moreButtonWidth: MORE_BUTTON_WIDTH, activeIndex }) + : { visible: items.map((_, index) => index), overflow: [] as number[] }; + return ( + > + {visible.map((index) => items[index])} + {overflow.length > 0 && ( + + + + + + {overflow.map((index) => { + const item = items[index]; + const itemValue = item.props.value; + return ( + itemValue !== undefined && setValue(itemValue)} + > + {item.props.children} + + ); + })} + + + )} + ); } diff --git a/ui/components/ui/tabs.utils.test.ts b/ui/components/ui/tabs.utils.test.ts new file mode 100644 index 00000000000..693af618ce0 --- /dev/null +++ b/ui/components/ui/tabs.utils.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { partitionTabs } from "./tabs.utils"; + +// Six 100px tabs, matching the Install Bifrost MCP harness strip that overflows. +const SIX = [100, 100, 100, 100, 100, 100]; +const MORE = 40; + +describe("partitionTabs", () => { + it("keeps every tab inline when they all fit", () => { + expect(partitionTabs({ itemWidths: SIX, containerWidth: 600, moreButtonWidth: MORE, activeIndex: 0 })).toEqual({ + visible: [0, 1, 2, 3, 4, 5], + overflow: [], + }); + }); + + it("keeps every tab inline before the container has been measured", () => { + // First paint reports 0; collapsing there would flash a dropdown that + // immediately disappears once layout settles. + expect(partitionTabs({ itemWidths: SIX, containerWidth: 0, moreButtonWidth: MORE, activeIndex: 0 })).toEqual({ + visible: [0, 1, 2, 3, 4, 5], + overflow: [], + }); + }); + + it("moves the tabs that do not fit into the overflow, reserving room for the more button", () => { + // 450px total: the more button takes 40, leaving 410 => four 100px tabs. + expect(partitionTabs({ itemWidths: SIX, containerWidth: 450, moreButtonWidth: MORE, activeIndex: 0 })).toEqual({ + visible: [0, 1, 2, 3], + overflow: [4, 5], + }); + }); + + it("preserves DOM order in both groups", () => { + const result = partitionTabs({ itemWidths: SIX, containerWidth: 250, moreButtonWidth: MORE, activeIndex: 0 }); + expect(result.visible).toEqual([...result.visible].sort((a, b) => a - b)); + expect(result.overflow).toEqual([...result.overflow].sort((a, b) => a - b)); + expect([...result.visible, ...result.overflow].sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4, 5]); + }); + + it("pulls the active tab inline when it would otherwise be hidden", () => { + // Active tab 5 must stay visible: the selected harness is the one the + // user is reading, so it cannot be the one buried in the dropdown. + const result = partitionTabs({ itemWidths: SIX, containerWidth: 450, moreButtonWidth: MORE, activeIndex: 5 }); + expect(result.visible).toContain(5); + expect(result.overflow).not.toContain(5); + }); + + it("keeps the active tab inline even when only one tab fits", () => { + const result = partitionTabs({ itemWidths: SIX, containerWidth: 145, moreButtonWidth: MORE, activeIndex: 3 }); + expect(result.visible).toEqual([3]); + expect(result.overflow).toEqual([0, 1, 2, 4, 5]); + }); + + it("keeps filling after a tab that is too wide to fit", () => { + // A single wide tab must not strand the row: stopping at it left ~190px of + // empty space on the dashboard while three tabs sat in the dropdown. + const result = partitionTabs({ itemWidths: [50, 300, 50, 50], containerWidth: 240, moreButtonWidth: MORE, activeIndex: 0 }); + expect(result.visible).toEqual([0, 2, 3]); + expect(result.overflow).toEqual([1]); + }); + + it("leaves no room for a tab it could still have fitted", () => { + const itemWidths = [50, 300, 50, 50]; + const containerWidth = 240; + const { visible, overflow } = partitionTabs({ itemWidths, containerWidth, moreButtonWidth: MORE, activeIndex: 0 }); + const used = visible.reduce((sum, index) => sum + itemWidths[index], 0); + const available = containerWidth - MORE; + for (const index of overflow) { + expect(used + itemWidths[index]).toBeGreaterThan(available); + } + }); + + it("handles an empty strip", () => { + expect(partitionTabs({ itemWidths: [], containerWidth: 300, moreButtonWidth: MORE, activeIndex: -1 })).toEqual({ + visible: [], + overflow: [], + }); + }); +}); \ No newline at end of file diff --git a/ui/components/ui/tabs.utils.ts b/ui/components/ui/tabs.utils.ts new file mode 100644 index 00000000000..b2a645235ca --- /dev/null +++ b/ui/components/ui/tabs.utils.ts @@ -0,0 +1,72 @@ +/** + * Splits a tab strip into the triggers that fit on one row and the ones that + * have to move into the trailing overflow dropdown. + * + * A horizontally scrolling strip hides tabs behind an affordance most people + * never find - especially inside a sheet, where a trackpad's horizontal gesture + * fights the sheet's own scroll. Collapsing the remainder into a dropdown keeps + * every tab reachable with a normal click. + */ +export interface TabsOverflowInput { + /** Measured width of each trigger, in DOM order. */ + itemWidths: number[]; + /** Width available to the row. */ + containerWidth: number; + /** Width the "more" dropdown trigger occupies once it is shown. */ + moreButtonWidth: number; + /** Index of the active trigger, or -1 when nothing is selected. */ + activeIndex: number; +} + +export interface TabsOverflowResult { + /** Indices rendered inline, in DOM order. */ + visible: number[]; + /** Indices rendered inside the dropdown, in DOM order. */ + overflow: number[]; +} + +export function partitionTabs({ itemWidths, containerWidth, moreButtonWidth, activeIndex }: TabsOverflowInput): TabsOverflowResult { + const allVisible = () => ({ visible: itemWidths.map((_, index) => index), overflow: [] as number[] }); + + if (itemWidths.length === 0) return { visible: [], overflow: [] }; + // Width is 0 until the first layout pass. Collapsing on that reading would + // flash a dropdown that vanishes a frame later. + if (containerWidth <= 0) return allVisible(); + + const total = itemWidths.reduce((sum, width) => sum + width, 0); + if (total <= containerWidth) return allVisible(); + + // Overflowing, so the dropdown trigger is on screen and takes its own room. + const available = containerWidth - moreButtonWidth; + const hasActive = activeIndex >= 0 && activeIndex < itemWidths.length; + + // The selected tab is the one being read, so it claims its width before + // anything else and never hides in the dropdown. + const activeFits = hasActive && itemWidths[activeIndex] <= available; + let used = activeFits ? itemWidths[activeIndex] : 0; + + const visible: number[] = []; + for (let index = 0; index < itemWidths.length; index++) { + if (hasActive && index === activeIndex) continue; + // `continue`, not `break`: one unusually wide tab must not strand the row. + // Stopping at it pushes every narrower tab behind it into the dropdown and + // leaves the freed space visibly empty. Skipping it keeps the remaining + // tabs inline, still in DOM order - only the tab that genuinely cannot fit + // moves into the menu. + if (used + itemWidths[index] > available) continue; + visible.push(index); + used += itemWidths[index]; + } + + if (activeFits) { + visible.push(activeIndex); + visible.sort((a, b) => a - b); + } else if (hasActive && visible.length === 0) { + // Nothing fits at all - show the selected tab rather than an empty row. + visible.push(activeIndex); + } + + const inVisible = new Set(visible); + const overflow = itemWidths.map((_, index) => index).filter((index) => !inVisible.has(index)); + return { visible, overflow }; +} \ No newline at end of file diff --git a/ui/lib/contexts/topbarContext.tsx b/ui/lib/contexts/topbarContext.tsx new file mode 100644 index 00000000000..07ea9c1f80a --- /dev/null +++ b/ui/lib/contexts/topbarContext.tsx @@ -0,0 +1,78 @@ +import { createContext, useContext, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react"; +import { EMPTY_TITLE_ENTRY, claimTitle, releaseTitle, type TopbarTitleEntry } from "./topbarContext.utils"; + +interface TopbarContextValue { + /** Page-supplied title override; null means "fall back to the route-derived title". */ + title: string | null; + /** Write side, ownership-aware. Prefer useSetTopbarTitle over calling this directly. */ + setTitleEntry: Dispatch>; + /** + * DOM node the topbar exposes next to the title. Page descriptions are + * portalled into it rather than lifted through state, because a description + * is arbitrary JSX (links, , conditional spans) whose identity changes + * every render — storing that in context would loop. + */ + descriptionSlot: HTMLElement | null; + setDescriptionSlot: Dispatch>; +} + +const TopbarContext = createContext(null); + +export function TopbarProvider({ children }: { children: React.ReactNode }) { + const [titleEntry, setTitleEntry] = useState(EMPTY_TITLE_ENTRY); + const [descriptionSlot, setDescriptionSlot] = useState(null); + const value = useMemo( + () => ({ title: titleEntry.value, setTitleEntry, descriptionSlot, setDescriptionSlot }), + [titleEntry.value, descriptionSlot], + ); + return {children}; +} + +/** Read side — used by . Returns null outside a provider so the topbar still renders. */ +export function useTopbarTitle(): string | null { + return useContext(TopbarContext)?.title ?? null; +} + +/** Registers the topbar's description anchor. Called by via a ref callback. */ +export function useDescriptionSlotRef() { + return useContext(TopbarContext)?.setDescriptionSlot; +} + +/** Read side for , which portals its description into this node. */ +export function useDescriptionSlot(): HTMLElement | null { + return useContext(TopbarContext)?.descriptionSlot ?? null; +} + +/** + * Write side — a page calls this to name itself in the topbar instead of + * rendering its own

: + * + * useSetTopbarTitle("Budgets & Limits"); + * + * Pass undefined/null to leave the route-derived fallback in place. + * + * The title is cleared on unmount, but only if this caller still owns it: + * during a route transition the incoming page mounts and sets its title before + * the outgoing page's cleanup runs, so an unconditional reset would wipe the + * new title. Ownership is an opaque per-caller token rather than the title + * text, because two routes may legitimately share a title string — see + * topbarContext.utils.ts. + */ +export function useSetTopbarTitle(title: string | null | undefined) { + const setTitleEntry = useContext(TopbarContext)?.setTitleEntry; + const resolved = title ?? null; + // One token per hook instance, stable for its whole lifetime. + const ownerRef = useRef(null); + if (ownerRef.current === null) ownerRef.current = Symbol("topbar-title"); + const owner = ownerRef.current; + + useEffect(() => { + if (!setTitleEntry) return; + setTitleEntry((current) => claimTitle(current, owner, resolved)); + }, [setTitleEntry, owner, resolved]); + + useEffect(() => { + if (!setTitleEntry) return; + return () => setTitleEntry((current) => releaseTitle(current, owner)); + }, [setTitleEntry, owner]); +} \ No newline at end of file diff --git a/ui/lib/contexts/topbarContext.utils.test.ts b/ui/lib/contexts/topbarContext.utils.test.ts new file mode 100644 index 00000000000..f3850d2307d --- /dev/null +++ b/ui/lib/contexts/topbarContext.utils.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { EMPTY_TITLE_ENTRY, claimTitle, releaseTitle } from "./topbarContext.utils"; + +const pageA = Symbol("page-a"); +const pageB = Symbol("page-b"); + +describe("claimTitle", () => { + it("records the value and its owner", () => { + expect(claimTitle(EMPTY_TITLE_ENTRY, pageA, "Logs")).toEqual({ value: "Logs", owner: pageA }); + }); + + it("lets a later page take over the title", () => { + const owned = claimTitle(EMPTY_TITLE_ENTRY, pageA, "Logs"); + expect(claimTitle(owned, pageB, "Budgets & Limits")).toEqual({ value: "Budgets & Limits", owner: pageB }); + }); + + it("returns the same object when nothing changed, so the provider does not re-render", () => { + const owned = claimTitle(EMPTY_TITLE_ENTRY, pageA, "Logs"); + expect(claimTitle(owned, pageA, "Logs")).toBe(owned); + }); +}); + +describe("releaseTitle", () => { + it("clears the title when the owner is still the one unmounting", () => { + const owned = claimTitle(EMPTY_TITLE_ENTRY, pageA, "Logs"); + expect(releaseTitle(owned, pageA)).toEqual(EMPTY_TITLE_ENTRY); + }); + + it("leaves a title set by a different page alone", () => { + // Route change: B mounts and claims before A's cleanup runs. + const handedOver = claimTitle(claimTitle(EMPTY_TITLE_ENTRY, pageA, "Logs"), pageB, "Budgets & Limits"); + expect(releaseTitle(handedOver, pageA)).toEqual({ value: "Budgets & Limits", owner: pageB }); + }); + + it("leaves the incoming page's title alone even when both pages use the same text", () => { + // The reason ownership cannot be inferred from the title string: A and B + // both call themselves "Logs", so comparing text would make A's cleanup + // clear the title B just claimed, dropping the topbar to the + // route-derived fallback. + const handedOver = claimTitle(claimTitle(EMPTY_TITLE_ENTRY, pageA, "Logs"), pageB, "Logs"); + expect(releaseTitle(handedOver, pageA)).toEqual({ value: "Logs", owner: pageB }); + }); +}); \ No newline at end of file diff --git a/ui/lib/contexts/topbarContext.utils.ts b/ui/lib/contexts/topbarContext.utils.ts new file mode 100644 index 00000000000..b40317e7532 --- /dev/null +++ b/ui/lib/contexts/topbarContext.utils.ts @@ -0,0 +1,32 @@ +/** + * Ownership bookkeeping for the topbar title. + * + * A page names itself in the topbar on mount and gives the name back on + * unmount. React runs those out of order across a route change: the incoming + * page's effect fires *before* the outgoing page's cleanup, so a release that + * did not check ownership would wipe the title the new page just set. + * + * Ownership is tracked with an opaque per-caller token rather than the title + * text, because two routes are free to use the same title string and text + * comparison cannot tell "the page I replaced" from "the page that replaced + * me". + */ +export interface TopbarTitleEntry { + /** null means "fall back to the route-derived title". */ + value: string | null; + /** Opaque token identifying the caller that set `value`. */ + owner: symbol | null; +} + +export const EMPTY_TITLE_ENTRY: TopbarTitleEntry = { value: null, owner: null }; + +/** A caller takes the title, becoming its owner. */ +export function claimTitle(current: TopbarTitleEntry, owner: symbol, value: string | null): TopbarTitleEntry { + if (current.value === value && current.owner === owner) return current; + return { value, owner }; +} + +/** A caller gives the title back. A no-op unless that caller still owns it. */ +export function releaseTitle(current: TopbarTitleEntry, owner: symbol): TopbarTitleEntry { + return current.owner === owner ? EMPTY_TITLE_ENTRY : current; +} \ No newline at end of file