diff --git a/.server-changes/appearance-themes-and-options.md b/.server-changes/appearance-themes-and-options.md new file mode 100644 index 00000000000..9f4637763fd --- /dev/null +++ b/.server-changes/appearance-themes-and-options.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +The dashboard has two new themes, Black and White, plus appearance options for stronger colors and underlined links. diff --git a/apps/webapp/app/assets/icons/CircleFilledIcon.tsx b/apps/webapp/app/assets/icons/CircleFilledIcon.tsx new file mode 100644 index 00000000000..a6d10485bee --- /dev/null +++ b/apps/webapp/app/assets/icons/CircleFilledIcon.tsx @@ -0,0 +1,16 @@ +/** Solid circle. Paired with {@link CircleOutlineIcon} by the Black and White + * theme options — the filled disc reads as the opposite of the active theme. */ +export function CircleFilledIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/CircleOutlineIcon.tsx b/apps/webapp/app/assets/icons/CircleOutlineIcon.tsx new file mode 100644 index 00000000000..e60de21907f --- /dev/null +++ b/apps/webapp/app/assets/icons/CircleOutlineIcon.tsx @@ -0,0 +1,16 @@ +/** Hollow circle. Paired with {@link CircleFilledIcon} by the Black and White + * theme options, which show the active theme's background through the ring. */ +export function CircleOutlineIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/EditPencilIcon.tsx b/apps/webapp/app/assets/icons/EditPencilIcon.tsx new file mode 100644 index 00000000000..18728c03e90 --- /dev/null +++ b/apps/webapp/app/assets/icons/EditPencilIcon.tsx @@ -0,0 +1,22 @@ +/** Pencil over a couple of text lines — editing a value in place. */ +export function EditPencilIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MonitorIcon.tsx b/apps/webapp/app/assets/icons/MonitorIcon.tsx new file mode 100644 index 00000000000..09aae279843 --- /dev/null +++ b/apps/webapp/app/assets/icons/MonitorIcon.tsx @@ -0,0 +1,21 @@ +/** Monitor on a stand — the System theme, which follows the OS appearance. */ +export function MonitorIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MoonIcon.tsx b/apps/webapp/app/assets/icons/MoonIcon.tsx new file mode 100644 index 00000000000..f3e20e27f2c --- /dev/null +++ b/apps/webapp/app/assets/icons/MoonIcon.tsx @@ -0,0 +1,21 @@ +/** Crescent moon — the dark theme. */ +export function MoonIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SunIcon.tsx b/apps/webapp/app/assets/icons/SunIcon.tsx new file mode 100644 index 00000000000..1601722dd16 --- /dev/null +++ b/apps/webapp/app/assets/icons/SunIcon.tsx @@ -0,0 +1,29 @@ +export function SunIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx b/apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx new file mode 100644 index 00000000000..51b8136e1dc --- /dev/null +++ b/apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx @@ -0,0 +1,23 @@ +/** Toggle switch, knob to the left. */ +export function ToggleSwitchIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/images/producthunt.png b/apps/webapp/app/assets/images/producthunt.png deleted file mode 100644 index e27a96f6976..00000000000 Binary files a/apps/webapp/app/assets/images/producthunt.png and /dev/null differ diff --git a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx index 02c77d65a73..3aa23807620 100644 --- a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx +++ b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx @@ -20,6 +20,7 @@ import { Paragraph } from "~/components/primitives/Paragraph"; import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton"; import type { BillingLimitResult } from "~/services/billingLimit.schemas"; import { formatCurrency } from "~/utils/numberFormatter"; +import { TextLink } from "~/components/primitives/TextLink"; export const billingLimitFormSchema = z.discriminatedUnion("mode", [ z.object({ @@ -339,10 +340,7 @@ function LimitReachedCalloutContent({ When this limit is reached, queued runs will be held for {gracePeriodLabel}, then new triggers will be rejected until you increase or remove the limit. Limits are enforced with a short delay, so spend may briefly exceed the limit before grace begins. See our{" "} - - terms - {" "} - for refund policy details. + terms for refund policy details. {cancelInProgressRuns ? ( <> In-progress runs will be cancelled when the limit is hit. ) : null} diff --git a/apps/webapp/app/components/billing/FreePlanUsage.tsx b/apps/webapp/app/components/billing/FreePlanUsage.tsx index d1f0716567c..a73e3a8a411 100644 --- a/apps/webapp/app/components/billing/FreePlanUsage.tsx +++ b/apps/webapp/app/components/billing/FreePlanUsage.tsx @@ -1,6 +1,7 @@ import { ArrowUpCircleIcon } from "@heroicons/react/24/outline"; import { Link } from "@remix-run/react"; import { motion, useMotionValue, useTransform } from "framer-motion"; +import { textLinkClassName } from "~/components/primitives/TextLink"; import { useThemeColor } from "~/hooks/useThemeColor"; import { cn } from "~/utils/cn"; @@ -31,7 +32,7 @@ export function FreePlanUsage({ to, percentage }: { to: string; percentage: numb Free Plan - + Upgrade diff --git a/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx b/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx index 70a5da7b826..82558fbdafb 100644 --- a/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx +++ b/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx @@ -1,6 +1,7 @@ import { Link } from "@remix-run/react"; import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix"; import { LinkButton } from "~/components/primitives/Buttons"; +import { textLinkClassName } from "~/components/primitives/TextLink"; import { useOrganization } from "~/hooks/useOrganizations"; import { v3BillingPath } from "~/utils/pathBuilder"; import { ASK_AGENT_LABEL } from "./agent-identity"; @@ -48,10 +49,7 @@ export function AgentQuotaNotice({ remaining, limit }: { remaining: number; limi {remaining} of {limit} free messages left · - + Upgrade diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx index cc1370c12a0..3ecfba0fe9d 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx @@ -54,11 +54,13 @@ export function DashboardAgentComposer({ const sendButton = isStreaming ? ( @@ -109,7 +110,7 @@ function getMarkdownComponents(onLinkClick?: () => void) { href={href} target="_blank" rel="noopener noreferrer" - className="relative z-20 text-indigo-400 underline transition-colors hover:text-indigo-300" + className={cn(textLinkClassName(), "relative z-20")} onClick={(e) => { e.stopPropagation(); onLinkClick?.(); diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index e8a6a99f229..15c940b5f89 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -1,8 +1,4 @@ -import { - ArrowTopRightOnSquareIcon, - ChevronRightIcon, - ExclamationTriangleIcon, -} from "@heroicons/react/24/outline"; +import { ArrowTopRightOnSquareIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid"; import { Form, @@ -25,44 +21,27 @@ import { useState, } from "react"; import { AIChatIcon } from "~/assets/icons/AIChatIcon"; -import { AIPenIcon } from "~/assets/icons/AIPenIcon"; import { ArrowLeftRightIcon } from "~/assets/icons/ArrowLeftRightIcon"; import { ArrowRightSquareIcon } from "~/assets/icons/ArrowRightSquareIcon"; import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon"; -import { BatchesIcon } from "~/assets/icons/BatchesIcon"; import { BellIcon } from "~/assets/icons/BellIcon"; -import { Box3DIcon } from "~/assets/icons/Box3DIcon"; -import { BugIcon } from "~/assets/icons/BugIcon"; import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon"; -import { ChartBarIcon } from "~/assets/icons/ChartBarIcon"; -import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon"; -import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; -import { DeploymentsIcon } from "~/assets/icons/DeploymentsIcon"; -import { DialIcon } from "~/assets/icons/DialIcon"; import { DropdownIcon } from "~/assets/icons/DropdownIcon"; -import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { EyeClosedIcon } from "~/assets/icons/EyeClosedIcon"; import { EyeOpenIcon } from "~/assets/icons/EyeOpenIcon"; import { FolderClosedIcon } from "~/assets/icons/FolderClosedIcon"; import { FolderOpenIcon } from "~/assets/icons/FolderOpenIcon"; -import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon"; import { HomeIcon } from "~/assets/icons/HomeIcon"; -import { IDIcon } from "~/assets/icons/IDIcon"; import { IntegrationsIcon } from "~/assets/icons/IntegrationsIcon"; -import { KeyIcon } from "~/assets/icons/KeyIcon"; import { LeftSideMenuCollapsedIcon } from "~/assets/icons/LeftSideMenuCollapsedIcon"; import { LeftSideMenuIcon } from "~/assets/icons/LeftSideMenuIcon"; -import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; -import { LogsIcon } from "~/assets/icons/LogsIcon"; import { PlusIcon } from "~/assets/icons/PlusIcon"; -import { QueuesIcon } from "~/assets/icons/QueuesIcon"; import { RunsIcon } from "~/assets/icons/RunsIcon"; import { ShieldIcon } from "~/assets/icons/ShieldIcon"; import { SidebarCustomizeIcon } from "~/assets/icons/SidebarCustomizeIcon"; import { SlidersIcon } from "~/assets/icons/SlidersIcon"; import { TasksIcon } from "~/assets/icons/TasksIcon"; import { UsageIcon } from "~/assets/icons/UsageIcon"; -import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; import { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; import { UserCrossIcon } from "~/assets/icons/UserCrossIcon"; import { UserGroupIcon } from "~/assets/icons/UserGroupIcon"; @@ -95,10 +74,7 @@ import { accountSecurityPath, personalAccessTokensPath, adminPath, - branchesPath, - concurrencyPath, docsPath, - limitsPath, logoutPath, newOrganizationPath, newProjectPath, @@ -108,36 +84,20 @@ import { organizationSsoPath, organizationTeamPath, organizationVercelIntegrationPath, - queryPath, - regionsPath, - v3ApiKeysPath, - v3BatchesPath, v3BillingLimitsPath, v3BillingPath, v3PrivateConnectionsPath, - v3BulkActionsPath, - v3DashboardsLandingPath, - v3DeploymentsPath, v3EnvironmentPath, - v3EnvironmentVariablesPath, - v3ErrorsPath, - v3LogsPath, - v3ModelsPath, - v3ProjectAlertsPath, v3ProjectSettingsGeneralPath, - v3ProjectSettingsIntegrationsPath, - v3PromptsPath, - v3QueuesPath, v3RunsPath, v3SessionsPath, v3UsagePath, - v3WaitpointTokensPath, v3WebhooksPath, } from "~/utils/pathBuilder"; import { FreePlanUsage } from "../billing/FreePlanUsage"; import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; -import { AlphaBadge, NewBadge } from "../FeatureBadges"; -import { Button, ButtonContent, LinkButton } from "../primitives/Buttons"; +import { NewBadge } from "../FeatureBadges"; +import { Button, LinkButton } from "../primitives/Buttons"; import { Dialog, DialogTrigger } from "../primitives/Dialog"; import { type RenderIcon } from "../primitives/Icon"; import { Paragraph } from "../primitives/Paragraph"; @@ -160,6 +120,7 @@ import { } from "../primitives/Tooltip"; import { ShortcutsAutoOpen } from "../Shortcuts"; import { type FavoritePage } from "~/services/dashboardPreferences.server"; +import { AppearanceMenuItem } from "./AppearanceMenuItem"; import { CustomizeSidebarDialog, type CustomizeSidebarSection, @@ -180,6 +141,8 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { NotificationPanel } from "./NotificationPanel"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem, SideMenuLabel } from "./SideMenuItem"; +import { buildSideMenuSections, type SideMenuSectionConfig } from "./sideMenuSections"; +import { SideMenuPopoverSubMenu } from "./SideMenuPopoverSubMenu"; import { SideMenuSection } from "./SideMenuSection"; import { isItemHidden, @@ -197,31 +160,6 @@ function getSectionCollapsed( return sideMenu?.collapsedSections?.[sectionId] ?? false; } -type SideMenuItemConfig = { - /** Stable id used for hidden/order preferences; never rename once shipped. */ - id: string; - name: string; - icon: RenderIcon; - activeIconColor: string; - inactiveIconColor?: string; - to: string; - dataAction?: string; - badge?: ReactNode; - trailingIconClassName?: string; - /** Hidden for every user who hasn't set their own preference for this item. */ - defaultHidden?: boolean; - /** Right-side action (e.g. the + button on Dashboards); only rendered when visible. */ - action?: ReactNode; - /** Extra content rendered directly after the item (e.g. the dashboards list). */ - after?: ReactNode; -}; - -type SideMenuSectionConfig = { - id: SideMenuSectionId; - title: string; - items: SideMenuItemConfig[]; -}; - // Impersonation accent (menu border + "Stop impersonating"). Full class strings so Tailwind's // static scanner picks them up. const IMPERSONATION_ACCENT = { @@ -806,223 +744,32 @@ export function SideMenu({ // The customizable sections (everything except Tasks/Runs/Sessions), in DEFAULT order. The // user's saved order/hidden preferences are applied at render below. - const staticSections: SideMenuSectionConfig[] = []; - - if (isAdmin || featureFlags.hasAiAccess) { - staticSections.push({ - id: "ai", - title: "AI", - items: [ - { - id: "prompts", - name: "Prompts", - icon: AIPenIcon, - trailingIconClassName: "size-6", - activeIconColor: "text-aiPrompts", - to: v3PromptsPath(organization, project, environment), - dataAction: "prompts", - badge: , - }, - { - id: "models", - name: "Models", - icon: Box3DIcon, - activeIconColor: "text-models", - to: v3ModelsPath(organization, project, environment), - dataAction: "models", - badge: , - }, - ], - }); - } - - if (isAdmin || featureFlags.hasQueryAccess || featureFlags.hasLogsPageAccess) { - staticSections.push({ - id: "metrics", - title: "Observability", - items: [ - ...(isAdmin || featureFlags.hasLogsPageAccess - ? [ - { - id: "logs", - name: "Logs", - icon: LogsIcon, - activeIconColor: "text-logs", - to: v3LogsPath(organization, project, environment), - dataAction: "logs", - badge: , - } satisfies SideMenuItemConfig, - ] - : []), - ...(isAdmin || featureFlags.hasQueryAccess - ? [ - { - id: "errors", - name: "Errors", - icon: BugIcon, - activeIconColor: "text-errors", - to: v3ErrorsPath(organization, project, environment), - dataAction: "errors", - }, - { - id: "query", - name: "Query", - icon: CodeSquareIcon, - activeIconColor: "text-query", - to: queryPath(organization, project, environment), - dataAction: "query", - }, - { - id: "queues", - name: "Queues", - icon: QueuesIcon, - activeIconColor: "text-queues", - to: v3QueuesPath(organization, project, environment), - dataAction: "queues", - }, - { - id: "dashboards", - name: "Dashboards", - icon: ChartBarIcon, - activeIconColor: "text-metrics", - to: v3DashboardsLandingPath(organization, project, environment), - dataAction: "dashboards-landing", - action: ( - - ), - after: ( - - ), - }, - ] - : []), - ], - }); - } - - staticSections.push({ - id: "deployments", - title: "Deployments", - items: [ - { - id: "deployments", - name: "Deploys", - icon: DeploymentsIcon, - activeIconColor: "text-deployments", - to: v3DeploymentsPath(organization, project, environment), - dataAction: "deployments", - }, - { - id: "environment-variables", - name: "Environment variables", - icon: IDIcon, - activeIconColor: "text-environmentVariables", - to: v3EnvironmentVariablesPath(organization, project, environment), - dataAction: "environment variables", - }, - { - id: "preview-branches", - name: "Preview branches", - icon: BranchEnvironmentIconSmall, - activeIconColor: "text-previewBranches", - to: branchesPath(organization, project, environment), - dataAction: "preview-branches", - }, - { - id: "regions", - name: "Regions", - icon: GlobeLinesIcon, - activeIconColor: "text-regions", - to: regionsPath(organization, project, environment), - dataAction: "regions", - }, - ], - }); - - staticSections.push({ - id: "manage", - title: "Manage", - items: [ - { - id: "waitpoint-tokens", - name: "Waitpoint tokens", - icon: WaitpointTokenIcon, - activeIconColor: "text-sky-500", - to: v3WaitpointTokensPath(organization, project, environment), - dataAction: "waitpoint-tokens", - }, - { - id: "batches", - name: "Batches", - icon: BatchesIcon, - activeIconColor: "text-batches", - to: v3BatchesPath(organization, project, environment), - dataAction: "batches", - }, - { - id: "bulk-actions", - name: "Bulk actions", - icon: ListCheckedIcon, - activeIconColor: "text-text-bright", - to: v3BulkActionsPath(organization, project, environment), - dataAction: "bulk actions", - }, - { - id: "api-keys", - name: "API keys", - icon: KeyIcon, - activeIconColor: "text-text-bright", - to: v3ApiKeysPath(organization, project, environment), - dataAction: "api keys", - }, - { - id: "alerts", - name: "Alerts", - icon: BellIcon, - activeIconColor: "text-text-bright", - to: v3ProjectAlertsPath(organization, project, environment), - dataAction: "alerts", - }, - ...(isManagedCloud - ? [ - { - id: "concurrency", - name: "Concurrency", - icon: ConcurrencyIcon, - activeIconColor: "text-text-bright", - to: concurrencyPath(organization, project, environment), - dataAction: "concurrency", - } satisfies SideMenuItemConfig, - ] - : []), - { - id: "limits", - name: "Limits", - icon: DialIcon, - activeIconColor: "text-text-bright", - to: limitsPath(organization, project, environment), - dataAction: "limits", - }, - { - id: "integrations", - name: "Integrations", - icon: IntegrationsIcon, - activeIconColor: "text-text-bright", - to: v3ProjectSettingsIntegrationsPath(organization, project, environment), - dataAction: "project-settings-integrations", - }, - ], + const staticSections = buildSideMenuSections({ + organization, + project, + environment, + isAdmin, + featureFlags, + isManagedCloud, + dashboards: { + action: ( + + ), + after: ( + + ), + }, }); const sideMenuPrefs = user.dashboardPreferences.sideMenu; @@ -1930,6 +1677,7 @@ function AccountMenuItems({ leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} className={SIDE_MENU_POPOVER_ITEM_LABEL} /> + (null); - - useEffect(() => { - return () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - }; - }, []); - - // Close the submenu on navigation (the parent popover closes too). - useEffect(() => { - // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. - setIsOpen(false); - }, [navigation.location?.pathname]); - - const openNow = () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - setIsOpen(true); - }; - const closeSoon = () => { - // Small delay before closing so the pointer can move onto the content. - timeoutRef.current = setTimeout(() => setIsOpen(false), 150); - }; - - return ( - setIsOpen(open)} open={isOpen}> -
- - - {title} - - - - {children} - -
-
- ); -} - function SwitchOrganizations({ organizations, organization, diff --git a/apps/webapp/app/components/navigation/SideMenuPopoverSubMenu.tsx b/apps/webapp/app/components/navigation/SideMenuPopoverSubMenu.tsx new file mode 100644 index 00000000000..65a3719fbc8 --- /dev/null +++ b/apps/webapp/app/components/navigation/SideMenuPopoverSubMenu.tsx @@ -0,0 +1,88 @@ +import { ChevronRightIcon } from "@heroicons/react/24/outline"; +import { useNavigation } from "@remix-run/react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { cn } from "~/utils/cn"; +import { ButtonContent } from "../primitives/Buttons"; +import { type RenderIcon } from "../primitives/Icon"; +import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover"; +import { SIDE_MENU_POPOVER_ITEM_ICON, SIDE_MENU_POPOVER_ITEM_LABEL } from "./sideMenuTypes"; + +/** + * A menu item whose chevron reveals `children` in a popover to the right, with a + * short close delay so the pointer can cross the gap. + */ +export function SideMenuPopoverSubMenu({ + title, + icon, + leadingIconClassName, + contentClassName, + children, +}: { + title: string; + icon: RenderIcon; + leadingIconClassName?: string; + /** Override the submenu panel's styling, e.g. a narrower width for short entries. */ + contentClassName?: string; + children: ReactNode; +}) { + const navigation = useNavigation(); + const [isOpen, setIsOpen] = useState(false); + const timeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + // Close the submenu on navigation (the parent popover closes too). + useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. + setIsOpen(false); + }, [navigation.location?.pathname]); + + const openNow = () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + setIsOpen(true); + }; + const closeSoon = () => { + // Small delay before closing so the pointer can move onto the content. + timeoutRef.current = setTimeout(() => setIsOpen(false), 150); + }; + + return ( + setIsOpen(open)} open={isOpen}> +
+ + + {title} + + + + {children} + +
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/sideMenuSections.tsx b/apps/webapp/app/components/navigation/sideMenuSections.tsx new file mode 100644 index 00000000000..87d5da61195 --- /dev/null +++ b/apps/webapp/app/components/navigation/sideMenuSections.tsx @@ -0,0 +1,300 @@ +import { type ReactNode } from "react"; +import { AIPenIcon } from "~/assets/icons/AIPenIcon"; +import { BatchesIcon } from "~/assets/icons/BatchesIcon"; +import { BellIcon } from "~/assets/icons/BellIcon"; +import { Box3DIcon } from "~/assets/icons/Box3DIcon"; +import { BugIcon } from "~/assets/icons/BugIcon"; +import { ChartBarIcon } from "~/assets/icons/ChartBarIcon"; +import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon"; +import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; +import { DeploymentsIcon } from "~/assets/icons/DeploymentsIcon"; +import { DialIcon } from "~/assets/icons/DialIcon"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; +import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon"; +import { IDIcon } from "~/assets/icons/IDIcon"; +import { IntegrationsIcon } from "~/assets/icons/IntegrationsIcon"; +import { KeyIcon } from "~/assets/icons/KeyIcon"; +import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; +import { LogsIcon } from "~/assets/icons/LogsIcon"; +import { QueuesIcon } from "~/assets/icons/QueuesIcon"; +import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; +import { + type EnvironmentForPath, + type OrgForPath, + type ProjectForPath, + branchesPath, + concurrencyPath, + limitsPath, + queryPath, + regionsPath, + v3ApiKeysPath, + v3BatchesPath, + v3BulkActionsPath, + v3DashboardsLandingPath, + v3DeploymentsPath, + v3EnvironmentVariablesPath, + v3ErrorsPath, + v3LogsPath, + v3ModelsPath, + v3ProjectAlertsPath, + v3ProjectSettingsIntegrationsPath, + v3PromptsPath, + v3QueuesPath, + v3WaitpointTokensPath, +} from "~/utils/pathBuilder"; +import { AlphaBadge, NewBadge } from "../FeatureBadges"; +import { type RenderIcon } from "../primitives/Icon"; +import { type SideMenuSectionId } from "./sideMenuTypes"; + +// The side menu's customizable sections, in DEFAULT order. Outside SideMenu so +// the profile page can build the same list without one to read from. + +type SideMenuItemConfig = { + /** Stable id used for hidden/order preferences; never rename once shipped. */ + id: string; + name: string; + icon: RenderIcon; + activeIconColor: string; + inactiveIconColor?: string; + to: string; + dataAction?: string; + badge?: ReactNode; + trailingIconClassName?: string; + /** Hidden for every user who hasn't set their own preference for this item. */ + defaultHidden?: boolean; + /** Right-side action (e.g. the + button on Dashboards); only rendered when visible. */ + action?: ReactNode; + /** Extra content rendered directly after the item (e.g. the dashboards list). */ + after?: ReactNode; +}; + +export type SideMenuSectionConfig = { + id: SideMenuSectionId; + title: string; + items: SideMenuItemConfig[]; +}; + +export function buildSideMenuSections({ + organization, + project, + environment, + isAdmin, + featureFlags, + isManagedCloud, + dashboards, +}: { + organization: OrgForPath; + project: ProjectForPath; + environment: EnvironmentForPath; + isAdmin: boolean; + featureFlags: { hasAiAccess?: boolean; hasQueryAccess?: boolean; hasLogsPageAccess?: boolean }; + isManagedCloud: boolean; + /** Side-menu-only extras on the Dashboards item; the customize modal has no use for them. */ + dashboards?: { action?: ReactNode; after?: ReactNode }; +}): SideMenuSectionConfig[] { + const staticSections: SideMenuSectionConfig[] = []; + + if (isAdmin || featureFlags.hasAiAccess) { + staticSections.push({ + id: "ai", + title: "AI", + items: [ + { + id: "prompts", + name: "Prompts", + icon: AIPenIcon, + trailingIconClassName: "size-6", + activeIconColor: "text-aiPrompts", + to: v3PromptsPath(organization, project, environment), + dataAction: "prompts", + badge: , + }, + { + id: "models", + name: "Models", + icon: Box3DIcon, + activeIconColor: "text-models", + to: v3ModelsPath(organization, project, environment), + dataAction: "models", + badge: , + }, + ], + }); + } + + if (isAdmin || featureFlags.hasQueryAccess || featureFlags.hasLogsPageAccess) { + staticSections.push({ + id: "metrics", + title: "Observability", + items: [ + ...(isAdmin || featureFlags.hasLogsPageAccess + ? [ + { + id: "logs", + name: "Logs", + icon: LogsIcon, + activeIconColor: "text-logs", + to: v3LogsPath(organization, project, environment), + dataAction: "logs", + badge: , + } satisfies SideMenuItemConfig, + ] + : []), + ...(isAdmin || featureFlags.hasQueryAccess + ? [ + { + id: "errors", + name: "Errors", + icon: BugIcon, + activeIconColor: "text-errors", + to: v3ErrorsPath(organization, project, environment), + dataAction: "errors", + } satisfies SideMenuItemConfig, + { + id: "query", + name: "Query", + icon: CodeSquareIcon, + activeIconColor: "text-query", + to: queryPath(organization, project, environment), + dataAction: "query", + } satisfies SideMenuItemConfig, + { + id: "queues", + name: "Queues", + icon: QueuesIcon, + activeIconColor: "text-queues", + to: v3QueuesPath(organization, project, environment), + dataAction: "queues", + } satisfies SideMenuItemConfig, + { + id: "dashboards", + name: "Dashboards", + icon: ChartBarIcon, + activeIconColor: "text-metrics", + to: v3DashboardsLandingPath(organization, project, environment), + dataAction: "dashboards-landing", + action: dashboards?.action, + after: dashboards?.after, + } satisfies SideMenuItemConfig, + ] + : []), + ], + }); + } + + staticSections.push({ + id: "deployments", + title: "Deployments", + items: [ + { + id: "deployments", + name: "Deploys", + icon: DeploymentsIcon, + activeIconColor: "text-deployments", + to: v3DeploymentsPath(organization, project, environment), + dataAction: "deployments", + }, + { + id: "environment-variables", + name: "Environment variables", + icon: IDIcon, + activeIconColor: "text-environmentVariables", + to: v3EnvironmentVariablesPath(organization, project, environment), + dataAction: "environment variables", + }, + { + id: "preview-branches", + name: "Preview branches", + icon: BranchEnvironmentIconSmall, + activeIconColor: "text-previewBranches", + to: branchesPath(organization, project, environment), + dataAction: "preview-branches", + }, + { + id: "regions", + name: "Regions", + icon: GlobeLinesIcon, + activeIconColor: "text-regions", + to: regionsPath(organization, project, environment), + dataAction: "regions", + }, + ], + }); + + staticSections.push({ + id: "manage", + title: "Manage", + items: [ + { + id: "waitpoint-tokens", + name: "Waitpoint tokens", + icon: WaitpointTokenIcon, + activeIconColor: "text-sky-500", + to: v3WaitpointTokensPath(organization, project, environment), + dataAction: "waitpoint-tokens", + }, + { + id: "batches", + name: "Batches", + icon: BatchesIcon, + activeIconColor: "text-batches", + to: v3BatchesPath(organization, project, environment), + dataAction: "batches", + }, + { + id: "bulk-actions", + name: "Bulk actions", + icon: ListCheckedIcon, + activeIconColor: "text-text-bright", + to: v3BulkActionsPath(organization, project, environment), + dataAction: "bulk actions", + }, + { + id: "api-keys", + name: "API keys", + icon: KeyIcon, + activeIconColor: "text-text-bright", + to: v3ApiKeysPath(organization, project, environment), + dataAction: "api keys", + }, + { + id: "alerts", + name: "Alerts", + icon: BellIcon, + activeIconColor: "text-text-bright", + to: v3ProjectAlertsPath(organization, project, environment), + dataAction: "alerts", + }, + ...(isManagedCloud + ? [ + { + id: "concurrency", + name: "Concurrency", + icon: ConcurrencyIcon, + activeIconColor: "text-text-bright", + to: concurrencyPath(organization, project, environment), + dataAction: "concurrency", + } satisfies SideMenuItemConfig, + ] + : []), + { + id: "limits", + name: "Limits", + icon: DialIcon, + activeIconColor: "text-text-bright", + to: limitsPath(organization, project, environment), + dataAction: "limits", + }, + { + id: "integrations", + name: "Integrations", + icon: IntegrationsIcon, + activeIconColor: "text-text-bright", + to: v3ProjectSettingsIntegrationsPath(organization, project, environment), + dataAction: "project-settings-integrations", + }, + ], + }); + + return staticSections; +} diff --git a/apps/webapp/app/components/primitives/Avatar.tsx b/apps/webapp/app/components/primitives/Avatar.tsx index 52b2af5d9a6..e044e7fae73 100644 --- a/apps/webapp/app/components/primitives/Avatar.tsx +++ b/apps/webapp/app/components/primitives/Avatar.tsx @@ -118,14 +118,31 @@ function styleFromSize(size: number) { }; } -// Bright tiles (Yellow, Orange) need dark letters for contrast; the rest read -// best with white. +const AVATAR_LETTER_DARK = "#272a2e"; +const AVATAR_LETTER_LIGHT = "#ffffff"; + +/** WCAG relative luminance, so the pair below compares the way a checker would. */ +function relativeLuminance(r: number, g: number, b: number): number { + const channel = (value: number) => { + const c = value / 255; + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +} + +function contrastRatio(a: number, b: number): number { + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +} + +/** Dark or white initials, whichever holds more contrast against the tile. */ function letterColorForBackground(hex: string): string { const match = /^#?([0-9a-f]{6})$/i.exec(hex); - if (!match) return "#fff"; + if (!match) return AVATAR_LETTER_LIGHT; const n = parseInt(match[1], 16); - const luminance = 0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255); - return luminance > 140 ? "#272A2E" : "#fff"; + const tile = relativeLuminance((n >> 16) & 255, (n >> 8) & 255, n & 255); + const onDark = contrastRatio(tile, relativeLuminance(39, 42, 46)); + const onLight = contrastRatio(tile, 1); + return onDark >= onLight ? AVATAR_LETTER_DARK : AVATAR_LETTER_LIGHT; } function AvatarLetters({ diff --git a/apps/webapp/app/components/primitives/Badge.tsx b/apps/webapp/app/components/primitives/Badge.tsx index 15733bcc647..873d4cc8c56 100644 --- a/apps/webapp/app/components/primitives/Badge.tsx +++ b/apps/webapp/app/components/primitives/Badge.tsx @@ -3,15 +3,16 @@ import { cn } from "~/utils/cn"; const variants = { default: - "grid place-items-center rounded-full px-2 h-5 tracking-wider text-xxs bg-background-hover text-text-bright uppercase whitespace-nowrap", + "grid place-items-center rounded-full px-2 h-5 tracking-wider text-xxs bg-background-hover text-text-bright system:bg-charcoal-500 system:text-white uppercase whitespace-nowrap", "extra-small": - "grid place-items-center border border-border-bright rounded-sm px-1 h-4 text-xxs bg-background-bright text-blue-500 system:border-transparent system:bg-blue-500/10 system:text-blue-500 whitespace-nowrap", + "grid place-items-center border border-border-bright rounded-sm px-1 h-4 text-xxs bg-background-bright text-blue-500 system:border-transparent system:bg-blue-500 system:text-white whitespace-nowrap", small: - "grid place-items-center border border-border-bright rounded-sm px-1 h-5 text-xs bg-background-bright text-blue-500 system:border-transparent system:bg-blue-500/10 system:text-blue-500 whitespace-nowrap", + "grid place-items-center border border-border-bright rounded-sm px-1 h-5 text-xs bg-background-bright text-blue-500 system:border-transparent system:bg-blue-500 system:text-white whitespace-nowrap", "outline-rounded": - "grid place-items-center rounded-full px-1 h-4 tracking-wider text-xxs border border-blue-500 text-blue-500 uppercase whitespace-nowrap", + "grid place-items-center rounded-full px-1 h-4 tracking-wider text-xxs border border-blue-500 text-blue-500 system:border-transparent system:bg-blue-500 system:text-white uppercase whitespace-nowrap", + // White, not text-text-bright: the fill is the same in every theme. rounded: - "grid place-items-center rounded-full px-1.5 h-4 text-xxs border bg-blue-600 text-text-bright system:border-transparent system:text-white uppercase whitespace-nowrap", + "grid place-items-center rounded-full px-1.5 h-4 text-xxs border bg-blue-600 text-white system:border-transparent uppercase whitespace-nowrap", }; type BadgeProps = React.HTMLAttributes & { diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 7446eccf041..03d3c55384a 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -61,7 +61,8 @@ const theme = { primary: { textColor: "text-white transition group-disabled/button:text-white/60", button: - "bg-indigo-600 border border-indigo-500 group-hover/button:bg-indigo-500 group-hover/button:border-indigo-400 group-disabled/button:opacity-50 group-disabled/button:bg-indigo-600 group-disabled/button:border-indigo-500 group-disabled/button:pointer-events-none", + // Shares --color-accent-fill with the switch's checked track. + "bg-accent-fill border border-lavender-500 group-hover/button:bg-lavender-500 group-hover/button:border-lavender-400 group-disabled/button:opacity-50 group-disabled/button:bg-accent-fill group-disabled/button:border-lavender-500 group-disabled/button:pointer-events-none", shortcut: "border-white/40 text-white group-hover/button:border-white/60 group-hover/button:text-white", icon: "text-white", @@ -69,7 +70,8 @@ const theme = { secondary: { textColor: "text-text-bright transition group-disabled/button:text-text-dimmed/80", button: - "bg-secondary border border-border-bright/50 shadow-xs group-hover/button:bg-background-raised group-disabled/button:bg-secondary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none", + // Hover steps up the scale; background-raised would be darker. + "bg-secondary border border-border-bright/50 shadow-xs group-hover/button:bg-background-raised dark:group-hover/button:bg-surface-control group-disabled/button:bg-secondary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none", shortcut: "border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed", icon: "text-text-bright", @@ -296,6 +298,9 @@ export function ButtonContent(props: ButtonContentPropsType) { className={cn( textAlignLeft ? "text-left" : "justify-center", "flex w-full items-center", + // Set here, not on the string branch below, so element children + // inherit the variant's colour rather than the page's. + textColorClassName, iconSpacingClassName, iconSpacing, showSpinner && "invisible" @@ -315,9 +320,7 @@ export function ButtonContent(props: ButtonContentPropsType) { {text && (typeof text === "string" ? ( - - {text} - + {text} ) : ( text ))} @@ -345,8 +348,11 @@ export function ButtonContent(props: ButtonContentPropsType) { renderShortcutKey()} {showSpinner && ( - - + // Inherits the variant's colour so the spinner tracks its button. + + )} diff --git a/apps/webapp/app/components/primitives/Calendar.tsx b/apps/webapp/app/components/primitives/Calendar.tsx index 22ddc7d4149..7d96a370527 100644 --- a/apps/webapp/app/components/primitives/Calendar.tsx +++ b/apps/webapp/app/components/primitives/Calendar.tsx @@ -103,8 +103,10 @@ export function Calendar({ ), range_start: "day-range-start rounded-l-md", range_end: "day-range-end rounded-r-md", + // White rather than text-text-bright: that token is near-black on the + // light themes, which put the selected day at 2.66:1 on indigo-600. selected: - "bg-indigo-600 text-text-bright hover:bg-indigo-600 hover:text-text-bright focus:bg-indigo-600 focus:text-text-bright rounded-md", + "bg-indigo-600 text-white hover:bg-indigo-600 hover:text-white focus:bg-indigo-600 focus:text-white rounded-md", today: "bg-background-raised text-text-bright rounded-md", outside: "day-outside text-text-dimmed opacity-50 aria-selected:bg-background-raised/50 aria-selected:text-text-dimmed aria-selected:opacity-30", diff --git a/apps/webapp/app/components/primitives/CopyButton.tsx b/apps/webapp/app/components/primitives/CopyButton.tsx index 88a663dfe27..566609a853d 100644 --- a/apps/webapp/app/components/primitives/CopyButton.tsx +++ b/apps/webapp/app/components/primitives/CopyButton.tsx @@ -104,6 +104,9 @@ export function CopyButton({ return ( ; without asChild the tooltip + // trigger wraps it in its own, and the browser parser splits the nested + // buttons apart, which React then fails to hydrate. asChild tabbable button={iconButton} diff --git a/apps/webapp/app/components/primitives/PulsingDot.tsx b/apps/webapp/app/components/primitives/PulsingDot.tsx index 97c2a109306..7a59df7bc18 100644 --- a/apps/webapp/app/components/primitives/PulsingDot.tsx +++ b/apps/webapp/app/components/primitives/PulsingDot.tsx @@ -9,15 +9,17 @@ export function PulsingDot({ ringClassName?: string; dotClassName?: string; }) { + /* The dot fills the container, so resizing the whole thing scales the dot and + the ping ring together. */ return ( - + - + ); } diff --git a/apps/webapp/app/components/primitives/SegmentedControl.tsx b/apps/webapp/app/components/primitives/SegmentedControl.tsx index 6d88d2aff3b..eca5438c34a 100644 --- a/apps/webapp/app/components/primitives/SegmentedControl.tsx +++ b/apps/webapp/app/components/primitives/SegmentedControl.tsx @@ -24,7 +24,7 @@ const theme = { selected: "absolute inset-0 rounded-[2px] outline-solid outline-3 outline-primary", }, secondary: { - base: "bg-transparent dark:bg-background-raised/50", + base: "bg-segmented-track", active: "text-text-bright", inactive: "text-text-dimmed transition hover:text-text-bright", selected: diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index 04b7467dec7..466dcc5c82e 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -30,8 +30,9 @@ const style = { "bg-transparent focus-custom hover:bg-tertiary disabled:bg-transparent disabled:pointer-events-none", }, secondary: { + // Matches the secondary button's hover. button: - "bg-secondary focus-custom border border-border-bright/50 shadow-xs hover:text-text-bright text-text-bright hover:bg-background-raised", + "bg-secondary focus-custom border border-border-bright/50 shadow-xs text-text-bright hover:bg-background-raised dark:hover:bg-surface-control", }, }; @@ -355,8 +356,9 @@ export function SelectTrigger({ {dropdownIcon === true ? ( ) : !dropdownIcon ? null : ( diff --git a/apps/webapp/app/components/primitives/SettingsLayout.tsx b/apps/webapp/app/components/primitives/SettingsLayout.tsx index 1cb5b651f07..784174c07ea 100644 --- a/apps/webapp/app/components/primitives/SettingsLayout.tsx +++ b/apps/webapp/app/components/primitives/SettingsLayout.tsx @@ -40,6 +40,13 @@ export function SettingsContainer({ ); } +/** + * For pages that hand-roll their sections. Must stay in step with the `mt-13` in + * `SettingsSection`: Tailwind only generates classes written as full literals, + * so the two can't be composed. + */ +export const SETTINGS_SECTION_GAP = "mt-13"; + /** A group of related rows. Adds vertical spacing between sibling sections. */ export function SettingsSection({ children, @@ -49,7 +56,7 @@ export function SettingsSection({ className?: string; }) { return ( -
{children}
+
{children}
); } @@ -112,7 +119,6 @@ export function SettingsRowTitle({ ); } -/** Description/subtitle typography for a row. */ export function SettingsRowDescription({ children, className, @@ -127,6 +133,9 @@ export function SettingsRowDescription({ ); } +/** Title-to-description spacing, shared with anything hand-rolling the pair. */ +export const SETTINGS_ROW_TITLE_GAP = "space-y-0.5"; + /** * A single settings row: title + description on the left, action on the right. * @@ -168,7 +177,7 @@ export function SettingsRow({ )} > {children ?? ( -
+
{title ? ( {title} @@ -227,7 +236,7 @@ export function SettingsAlertRow({ return ( -
+
{title} diff --git a/apps/webapp/app/components/primitives/Slider.tsx b/apps/webapp/app/components/primitives/Slider.tsx index e3ab6cdcd4e..c165aa97c15 100644 --- a/apps/webapp/app/components/primitives/Slider.tsx +++ b/apps/webapp/app/components/primitives/Slider.tsx @@ -1,21 +1,24 @@ import * as RadixSlider from "@radix-ui/react-slider"; -import type { ComponentProps } from "react"; +import { type ComponentProps, useEffect, useState } from "react"; import { cn } from "~/utils/cn"; import type { RenderIcon } from "./Icon"; import { Icon } from "./Icon"; +import { SimpleTooltip } from "./Tooltip"; const variants = { - /* Quiet variant for settings rows: no hover box, no thumb halo */ settings: { container: "h-6 gap-1 rounded-sm px-1", icons: "h-4 w-4 text-text-bright", root: "h-4 grow", track: "h-1 bg-grid-bright", range: "bg-transparent", - // Matches the Switch thumb; the secondary-button hairline+shadow keeps the - // white dot visible on the light track + // #f4f4f6 (white/charcoal-100 midpoint) is opaque on purpose: a half-alpha + // hover would let the track line show through the handle. thumb: - "h-3 w-3 border border-border-bright bg-white shadow-sm dark:border-transparent dark:bg-charcoal-200 dark:shadow-none", + "h-4.5 w-4.5 border border-border-bright bg-white shadow-sm hover:bg-[#f4f4f6] dark:border-transparent dark:bg-charcoal-300 dark:shadow-none dark:hover:bg-charcoal-200", + thumbSize: 18, + mark: "bg-grid-bright border-background-dimmed", + markHover: "hover:bg-text-dimmed", }, tertiary: { container: "h-6 gap-1 rounded-sm hover:bg-background-raised px-1", @@ -23,8 +26,10 @@ const variants = { root: "h-4", track: "h-1 bg-grid-bright group-hover:bg-background-dimmed", range: "bg-transparent group-hover:bg-secondary", - thumb: - "h-3 w-3 border-2 border-text-dimmed bg-grid-bright shadow-[0_1px_3px_4px_rgb(0_0_0/0.2),0_1px_2px_-1px_rgb(0_0_0/0.1)] hover:border-text-dimmed focus:shadow-[0_1px_3px_4px_rgb(0_0_0/0.2),0_1px_2px_-1px_rgb(0_0_0/0.1)]", + thumb: "h-3 w-3 border-2 border-text-dimmed bg-grid-bright", + thumbSize: 12, + mark: "bg-grid-bright border-background-dimmed", + markHover: "hover:bg-text-dimmed", }, }; @@ -34,6 +39,18 @@ export type SliderProps = ComponentProps & { LeadingIcon?: RenderIcon; TrailingIcon?: RenderIcon; variant: VariantName; + /** Label above the thumb while hovered or dragged. Reads `value`, so pass one. */ + valueTooltip?: (value: number) => string; + /** Values to tick on the track, e.g. the setting's default. */ + marks?: SliderMark[]; +}; + +type SliderMark = { + value: number; + /** Tooltip on hover, and the accessible name once `onSelect` is set. */ + label?: string; + /** Makes the mark a button, e.g. to reset the setting to its default. */ + onSelect?: () => void; }; export function Slider({ @@ -42,11 +59,35 @@ export function Slider({ LeadingIcon, TrailingIcon, "aria-label": ariaLabel, + valueTooltip, + marks, ...props }: SliderProps) { const variation = variants[variant]; + const [isDragging, setIsDragging] = useState(false); + + /* Root's onPointerUp only fires if the release lands back on the slider, so + watch the window while a drag is in progress. */ + useEffect(() => { + if (!isDragging) return; + const stop = () => setIsDragging(false); + window.addEventListener("pointerup", stop); + window.addEventListener("pointercancel", stop); + return () => { + window.removeEventListener("pointerup", stop); + window.removeEventListener("pointercancel", stop); + }; + }, [isDragging]); + const currentValue = props.value?.[0] ?? props.defaultValue?.[0] ?? 0; + const min = props.min ?? 0; + const max = props.max ?? 100; + return ( -
+
setIsDragging(false)} + > {LeadingIcon && } { + props.onPointerDown?.(event); + setIsDragging(true); + }} + onPointerUp={(event) => { + props.onPointerUp?.(event); + setIsDragging(false); + }} + onPointerCancel={(event) => { + props.onPointerCancel?.(event); + setIsDragging(false); + }} > - {/* The thumb is the role="slider" element, so the label lives here */} + {marks?.map((mark) => { + const percent = ((mark.value - min) / (max - min)) * 100; + if (!Number.isFinite(percent) || percent < 0 || percent > 100) return null; + // Same formula as Radix's `getThumbInBoundsOffset`, so the tick lands + // under the thumb's centre. + const offset = variation.thumbSize * (0.5 - percent / 100); + const style = { left: `calc(${percent}% + ${offset}px)` }; + // box-content hangs the borders outside the 2px line, so they read as + // a gap in the track rather than eating it. + const markClassName = cn( + "absolute top-1/2 box-content h-4 w-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-x-[3px]", + variation.mark + ); + + if (!mark.onSelect) { + return ; + } + + return ( + event.stopPropagation()} + onClick={mark.onSelect} + /> + } + /> + ); + })} + > + {valueTooltip && ( + + {valueTooltip(currentValue)} + + + )} + {TrailingIcon && }
diff --git a/apps/webapp/app/components/primitives/Spinner.tsx b/apps/webapp/app/components/primitives/Spinner.tsx index 1fccb133304..0e86e3f557d 100644 --- a/apps/webapp/app/components/primitives/Spinner.tsx +++ b/apps/webapp/app/components/primitives/Spinner.tsx @@ -11,7 +11,7 @@ export function Spinner({ color = "blue", }: { className?: string; - color?: "blue" | "white" | "muted" | "dark" | CustomColor; + color?: "blue" | "white" | "muted" | "dark" | "inherit" | CustomColor; }) { const colors = { blue: { @@ -22,13 +22,22 @@ export function Spinner({ background: "rgba(255, 255, 255, 0.4)", foreground: "rgba(255, 255, 255)", }, + /* Theme tokens rather than fixed values, so a muted spinner stays muted + against a light surface instead of staying dark-theme navy. */ muted: { - background: "#1C2433", - foreground: "#3C4B62", + background: "var(--color-grid-bright)", + foreground: "var(--color-text-dimmed)", }, dark: { - background: "rgba(18, 19, 23, 0.35)", - foreground: "#1A1B1F", + background: "color-mix(in srgb, var(--color-charcoal-900) 35%, transparent)", + foreground: "var(--color-charcoal-900)", + }, + /* Takes the surrounding text color, so it follows both the theme and + whatever it sits on - white on a primary button, dark ink on a + secondary one once the theme is light. */ + inherit: { + background: "color-mix(in srgb, currentColor 40%, transparent)", + foreground: "currentColor", }, }; diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx index eed7799fea5..a78623ee017 100644 --- a/apps/webapp/app/components/primitives/Switch.tsx +++ b/apps/webapp/app/components/primitives/Switch.tsx @@ -5,6 +5,18 @@ import * as SwitchPrimitives from "@radix-ui/react-switch"; import { cn } from "~/utils/cn"; import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys"; +/* + The thumb sits inside the track's 2px transparent border, so an even gap all + round means thumb height == the content box and a travel of (width - thumb). + The h-4 w-7.5 track is a 12x26 box, hence a 12px thumb travelling 14px. The + press squish widens the thumb and takes the same off the travel, +4/-4, so the + leading edge stays pinned. `small` still has the original uneven gap. +*/ +const MEDIUM_THUMB = cn( + "h-3 w-3 data-[state=checked]:translate-x-3.5 data-[state=unchecked]:translate-x-0", + "group-active:w-4 group-active:data-[state=checked]:translate-x-2.5" +); + const small = { container: "flex items-center h-6 gap-x-1.5 rounded hover:bg-tertiary pr-1 py-[0.1rem] pl-1.5 hover:disabled:bg-background-raised transition focus-custom disabled:opacity-50 text-text-dimmed hover:text-text-bright disabled:hover:cursor-not-allowed hover:cursor-pointer disabled:hover:text-rose-500", @@ -55,10 +67,14 @@ const variations = { container: "flex items-center gap-x-2 rounded-md hover:bg-tertiary py-1.5 px-2 transition focus-custom", root: "h-4 w-7.5", - thumb: cn( - "h-3.5 w-3.5 data-[state=checked]:translate-x-3 data-[state=unchecked]:translate-x-0", - "group-active:w-4.5 group-active:data-[state=checked]:translate-x-2" - ), + thumb: MEDIUM_THUMB, + text: "text-sm text-text-dimmed", + }, + /* Medium without the hover box, for rows that carry their own affordance. */ + "minimal/medium": { + container: "flex items-center gap-x-2 rounded-md focus-custom", + root: "h-4 w-7.5", + thumb: MEDIUM_THUMB, text: "text-sm text-text-dimmed", }, }; @@ -98,14 +114,17 @@ export const Switch = React.forwardRef
diff --git a/apps/webapp/app/components/primitives/Tabs.tsx b/apps/webapp/app/components/primitives/Tabs.tsx index d7762f33843..aa7631f4682 100644 --- a/apps/webapp/app/components/primitives/Tabs.tsx +++ b/apps/webapp/app/components/primitives/Tabs.tsx @@ -15,7 +15,7 @@ export const TITLE_BAR_CHROME = "flex h-10 shrink-0 gap-x-6 border-b border-grid const titleTabLabel = cn(headerVariants.header2.text, "transition duration-200"); const titleTabIndicator = "h-0.5 w-full bg-indigo-500"; const titleTabIndicatorIdle = - "h-0.5 w-full bg-surface-control-active opacity-0 transition duration-200 group-hover:opacity-100"; + "h-0.5 w-full bg-surface-control-active opacity-0 transition duration-200 group-hover/tab:opacity-100"; export type TabsProps = { tabs: { @@ -98,7 +98,7 @@ function TabLink({ return ( {({ isActive, isPending }) => { @@ -111,7 +111,7 @@ function TabLink({ "text-sm transition duration-200", active ? "text-text-bright" - : "text-text-dimmed transition group-hover:text-text-bright" + : "text-text-dimmed transition group-hover/tab:text-text-bright" )} > {children} @@ -133,7 +133,7 @@ function TabLink({ if (variant === "title") { return ( - + {({ isActive, isPending }) => { const active = isActive || isPending; return ( @@ -142,7 +142,9 @@ function TabLink({ {children} @@ -190,7 +192,7 @@ function TabLink({ // underline variant (default) return ( - + {({ isActive, isPending }) => { return ( <> @@ -211,7 +213,7 @@ function TabLink({ className="mt-1 h-0.5 w-full bg-indigo-500" /> ) : ( -
+
)} ); @@ -225,12 +227,15 @@ export function TabButton({ layoutId, shortcut, variant = "underline", + size = "base", ...props }: { isActive: boolean; shortcut?: ShortcutDefinition; layoutId: string; variant?: Variants; + /** `"small"` drops the title variant's label to body size. Layout is unchanged. */ + size?: "base" | "small"; } & React.ButtonHTMLAttributes) { const ref = useRef(null); @@ -249,7 +254,7 @@ export function TabButton({ return ( ); diff --git a/apps/webapp/app/components/primitives/charts/ChartLine.tsx b/apps/webapp/app/components/primitives/charts/ChartLine.tsx index 3e14ca06c2b..5275c2d1393 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLine.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLine.tsx @@ -168,7 +168,7 @@ function ReferenceLineLabel({ y={viewBox.y} dominantBaseline="middle" textAnchor="start" - fill="#878C99" + className="fill-text-dimmed" fontSize={REFERENCE_LABEL_FONT_SIZE} > {value} @@ -180,7 +180,7 @@ function ReferenceLineLabel({ x={viewBox.x + viewBox.width - 4} y={viewBox.y + 12} textAnchor="end" - fill="#878C99" + className="fill-text-dimmed" fontSize={REFERENCE_LABEL_FONT_SIZE} > {value} @@ -458,7 +458,7 @@ export function ChartLineRenderer({ - + {label}
@@ -274,12 +270,7 @@ export function renderPart(part: UIMessage["parts"][number], i: number) { } return ( diff --git a/apps/webapp/app/components/runs/v3/ai/AIToolsInventory.tsx b/apps/webapp/app/components/runs/v3/ai/AIToolsInventory.tsx index beafcbb7000..77c534bd73a 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIToolsInventory.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIToolsInventory.tsx @@ -2,6 +2,8 @@ import { useState } from "react"; import { CodeBlock } from "~/components/code/CodeBlock"; import type { AISpanData, ToolDefinition } from "./types"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { textLinkClassName } from "~/components/primitives/TextLink"; +import { cn } from "~/utils/cn"; export function AIToolsInventory({ aiData }: { aiData: AISpanData }) { const defs = aiData.toolDefinitions ?? []; @@ -49,7 +51,7 @@ function ToolDefRow({ def, wasCalled }: { def: ToolDefinition; wasCalled: boolea diff --git a/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx b/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx index 53f561c652c..c28ed7b49b3 100644 --- a/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx +++ b/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx @@ -245,7 +245,7 @@ export function PurchaseSchedulesModal({ type="submit" disabled={isLoading} > - {`Send request for ${formatNumber( + {`Send request for ${formatNumber( amountValue )}`} @@ -259,7 +259,7 @@ export function PurchaseSchedulesModal({ disabled={isLoading || state === "need_to_delete"} LeadingIcon={isLoading ? SpinnerWhite : undefined} > - {`Remove ${formatNumber( + {`Remove ${formatNumber( extraSchedules - amountValue )} ${extraSchedules - amountValue === 1 ? "schedule" : "schedules"}`} @@ -273,7 +273,7 @@ export function PurchaseSchedulesModal({ disabled={isLoading || state === "no_change"} LeadingIcon={isLoading ? SpinnerWhite : undefined} > - {`Purchase ${formatNumber( + {`Purchase ${formatNumber( amountValue - extraSchedules )} ${amountValue - extraSchedules === 1 ? "schedule" : "schedules"}`} diff --git a/apps/webapp/app/components/themeOptions.ts b/apps/webapp/app/components/themeOptions.ts new file mode 100644 index 00000000000..3e287be6194 --- /dev/null +++ b/apps/webapp/app/components/themeOptions.ts @@ -0,0 +1,54 @@ +import { type FunctionComponent } from "react"; +import { CircleFilledIcon } from "~/assets/icons/CircleFilledIcon"; +import { CircleOutlineIcon } from "~/assets/icons/CircleOutlineIcon"; +import { MonitorIcon } from "~/assets/icons/MonitorIcon"; +import { MoonIcon } from "~/assets/icons/MoonIcon"; +import { SunIcon } from "~/assets/icons/SunIcon"; +import { type ThemeAppearance } from "~/hooks/useSystemThemeSync"; +import { type ThemePreference } from "~/utils/themePreference"; + +export type ThemeOption = { + value: ThemePreference; + label: string; + icon: FunctionComponent<{ className?: string }>; +}; + +/** Shared by every theme picker, in display order. */ +export const THEME_OPTIONS: ThemeOption[] = [ + { value: "system", label: "System", icon: MonitorIcon }, + { value: "light", label: "Light", icon: SunIcon }, + { value: "dark", label: "Dark", icon: MoonIcon }, +]; + +/** Account page only. Icons are the dark-theme pair; `themeOptionIcon` swaps them. */ +const FLAT_OPTIONS: ThemeOption[] = [ + { value: "white", label: "White", icon: CircleFilledIcon }, + { value: "black", label: "Black", icon: CircleOutlineIcon }, +]; + +export const ALL_THEME_OPTIONS: ThemeOption[] = [...THEME_OPTIONS, ...FLAT_OPTIONS]; + +export const THEME_OPTIONS_BY_VALUE = Object.fromEntries( + ALL_THEME_OPTIONS.map((option) => [option.value, option]) +) as Record; + +/** + * Black and White show the active background through the circle: the option + * matching the current end is a ring, the opposing one a solid disc. + */ +export function themeOptionIcon(option: ThemeOption, appearance: ThemeAppearance) { + if (option.value === "black") { + return appearance === "dark" ? CircleOutlineIcon : CircleFilledIcon; + } + if (option.value === "white") { + return appearance === "light" ? CircleOutlineIcon : CircleFilledIcon; + } + return option.icon; +} + +export const SYSTEM_LIGHT_OPTIONS: ThemeOption[] = ALL_THEME_OPTIONS.filter( + (option) => option.value === "light" || option.value === "white" +); +export const SYSTEM_DARK_OPTIONS: ThemeOption[] = ALL_THEME_OPTIONS.filter( + (option) => option.value === "dark" || option.value === "black" +); diff --git a/apps/webapp/app/hooks/useSystemThemeSync.ts b/apps/webapp/app/hooks/useSystemThemeSync.ts index 6b2a678396f..bfd168c6a5b 100644 --- a/apps/webapp/app/hooks/useSystemThemeSync.ts +++ b/apps/webapp/app/hooks/useSystemThemeSync.ts @@ -1,29 +1,96 @@ -import { useEffect } from "react"; -import { type ThemePreference } from "~/utils/themePreference"; +import { useEffect, useState } from "react"; +import { + type SystemDarkTheme, + type SystemLightTheme, + type ThemePreference, +} from "~/utils/themePreference"; + +/** Which theme `system` lands on at each end of the OS setting. */ +export type SystemThemes = { light: SystemLightTheme; dark: SystemDarkTheme }; + +const DEFAULT_SYSTEM_THEMES: SystemThemes = { light: "light", dark: "dark" }; + +/** Which end of the scale a theme sits on. */ +export type ThemeAppearance = "dark" | "light"; + +function themeAppearance(preference: ThemePreference, prefersDark: boolean): ThemeAppearance { + if (preference === "system") return prefersDark ? "dark" : "light"; + return preference === "light" || preference === "white" ? "light" : "dark"; +} /** - * Keeps `data-theme` on in sync with the preference. For `system` it - * follows the OS color scheme live; for pinned themes it writes the attribute - * explicitly - React can skip the write when its virtual DOM already matched - * the SSR fallback while the inline script had changed the real attribute. - * The single resolution rule (dark vs light) lives here and in the blocking - * inline script in root.tsx; downstream consumers react to the `data-theme` - * mutation (see useThemeColor). + * Resolved appearance, tracking the OS while the preference is `system`. Defaults + * to dark before the effect runs, matching root.tsx's SSR fallback. */ -export function useSystemThemeSync(preference: ThemePreference) { +export function useThemeAppearance(preference: ThemePreference): ThemeAppearance { + const [prefersDark, setPrefersDark] = useState(true); + + useEffect(() => { + if (preference !== "system") return; + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const apply = () => setPrefersDark(media.matches); + apply(); + media.addEventListener("change", apply); + return () => media.removeEventListener("change", apply); + }, [preference]); + + return themeAppearance(preference, prefersDark); +} + +/** Only `system` needs resolving; it lands on the variant picked for that end. */ +export function resolveThemePreference( + preference: ThemePreference, + prefersDark: boolean, + systemThemes: SystemThemes = DEFAULT_SYSTEM_THEMES +): ThemePreference { + if (preference !== "system") return preference; + return prefersDark ? systemThemes.dark : systemThemes.light; +} + +/** Just the percent; each theme maps it onto its own range in CSS. */ +export function applyThemeContrast(percent: number) { + document.documentElement.style.setProperty("--theme-contrast-percent", String(percent / 100)); +} + +/** Applies a theme immediately, rather than waiting for the loader round-trip. */ +export function applyThemePreference( + preference: ThemePreference, + systemThemes: SystemThemes = DEFAULT_SYSTEM_THEMES +) { + const prefersDark = + preference === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches; + document.documentElement.setAttribute( + "data-theme", + resolveThemePreference(preference, prefersDark, systemThemes) + ); + document.documentElement.setAttribute("data-theme-preference", preference); +} + +/** + * Keeps `data-theme` in sync with the preference. Pinned themes are written + * explicitly: React can skip a write its virtual DOM thinks already matched, + * while root.tsx's inline script had changed the real attribute. + */ +export function useSystemThemeSync( + preference: ThemePreference, + systemThemes: SystemThemes = DEFAULT_SYSTEM_THEMES +) { + const { light, dark } = systemThemes; + useEffect(() => { if (preference !== "system") { - document.documentElement.setAttribute("data-theme", preference); + applyThemePreference(preference); return; } const media = window.matchMedia("(prefers-color-scheme: dark)"); const apply = () => { - document.documentElement.setAttribute("data-theme", media.matches ? "dark" : "light"); + document.documentElement.setAttribute("data-theme", media.matches ? dark : light); }; apply(); media.addEventListener("change", apply); return () => media.removeEventListener("change", apply); - }, [preference]); + // Destructured so a fresh object each render doesn't re-run this + }, [preference, light, dark]); } diff --git a/apps/webapp/app/hooks/useThemeMode.ts b/apps/webapp/app/hooks/useThemeMode.ts index e2d39d94989..5145409e89b 100644 --- a/apps/webapp/app/hooks/useThemeMode.ts +++ b/apps/webapp/app/hooks/useThemeMode.ts @@ -1,7 +1,13 @@ import { useEffect, useState } from "react"; +import { SystemLightTheme } from "~/utils/themePreference"; export type ThemeMode = "dark" | "light"; +/* Which themes read as light. Taken from the enum that also drives the "Light" + end of the `system` preference, so a new theme only has to be classified once + - anything not in here (dark, black) reads as dark. */ +const LIGHT_THEMES = new Set(SystemLightTheme.options); + /** * The active theme's mode, for colors that can't come from a CSS variable. Resolved in an * effect so server and hydration renders agree; `root.tsx` can flip `data-theme` pre-paint. @@ -10,7 +16,8 @@ export function useThemeMode(): ThemeMode { const [mode, setMode] = useState("dark"); useEffect(() => { const resolve = () => { - setMode(document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"); + const theme = document.documentElement.getAttribute("data-theme"); + setMode(theme !== null && LIGHT_THEMES.has(theme) ? "light" : "dark"); }; resolve(); const observer = new MutationObserver(resolve); diff --git a/apps/webapp/app/models/user.server.ts b/apps/webapp/app/models/user.server.ts index ffca2ee0adf..302e16c2953 100644 --- a/apps/webapp/app/models/user.server.ts +++ b/apps/webapp/app/models/user.server.ts @@ -397,3 +397,37 @@ export function updateUser({ }, }); } + +/** + * One column each. `updateUser` above is the onboarding write and confirms basic + * details as a side effect, which is wrong for a profile edit. + */ +export function updateUserName({ id, name }: Pick) { + return prisma.user.update({ + where: { id }, + data: { name }, + }); +} + +export function updateUserEmail({ id, email }: Pick) { + return prisma.user.update({ + where: { id }, + data: { email }, + }); +} + +/** + * `updateMany` so the WHERE does the comparing: a redundant request updates zero + * rows rather than churning the row and its updatedAt. + */ +export async function updateUserMarketingEmails({ + id, + marketingEmails, +}: Pick) { + const { count } = await prisma.user.updateMany({ + where: { id, marketingEmails: { not: marketingEmails } }, + data: { marketingEmails }, + }); + + return { changed: count > 0 }; +} diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index 9737ab58ddd..e5d4fae1050 100644 --- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -7,9 +7,7 @@ import { newOrganizationPath, newProjectPath } from "~/utils/pathBuilder"; import { SelectBestEnvironmentPresenter } from "./SelectBestEnvironmentPresenter.server"; import { sortEnvironments } from "~/utils/environmentSort"; import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar"; -import { env } from "~/env.server"; -import { flags } from "~/v3/featureFlags.server"; -import { validatePartialFeatureFlags } from "~/v3/featureFlags"; +import { globalFeatureFlags, mergeOrgFeatureFlags } from "~/v3/featureFlags.server"; import { hydrateEnvsWithActivity } from "./v3/BranchesPresenter.server"; export class OrganizationsPresenter { @@ -155,23 +153,10 @@ export class OrganizationsPresenter { }, }); - // Get global feature flags with env-var-based defaults - const globalFlags = await flags({ - defaultValues: { - hasAiAccess: env.AI_FEATURES_ENABLED === "1", - hasDashboardAgentAccess: env.DASHBOARD_AGENT_ENABLED === "1", - hasPrivateConnections: env.PRIVATE_CONNECTIONS_ENABLED === "1", - }, - }); + const globalFlags = await globalFeatureFlags(); return orgs.map((org) => { - const orgFlagsResult = org.featureFlags - ? validatePartialFeatureFlags(org.featureFlags as Record) - : ({ success: false } as const); - const orgFlags = orgFlagsResult.success ? orgFlagsResult.data : {}; - - // Combine global flags with org flags (org flags win) - const combinedFlags = { ...globalFlags, ...orgFlags }; + const combinedFlags = mergeOrgFeatureFlags(globalFlags, org.featureFlags); return { id: org.id, diff --git a/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts b/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts index c97d7ca92df..a76c62fa218 100644 --- a/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts @@ -3,6 +3,7 @@ import { type ClickHouse } from "@internal/clickhouse"; import { z } from "zod"; import { $replica } from "~/db.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { backstopPromise } from "~/utils/backstopPromise"; import { singleton } from "~/utils/singleton"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; @@ -85,15 +86,18 @@ class AgentListPresenter { }; } - // All queries are deferred for streaming - const activeStates = this.#getActiveStates(clickhouse, environmentId, slugs); - const conversationSparklines = this.#getConversationSparklines( - clickhouse, - environmentId, - slugs + // Deferred for streaming, and backstopped: consumers subscribe late or, + // for some callers, not at all. + const activeStates = backstopPromise(this.#getActiveStates(clickhouse, environmentId, slugs)); + const conversationSparklines = backstopPromise( + this.#getConversationSparklines(clickhouse, environmentId, slugs) + ); + const costSparklines = backstopPromise( + this.#getCostSparklines(clickhouse, environmentId, slugs) + ); + const tokenSparklines = backstopPromise( + this.#getTokenSparklines(clickhouse, environmentId, slugs) ); - const costSparklines = this.#getCostSparklines(clickhouse, environmentId, slugs); - const tokenSparklines = this.#getTokenSparklines(clickhouse, environmentId, slugs); return { agents, activeStates, conversationSparklines, costSparklines, tokenSparklines }; } diff --git a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts index 4b268a56c5e..082376dbf09 100644 --- a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts @@ -9,6 +9,7 @@ import { ClickHouseEnvironmentMetricsRepository, type CurrentRunningStats, } from "~/services/environmentMetricsRepository.server"; +import { backstopPromise } from "~/utils/backstopPromise"; import { singleton } from "~/utils/singleton"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; @@ -84,16 +85,17 @@ class TaskListPresenter { }); // IMPORTANT: Don't await this, we want to return the promise - // so we can defer the loading of the data. The caller is responsible for - // consuming it — an unconsumed promise here would become an unhandled - // rejection if the underlying query fails. - const runningStats = environmentMetricsRepository.getCurrentRunningStats({ - organizationId, - projectId, - environmentId, - days: 6, - tasks: slugs, - }); + // so we can defer the loading of the data. Backstopped: the caller + // subscribes only after further awaits. + const runningStats = backstopPromise( + environmentMetricsRepository.getCurrentRunningStats({ + organizationId, + projectId, + environmentId, + days: 6, + tasks: slugs, + }) + ); return { tasks, runningStats }; } diff --git a/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts b/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts index 243bf7a9fec..7f444a269de 100644 --- a/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts @@ -8,6 +8,7 @@ import { import { z } from "zod"; import { $replica } from "~/db.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { backstopPromise } from "~/utils/backstopPromise"; import { singleton } from "~/utils/singleton"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; import { agentListPresenter, type AgentActiveState } from "./AgentListPresenter.server"; @@ -69,26 +70,31 @@ class UnifiedTaskListPresenter { const items = toUnifiedItems(taskResult.tasks, agentResult.agents); const allSlugs = items.map((item) => item.slug); + // Backstopped: the route subscribes via typeddefer only after further + // awaits, so a rejection in that gap would be unhandled. const hourlyActivity: Promise = allSlugs.length === 0 ? Promise.resolve({}) - : (async () => { - const clickhouse = await clickhouseFactory.getClickhouseForOrganization( - args.organizationId, - "standard" - ); - return getHourlyTaskActivity(clickhouse, { - organizationId: args.organizationId, - projectId: args.projectId, - environmentId: args.environmentId, - slugs: allSlugs, - }); - })(); + : backstopPromise( + (async () => { + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + args.organizationId, + "standard" + ); + return getHourlyTaskActivity(clickhouse, { + organizationId: args.organizationId, + projectId: args.projectId, + environmentId: args.environmentId, + slugs: allSlugs, + }); + })() + ); - const runningStates: Promise = Promise.all([ - taskResult.runningStats, - agentResult.activeStates, - ]).then(([runningStats, activeStates]) => mergeRunningStates(runningStats, activeStates)); + const runningStates: Promise = backstopPromise( + Promise.all([taskResult.runningStats, agentResult.activeStates]).then( + ([runningStats, activeStates]) => mergeRunningStates(runningStats, activeStates) + ) + ); return { items, hourlyActivity, runningStates }; } diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index b1d38ef2ef7..3cb547db4c7 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -20,11 +20,15 @@ import { TimezoneSetter } from "./components/TimezoneSetter"; import { env } from "./env.server"; import { featuresForRequest } from "./features.server"; import { usePostHog } from "./hooks/usePostHog"; -import { useSystemThemeSync } from "./hooks/useSystemThemeSync"; +import { resolveThemePreference, useSystemThemeSync } from "./hooks/useSystemThemeSync"; import { getImpersonationState } from "./services/impersonation.server"; import { getUser } from "./services/session.server"; import { + normalizeIconContrast, + normalizeSystemDarkTheme, + normalizeSystemLightTheme, normalizeThemeContrast, + normalizeUnderlineLinks, normalizeThemePreference, type ThemePreference, } from "~/utils/themePreference"; @@ -81,20 +85,29 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }; const user = await getUser(request); - // Theme switching is feature-flagged; while off, everyone stays on the - // classic theme even if a preference was saved earlier. Admins always get - // the switcher so the team can dogfood before the flag flips. Cached: the - // root loader runs on every document request and client navigation. + // Feature-flagged; while off everyone stays on Dark at contrast 0. Admins + // always get it. Cached: this loader runs on every request and navigation. const showThemeSwitcher = user ? user.admin || (await cachedFlag({ key: "hasThemeSwitcher", defaultValue: false })) : false; - // Logged-out pages (login, invites) always render the branded Classic look. + // Logged-out pages always render the branded dark look. const themePreference: ThemePreference = showThemeSwitcher ? normalizeThemePreference(user?.dashboardPreferences.theme) - : "classic"; + : "dark"; const themeContrast = showThemeSwitcher ? normalizeThemeContrast(user?.dashboardPreferences.contrast) : 0; + // Forced off with the switcher hidden, so unflagged pages render the base set. + const iconContrast = showThemeSwitcher + ? normalizeIconContrast(user?.dashboardPreferences.iconContrast) + : false; + const underlineLinks = showThemeSwitcher + ? normalizeUnderlineLinks(user?.dashboardPreferences.underlineLinks) + : false; + const systemThemes = { + light: normalizeSystemLightTheme(user?.dashboardPreferences.systemLightTheme), + dark: normalizeSystemDarkTheme(user?.dashboardPreferences.systemDarkTheme), + }; // Display-only: while impersonating, an admin can ask to see the dashboard // the way the impersonated user sees it. Exposed from root so every route can // read it. @@ -125,7 +138,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { kapa, timezone, showThemeSwitcher, + iconContrast, + underlineLinks, themePreference, + systemThemes, themeContrast, // Consumed by ResizablePanel: the browser check must match between SSR // and hydration, so it is derived from the request user-agent. @@ -147,7 +163,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = (options) => { export function ErrorBoundary() { return ( - + @@ -170,29 +186,42 @@ export function ErrorBoundary() { } export default function App() { - const { posthogProjectKey, posthogUiHost, themePreference, themeContrast } = - useTypedLoaderData(); + const { + posthogProjectKey, + posthogUiHost, + themePreference, + themeContrast, + iconContrast, + underlineLinks, + systemThemes, + } = useTypedLoaderData(); usePostHog(posthogProjectKey, posthogUiHost); - useSystemThemeSync(themePreference); - // SSR falls back to dark for `system`; the inline script below corrects it - // before paint, and useSystemThemeSync keeps it live afterwards. - const resolvedTheme = themePreference === "system" ? "dark" : themePreference; + useSystemThemeSync(themePreference, systemThemes); + // SSR falls back to the dark end for `system`; the script below fixes it + // before paint, and useSystemThemeSync keeps it live after. + const resolvedTheme = resolveThemePreference(themePreference, true, systemThemes); return (