chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,67 @@
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
import type { User } from "@trigger.dev/database";
import { cn } from "~/utils/cn";
import {
accountPath,
accountSecurityPath,
personalAccessTokensPath,
rootPath,
} from "~/utils/pathBuilder";
import { AskAI } from "../AskAI";
import { LinkButton } from "../primitives/Buttons";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon";
import { ShieldIcon } from "~/assets/icons/ShieldIcon";
import { PadlockIcon } from "~/assets/icons/PadlockIcon";
export function AccountSideMenu({ user }: { user: User }) {
return (
<div
className={cn(
"grid h-full grid-rows-[2.5rem_auto_2.5rem] overflow-hidden border-r border-grid-bright bg-background-bright transition"
)}
>
<div className={cn("flex items-center justify-between p-1 transition")}>
<LinkButton
variant="minimal/medium"
LeadingIcon={ArrowLeftIcon}
to={rootPath()}
fullWidth
textAlignLeft
>
<span className="text-text-bright">Back to app</span>
</LinkButton>
</div>
<div className="mb-6 flex grow flex-col overflow-y-auto px-1 pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<SideMenuHeader title="Account" />
<SideMenuItem
name="Profile"
icon={AvatarCircleIcon}
activeIconColor="text-text-bright"
to={accountPath()}
data-action="account"
/>
<SideMenuItem
name="Personal Access Tokens"
icon={ShieldIcon}
activeIconColor="text-text-bright"
to={personalAccessTokensPath()}
data-action="tokens"
/>
<SideMenuItem
name="Security"
icon={PadlockIcon}
activeIconColor="text-text-bright"
to={accountSecurityPath()}
data-action="security"
/>
</div>
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
<HelpAndFeedback />
<AskAI />
</div>
</div>
);
}
@@ -0,0 +1,326 @@
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useNavigation } from "@remix-run/react";
import { motion } from "framer-motion";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { PlusIcon } from "@heroicons/react/20/solid";
import { useEffect, useState } from "react";
import { type ShortcutDefinition } from "~/hooks/useShortcutKeys";
import { type MatchedOrganization, useDashboardLimits } from "~/hooks/useOrganizations";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { Feedback } from "~/components/Feedback";
import { Button, LinkButton } from "../primitives/Buttons";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "../primitives/Dialog";
import { FormButtons } from "../primitives/FormButtons";
import { Input } from "../primitives/Input";
import { InputGroup } from "../primitives/InputGroup";
import { Label } from "../primitives/Label";
import { Paragraph } from "../primitives/Paragraph";
import { TextLink } from "../primitives/TextLink";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { useThemeColor } from "~/hooks/useThemeColor";
import { v3BillingPath } from "~/utils/pathBuilder";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
function useCreateDashboard({
organization,
project,
environment,
}: {
organization: { slug: string };
project: { slug: string };
environment: { slug: string };
}) {
const [isOpen, setIsOpen] = useState(false);
const navigation = useNavigation();
const limits = useDashboardLimits();
const plan = useCurrentPlan();
const isAtLimit = limits.used >= limits.limit;
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricDashboards;
const canExceed = typeof planLimits === "object" && planLimits.canExceed === true;
const canUpgrade = plan?.v3Subscription?.plan && !canExceed;
const isFreePlan = plan?.v3Subscription?.isPaying === false;
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/create`;
useEffect(() => {
if (navigation.formAction === formAction && navigation.state === "loading") {
setIsOpen(false);
}
}, [navigation.formAction, navigation.state, formAction]);
return {
isOpen,
setIsOpen,
isAtLimit,
canUpgrade: !!canUpgrade,
isFreePlan,
formAction,
limits,
organization,
};
}
export function CreateDashboardButton({
organization,
project,
environment,
isCollapsed,
}: {
organization: MatchedOrganization;
project: SideMenuProject;
environment: SideMenuEnvironment;
isCollapsed: boolean;
}) {
const dashboard = useCreateDashboard({ organization, project, environment });
if (isCollapsed) return null;
return (
<Dialog open={dashboard.isOpen} onOpenChange={dashboard.setIsOpen}>
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<button
type="button"
className="flex h-full w-full items-center justify-center rounded text-text-dimmed focus-custom hover:bg-surface-control hover:text-text-bright"
>
<PlusIcon className="size-4" />
</button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
Create dashboard
</TooltipContent>
</Tooltip>
</TooltipProvider>
{dashboard.isAtLimit ? (
<CreateDashboardUpgradeDialog
limits={dashboard.limits}
canUpgrade={dashboard.canUpgrade}
isFreePlan={dashboard.isFreePlan}
organization={dashboard.organization}
/>
) : (
<CreateDashboardDialog formAction={dashboard.formAction} limits={dashboard.limits} />
)}
</Dialog>
);
}
export function CreateDashboardPageButton({
organization,
project,
environment,
shortcut,
children,
}: {
organization: { slug: string };
project: { slug: string };
environment: { slug: string };
shortcut?: ShortcutDefinition;
/** Custom trigger element. When omitted, a default primary button is rendered. */
children?: React.ReactNode;
}) {
const dashboard = useCreateDashboard({ organization, project, environment });
return (
<Dialog open={dashboard.isOpen} onOpenChange={dashboard.setIsOpen}>
<DialogTrigger asChild>
{children ?? (
<Button
variant="primary/small"
LeadingIcon={PlusIcon}
shortcut={shortcut}
className="pr-2"
>
Create custom dashboard
</Button>
)}
</DialogTrigger>
{dashboard.isAtLimit ? (
<CreateDashboardUpgradeDialog
limits={dashboard.limits}
canUpgrade={dashboard.canUpgrade}
isFreePlan={dashboard.isFreePlan}
organization={dashboard.organization}
/>
) : (
<CreateDashboardDialog formAction={dashboard.formAction} limits={dashboard.limits} />
)}
</Dialog>
);
}
const PROGRESS_RING_R = 27.5;
const PROGRESS_RING_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_RING_R;
function CreateDashboardUpgradeDialog({
limits,
canUpgrade,
isFreePlan,
organization,
}: {
limits: { used: number; limit: number };
canUpgrade: boolean;
isFreePlan: boolean;
organization: { slug: string };
}) {
// Resolved to concrete colors - framer-motion can't interpolate var() strings.
// Must be called before the early return below (rules of hooks).
const progressColorSuccess = useThemeColor("--color-success", "#28bf5c");
const progressColorError = useThemeColor("--color-error", "#e11d48");
if (isFreePlan) {
return (
<DialogContent>
<DialogHeader>Upgrade to unlock dashboards</DialogHeader>
<div className="flex items-center gap-4 pt-3">
<ArrowUpCircleIcon className="ml-1 size-14 shrink-0 text-indigo-500" />
<DialogDescription className="pt-0">
Custom metric dashboards are available on paid plans. Upgrade to create dashboards and
track your task metrics.
</DialogDescription>
</div>
<DialogFooter className="flex justify-between">
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
<LinkButton variant="primary/medium" to={v3BillingPath(organization)}>
Upgrade plan
</LinkButton>
</DialogFooter>
</DialogContent>
);
}
const percentage = Math.min(limits.used / limits.limit, 1);
const filled = percentage * PROGRESS_RING_CIRCUMFERENCE;
return (
<DialogContent>
<DialogHeader>Dashboard limit reached</DialogHeader>
<div className="flex items-center gap-4 pt-3">
<div className="relative ml-1 mt-2 shrink-0" style={{ width: 60, height: 60 }}>
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="5"
r={PROGRESS_RING_R}
cx="30"
cy="30"
/>
<motion.circle
className="fill-none"
strokeWidth="5"
r={PROGRESS_RING_R}
cx="30"
cy="30"
strokeLinecap="round"
initial={{
strokeDasharray: `0 ${PROGRESS_RING_CIRCUMFERENCE}`,
stroke: progressColorSuccess,
}}
animate={{
strokeDasharray: `${filled} ${PROGRESS_RING_CIRCUMFERENCE}`,
stroke: progressColorError,
}}
transition={{ duration: 1.2, ease: "easeInOut" }}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-lg text-text-dimmed">
{limits.limit}
</span>
</div>
<DialogDescription className="pt-0">
{canUpgrade ? (
<>
{limits.limit === 1
? "Your plan includes 1 custom dashboard and it's already in use."
: `You've used all ${limits.limit} of your custom dashboards.`}{" "}
Upgrade your plan to create more.
</>
) : (
<>
{limits.limit === 1
? "Your plan includes 1 custom dashboard and it's already in use."
: `You've used all ${limits.limit} of your custom dashboards.`}{" "}
To create more, request a limit increase or visit the{" "}
<TextLink to={v3BillingPath(organization)}>billing page</TextLink> for pricing
details.
</>
)}
</DialogDescription>
</div>
<DialogFooter className="flex justify-between">
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
{canUpgrade ? (
<LinkButton variant="primary/medium" to={v3BillingPath(organization)}>
Upgrade plan
</LinkButton>
) : (
<Feedback
button={<Button variant="primary/medium">Request more</Button>}
defaultValue="help"
/>
)}
</DialogFooter>
</DialogContent>
);
}
function CreateDashboardDialog({
formAction,
limits,
}: {
formAction: string;
limits: { used: number; limit: number };
}) {
const navigation = useNavigation();
const [title, setTitle] = useState("");
const isLoading = navigation.formAction === formAction;
return (
<DialogContent className="sm:max-w-sm">
<DialogHeader>Create dashboard</DialogHeader>
<Form method="post" action={formAction} className="space-y-4 pt-3">
<InputGroup>
<Label>Title</Label>
<Input
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My Dashboard"
required
/>
</InputGroup>
<Paragraph variant="extra-small" className="text-text-dimmed">
{limits.used}/{limits.limit} dashboards used
</Paragraph>
<FormButtons
confirmButton={
<Button type="submit" variant="primary/medium" disabled={isLoading || !title.trim()}>
{isLoading ? "Creating..." : "Create"}
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
}
/>
</Form>
</DialogContent>
);
}
@@ -0,0 +1,196 @@
import { IconChartHistogram } from "@tabler/icons-react";
import { GripVerticalIcon } from "lucide-react";
import ReactGridLayout from "react-grid-layout";
import { AIMetricsIcon } from "~/assets/icons/AIMetricsIcon";
import { ChartArrowIcon } from "~/assets/icons/ChartArrowIcon";
import { type MatchedOrganization, useCustomDashboards } from "~/hooks/useOrganizations";
import { type UserWithDashboardPreferences } from "~/models/user.server";
import { type RenderIcon } from "~/components/primitives/Icon";
import { v3BuiltInDashboardPath, v3CustomDashboardPath } from "~/utils/pathBuilder";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
import { SideMenuItem } from "./SideMenuItem";
import { TreeConnectorBranch, TreeConnectorEnd } from "./TreeConnectors";
import { useReorderableList } from "./useReorderableList";
type SideMenuUser = Pick<UserWithDashboardPreferences, "dashboardPreferences"> & {
isImpersonating: boolean;
};
/**
* Unified item for the reorderable dashboards-section list. Combines the two
* built-in dashboards (Runs, Agents) and any user-created custom dashboards so
* they can be reordered together under the Dashboards parent.
*/
type DashboardChild =
| {
key: string;
kind: "builtin";
label: string;
path: string;
collapsedIcon: RenderIcon;
activeColor: string;
}
| {
key: string;
kind: "custom";
label: string;
path: string;
};
export function DashboardList({
organization,
project,
environment,
isCollapsed,
user,
}: {
organization: MatchedOrganization;
project: SideMenuProject;
environment: SideMenuEnvironment;
isCollapsed: boolean;
user: SideMenuUser;
}) {
const customDashboards = useCustomDashboards();
const items: DashboardChild[] = [
{
key: "builtin:overview",
kind: "builtin",
label: "Run metrics",
path: v3BuiltInDashboardPath(organization, project, environment, "overview"),
collapsedIcon: ChartArrowIcon,
activeColor: "text-runs",
},
{
key: "builtin:llm",
kind: "builtin",
label: "AI metrics",
path: v3BuiltInDashboardPath(organization, project, environment, "llm"),
collapsedIcon: AIMetricsIcon,
activeColor: "text-aiMetrics",
},
...customDashboards.map(
(d): DashboardChild => ({
key: `custom:${d.friendlyId}`,
kind: "custom",
label: d.title,
path: v3CustomDashboardPath(organization, project, environment, d),
})
),
];
const initialOrder =
user.dashboardPreferences.sideMenu?.organizations?.[organization.id]?.orderedItems?.[
"dashboardChildren"
];
const {
orderedItems,
layout,
containerRef,
gridWidth,
canReorder,
handleDrag,
handleDragStop,
getIsLast,
} = useReorderableList({
organizationId: organization.id,
listId: "dashboardChildren",
items,
itemKey: (item) => item.key,
initialOrder,
isImpersonating: user.isImpersonating,
});
return (
<div ref={containerRef}>
{canReorder ? (
<ReactGridLayout
layout={layout}
width={gridWidth}
gridConfig={{
cols: 1,
rowHeight: 32,
margin: [0, 0] as const,
containerPadding: [0, 0] as const,
}}
resizeConfig={{ enabled: false }}
dragConfig={{ enabled: !isCollapsed, handle: ".sidebar-drag-handle" }}
onDrag={handleDrag}
onDragStop={handleDragStop}
className="sidebar-reorder-grid"
autoSize
>
{orderedItems.map((item, index) => {
const isLast = getIsLast(item.key, index);
return (
<div key={item.key}>
<DashboardChildMenuItem
item={item}
isCollapsed={isCollapsed}
isLast={isLast}
showDragHandle
/>
</div>
);
})}
</ReactGridLayout>
) : (
orderedItems.map((item, index) => (
<DashboardChildMenuItem
key={item.key}
item={item}
isCollapsed={isCollapsed}
isLast={index === orderedItems.length - 1}
/>
))
)}
</div>
);
}
function DashboardChildMenuItem({
item,
isCollapsed,
isLast,
showDragHandle = false,
}: {
item: DashboardChild;
isCollapsed: boolean;
isLast: boolean;
showDragHandle?: boolean;
}) {
const collapsedIcon: RenderIcon =
item.kind === "builtin" ? item.collapsedIcon : IconChartHistogram;
const expandedIcon: RenderIcon = isLast ? TreeConnectorEnd : TreeConnectorBranch;
const activeIconColor =
item.kind === "builtin"
? isCollapsed
? item.activeColor
: undefined
: isCollapsed
? "text-text-bright"
: undefined;
const inactiveIconColor = isCollapsed ? "text-text-dimmed" : "text-tertiary";
return (
<SideMenuItem
name={item.label}
icon={isCollapsed ? collapsedIcon : expandedIcon}
activeIconColor={activeIconColor}
inactiveIconColor={inactiveIconColor}
to={item.path}
isCollapsed={isCollapsed}
disableIconHover
action={
showDragHandle ? (
<div className="sidebar-drag-handle flex h-full w-full cursor-grab items-center justify-center rounded text-text-dimmed opacity-0 group-hover/menuitem:opacity-100 hover:text-text-bright active:cursor-grabbing">
<GripVerticalIcon className="size-3.5" />
</div>
) : undefined
}
/>
);
}
@@ -0,0 +1,363 @@
import { ChevronRightIcon, Cog8ToothIcon } from "@heroicons/react/20/solid";
import { DEFAULT_DEV_BRANCH } from "@trigger.dev/core/v3/utils/gitBranch";
import { isBranchableEnvironment } from "~/utils/branchableEnvironment";
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
import { useNavigation, useRevalidator } from "@remix-run/react";
import { useEffect, useRef, useState } from "react";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useEnvironmentSwitcher } from "~/hooks/useEnvironmentSwitcher";
import { useFeatures } from "~/hooks/useFeatures";
import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { branchesPath, branchesDevPath, docsPath, v3BillingPath } from "~/utils/pathBuilder";
import {
EnvironmentCombo,
EnvironmentIcon,
EnvironmentLabel,
environmentFullTitle,
environmentTextClassName,
} from "../environments/EnvironmentLabel";
import { ButtonContent } from "../primitives/Buttons";
import { Header2 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
import {
Popover,
PopoverContent,
PopoverMenuItem,
PopoverSectionHeader,
PopoverTrigger,
} from "../primitives/Popover";
import { TextLink } from "../primitives/TextLink";
import { SimpleTooltip } from "../primitives/Tooltip";
import { V4Badge } from "../V4Badge";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
import { Badge } from "../primitives/Badge";
export function EnvironmentSelector({
organization,
project,
environment,
className,
isCollapsed = false,
}: {
organization: MatchedOrganization;
project: SideMenuProject;
environment: SideMenuEnvironment;
className?: string;
isCollapsed?: boolean;
}) {
const { isManagedCloud } = useFeatures();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const navigation = useNavigation();
const { urlForEnvironment } = useEnvironmentSwitcher();
const revalidator = useRevalidator();
useEffect(() => {
setIsMenuOpen(false);
}, [navigation.location?.pathname]);
// Fetch immediately on open so the list is fresh right away
useEffect(() => {
if (isMenuOpen && revalidator.state !== "loading") {
revalidator.revalidate();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMenuOpen]);
const hasStaging = project.environments.some((env) => env.type === "STAGING");
return (
<Popover onOpenChange={(open) => setIsMenuOpen(open)} open={isMenuOpen}>
<SimpleTooltip
button={
<PopoverTrigger
className={cn(
"group flex h-8 items-center rounded pl-1.75 transition-colors hover:bg-background-hover",
isCollapsed ? "justify-center pr-0.5" : "justify-between pr-1",
className
)}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<EnvironmentIcon environment={environment} className="size-5 shrink-0" />
<span
className={cn(
"flex min-w-0 items-center overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
)}
>
<EnvironmentLabel
environment={environment}
className="text-[0.90625rem] font-medium tracking-[-0.01em]"
disableTooltip
/>
</span>
</span>
<span
className={cn(
"overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[16px] opacity-100"
)}
>
<DropdownIcon className="size-4 min-w-4 text-text-dimmed transition group-hover:text-text-bright" />
</span>
</PopoverTrigger>
}
content={environmentFullTitle(environment)}
side="right"
sideOffset={8}
hidden={!isCollapsed}
buttonClassName="h-8!"
asChild
disableHoverableContent
/>
<PopoverContent
className="min-w-56 overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
side={isCollapsed ? "right" : "bottom"}
sideOffset={isCollapsed ? 8 : 4}
align="start"
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
>
<div className="flex flex-col gap-1 p-1">
{project.environments
.filter((env) => env.parentEnvironmentId === null)
.map((env) => {
const renderAsBranchable = isBranchableEnvironment(env);
if (renderAsBranchable) {
const branchEnvironments = project.environments.filter(
(e) => e.parentEnvironmentId === env.id
);
const allBranchEnvironments =
env.type === "DEVELOPMENT" ? [env, ...branchEnvironments] : branchEnvironments;
return (
<Branches
key={env.id}
parentEnvironment={env}
branchEnvironments={allBranchEnvironments}
currentEnvironment={environment}
/>
);
}
return (
<PopoverMenuItem
key={env.id}
to={urlForEnvironment(env)}
title={<EnvironmentCombo environment={env} className="mx-auto grow text-2sm" />}
isSelected={env.id === environment.id}
/>
);
})}
</div>
{!hasStaging && isManagedCloud && (
<>
<PopoverSectionHeader title="Additional environments" />
<div className="p-1">
<PopoverMenuItem
key="staging"
to={v3BillingPath(
organization,
"Upgrade to unlock a Staging environment for your projects."
)}
title={
<div className="flex w-full items-center justify-between">
<EnvironmentCombo environment={{ type: "STAGING" }} className="text-2sm" />
<span className="text-indigo-500">Upgrade</span>
</div>
}
isSelected={false}
/>
<PopoverMenuItem
key="preview"
to={v3BillingPath(
organization,
"Upgrade to unlock Preview environments for your projects."
)}
title={
<div className="flex w-full items-center justify-between">
<EnvironmentCombo environment={{ type: "PREVIEW" }} className="text-2sm" />
<span className="text-indigo-500">Upgrade</span>
</div>
}
isSelected={false}
/>
</div>
</>
)}
</PopoverContent>
</Popover>
);
}
function Branches({
parentEnvironment,
branchEnvironments,
currentEnvironment,
}: {
parentEnvironment: SideMenuEnvironment;
branchEnvironments: SideMenuEnvironment[];
currentEnvironment: SideMenuEnvironment;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { urlForEnvironment } = useEnvironmentSwitcher();
const navigation = useNavigation();
const [isMenuOpen, setMenuOpen] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// Clear timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
useEffect(() => {
setMenuOpen(false);
}, [navigation.location?.pathname]);
const handleMouseEnter = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setMenuOpen(true);
};
const handleMouseLeave = () => {
// Small delay before closing to allow moving to the content
timeoutRef.current = setTimeout(() => {
setMenuOpen(false);
}, 150);
};
const activeBranches = branchEnvironments.filter((env) => env.archivedAt === null);
const state =
branchEnvironments.length === 0
? "no-branches"
: activeBranches.length === 0
? "no-active-branches"
: "has-branches";
// Only surface the active environment's archived-branch item in the submenu it
// actually belongs to. Both Development and Preview render this component, so
// without the parent check an archived dev branch would leak into the Preview
// submenu (and vice-versa).
const currentBranchIsArchived =
environment.archivedAt !== null && environment.parentEnvironmentId === parentEnvironment.id;
const envTextClassName = environmentTextClassName(parentEnvironment);
return (
<Popover onOpenChange={(open) => setMenuOpen(open)} open={isMenuOpen}>
<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} className="flex">
<PopoverTrigger className="w-full justify-between overflow-hidden focus-custom">
<ButtonContent
variant="small-menu-item"
className="hover:bg-background-hover"
TrailingIcon={ChevronRightIcon}
trailingIconClassName="text-text-dimmed"
textAlignLeft
fullWidth
>
<EnvironmentCombo environment={parentEnvironment} className="mx-auto grow text-2sm" />
</ButtonContent>
</PopoverTrigger>
<PopoverContent
className="min-w-64 overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
align="start"
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
side="right"
alignOffset={0}
sideOffset={-4}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="flex flex-col gap-1 p-1">
{currentBranchIsArchived && (
<PopoverMenuItem
key={environment.id}
to={urlForEnvironment(environment)}
title={
<>
<span className={cn("block w-full", envTextClassName)}>
{environment.branchName}
</span>
<Badge variant="extra-small">Archived</Badge>
</>
}
icon={
<BranchEnvironmentIconSmall className={cn("size-4 shrink-0", envTextClassName)} />
}
isSelected={environment.id === currentEnvironment.id}
/>
)}
{state === "has-branches" ? (
<>
{branchEnvironments
.filter((env) => env.archivedAt === null)
.map((env) => (
<PopoverMenuItem
key={env.id}
to={urlForEnvironment(env)}
title={
<span className={cn("block w-full", envTextClassName)}>
{env.branchName ?? DEFAULT_DEV_BRANCH}
</span>
}
icon={
<BranchEnvironmentIconSmall
className={cn("size-4 shrink-0", envTextClassName)}
/>
}
isSelected={env.id === currentEnvironment.id}
/>
))}
</>
) : state === "no-branches" ? (
<div className="flex max-w-sm flex-col gap-1 p-2">
<div className="flex items-center gap-1">
<BranchEnvironmentIconSmall className={cn("size-4", envTextClassName)} />
<Header2>Create your first branch</Header2>
</div>
<Paragraph spacing variant="small">
Branches are a way to test new features in isolation before merging them into the
main environment.
</Paragraph>
<Paragraph variant="small">
Branches are only available when using <V4Badge inline /> or above. Read our{" "}
<TextLink to={docsPath("upgrade-to-v4")}>v4 upgrade guide</TextLink> to learn
more.
</Paragraph>
</div>
) : (
<div className="flex max-w-sm flex-col gap-1 p-2">
<Paragraph variant="extra-small">All branches are archived.</Paragraph>
</div>
)}
</div>
<div className="border-t border-grid-bright p-1">
{parentEnvironment.type === "DEVELOPMENT" ? (
<PopoverMenuItem
to={branchesDevPath(organization, project, environment)}
title="Manage dev branches"
icon={<Cog8ToothIcon className="size-4 text-text-dimmed" />}
leadingIconClassName="text-text-dimmed"
/>
) : (
<PopoverMenuItem
to={branchesPath(organization, project, environment)}
title="Manage preview branches"
icon={<Cog8ToothIcon className="size-4 text-text-dimmed" />}
leadingIconClassName="text-text-dimmed"
/>
)}
</div>
</PopoverContent>
</div>
</Popover>
);
}
@@ -0,0 +1,191 @@
import { ArrowUpRightIcon } from "@heroicons/react/20/solid";
import { motion } from "framer-motion";
import { Fragment, useState } from "react";
import { BookIcon } from "~/assets/icons/BookIcon";
import { BulbIcon } from "~/assets/icons/BulbIcon";
import { EnvelopeIcon } from "~/assets/icons/EnvelopeIcon";
import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon";
import { RadarPulseIcon } from "~/assets/icons/RadarPulseIcon";
import { StarIcon } from "~/assets/icons/StarIcon";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { sanitizeHttpUrl } from "~/utils/sanitizeUrl";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { useRecentChangelogs } from "~/routes/resources.platform-changelogs";
import { cn } from "~/utils/cn";
import { Feedback } from "../Feedback";
import { Shortcuts } from "../Shortcuts";
import { Button } from "../primitives/Buttons";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { SimpleTooltip } from "../primitives/Tooltip";
import { SideMenuItem } from "./SideMenuItem";
export function HelpAndFeedback({
disableShortcut = false,
isCollapsed = false,
organizationId,
projectId,
}: {
disableShortcut?: boolean;
isCollapsed?: boolean;
organizationId?: string;
projectId?: string;
}) {
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
const _currentPlan = useCurrentPlan();
const { changelogs } = useRecentChangelogs(organizationId, projectId);
useShortcutKeys({
shortcut: disableShortcut ? undefined : { key: "h", enabledOnInputElements: false },
action: (e) => {
e.preventDefault();
e.stopPropagation();
setHelpMenuOpen(true);
},
});
return (
<motion.div
layout="position"
transition={{ duration: 0.2, ease: "easeInOut" }}
className={isCollapsed ? undefined : "flex-1"}
>
<Popover open={isHelpMenuOpen} onOpenChange={setHelpMenuOpen}>
<SimpleTooltip
button={
<PopoverTrigger
className={cn(
"group flex h-8 items-center gap-1.5 rounded pl-1.75 pr-2 transition-colors hover:bg-background-hover focus-custom",
isCollapsed ? "w-full" : "w-full justify-between"
)}
>
<span className="flex items-center gap-1.5 overflow-hidden">
<QuestionMarkIcon className="size-5 min-w-5 shrink-0 text-success" />
<span
className={cn(
"overflow-hidden whitespace-nowrap text-2sm text-text-bright transition-all duration-150",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[150px] opacity-100"
)}
>
Help & Feedback
</span>
</span>
<ShortcutKey
className={cn(
"size-4 flex-none transition-all duration-150",
isCollapsed ? "hidden" : ""
)}
shortcut={{ key: "h" }}
variant="medium/bright"
/>
</PopoverTrigger>
}
content={
<span className="flex items-center gap-1">
Help & Feedback
<ShortcutKey shortcut={{ key: "h" }} variant="medium/bright" />
</span>
}
side="right"
sideOffset={8}
hidden={!isCollapsed}
buttonClassName="h-8! w-full"
asChild
disableHoverableContent
/>
<PopoverContent
className="min-w-56 divide-y divide-grid-bright overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
side={isCollapsed ? "right" : "top"}
sideOffset={isCollapsed ? 8 : 4}
align="start"
>
<Fragment>
<div className="flex flex-col gap-1 p-1">
<SideMenuItem
name="Documentation"
icon={BookIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
to="https://trigger.dev/docs"
data-action="documentation"
target="_blank"
/>
</div>
<div className="flex flex-col gap-1 p-1">
<SideMenuItem
name="Status"
icon={RadarPulseIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
to="https://status.trigger.dev/"
data-action="status"
target="_blank"
/>
<SideMenuItem
name="Suggest a feature"
icon={BulbIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
to="https://feedback.trigger.dev/"
data-action="suggest-a-feature"
target="_blank"
/>
<Shortcuts />
<Feedback
button={
<Button
variant="small-menu-item"
className="pl-2"
LeadingIcon={EnvelopeIcon}
leadingIconClassName="pr-1 text-text-dimmed group-hover/button:text-text-bright"
data-action="contact-us"
fullWidth
textAlignLeft
>
Contact us
</Button>
}
/>
</div>
<div className="flex flex-col gap-1 p-1">
<Paragraph className="pb-1 pl-1.5 pt-1.5 text-xs">What's new</Paragraph>
{changelogs.map((entry) => (
<SideMenuItem
key={entry.id}
name={entry.title}
icon={GrayDotIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-text-dimmed"
activeIconColor="text-text-dimmed"
to={sanitizeHttpUrl(entry.actionUrl) ?? "https://trigger.dev/changelog"}
target="_blank"
/>
))}
<SideMenuItem
name="Full changelog"
icon={StarIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-text-dimmed"
activeIconColor="text-text-dimmed"
to="https://trigger.dev/changelog"
data-action="full-changelog"
target="_blank"
/>
</div>
</Fragment>
</PopoverContent>
</Popover>
</motion.div>
);
}
function GrayDotIcon({ className }: { className?: string }) {
return (
<span className={cn("flex items-center justify-center", className)}>
<span className="block h-1.5 w-1.5 rounded-full bg-text-dimmed" />
</span>
);
}
@@ -0,0 +1,142 @@
import { XMarkIcon } from "@heroicons/react/20/solid";
import { useLayoutEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { cn } from "~/utils/cn";
export function NotificationCard({
title,
description,
image,
actionUrl,
onDismiss,
onCardClick,
onLinkClick,
}: {
title: string;
description: string;
image?: string;
actionUrl?: string;
onDismiss?: () => void;
onCardClick?: () => void;
onLinkClick?: () => void;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const [isOverflowing, setIsOverflowing] = useState(false);
const descriptionRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const el = descriptionRef.current;
if (!el) return;
const check = () => setIsOverflowing(el.scrollHeight - el.clientHeight > 1);
check();
const observer = new ResizeObserver(check);
observer.observe(el);
return () => observer.disconnect();
}, [description]);
const handleDismiss = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onDismiss?.();
};
const handleToggleExpand = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsExpanded((v) => !v);
};
const safeActionUrl = sanitizeUrl(actionUrl);
const safeImage = sanitizeUrl(image);
return (
<div className="group/card relative overflow-hidden rounded border border-border-bright bg-background-raised/50 shadow-lg">
{safeActionUrl && (
<a
href={safeActionUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={title}
onClick={onCardClick}
className="absolute inset-0 z-10"
/>
)}
<div className="flex items-start gap-1 px-2.5 pt-2">
<p className="flex-1 text-[13px] font-medium leading-normal text-text-bright">{title}</p>
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss notification"
title="Dismiss notification"
className="relative z-20 -mr-1 shrink-0 rounded p-0.5 text-text-dimmed opacity-0 transition group-hover/card:opacity-100 hover:bg-background-raised hover:text-text-bright focus-visible:opacity-100"
>
<XMarkIcon className="size-3.5" />
</button>
</div>
<div className="px-2.5 pb-2">
<div ref={descriptionRef} className={cn(!isExpanded && "line-clamp-3")}>
<ReactMarkdown components={getMarkdownComponents(onLinkClick)}>
{description}
</ReactMarkdown>
</div>
{(isOverflowing || isExpanded) && (
<button
type="button"
onClick={handleToggleExpand}
className="relative z-20 mt-0.5 text-xs text-indigo-400 hover:text-indigo-300"
>
{isExpanded ? "Show less" : "Show more"}
</button>
)}
{safeImage && <img src={safeImage} alt="" className="mt-1.5 rounded" />}
</div>
</div>
);
}
function getMarkdownComponents(onLinkClick?: () => void) {
return {
p: ({ children }: { children?: React.ReactNode }) => (
<p className="my-0.5 text-xs leading-normal text-text-dimmed">{children}</p>
),
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="relative z-20 text-indigo-400 underline transition-colors hover:text-indigo-300"
onClick={(e) => {
e.stopPropagation();
onLinkClick?.();
}}
>
{children}
</a>
),
strong: ({ children }: { children?: React.ReactNode }) => (
<strong className="font-semibold text-text-bright">{children}</strong>
),
em: ({ children }: { children?: React.ReactNode }) => <em>{children}</em>,
code: ({ children }: { children?: React.ReactNode }) => (
<code className="rounded bg-background-raised px-1 py-0.5 text-[11px]">{children}</code>
),
};
}
const SAFE_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"]);
/** Sanitize a URL to prevent XSS via javascript: or data: URIs. Returns "" if invalid. */
function sanitizeUrl(url: string | undefined): string {
if (!url) return "";
try {
const parsed = new URL(url);
return SAFE_URL_PROTOCOLS.has(parsed.protocol) ? parsed.href : "";
} catch {
return "";
}
}
@@ -0,0 +1,151 @@
import { useFetcher } from "@remix-run/react";
import { useCallback, useEffect, useRef, useState } from "react";
import simplur from "simplur";
import { NotificationIcon } from "~/assets/icons/NotificationIcon";
import { Button } from "~/components/primitives/Buttons";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { usePlatformNotifications } from "~/routes/resources.platform-notifications";
import { NotificationCard } from "./NotificationCard";
type Notification = {
id: string;
friendlyId: string;
scope: string;
priority: number;
payload: {
version: string;
data: {
title: string;
description: string;
image?: string;
actionLabel?: string;
actionUrl?: string;
dismissOnAction?: boolean;
};
};
isRead: boolean;
};
export function NotificationPanel({
isCollapsed,
hasIncident,
organizationId,
projectId,
}: {
isCollapsed: boolean;
hasIncident: boolean;
organizationId: string;
projectId: string;
}) {
const { notifications } = usePlatformNotifications(organizationId, projectId) as {
notifications: Notification[];
};
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
const dismissFetcher = useFetcher();
const seenIdsRef = useRef<Set<string>>(new Set());
const seenFetcher = useFetcher();
const clickedIdsRef = useRef<Set<string>>(new Set());
const clickFetcher = useFetcher();
const visibleNotifications = notifications.filter((n) => !dismissedIds.has(n.id));
const notification = visibleNotifications[0] ?? null;
const handleDismiss = useCallback((id: string) => {
setDismissedIds((prev) => new Set(prev).add(id));
dismissFetcher.submit(
{},
{
method: "POST",
action: `/resources/platform-notifications/${id}/dismiss`,
}
);
}, []);
const fireClickBeacon = useCallback((id: string) => {
if (clickedIdsRef.current.has(id)) return;
clickedIdsRef.current.add(id);
clickFetcher.submit(
{},
{
method: "POST",
action: `/resources/platform-notifications/${id}/clicked`,
}
);
}, []);
// Fire seen beacon
const fireSeenBeacon = useCallback((n: Notification) => {
if (seenIdsRef.current.has(n.id)) return;
seenIdsRef.current.add(n.id);
seenFetcher.submit(
{},
{
method: "POST",
action: `/resources/platform-notifications/${n.id}/seen`,
}
);
}, []);
// Beacon current notification on mount
useEffect(() => {
if (notification && !hasIncident) {
fireSeenBeacon(notification);
}
}, [notification?.id, hasIncident]);
if (!notification) {
return null;
}
const { title, description, image, actionUrl, dismissOnAction } = notification.payload.data;
const card = (
<NotificationCard
title={title}
description={description}
image={image}
actionUrl={actionUrl}
onDismiss={() => handleDismiss(notification.id)}
onCardClick={() => {
fireClickBeacon(notification.id);
if (dismissOnAction) {
handleDismiss(notification.id);
}
}}
onLinkClick={() => fireClickBeacon(notification.id)}
/>
);
return (
<Popover>
<div className={isCollapsed ? "p-1" : "p-2"}>
{isCollapsed ? (
<SimpleTooltip
asChild
button={
<div className="relative">
<PopoverTrigger asChild>
<Button variant="small-menu-item" className="h-8 w-8.75 justify-center">
<NotificationIcon className="size-5 text-success" />
</Button>
</PopoverTrigger>
</div>
}
content={simplur`${visibleNotifications.length} notification[|s]`}
side="right"
sideOffset={8}
disableHoverableContent
/>
) : (
card
)}
</div>
<PopoverContent side="right" sideOffset={8} align="end" className="w-56 min-w-0! p-0">
{card}
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,248 @@
import { ArrowLeftIcon, LinkIcon } from "@heroicons/react/24/solid";
import { BellIcon } from "~/assets/icons/BellIcon";
import { CreditCardIcon } from "~/assets/icons/CreditCardIcon";
import { PadlockIcon } from "~/assets/icons/PadlockIcon";
import { UsageIcon } from "~/assets/icons/UsageIcon";
import { RolesIcon } from "~/assets/icons/RolesIcon";
import { SlackIcon } from "~/assets/icons/SlackIcon";
import { SlidersIcon } from "~/assets/icons/SlidersIcon";
import { UserGroupIcon } from "~/assets/icons/UserGroupIcon";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { useFeatureFlags } from "~/hooks/useFeatureFlags";
import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import {
organizationRolesPath,
organizationSettingsPath,
organizationSlackIntegrationPath,
organizationSsoPath,
organizationTeamPath,
organizationVercelIntegrationPath,
rootPath,
v3BillingLimitsPath,
v3BillingPath,
v3PrivateConnectionsPath,
v3UsagePath,
} from "~/utils/pathBuilder";
import { LinkButton } from "../primitives/Buttons";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { Paragraph } from "../primitives/Paragraph";
import { Badge } from "../primitives/Badge";
import { useHasAdminAccess } from "~/hooks/useUser";
import { AskAI } from "../AskAI";
export type BuildInfo = {
appVersion: string | undefined;
packageVersion: string;
buildTimestampSeconds: string | undefined;
gitSha: string | undefined;
gitRefName: string | undefined;
};
export function OrganizationSettingsSideMenu({
organization,
buildInfo,
isUsingPlugin,
isSsoUsingPlugin,
}: {
organization: MatchedOrganization;
buildInfo: BuildInfo;
isUsingPlugin: boolean;
isSsoUsingPlugin: boolean;
}) {
const { isManagedCloud } = useFeatures();
const featureFlags = useFeatureFlags();
const currentPlan = useCurrentPlan();
const showSelfServe = useShowSelfServe();
const isAdmin = useHasAdminAccess();
const showBuildInfo = isAdmin || !isManagedCloud;
return (
<div
className={cn(
"grid h-full grid-rows-[2.5rem_auto_2.5rem] overflow-hidden border-r border-grid-bright bg-background-bright transition"
)}
>
<div className={cn("flex items-center justify-between p-1 transition")}>
<LinkButton
variant="minimal/medium"
LeadingIcon={ArrowLeftIcon}
to={rootPath()}
fullWidth
textAlignLeft
>
<span className="text-text-bright">Back to app</span>
</LinkButton>
</div>
<div className="mb-6 flex grow flex-col gap-4 overflow-y-auto px-1 pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="flex flex-col">
<div className="mb-1">
<SideMenuHeader title="Organization" />
</div>
{isManagedCloud && (
<>
<SideMenuItem
name="Usage"
icon={UsageIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3UsagePath(organization)}
data-action="usage"
/>
<SideMenuItem
name="Billing"
icon={CreditCardIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3BillingPath(organization)}
data-action="billing"
badge={
currentPlan?.v3Subscription?.isPaying ? (
<Badge variant="extra-small">{currentPlan?.v3Subscription?.plan?.title}</Badge>
) : undefined
}
/>
{showSelfServe ? (
<SideMenuItem
name="Billing limits"
icon={BellIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3BillingLimitsPath(organization)}
data-action="billing-limits"
/>
) : null}
</>
)}
<SideMenuItem
name="Team"
icon={UserGroupIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={organizationTeamPath(organization)}
data-action="team"
/>
{featureFlags.hasPrivateConnections && (
<SideMenuItem
name="Private Connections"
icon={LinkIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3PrivateConnectionsPath(organization)}
data-action="private-connections"
/>
)}
{isUsingPlugin && (
<SideMenuItem
name="Roles"
icon={RolesIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={organizationRolesPath(organization)}
data-action="roles"
/>
)}
{isSsoUsingPlugin && (
<SideMenuItem
name="SSO & Directory Sync"
icon={PadlockIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={organizationSsoPath(organization)}
data-action="sso"
badge={
currentPlan?.v3Subscription?.plan?.code === "enterprise" ? undefined : (
<Badge variant="extra-small">Enterprise</Badge>
)
}
/>
)}
<SideMenuItem
name="Settings"
icon={SlidersIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={organizationSettingsPath(organization)}
data-action="settings"
/>
</div>
<div className="flex flex-col">
<div className="mb-1">
<SideMenuHeader title="Integrations" />
</div>
<SideMenuItem
name="Vercel"
icon={VercelLogo}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
iconClassName="size-4 ml-[0.15rem]"
to={organizationVercelIntegrationPath(organization)}
data-action="integrations"
/>
<SideMenuItem
name="Slack"
icon={SlackIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
iconClassName="size-4 ml-0.5"
to={organizationSlackIntegrationPath(organization)}
data-action="integrations"
/>
</div>
<div className="flex flex-col gap-1">
<SideMenuHeader title="App version" />
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
{buildInfo.appVersion || `v${buildInfo.packageVersion}`}
</Paragraph>
</div>
{showBuildInfo && buildInfo.buildTimestampSeconds && (
<div className="flex flex-col gap-1">
<SideMenuHeader title="Build timestamp" />
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
{new Date(Number(buildInfo.buildTimestampSeconds) * 1000).toISOString()}
</Paragraph>
</div>
)}
{showBuildInfo && buildInfo.gitRefName && (
<div className="flex flex-col gap-1">
<SideMenuHeader title="Git ref" />
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
<a
href={`https://github.com/triggerdotdev/trigger.dev/tree/${buildInfo.gitRefName}`}
target="_blank"
rel="noopener noreferrer"
className="transition hover:text-text-bright"
>
{buildInfo.gitRefName}
</a>
</Paragraph>
</div>
)}
{showBuildInfo && buildInfo.gitSha && (
<div className="flex flex-col gap-1">
<SideMenuHeader title="Git sha" />
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
<a
href={`https://github.com/triggerdotdev/trigger.dev/commit/${buildInfo.gitSha}`}
target="_blank"
rel="noopener noreferrer"
className="transition hover:text-text-bright"
>
{buildInfo.gitSha.slice(0, 9)}
</a>
</Paragraph>
</div>
)}
</div>
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
<HelpAndFeedback organizationId={organization.id} />
<AskAI />
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
import { useNavigation } from "@remix-run/react";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover";
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
export function SideMenuHeader({
title,
children,
isCollapsed = false,
collapsedTitle,
}: {
title: string;
children?: React.ReactNode;
isCollapsed?: boolean;
/** When provided, this text stays visible when collapsed and the rest fades out */
collapsedTitle?: string;
}) {
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
const navigation = useNavigation();
useEffect(() => {
setHeaderMenuOpen(false);
}, [navigation.location?.pathname]);
// If collapsedTitle is provided and title starts with it, split the title
const hasCollapsedTitle = collapsedTitle && title.startsWith(collapsedTitle);
const visiblePart = hasCollapsedTitle ? collapsedTitle : title;
const fadingPart = hasCollapsedTitle ? title.slice(collapsedTitle.length) : "";
return (
<motion.div
className="group flex h-4 items-center justify-between overflow-hidden pl-1.5"
initial={false}
animate={{
opacity: hasCollapsedTitle ? 1 : isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
<h2 className="text-xs whitespace-nowrap">
{visiblePart}
{fadingPart && (
<motion.span
initial={false}
animate={{
opacity: isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
{fadingPart}
</motion.span>
)}
</h2>
{children !== undefined ? (
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
<PopoverCustomTrigger className="p-1">
<EllipsisHorizontalIcon className="h-4 w-4 text-text-faint transition group-hover:text-text-bright" />
</PopoverCustomTrigger>
<PopoverContent
className="min-w-max overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
align="start"
>
<div className="flex flex-col gap-1 p-1">{children}</div>
</PopoverContent>
</Popover>
) : null}
</motion.div>
);
}
@@ -0,0 +1,158 @@
import { type AnchorHTMLAttributes, type ReactNode } from "react";
import { Link } from "@remix-run/react";
import { motion } from "framer-motion";
import { usePathName } from "~/hooks/usePathName";
import { cn } from "~/utils/cn";
import { type RenderIcon, Icon } from "../primitives/Icon";
import { SimpleTooltip } from "../primitives/Tooltip";
export function SideMenuItem({
icon,
activeIconColor,
inactiveIconColor,
iconClassName,
trailingIcon,
trailingIconClassName,
name,
to,
badge,
target,
isCollapsed = false,
action,
disableIconHover = false,
indented = false,
"data-action": dataAction,
}: {
icon?: RenderIcon;
activeIconColor?: string;
inactiveIconColor?: string;
iconClassName?: string;
trailingIcon?: RenderIcon;
trailingIconClassName?: string;
name: string;
to: string;
badge?: ReactNode;
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
isCollapsed?: boolean;
action?: ReactNode;
disableIconHover?: boolean;
/**
* Visually indented variant — same item, just pushed further from
* the left edge so it reads as a child of the row above. Used for
* grouped sub-items like the Tasks > (Agents / Standard / Scheduled)
* cluster. The indent is only applied when the side menu is expanded.
*/
indented?: boolean;
"data-action"?: string;
}) {
const pathName = usePathName();
const isActive = pathName === to;
const isIndented = indented && !isCollapsed;
const linkElement = (
<Link
to={to}
target={target}
data-action={dataAction}
className={cn(
"group/menulink flex h-8 items-center gap-2 overflow-hidden rounded pl-1.75 pr-2",
isIndented ? "min-w-0 flex-1" : "w-full",
isActive
? "bg-tertiary text-text-bright"
: "text-text-dimmed group-hover/menuitem:bg-background-hover group-hover/menuitem:text-text-bright hover:bg-background-hover hover:text-text-bright"
)}
>
<Icon
icon={icon}
className={cn(
"size-5 shrink-0",
isActive ? activeIconColor : (inactiveIconColor ?? "text-text-dimmed"),
!isActive &&
!disableIconHover &&
"group-hover/menuitem:text-text-bright group-hover/menulink:text-text-bright",
iconClassName
)}
/>
<motion.div
className="flex min-w-0 flex-1 items-center justify-between overflow-hidden"
initial={false}
animate={{
width: isCollapsed ? 0 : "auto",
opacity: isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<span className="select-none truncate text-[0.90625rem] font-medium tracking-[-0.01em]">
{name}
</span>
{badge && !isCollapsed && (
<motion.div
className="ml-1 flex shrink-0 items-center gap-1"
initial={false}
animate={{
opacity: 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
{badge}
</motion.div>
)}
{trailingIcon && !isCollapsed && (
<Icon icon={trailingIcon} className={cn("ml-1 size-4 shrink-0", trailingIconClassName)} />
)}
</motion.div>
</Link>
);
const link = isIndented ? (
<div className="flex w-full">
<div aria-hidden className="w-3 shrink-0" />
{linkElement}
</div>
) : (
linkElement
);
if (action) {
return (
<div className="group/menuitem relative h-8 w-full">
<SimpleTooltip
button={link}
content={name}
side="right"
sideOffset={8}
buttonClassName="h-8! block w-full"
hidden={!isCollapsed}
asChild
disableHoverableContent
/>
{!isCollapsed && (
<div
className={cn(
"absolute bottom-1 right-1 top-1 flex aspect-square items-center justify-center rounded",
isActive
? "group-hover/menuitem:bg-tertiary"
: "group-hover/menuitem:bg-background-hover"
)}
>
{action}
</div>
)}
</div>
);
}
return (
<SimpleTooltip
button={link}
content={name}
side="right"
sideOffset={8}
buttonClassName="h-8! block w-full"
hidden={!isCollapsed}
asChild
disableHoverableContent
/>
);
}
@@ -0,0 +1,117 @@
import { AnimatePresence, motion } from "framer-motion";
import React, { useCallback, useState } from "react";
import { ToggleArrowIcon } from "~/assets/icons/ToggleArrowIcon";
type Props = {
title: string;
initialCollapsed?: boolean;
onCollapseToggle?: (isCollapsed: boolean) => void;
children: React.ReactNode;
/** When true, hides the section header and shows only children */
isSideMenuCollapsed?: boolean;
itemSpacingClassName?: string;
/** Optional action element (e.g., + button) to render on the right side of the header */
headerAction?: React.ReactNode;
};
/** A collapsible section for the side menu
* The collapsed state is passed in as a prop, and there's a callback when it's toggled so we can save the state.
*/
export function SideMenuSection({
title,
initialCollapsed = false,
onCollapseToggle,
children,
isSideMenuCollapsed = false,
itemSpacingClassName = "space-y-px",
headerAction,
}: Props) {
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
const handleToggle = useCallback(() => {
const newIsCollapsed = !isCollapsed;
setIsCollapsed(newIsCollapsed);
onCollapseToggle?.(newIsCollapsed);
}, [isCollapsed, onCollapseToggle]);
return (
<div className="w-full overflow-hidden">
{/* Header container - stays in DOM to preserve height */}
<div className="relative w-full">
{/* Header - fades out when sidebar is collapsed */}
<motion.div
className="group/section flex cursor-pointer items-center justify-between overflow-hidden rounded-sm py-1 pl-1.5 pr-1 transition hover:bg-background-hover"
initial={false}
animate={{
opacity: isSideMenuCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
onClick={isSideMenuCollapsed ? undefined : handleToggle}
style={{ cursor: isSideMenuCollapsed ? "default" : "pointer" }}
>
<div className="flex items-center gap-1 text-text-dimmed transition group-hover/section:text-text-bright">
<h2 className="whitespace-nowrap text-xs">{title}</h2>
<motion.div
initial={isCollapsed}
animate={{ rotate: isCollapsed ? -90 : 0 }}
transition={{ duration: 0.2 }}
>
<ToggleArrowIcon className="size-2" />
</motion.div>
</div>
{headerAction && <div className="flex items-center">{headerAction}</div>}
</motion.div>
{/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */}
<motion.div
className="absolute left-2 right-2 top-1 h-px bg-surface-control"
initial={false}
animate={{
opacity: isSideMenuCollapsed && !isCollapsed ? 1 : 0,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
/>
</div>
<AnimatePresence initial={false}>
<motion.div
className="w-full"
initial={isCollapsed ? "collapsed" : "expanded"}
animate={isCollapsed ? "collapsed" : "expanded"}
exit="collapsed"
variants={{
expanded: {
height: "auto",
transition: {
height: { duration: 0.3, ease: "easeInOut" },
},
},
collapsed: {
height: 0,
transition: {
height: { duration: 0.2, ease: "easeInOut" },
},
},
}}
style={{ overflow: "hidden" }}
>
<motion.div
className={`w-full ${itemSpacingClassName}`}
variants={{
expanded: {
translateY: 0,
opacity: 1,
transition: { duration: 0.3, ease: "easeInOut" },
},
collapsed: {
translateY: "-100%",
opacity: 0,
transition: { duration: 0.2, ease: "easeInOut" },
},
}}
>
{children}
</motion.div>
</motion.div>
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,29 @@
import { cn } from "~/utils/cn";
// Tree connector icons for sub-items. The SVG viewBox is 20x20 matching the size-5 icon area.
// Lines extend to y=-6 and y=26 to fill the full 32px row height (6px gap above/below the 20px icon).
export function TreeConnectorBranch({ className }: { className?: string }) {
return (
<svg
className={cn("overflow-visible", className, "text-charcoal-600")}
viewBox="0 0 20 20"
fill="none"
>
<line x1="10" y1="-6" x2="10" y2="26" stroke="currentColor" strokeWidth="1" />
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
</svg>
);
}
export function TreeConnectorEnd({ className }: { className?: string }) {
return (
<svg
className={cn("overflow-visible", className, "text-charcoal-600")}
viewBox="0 0 20 20"
fill="none"
>
<line x1="10" y1="-6" x2="10" y2="10" stroke="currentColor" strokeWidth="1" />
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
</svg>
);
}
@@ -0,0 +1,14 @@
import { z } from "zod";
// Valid section IDs that can have their collapsed state toggled
export const SideMenuSectionIdSchema = z.enum([
"ai",
"manage",
"metrics",
"deployments",
"project-settings",
"tasks",
]);
// Inferred type from the schema
export type SideMenuSectionId = z.infer<typeof SideMenuSectionIdSchema>;
@@ -0,0 +1,127 @@
import { useFetcher } from "@remix-run/react";
import { type Ref, useCallback, useEffect, useMemo, useState } from "react";
import { type Layout, useContainerWidth } from "react-grid-layout";
/**
* Generic hook for managing a reorderable list in the side menu.
*
* Handles order state, sorting, grid layout, drag callbacks, and persistence
* via the `/resources/preferences/sidemenu` resource route.
*
* @param organizationId - Organization ID for scoping the persisted order
* @param listId - Identifier for this list (e.g. "customDashboards")
* @param items - The items to reorder
* @param itemKey - Extract a stable string key from each item
* @param initialOrder - Initial order from stored preferences (if any)
* @param isImpersonating - Skip persistence when impersonating
*/
export function useReorderableList<T>({
organizationId,
listId,
items,
itemKey,
initialOrder,
isImpersonating,
}: {
organizationId: string;
listId: string;
items: T[];
itemKey: (item: T) => string;
initialOrder: string[] | undefined;
isImpersonating: boolean;
}) {
const orderFetcher = useFetcher();
const [order, setOrder] = useState<string[]>(() => initialOrder ?? items.map(itemKey));
// Sync order when organizationId changes (component may not remount)
useEffect(() => {
setOrder(initialOrder ?? items.map(itemKey));
}, [organizationId]);
// Sort items by stored order, new items go to end
const orderedItems = useMemo(() => {
const orderMap = new Map(order.map((id, i) => [id, i]));
return [...items].sort((a, b) => {
const aIdx = orderMap.get(itemKey(a)) ?? Infinity;
const bIdx = orderMap.get(itemKey(b)) ?? Infinity;
return aIdx - bIdx;
});
}, [items, order, itemKey]);
// Layout for ReactGridLayout (1-column vertical list, each item h=1 row)
const layout = useMemo(
() =>
orderedItems.map((item, i) => ({
i: itemKey(item),
x: 0,
y: i,
w: 1,
h: 1,
})),
[orderedItems, itemKey]
);
// Width measurement for ReactGridLayout
const {
width: gridWidth,
containerRef,
mounted: gridMounted,
} = useContainerWidth({ initialWidth: 216 });
const canReorder = orderedItems.length >= 2;
// Track layout during drag for real-time visual updates
const [dragLayout, setDragLayout] = useState<Layout | null>(null);
const handleDrag = useCallback((layout: Layout) => {
setDragLayout(layout);
}, []);
// Handle drag stop - extract new order from layout y-positions
const handleDragStop = useCallback(
(layout: Layout) => {
setDragLayout(null);
const sorted = [...layout].sort((a, b) => a.y - b.y);
const newOrder = sorted.map((item) => item.i);
if (JSON.stringify(newOrder) === JSON.stringify(order)) return;
setOrder(newOrder);
// Persist immediately
if (!isImpersonating) {
const formData = new FormData();
formData.append("organizationId", organizationId);
formData.append("listId", listId);
formData.append("itemOrder", JSON.stringify(newOrder));
orderFetcher.submit(formData, {
method: "POST",
action: "/resources/preferences/sidemenu",
});
}
},
[order, organizationId, listId, isImpersonating, orderFetcher]
);
// Compute which item is visually last (during drag or at rest)
const getIsLast = useCallback(
(key: string, index: number) => {
if (dragLayout) {
const maxY = Math.max(...dragLayout.map((l) => l.y));
return dragLayout.find((l) => l.i === key)?.y === maxY;
}
return index === orderedItems.length - 1;
},
[dragLayout, orderedItems.length]
);
return {
orderedItems,
layout,
containerRef: containerRef as Ref<HTMLDivElement>,
gridWidth,
gridMounted,
canReorder,
handleDrag,
handleDragStop,
getIsLast,
};
}