Files
triggerdotdev--trigger.dev/apps/webapp/app/components/navigation/SideMenu.tsx
T
2026-07-13 13:32:57 +08:00

1306 lines
46 KiB
TypeScript

import {
ArrowTopRightOnSquareIcon,
ChevronRightIcon,
ExclamationTriangleIcon,
PencilSquareIcon,
} from "@heroicons/react/24/outline";
import { Link, useFetcher, useNavigation } from "@remix-run/react";
import { LayoutGroup, motion } from "framer-motion";
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import simplur from "simplur";
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 { 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 { 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 { 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 { SlidersIcon } from "~/assets/icons/SlidersIcon";
import { TasksIcon } from "~/assets/icons/TasksIcon";
import { UsageIcon } from "~/assets/icons/UsageIcon";
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
import { Avatar } from "~/components/primitives/Avatar";
import { type MatchedEnvironment } from "~/hooks/useEnvironment";
import { useFeatureFlags } from "~/hooks/useFeatureFlags";
import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { type MatchedProject } from "~/hooks/useProject";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { useHasAdminAccess } from "~/hooks/useUser";
import { type UserWithDashboardPreferences } from "~/models/user.server";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { type FeedbackType } from "~/routes/resources.feedback";
import { IncidentStatusPanel, useIncidentStatus } from "~/routes/resources.incidents";
import { cn } from "~/utils/cn";
import {
accountPath,
adminPath,
branchesPath,
concurrencyPath,
limitsPath,
logoutPath,
newOrganizationPath,
newProjectPath,
organizationPath,
organizationSettingsPath,
organizationTeamPath,
queryPath,
regionsPath,
v3ApiKeysPath,
v3BatchesPath,
v3BillingPath,
v3BulkActionsPath,
v3DashboardsLandingPath,
v3DeploymentsPath,
v3EnvironmentPath,
v3EnvironmentVariablesPath,
v3ErrorsPath,
v3LogsPath,
v3ModelsPath,
v3ProjectAlertsPath,
v3ProjectPath,
v3ProjectSettingsGeneralPath,
v3ProjectSettingsIntegrationsPath,
v3PromptsPath,
v3QueuesPath,
v3RunsPath,
v3SessionsPath,
v3UsagePath,
v3WaitpointTokensPath,
} from "~/utils/pathBuilder";
import { AskAI } from "../AskAI";
import { FreePlanUsage } from "../billing/FreePlanUsage";
import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence";
import { AlphaBadge, NewBadge } from "../FeatureBadges";
import { ImpersonationBanner } from "../ImpersonationBanner";
import { Button, ButtonContent, LinkButton } from "../primitives/Buttons";
import { Dialog, DialogTrigger } from "../primitives/Dialog";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { TextLink } from "../primitives/TextLink";
import {
SimpleTooltip,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../primitives/Tooltip";
import { ShortcutsAutoOpen } from "../Shortcuts";
import { CreateDashboardButton } from "./DashboardDialogs";
import { DashboardList } from "./DashboardList";
import { EnvironmentSelector } from "./EnvironmentSelector";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { NotificationPanel } from "./NotificationPanel";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { SideMenuSection } from "./SideMenuSection";
import { type SideMenuSectionId } from "./sideMenuTypes";
/** Get the collapsed state for a specific side menu section from user preferences */
function getSectionCollapsed(
sideMenu: { collapsedSections?: Record<string, boolean> } | undefined,
sectionId: SideMenuSectionId
): boolean {
return sideMenu?.collapsedSections?.[sectionId] ?? false;
}
type SideMenuUser = Pick<
UserWithDashboardPreferences,
"email" | "admin" | "dashboardPreferences"
> & {
isImpersonating: boolean;
};
export type SideMenuProject = Pick<
MatchedProject,
"id" | "name" | "slug" | "version" | "environments" | "engine" | "createdAt"
>;
export type SideMenuEnvironment = MatchedEnvironment;
type SideMenuProps = {
user: SideMenuUser;
project: SideMenuProject;
environment: SideMenuEnvironment;
organization: MatchedOrganization;
organizations: MatchedOrganization[];
button?: ReactNode;
defaultValue?: FeedbackType;
};
export function SideMenu({
user,
project,
environment,
organization,
organizations,
}: SideMenuProps) {
const borderRef = useRef<HTMLDivElement>(null);
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(
user.dashboardPreferences.sideMenu?.isCollapsed ?? false
);
const preferencesFetcher = useFetcher();
const pendingPreferencesRef = useRef<{
isCollapsed?: boolean;
sectionId?: SideMenuSectionId;
sectionCollapsed?: boolean;
}>({});
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const currentPlan = useCurrentPlan();
const { isConnected } = useDevPresence();
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
const isAdmin = useHasAdminAccess();
const { isManagedCloud } = useFeatures();
const featureFlags = useFeatureFlags();
const incidentStatus = useIncidentStatus();
const isV3Project = project.engine === "V1";
const persistSideMenuPreferences = useCallback(
(data: {
isCollapsed?: boolean;
sectionId?: SideMenuSectionId;
sectionCollapsed?: boolean;
}) => {
if (user.isImpersonating) return;
// Merge with any pending changes
pendingPreferencesRef.current = {
...pendingPreferencesRef.current,
...data,
};
// Clear existing timeout
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// Debounce the actual submission by 500ms
debounceTimeoutRef.current = setTimeout(() => {
const pending = pendingPreferencesRef.current;
const formData = new FormData();
if (pending.isCollapsed !== undefined) {
formData.append("isCollapsed", String(pending.isCollapsed));
}
if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) {
formData.append("sectionId", pending.sectionId);
formData.append("sectionCollapsed", String(pending.sectionCollapsed));
}
preferencesFetcher.submit(formData, {
method: "POST",
action: "/resources/preferences/sidemenu",
});
pendingPreferencesRef.current = {};
}, 500);
},
[user.isImpersonating, preferencesFetcher]
);
// Flush pending preferences on unmount to avoid losing the last toggle
useEffect(() => {
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
if (user.isImpersonating) return;
const pending = pendingPreferencesRef.current;
const hasPendingChanges =
pending.isCollapsed !== undefined ||
(pending.sectionId !== undefined && pending.sectionCollapsed !== undefined);
if (hasPendingChanges) {
const formData = new FormData();
if (pending.isCollapsed !== undefined) {
formData.append("isCollapsed", String(pending.isCollapsed));
}
if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) {
formData.append("sectionId", pending.sectionId);
formData.append("sectionCollapsed", String(pending.sectionCollapsed));
}
preferencesFetcher.submit(formData, {
method: "POST",
action: "/resources/preferences/sidemenu",
});
pendingPreferencesRef.current = {};
}
};
}, [preferencesFetcher, user.isImpersonating]);
const handleToggleCollapsed = () => {
const newIsCollapsed = !isCollapsed;
setIsCollapsed(newIsCollapsed);
persistSideMenuPreferences({ isCollapsed: newIsCollapsed });
};
/** Generic handler for any collapsible section - just pass the section ID */
const handleSectionToggle = useCallback(
(sectionId: SideMenuSectionId) => (collapsed: boolean) => {
persistSideMenuPreferences({ sectionId, sectionCollapsed: collapsed });
},
[persistSideMenuPreferences]
);
useShortcutKeys({
shortcut: { modifiers: ["mod"], key: "b", enabledOnInputElements: true },
action: handleToggleCollapsed,
});
useEffect(() => {
const handleScroll = () => {
if (borderRef.current) {
const shouldShowHeaderDivider = borderRef.current.scrollTop > 1;
if (showHeaderDivider !== shouldShowHeaderDivider) {
setShowHeaderDivider(shouldShowHeaderDivider);
}
}
};
borderRef.current?.addEventListener("scroll", handleScroll);
return () => borderRef.current?.removeEventListener("scroll", handleScroll);
}, [showHeaderDivider]);
return (
<div
className={cn(
"relative h-full border-r border-grid-bright bg-background-bright transition-all duration-200",
isCollapsed ? "w-11" : "w-56"
)}
>
<CollapseToggle isCollapsed={isCollapsed} onToggle={handleToggleCollapsed} />
<div className="absolute inset-0 grid grid-cols-[100%] grid-rows-[2.5rem_1fr_auto] overflow-hidden">
<div
className={cn(
"flex min-w-0 items-center overflow-hidden border-b px-1 py-1 transition duration-300",
showHeaderDivider || isCollapsed ? "border-grid-bright" : "border-transparent"
)}
>
<div className={cn("min-w-0", !isCollapsed && "flex-1")}>
<ProjectSelector
organizations={organizations}
organization={organization}
project={project}
user={user}
isCollapsed={isCollapsed}
/>
</div>
{isAdmin && !user.isImpersonating ? (
<CollapsibleElement isCollapsed={isCollapsed}>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger>
<LinkButton
variant="minimal/medium"
to={adminPath()}
TrailingIcon={HomeIcon}
trailingIconClassName="h-4.5 w-4.5"
className="h-8 w-8"
/>
</TooltipTrigger>
<TooltipContent side="bottom" className={"text-xs"}>
Admin dashboard
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CollapsibleElement>
) : isAdmin && user.isImpersonating ? (
<CollapsibleElement isCollapsed={isCollapsed}>
<ImpersonationBanner />
</CollapsibleElement>
) : null}
</div>
<div
className={cn(
"min-h-0 overflow-y-auto pt-2",
isCollapsed
? "scrollbar-none"
: "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
)}
ref={borderRef}
>
<div className="mb-6 flex w-full flex-col gap-4 overflow-hidden px-1">
<div className="w-full space-y-1">
<SideMenuHeader
title={"Environment"}
isCollapsed={isCollapsed}
collapsedTitle="Env"
/>
<div className="flex items-center">
<EnvironmentSelector
organization={organization}
project={project}
environment={environment}
className="w-full"
isCollapsed={isCollapsed}
/>
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
<CollapsibleElement isCollapsed={isCollapsed}>
<Dialog>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-flex">
<DialogTrigger asChild>
<Button
variant="minimal/small"
className="aspect-square h-7 p-1"
LeadingIcon={<ConnectionIcon isConnected={isConnected} />}
/>
</DialogTrigger>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={"text-xs"}>
{isConnected === undefined
? "Checking connection…"
: isConnected
? "Your dev server is connected"
: "Your dev server is not connected"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DevPresencePanel isConnected={isConnected} />
</Dialog>
</CollapsibleElement>
)}
</div>
</div>
<div className="w-full space-y-0">
<SideMenuItem
name="Tasks"
icon={TasksIcon}
activeIconColor="text-tasks"
inactiveIconColor="text-text-dimmed"
to={v3EnvironmentPath(organization, project, environment)}
data-action="tasks"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Runs"
icon={RunsIcon}
activeIconColor="text-runs"
inactiveIconColor="text-text-dimmed"
to={v3RunsPath(organization, project, environment)}
data-action="runs"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Sessions"
icon={AIChatIcon}
activeIconColor="text-sessions"
inactiveIconColor="text-text-dimmed"
to={v3SessionsPath(organization, project, environment)}
data-action="sessions"
badge={<NewBadge />}
isCollapsed={isCollapsed}
/>
</div>
{(user.admin || user.isImpersonating || featureFlags.hasAiAccess) && (
<SideMenuSection
title="AI"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={getSectionCollapsed(user.dashboardPreferences.sideMenu, "ai")}
onCollapseToggle={handleSectionToggle("ai")}
>
<SideMenuItem
name="Prompts"
icon={AIPenIcon}
trailingIconClassName="size-6"
activeIconColor="text-aiPrompts"
inactiveIconColor="text-text-dimmed"
to={v3PromptsPath(organization, project, environment)}
data-action="prompts"
badge={<NewBadge />}
isCollapsed={isCollapsed}
/>
{(user.admin || user.isImpersonating || featureFlags.hasAiAccess) && (
<SideMenuItem
name="Models"
icon={Box3DIcon}
activeIconColor="text-models"
inactiveIconColor="text-text-dimmed"
to={v3ModelsPath(organization, project, environment)}
data-action="models"
badge={<NewBadge />}
isCollapsed={isCollapsed}
/>
)}
</SideMenuSection>
)}
{(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
<SideMenuSection
title="Observability"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={getSectionCollapsed(
user.dashboardPreferences.sideMenu,
"metrics"
)}
onCollapseToggle={handleSectionToggle("metrics")}
>
{(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
<SideMenuItem
name="Logs"
icon={LogsIcon}
activeIconColor="text-logs"
inactiveIconColor="text-text-dimmed"
to={v3LogsPath(organization, project, environment)}
data-action="logs"
badge={<AlphaBadge />}
isCollapsed={isCollapsed}
/>
)}
<SideMenuItem
name="Errors"
icon={BugIcon}
activeIconColor="text-errors"
inactiveIconColor="text-text-dimmed"
to={v3ErrorsPath(organization, project, environment)}
data-action="errors"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Query"
icon={CodeSquareIcon}
activeIconColor="text-query"
inactiveIconColor="text-text-dimmed"
to={queryPath(organization, project, environment)}
data-action="query"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Queues"
icon={QueuesIcon}
activeIconColor="text-queues"
inactiveIconColor="text-text-dimmed"
to={v3QueuesPath(organization, project, environment)}
data-action="queues"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Dashboards"
icon={ChartBarIcon}
activeIconColor="text-metrics"
inactiveIconColor="text-text-dimmed"
to={v3DashboardsLandingPath(organization, project, environment)}
data-action="dashboards-landing"
isCollapsed={isCollapsed}
action={
<CreateDashboardButton
organization={organization}
project={project}
environment={environment}
isCollapsed={isCollapsed}
/>
}
/>
<DashboardList
organization={organization}
project={project}
environment={environment}
isCollapsed={isCollapsed}
user={user}
/>
</SideMenuSection>
)}
<SideMenuSection
title="Deployments"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={getSectionCollapsed(
user.dashboardPreferences.sideMenu,
"deployments"
)}
onCollapseToggle={handleSectionToggle("deployments")}
>
<SideMenuItem
name="Deploys"
icon={DeploymentsIcon}
activeIconColor="text-deployments"
inactiveIconColor="text-text-dimmed"
to={v3DeploymentsPath(organization, project, environment)}
data-action="deployments"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Environment variables"
icon={IDIcon}
activeIconColor="text-environmentVariables"
inactiveIconColor="text-text-dimmed"
to={v3EnvironmentVariablesPath(organization, project, environment)}
data-action="environment variables"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Preview branches"
icon={BranchEnvironmentIconSmall}
activeIconColor="text-previewBranches"
inactiveIconColor="text-text-dimmed"
to={branchesPath(organization, project, environment)}
data-action="preview-branches"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Regions"
icon={GlobeLinesIcon}
activeIconColor="text-regions"
inactiveIconColor="text-text-dimmed"
to={regionsPath(organization, project, environment)}
data-action="regions"
isCollapsed={isCollapsed}
/>
</SideMenuSection>
<SideMenuSection
title="Manage"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={getSectionCollapsed(user.dashboardPreferences.sideMenu, "manage")}
onCollapseToggle={handleSectionToggle("manage")}
>
<SideMenuItem
name="Waitpoint tokens"
icon={WaitpointTokenIcon}
activeIconColor="text-sky-500"
inactiveIconColor="text-text-dimmed"
to={v3WaitpointTokensPath(organization, project, environment)}
data-action="waitpoint-tokens"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Batches"
icon={BatchesIcon}
activeIconColor="text-batches"
inactiveIconColor="text-text-dimmed"
to={v3BatchesPath(organization, project, environment)}
data-action="batches"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Bulk actions"
icon={ListCheckedIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3BulkActionsPath(organization, project, environment)}
data-action="bulk actions"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="API keys"
icon={KeyIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ApiKeysPath(organization, project, environment)}
data-action="api keys"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Alerts"
icon={BellIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectAlertsPath(organization, project, environment)}
data-action="alerts"
isCollapsed={isCollapsed}
/>
{isManagedCloud && (
<SideMenuItem
name="Concurrency"
icon={ConcurrencyIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={concurrencyPath(organization, project, environment)}
data-action="concurrency"
isCollapsed={isCollapsed}
/>
)}
<SideMenuItem
name="Limits"
icon={DialIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={limitsPath(organization, project, environment)}
data-action="limits"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Integrations"
icon={IntegrationsIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
data-action="project-settings-integrations"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Project settings"
icon={SlidersIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectSettingsGeneralPath(organization, project, environment)}
data-action="project-settings-general"
isCollapsed={isCollapsed}
/>
</SideMenuSection>
</div>
</div>
<div>
<NotificationPanel
isCollapsed={isCollapsed}
hasIncident={incidentStatus.hasIncident}
organizationId={organization.id}
projectId={project.id}
/>
<IncidentStatusPanel
isCollapsed={isCollapsed}
title={incidentStatus.title}
hasIncident={incidentStatus.hasIncident}
isManagedCloud={incidentStatus.isManagedCloud}
/>
<V3DeprecationPanel
isCollapsed={isCollapsed}
isV3={isV3Project}
projectCreatedAt={project.createdAt}
hasIncident={incidentStatus.hasIncident}
isManagedCloud={incidentStatus.isManagedCloud}
/>
<motion.div
layout
transition={{ duration: 0.2, ease: "easeInOut" }}
className={cn(
"flex flex-col gap-1 border-t border-grid-bright p-1",
isCollapsed && "items-center"
)}
>
<HelpAndAI
isCollapsed={isCollapsed}
organizationId={organization.id}
projectId={project.id}
/>
{isFreeUser && (
<CollapsibleHeight isCollapsed={isCollapsed}>
<FreePlanUsage
to={v3BillingPath(organization)}
percentage={currentPlan.v3Usage.usagePercentage}
/>
</CollapsibleHeight>
)}
</motion.div>
</div>
</div>
</div>
);
}
function V3DeprecationPanel({
isCollapsed,
isV3,
projectCreatedAt,
hasIncident,
isManagedCloud,
}: {
isCollapsed: boolean;
isV3: boolean;
projectCreatedAt: Date;
hasIncident: boolean;
isManagedCloud: boolean;
}) {
// Only show for projects created before v4 was released
const V4_RELEASE_DATE = new Date("2025-09-01");
const isLikelyV3 = isV3 && new Date(projectCreatedAt) < V4_RELEASE_DATE;
if (!isManagedCloud || !isLikelyV3 || hasIncident) {
return null;
}
return (
<Popover>
<div className="p-1">
<motion.div
initial={false}
animate={{
height: isCollapsed ? 0 : "auto",
opacity: isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<V3DeprecationContent />
</motion.div>
<motion.div
initial={false}
animate={{
height: isCollapsed ? "auto" : 0,
opacity: isCollapsed ? 1 : 0,
}}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<SimpleTooltip
button={
<PopoverTrigger className="flex h-8! w-full items-center justify-center rounded border border-amber-500/30 bg-amber-500/15 transition-colors hover:border-amber-500/50 hover:bg-amber-500/25">
<ExclamationTriangleIcon className="size-5 text-amber-400" />
</PopoverTrigger>
}
content="V3 deprecation warning"
side="right"
sideOffset={8}
disableHoverableContent
asChild
/>
</motion.div>
</div>
<PopoverContent side="right" sideOffset={8} align="start" className="w-52 min-w-0! p-0">
<V3DeprecationContent />
</PopoverContent>
</Popover>
);
}
function V3DeprecationContent() {
return (
<div className="flex flex-col gap-2 rounded border border-amber-500/30 bg-amber-500/10 p-2 pt-1.5">
<div className="flex items-center gap-1 border-b border-amber-500/30 pb-1">
<ExclamationTriangleIcon className="size-4 text-amber-400" />
<Paragraph variant="small/bright" className="text-amber-300">
V3 deprecation warning
</Paragraph>
</div>
<Paragraph variant="extra-small/bright" className="text-amber-300">
This is a v3 project. V3 deploys will stop working on 1 April 2026. Full shutdown is 1 July
2026 where all v3 runs will stop executing. Migrate to v4 to avoid downtime.
</Paragraph>
<LinkButton
variant="secondary/small"
to="https://trigger.dev/docs/migrating-from-v3"
target="_blank"
fullWidth
TrailingIcon={ArrowTopRightOnSquareIcon}
trailingIconClassName="text-amber-300"
className="border-amber-500/30 bg-amber-500/15 hover:border-amber-500/50! hover:bg-amber-500/25!"
>
<span className="text-amber-300">View migration guide</span>
</LinkButton>
</div>
);
}
function ProjectSelector({
project,
organization,
organizations,
user,
isCollapsed = false,
}: {
project: SideMenuProject;
organization: MatchedOrganization;
organizations: MatchedOrganization[];
user: SideMenuUser;
isCollapsed?: boolean;
}) {
const currentPlan = useCurrentPlan();
const [isOrgMenuOpen, setOrgMenuOpen] = useState(false);
const navigation = useNavigation();
const { isManagedCloud } = useFeatures();
let plan: string | undefined = undefined;
if (currentPlan?.v3Subscription?.isPaying === false) {
plan = "Free";
} else if (currentPlan?.v3Subscription?.isPaying === true) {
plan = currentPlan.v3Subscription.plan?.title;
}
useEffect(() => {
setOrgMenuOpen(false);
}, [navigation.location?.pathname]);
return (
<Popover onOpenChange={(open) => setOrgMenuOpen(open)} open={isOrgMenuOpen}>
<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" : "w-full justify-between pr-1"
)}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<Avatar avatar={organization.avatar} size={1.25} orgName={organization.title} />
<span
className={cn(
"flex min-w-0 items-center gap-1.5 overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
)}
>
<SelectorDivider />
<span className="truncate text-[0.90625rem] font-medium tracking-[-0.01em] text-text-bright">
{project.name ?? "Select a project"}
</span>
</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={`${organization.title} / ${project.name ?? "Select a project"}`}
side="right"
sideOffset={8}
hidden={!isCollapsed}
buttonClassName="h-8!"
asChild
disableHoverableContent
/>
<PopoverContent
className="min-w-64 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-2 bg-background-hover p-2">
<div className="flex items-center gap-2.5">
<Link
to={organizationSettingsPath(organization)}
className="group relative box-content size-10 overflow-clip rounded-sm bg-background-bright"
>
<Avatar avatar={organization.avatar} size={2.5} orgName={organization.title} />
<div className="absolute inset-0 z-10 grid h-full w-full place-items-center bg-black/50 opacity-0 transition group-hover:opacity-100">
<PencilSquareIcon className="size-5 text-text-bright" />
</div>
</Link>
<div className="space-y-0.5">
<Paragraph variant="small/bright">{organization.title}</Paragraph>
<div className="flex items-baseline gap-2">
{plan && (
<TextLink
variant="secondary"
className="text-xs"
to={v3BillingPath(organization)}
>
{plan} plan
</TextLink>
)}
<TextLink
variant="secondary"
className="text-xs"
to={organizationTeamPath(organization)}
>{simplur`${organization.membersCount} member[|s]`}</TextLink>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<LinkButton
variant="secondary/small"
to={organizationSettingsPath(organization)}
fullWidth
iconSpacing="gap-1.5"
className="group-hover/button:border-border-brightest"
>
<SlidersIcon className="size-4 text-text-dimmed" />
<span className="text-text-bright">Settings</span>
</LinkButton>
{isManagedCloud && (
<LinkButton
variant="secondary/small"
to={v3UsagePath(organization)}
fullWidth
iconSpacing="gap-1.5"
className="group-hover/button:border-border-brightest"
>
<UsageIcon className="size-4 text-text-dimmed" />
<span className="text-text-bright">Usage</span>
</LinkButton>
)}
</div>
</div>
<div className="flex flex-col gap-1 p-1">
{organization.projects.map((p) => {
const isSelected = p.id === project.id;
return (
<PopoverMenuItem
key={p.id}
to={v3ProjectPath(organization, p)}
title={
<div className="flex w-full items-center justify-between text-text-bright">
<span className="grow truncate text-left">{p.name}</span>
</div>
}
isSelected={isSelected}
icon={isSelected ? FolderOpenIcon : FolderClosedIcon}
leadingIconClassName="text-indigo-500"
/>
);
})}
<PopoverMenuItem to={newProjectPath(organization)} title="New project" icon={PlusIcon} />
</div>
<div className="border-t border-grid-bright p-1">
{organizations.length > 1 ? (
<SwitchOrganizations organizations={organizations} organization={organization} />
) : (
<PopoverMenuItem
to={newOrganizationPath()}
title="New organization"
icon={PlusIcon}
leadingIconClassName="text-text-dimmed"
/>
)}
</div>
<div className="border-t border-grid-bright p-1">
<PopoverMenuItem
to={accountPath()}
title="Account"
icon={AvatarCircleIcon}
leadingIconClassName="text-text-dimmed"
/>
</div>
<div className="border-t border-grid-bright p-1">
<PopoverMenuItem
to={logoutPath()}
title="Logout"
icon={ArrowRightSquareIcon}
leadingIconClassName="text-text-dimmed"
danger
/>
</div>
</PopoverContent>
</Popover>
);
}
function SwitchOrganizations({
organizations,
organization,
}: {
organizations: MatchedOrganization[];
organization: MatchedOrganization;
}) {
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);
};
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"
LeadingIcon={ArrowLeftRightIcon}
leadingIconClassName="text-text-dimmed"
TrailingIcon={ChevronRightIcon}
trailingIconClassName="text-text-dimmed"
textAlignLeft
fullWidth
>
Switch organization
</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">
{organizations.map((org) => (
<PopoverMenuItem
key={org.id}
to={organizationPath(org)}
title={org.title}
icon={<Avatar size={1} avatar={org.avatar} orgName={org.title} />}
leadingIconClassName="text-text-dimmed"
isSelected={org.id === organization.id}
/>
))}
</div>
<div className="border-t border-grid-bright p-1">
<PopoverMenuItem
to={newOrganizationPath()}
title="New organization"
icon={PlusIcon}
leadingIconClassName="text-text-dimmed"
/>
</div>
</PopoverContent>
</div>
</Popover>
);
}
function SelectorDivider() {
return (
<svg width="6" height="21" viewBox="0 0 6 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<line
x1="5.3638"
y1="0.606339"
x2="0.606339"
y2="19.6362"
stroke="var(--color-border-bright)"
strokeLinecap="round"
/>
</svg>
);
}
/** Helper component that fades out but preserves width (collapses to 0 width) */
function CollapsibleElement({
isCollapsed,
children,
className,
}: {
isCollapsed: boolean;
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[100px] opacity-100",
className
)}
>
{children}
</div>
);
}
/** Helper component that fades out and collapses height completely */
function CollapsibleHeight({
isCollapsed,
children,
className,
}: {
isCollapsed: boolean;
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"grid transition-all duration-200 ease-in-out",
isCollapsed ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr] opacity-100",
className
)}
>
<div className="overflow-hidden">{children}</div>
</div>
);
}
function HelpAndAI({
isCollapsed,
organizationId,
projectId,
}: {
isCollapsed: boolean;
organizationId: string;
projectId: string;
}) {
return (
<LayoutGroup>
<div
className={cn(
"flex w-full",
isCollapsed ? "flex-col-reverse gap-1" : "items-center justify-between"
)}
>
<ShortcutsAutoOpen />
<HelpAndFeedback
isCollapsed={isCollapsed}
organizationId={organizationId}
projectId={projectId}
/>
<AskAI isCollapsed={isCollapsed} />
</div>
</LayoutGroup>
);
}
function AnimatedChevron({
isHovering,
isCollapsed,
}: {
isHovering: boolean;
isCollapsed: boolean;
}) {
// When hovering and expanded: left chevron (pointing left to collapse)
// When hovering and collapsed: right chevron (pointing right to expand)
// When not hovering: straight vertical line
const getRotation = () => {
if (!isHovering) return { top: 0, bottom: 0 };
if (isCollapsed) {
// Right chevron
return { top: -17, bottom: 17 };
} else {
// Left chevron
return { top: 17, bottom: -17 };
}
};
const { top, bottom } = getRotation();
// Calculate horizontal offset to keep chevron centered when rotated
// Left chevron: translate left (-1.5px)
// Right chevron: translate right (+1.5px)
const getTranslateX = () => {
if (!isHovering) return 0;
return isCollapsed ? 1.5 : -1.5;
};
return (
<motion.svg
width="4"
height="30"
viewBox="0 0 4 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="pointer-events-none relative z-10 overflow-visible text-charcoal-600 transition-colors group-hover:text-text-bright"
initial={false}
animate={{
x: getTranslateX(),
}}
transition={{ duration: 0.4, ease: "easeOut" }}
>
{/* Top segment */}
<motion.line
x1="2"
y1="1.5"
x2="2"
y2="15"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
initial={false}
animate={{
rotate: top,
}}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ transformOrigin: "2px 15px" }}
/>
{/* Bottom segment */}
<motion.line
x1="2"
y1="15"
x2="2"
y2="28.5"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
initial={false}
animate={{
rotate: bottom,
}}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ transformOrigin: "2px 15px" }}
/>
</motion.svg>
);
}
function CollapseToggle({ isCollapsed, onToggle }: { isCollapsed: boolean; onToggle: () => void }) {
const [isHovering, setIsHovering] = useState(false);
return (
<div className="absolute -right-3 top-1/2 z-10 -translate-y-1/2">
{/* Vertical line to mask the side menu border */}
<div
className={cn(
"pointer-events-none absolute left-1/2 top-1/2 h-10 w-px -translate-y-1/2 transition-colors duration-200",
isHovering ? "bg-background-hover" : "bg-background-bright"
)}
/>
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={isCollapsed ? "Expand side menu" : "Collapse side menu"}
onClick={onToggle}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className={cn(
"group flex h-12 w-6 items-center justify-center rounded-md text-text-dimmed transition-all duration-200 focus-custom",
isHovering
? "border border-grid-bright bg-background-bright shadow-md hover:bg-background-hover hover:text-text-bright"
: "border border-transparent bg-transparent"
)}
>
<AnimatedChevron isHovering={isHovering} isCollapsed={isCollapsed} />
</button>
</TooltipTrigger>
<TooltipContent side="right" className="flex items-center gap-2 text-xs">
{isCollapsed ? "Expand" : "Collapse"}
<span className="flex items-center">
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "b" }} variant="medium/bright" />
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}