Files
2026-07-13 13:32:57 +08:00

223 lines
7.2 KiB
TypeScript

import { AnimatePresence, motion } from "framer-motion";
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from "react";
import {
CheckingConnectionIcon,
ConnectedIcon,
DisconnectedIcon,
} from "~/assets/icons/ConnectionIcons";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useEventSource } from "~/hooks/useEventSource";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { docsPath } from "~/utils/pathBuilder";
import connectedImage from "../assets/images/cli-connected.png";
import disconnectedImage from "../assets/images/cli-disconnected.png";
import { InlineCode } from "./code/InlineCode";
import { Button } from "./primitives/Buttons";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "./primitives/Dialog";
import { Paragraph } from "./primitives/Paragraph";
import { TextLink } from "./primitives/TextLink";
import { PackageManagerProvider, TriggerDevStepV3 } from "./SetupCommands";
// Define Context types
type DevPresenceContextType = {
isConnected: boolean | undefined;
};
// Create Context with default values
const DevPresenceContext = createContext<DevPresenceContextType>({
isConnected: undefined,
});
// Provider component with enabled prop
interface DevPresenceProviderProps {
children: ReactNode;
enabled?: boolean;
}
export function DevPresenceProvider({ children, enabled = true }: DevPresenceProviderProps) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
// Only subscribe to event source if enabled is true
const streamedEvents = useEventSource(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/presence`,
{
event: "presence",
disabled: !enabled,
}
);
const [isConnected, setIsConnected] = useState<boolean | undefined>(undefined);
useEffect(() => {
// If disabled or no events
if (!enabled || streamedEvents === null) {
setIsConnected(undefined);
return;
}
try {
const data = JSON.parse(streamedEvents) as any;
if ("isConnected" in data && data.isConnected) {
try {
setIsConnected(true);
} catch (error) {
console.log("DevPresence: Failed to parse lastSeen timestamp", { error });
setIsConnected(false);
}
} else {
setIsConnected(false);
}
} catch (error) {
console.log("DevPresence: Failed to parse presence message", { error });
setIsConnected(false);
}
}, [streamedEvents, enabled]);
// Calculate isConnected and memoize the context value
const contextValue = useMemo(() => {
return { isConnected };
}, [isConnected, enabled]);
return <DevPresenceContext.Provider value={contextValue}>{children}</DevPresenceContext.Provider>;
}
// Custom hook to use the context
export function useDevPresence() {
const context = useContext(DevPresenceContext);
if (context === undefined) {
throw new Error("useDevPresence must be used within a DevPresenceProvider");
}
return context;
}
/**
* We need this for the legacy v1 engine, where we show the banner after a delay if there are no events.
*/
export function useCrossEngineIsConnected({
isCompleted,
logCount,
}: {
isCompleted: boolean;
logCount: number;
}) {
const project = useProject();
const environment = useEnvironment();
const { isConnected } = useDevPresence();
const [crossEngineIsConnected, setCrossEngineIsConnected] = useState<boolean | undefined>(
undefined
);
useEffect(() => {
if (project.engine === "V2") {
setCrossEngineIsConnected(isConnected);
return;
}
if (project.engine === "V1") {
if (isCompleted) {
setCrossEngineIsConnected(true);
return;
}
if (logCount <= 1) {
const timer = setTimeout(() => {
setCrossEngineIsConnected(false);
}, 5000);
return () => clearTimeout(timer);
} else {
setCrossEngineIsConnected(true);
}
}
}, [environment.type, project.engine, logCount, isConnected, isCompleted]);
return crossEngineIsConnected;
}
export function ConnectionIcon({ isConnected }: { isConnected: boolean | undefined }) {
if (isConnected === undefined) {
return <CheckingConnectionIcon className="size-5" />;
}
return isConnected ? (
<ConnectedIcon className="size-5" />
) : (
<DisconnectedIcon className="size-5" />
);
}
export function DevPresencePanel({ isConnected }: { isConnected: boolean | undefined }) {
return (
<DialogContent>
<DialogHeader>
{isConnected === undefined
? "Checking connection..."
: isConnected
? "Your dev server is connected"
: "Your dev server is not connected"}
</DialogHeader>
<div className="mt-2 flex flex-col gap-3 px-2">
<div className="flex flex-col items-center justify-center gap-6 px-6 py-10">
<img
src={isConnected === true ? connectedImage : disconnectedImage}
alt={isConnected === true ? "Connected" : "Disconnected"}
width={282}
height={45}
/>
<Paragraph variant="small" className={isConnected ? "text-success" : "text-error"}>
{isConnected === undefined
? "Checking connection..."
: isConnected
? "Your local dev server is connected to Trigger.dev"
: "Your local dev server is not connected to Trigger.dev"}
</Paragraph>
</div>
{isConnected ? null : (
<div className="space-y-3">
<PackageManagerProvider>
<TriggerDevStepV3 title="Run this command to connect" />
</PackageManagerProvider>
<Paragraph variant="small">
Run this CLI <InlineCode variant="extra-small">dev</InlineCode> command to connect to
the Trigger.dev servers to start developing locally. Keep it running while you develop
to stay connected. Learn more in the{" "}
<TextLink to={docsPath("cli-dev")}>CLI docs</TextLink>.
</Paragraph>
</div>
)}
</div>
</DialogContent>
);
}
export function DevDisconnectedBanner({ isConnected }: { isConnected: boolean | undefined }) {
return (
<Dialog>
<AnimatePresence>
{isConnected === false && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="flex"
>
<DialogTrigger asChild>
<Button
variant="minimal/small"
className="border border-error/20 bg-error/10 py-1 pl-1 pr-2 group-hover/button:border-error/30 group-hover/button:bg-error/20"
iconSpacing="gap-1"
LeadingIcon={<ConnectionIcon isConnected={false} />}
>
Your local dev server is not connected to Trigger.dev
</Button>
</DialogTrigger>
</motion.div>
)}
</AnimatePresence>
<DevPresencePanel isConnected={isConnected} />
</Dialog>
);
}