feat(Notifications): Platform notifications for CLI and Dashboard (#3254)

For human reviewer:

- Check if Redis connection + code makes sense
- Check CLI methods (it's on a hotpath)
- Check DB Migrations and new tables


##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

Spawning new CLI / Dashboard notifications, check MVP, check if failures
not produce any problems with CLI/Dashboard

---

## Changelog

Added notifications mechanism for Dashboard and CLI

---

## Screenshots



💯
This commit is contained in:
Oskar Otwinowski
2026-03-26 14:19:17 +01:00
committed by GitHub
parent cd7d7593aa
commit 8244ac6f84
28 changed files with 3402 additions and 89 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Add platform notifications support to the CLI. The `trigger dev` and `trigger login` commands now fetch and display platform notifications (info, warn, error, success) from the server. Includes discovery-based filtering to conditionally show notifications based on project file patterns, color markup rendering for styled terminal output, and a non-blocking display flow with a spinner fallback for slow fetches. Use `--skip-platform-notifications` flag with `trigger dev` to disable the notification check.
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
Add platform notifications to inform users about new features, changelogs, and platform events directly in the dashboard.
@@ -11,6 +11,7 @@ import {
import { cn } from "~/utils/cn";
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
import { Fragment, useState } from "react";
import { useRecentChangelogs } from "~/routes/resources.platform-changelogs";
import { motion } from "framer-motion";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
@@ -38,6 +39,7 @@ export function HelpAndFeedback({
}) {
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
const currentPlan = useCurrentPlan();
const { changelogs } = useRecentChangelogs();
useShortcutKeys({
shortcut: disableShortcut ? undefined : { key: "h", enabledOnInputElements: false },
@@ -140,96 +142,7 @@ export function HelpAndFeedback({
data-action="suggest-a-feature"
target="_blank"
/>
<SideMenuItem
name="Changelog"
icon={StarIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-sun-500"
activeIconColor="text-sun-500"
to="https://trigger.dev/changelog"
data-action="changelog"
target="_blank"
/>
<Shortcuts />
</div>
<div className="flex flex-col gap-1 p-1">
<Paragraph className="pb-1 pl-1.5 pt-1.5 text-xs">Need help?</Paragraph>
{currentPlan?.v3Subscription?.plan?.limits.support === "slack" && (
<div>
<Dialog>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={SlackIcon}
data-action="join-our-slack"
fullWidth
textAlignLeft
>
<div className="flex w-full items-center justify-between">
<span className="text-text-bright">Join our Slack</span>
<Badge variant="extra-small" className="uppercase">
Pro
</Badge>
</div>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Join our Slack</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<div className="flex items-center gap-4">
<Icon icon={SlackIcon} className="h-10 w-10 min-w-[2.5rem]" />
<Paragraph variant="base/bright">
As a subscriber, you have access to a dedicated Slack channel for 1-to-1
support with the Trigger.dev team.
</Paragraph>
</div>
<hr className="border-charcoal-800" />
<div>
<StepNumber stepNumber="1" title="Email us" />
<StepContentContainer>
<Paragraph>
Send us an email to this address from your Trigger.dev account email
address:
<ClipboardField
variant="secondary/medium"
value="priority-support@trigger.dev"
className="my-2"
/>
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Look out for an invite from Slack" />
<StepContentContainer>
<Paragraph>
As soon as we can, we'll setup a Slack Connect channel and say hello!
</Paragraph>
</StepContentContainer>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)}
<SideMenuItem
name="Ask in our Discord"
icon={DiscordIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
to="https://trigger.dev/discord"
data-action="join our discord"
target="_blank"
/>
<SideMenuItem
name="Book a 15 min call"
icon={CalendarDaysIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-rose-500"
activeIconColor="text-rose-500"
to="https://cal.com/team/triggerdotdev/founders-call"
data-action="book-a-call"
target="_blank"
/>
<Feedback
button={
<Button
@@ -246,9 +159,44 @@ export function HelpAndFeedback({
}
/>
</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={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,312 @@
import { BellAlertIcon, ChevronRightIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { useFetcher } from "@remix-run/react";
import { motion } from "framer-motion";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { Header3 } from "~/components/primitives/Headers";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { usePlatformNotifications } from "~/routes/resources.platform-notifications";
import { cn } from "~/utils/cn";
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 card = (
<NotificationCard
notification={notification}
onDismiss={handleDismiss}
onLinkClick={() => fireClickBeacon(notification.id)}
/>
);
return (
<Popover>
<div className="p-1">
{/* Expanded sidebar: show card directly */}
<motion.div
initial={false}
animate={{
height: isCollapsed ? 0 : "auto",
opacity: isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
{card}
</motion.div>
{/* Collapsed sidebar: show bell icon that opens popover */}
<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-charcoal-650 bg-charcoal-750/50 transition-colors hover:border-charcoal-600 hover:bg-charcoal-700/50">
<div className="relative">
<BellAlertIcon className="size-5 text-text-dimmed" />
<span
className="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium text-white"
style={{ backgroundColor: "#6366f1" }}
>
{visibleNotifications.length}
</span>
</div>
</PopoverTrigger>
}
content="Notifications"
side="right"
sideOffset={8}
disableHoverableContent
asChild
/>
</motion.div>
</div>
<PopoverContent side="right" sideOffset={8} align="start" className="w-56 !min-w-0 p-0">
{card}
</PopoverContent>
</Popover>
);
}
function NotificationCard({
notification,
onDismiss,
onLinkClick,
}: {
notification: Notification;
onDismiss: (id: string) => void;
onLinkClick: () => void;
}) {
const { title, description, image, actionUrl, dismissOnAction } = notification.payload.data;
const [isExpanded, setIsExpanded] = useState(false);
const [isOverflowing, setIsOverflowing] = useState(false);
const descriptionRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const el = descriptionRef.current;
if (el) {
setIsOverflowing(el.scrollHeight > el.clientHeight);
}
}, [description]);
const handleDismiss = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onDismiss(notification.id);
};
const handleToggleExpand = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsExpanded((v) => !v);
};
const handleCardClick = () => {
onLinkClick();
if (dismissOnAction) {
onDismiss(notification.id);
}
};
const Wrapper = actionUrl ? "a" : "div";
const wrapperProps = actionUrl
? {
href: actionUrl,
target: "_blank" as const,
rel: "noopener noreferrer" as const,
onClick: handleCardClick,
}
: {};
return (
<Wrapper
{...wrapperProps}
className="group/card group relative block overflow-hidden rounded border transition-colors border-grid-bright bg-charcoal-750/50 no-underline"
>
{/* Header: title + dismiss */}
<div className="relative flex items-start gap-1 px-2 pt-1.5">
<Header3 className="flex-1 !text-xs">
{title}
</Header3>
<button
type="button"
onClick={handleDismiss}
className="shrink-0 rounded p-0.5 text-text-dimmed transition-colors hover:bg-charcoal-700 hover:text-text-bright"
>
<XMarkIcon className="size-3.5" />
</button>
</div>
{/* Body: description + chevron */}
<div className="relative px-2 pb-2">
<div className="flex gap-1">
<div className="min-w-0 flex-1">
<div
ref={descriptionRef}
className={cn(!isExpanded && "line-clamp-3")}
>
<ReactMarkdown components={getMarkdownComponents(onLinkClick)}>{description}</ReactMarkdown>
</div>
{(isOverflowing || isExpanded) && (
<button
type="button"
onClick={handleToggleExpand}
className="mt-0.5 text-xs text-indigo-400 hover:text-indigo-300"
>
{isExpanded ? "Show less" : "Show more"}
</button>
)}
</div>
{actionUrl && (
<div className="mt-1 flex shrink-0 items-center pb-1 text-text-dimmed group-hover/card:text-text-bright transition-colors">
<ChevronRightIcon className="size-4" />
</div>
)}
</div>
{image && (
<img src={sanitizeImageUrl(image)} alt="" className="mt-1.5 rounded" />
)}
</div>
</Wrapper>
);
}
/** Sanitize image URL to prevent XSS via javascript: or data: URIs. */
function sanitizeImageUrl(url: string): string {
try {
const parsed = new URL(url);
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
return parsed.href;
}
return "";
} catch {
return "";
}
}
function getMarkdownComponents(onLinkClick: () => void) {
return {
p: ({ children }: { children?: React.ReactNode }) => (
<p className="my-0.5 text-xs leading-relaxed text-text-dimmed">{children}</p>
),
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-400 underline hover:text-indigo-300 transition-colors"
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-charcoal-700 px-1 py-0.5 text-[11px]">{children}</code>
),
};
}
@@ -53,6 +53,7 @@ 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 { NotificationPanel } from "./NotificationPanel";
import { cn } from "~/utils/cn";
import {
accountPath,
@@ -701,6 +702,12 @@ export function SideMenu({
hasIncident={incidentStatus.hasIncident}
isManagedCloud={incidentStatus.isManagedCloud}
/>
<NotificationPanel
isCollapsed={isCollapsed}
hasIncident={incidentStatus.hasIncident}
organizationId={organization.id}
projectId={project.id}
/>
<motion.div
layout
transition={{ duration: 0.2, ease: "easeInOut" }}
@@ -0,0 +1,64 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { err, ok, type Result } from "neverthrow";
import { prisma } from "~/db.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import {
createPlatformNotification,
type CreatePlatformNotificationInput,
} from "~/services/platformNotifications.server";
type AdminUser = { id: string; admin: boolean };
type AuthError = { status: number; message: string };
async function authenticateAdmin(request: Request): Promise<Result<AdminUser, AuthError>> {
const authResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authResult) {
return err({ status: 401, message: "Invalid or Missing API key" });
}
const user = await prisma.user.findUnique({
where: { id: authResult.userId },
select: { id: true, admin: true },
});
if (!user?.admin) {
return err({
status: user ? 403 : 401,
message: user ? "You must be an admin to perform this action" : "Invalid or Missing API key",
});
}
return ok(user);
}
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") {
return json({ error: "Method not allowed" }, { status: 405 });
}
const authResult = await authenticateAdmin(request);
if (authResult.isErr()) {
const { status, message } = authResult.error;
return json({ error: message }, { status });
}
let body: unknown;
try {
body = await request.json();
} catch {
return json({ error: "Invalid JSON body" }, { status: 400 });
}
const result = await createPlatformNotification(body as CreatePlatformNotificationInput);
if (result.isErr()) {
const error = result.error;
if (error.type === "validation") {
return json({ error: "Validation failed", details: error.issues }, { status: 400 });
}
return json({ error: error.message }, { status: 500 });
}
return json(result.value, { status: 201 });
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -36,6 +36,10 @@ export default function Page() {
label: "LLM Models",
to: "/admin/llm-models",
},
{
label: "Notifications",
to: "/admin/notifications",
},
]}
layoutId={"admin"}
/>
@@ -0,0 +1,22 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { getNextCliNotification } from "~/services/platformNotifications.server";
export async function loader({ request }: LoaderFunctionArgs) {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const url = new URL(request.url);
const projectRef = url.searchParams.get("projectRef") ?? undefined;
const notification = await getNextCliNotification({
userId: authenticationResult.userId,
projectRef,
});
return json({ notification });
}
@@ -0,0 +1,49 @@
import { json } from "@remix-run/node";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react";
import { useEffect, useRef } from "react";
import { requireUserId } from "~/services/session.server";
import { getRecentChangelogs } from "~/services/platformNotifications.server";
export const shouldRevalidate: ShouldRevalidateFunction = () => false;
export type PlatformChangelogsLoaderData = {
changelogs: Array<{ id: string; title: string; actionUrl?: string }>;
};
export async function loader({ request }: LoaderFunctionArgs) {
await requireUserId(request);
const changelogs = await getRecentChangelogs();
return json<PlatformChangelogsLoaderData>({ changelogs });
}
const POLL_INTERVAL_MS = 60_000;
export function useRecentChangelogs() {
const fetcher = useFetcher<typeof loader>();
const hasInitiallyFetched = useRef(false);
useEffect(() => {
const url = "/resources/platform-changelogs";
if (!hasInitiallyFetched.current && fetcher.state === "idle") {
hasInitiallyFetched.current = true;
fetcher.load(url);
}
const interval = setInterval(() => {
if (fetcher.state === "idle") {
fetcher.load(url);
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, []);
return {
changelogs: fetcher.data?.changelogs ?? [],
isLoading: fetcher.state !== "idle",
};
}
@@ -0,0 +1,17 @@
import { json } from "@remix-run/node";
import type { ActionFunctionArgs } from "@remix-run/node";
import { requireUserId } from "~/services/session.server";
import { recordNotificationClicked } from "~/services/platformNotifications.server";
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const notificationId = params.id;
if (!notificationId) {
return json({ success: false }, { status: 400 });
}
await recordNotificationClicked({ notificationId, userId });
return json({ success: true });
}
@@ -0,0 +1,17 @@
import { json } from "@remix-run/node";
import type { ActionFunctionArgs } from "@remix-run/node";
import { requireUserId } from "~/services/session.server";
import { dismissNotification } from "~/services/platformNotifications.server";
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const notificationId = params.id;
if (!notificationId) {
return json({ success: false }, { status: 400 });
}
await dismissNotification({ notificationId, userId });
return json({ success: true });
}
@@ -0,0 +1,17 @@
import { json } from "@remix-run/node";
import type { ActionFunctionArgs } from "@remix-run/node";
import { requireUserId } from "~/services/session.server";
import { recordNotificationSeen } from "~/services/platformNotifications.server";
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const notificationId = params.id;
if (!notificationId) {
return json({ success: false }, { status: 400 });
}
await recordNotificationSeen({ notificationId, userId });
return json({ success: true });
}
@@ -0,0 +1,61 @@
import { json } from "@remix-run/node";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react";
import { useEffect, useRef } from "react";
import { requireUserId } from "~/services/session.server";
import {
getActivePlatformNotifications,
type PlatformNotificationWithPayload,
} from "~/services/platformNotifications.server";
export const shouldRevalidate: ShouldRevalidateFunction = () => false;
export type PlatformNotificationsLoaderData = {
notifications: PlatformNotificationWithPayload[];
unreadCount: number;
};
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const url = new URL(request.url);
const organizationId = url.searchParams.get("organizationId");
const projectId = url.searchParams.get("projectId") ?? undefined;
if (!organizationId) {
return json<PlatformNotificationsLoaderData>({ notifications: [], unreadCount: 0 });
}
const result = await getActivePlatformNotifications({ userId, organizationId, projectId });
return json<PlatformNotificationsLoaderData>(result);
}
const POLL_INTERVAL_MS = 60000; // 1 minute
export function usePlatformNotifications(organizationId: string, projectId: string) {
const fetcher = useFetcher<typeof loader>();
const lastLoadedUrl = useRef<string | null>(null);
useEffect(() => {
const url = `/resources/platform-notifications?organizationId=${encodeURIComponent(organizationId)}&projectId=${encodeURIComponent(projectId)}`;
if (lastLoadedUrl.current !== url && fetcher.state === "idle") {
lastLoadedUrl.current = url;
fetcher.load(url);
}
const interval = setInterval(() => {
if (fetcher.state === "idle") {
fetcher.load(url);
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [organizationId, projectId]);
return {
notifications: fetcher.data?.notifications ?? [],
unreadCount: fetcher.data?.unreadCount ?? 0,
isLoading: fetcher.state !== "idle",
};
}
@@ -0,0 +1,45 @@
import { Redis } from "ioredis";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { logger } from "./logger.server";
const KEY_PREFIX = "cli-notif-ctr:";
const MAX_COUNTER = 1000;
function initializeRedis(): Redis | undefined {
const host = env.CACHE_REDIS_HOST;
if (!host) return undefined;
return new Redis({
connectionName: "platformNotificationCounter",
host,
port: env.CACHE_REDIS_PORT,
username: env.CACHE_REDIS_USERNAME,
password: env.CACHE_REDIS_PASSWORD,
keyPrefix: "tr:",
enableAutoPipelining: true,
...(env.CACHE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
});
}
const redis = singleton("platformNotificationCounter", initializeRedis);
/** Increment and return the user's CLI request counter (0-based, wraps at 1000→0). */
export async function incrementCliRequestCounter(userId: string): Promise<number> {
if (!redis) return 0;
try {
const key = `${KEY_PREFIX}${userId}`;
const value = await redis.incr(key);
if (value > MAX_COUNTER) {
await redis.set(key, "0");
return 0;
}
return value;
} catch (error) {
logger.error("Failed to increment CLI notification counter", { userId, error });
return 0;
}
}
@@ -0,0 +1,661 @@
import { z } from "zod";
import { errAsync, fromPromise, type ResultAsync } from "neverthrow";
import { prisma } from "~/db.server";
import { type PlatformNotificationScope, type PlatformNotificationSurface } from "@trigger.dev/database";
import { incrementCliRequestCounter } from "./platformNotificationCounter.server";
// --- Payload schema (spec v1) ---
const DiscoverySchema = z.object({
filePatterns: z.array(z.string().min(1)).min(1),
contentPattern: z
.string()
.max(200)
.optional()
.refine(
(val) => {
if (!val) return true;
try {
new RegExp(val);
return true;
} catch {
return false;
}
},
{ message: "contentPattern must be a valid regular expression" }
),
matchBehavior: z.enum(["show-if-found", "show-if-not-found"]),
});
const CardDataV1Schema = z.object({
type: z.enum(["card", "info", "warn", "error", "success", "changelog"]),
title: z.string(),
description: z.string(),
image: z.string().url().optional(),
actionLabel: z.string().optional(),
actionUrl: z.string().url().optional(),
dismissOnAction: z.boolean().optional(),
discovery: DiscoverySchema.optional(),
});
const PayloadV1Schema = z.object({
version: z.literal("1"),
data: CardDataV1Schema,
});
export type PayloadV1 = z.infer<typeof PayloadV1Schema>;
export type PlatformNotificationWithPayload = {
id: string;
friendlyId: string;
scope: string;
priority: number;
payload: PayloadV1;
isRead: boolean;
};
// --- Read: admin list with interaction stats ---
export async function getAdminNotificationsList({
page = 1,
pageSize = 20,
hideArchived = false,
}: {
page?: number;
pageSize?: number;
hideArchived?: boolean;
}) {
const where = hideArchived ? { archivedAt: null } : {};
const [notifications, total] = await Promise.all([
prisma.platformNotification.findMany({
where,
orderBy: [{ createdAt: "desc" }],
skip: (page - 1) * pageSize,
take: pageSize,
include: {
_count: {
select: { interactions: true },
},
interactions: {
select: {
webappDismissedAt: true,
webappClickedAt: true,
cliDismissedAt: true,
},
},
},
}),
prisma.platformNotification.count({ where }),
]);
return {
notifications: notifications.map((n) => {
const parsed = PayloadV1Schema.safeParse(n.payload);
return {
id: n.id,
friendlyId: n.friendlyId,
title: n.title,
surface: n.surface,
scope: n.scope,
priority: n.priority,
startsAt: n.startsAt,
endsAt: n.endsAt,
archivedAt: n.archivedAt,
createdAt: n.createdAt,
payload: n.payload,
payloadTitle: parsed.success ? parsed.data.data.title : null,
payloadType: parsed.success ? parsed.data.data.type : null,
payloadDescription: parsed.success ? parsed.data.data.description : null,
payloadActionUrl: parsed.success ? parsed.data.data.actionUrl : null,
payloadImage: parsed.success ? parsed.data.data.image : null,
cliMaxShowCount: n.cliMaxShowCount,
cliMaxDaysAfterFirstSeen: n.cliMaxDaysAfterFirstSeen,
cliShowEvery: n.cliShowEvery,
stats: {
seen: n._count.interactions,
clicked: n.interactions.filter((i) => i.webappClickedAt !== null).length,
dismissed: n.interactions.filter(
(i) => i.webappDismissedAt !== null || i.cliDismissedAt !== null
).length,
},
};
}),
total,
page,
pageCount: Math.ceil(total / pageSize),
};
}
// --- Read: active notifications for webapp ---
export async function getActivePlatformNotifications({
userId,
organizationId,
projectId,
}: {
userId: string;
organizationId: string;
projectId?: string;
}) {
const now = new Date();
const notifications = await prisma.platformNotification.findMany({
where: {
surface: "WEBAPP",
archivedAt: null,
startsAt: { lte: now },
endsAt: { gt: now },
AND: [
{
OR: [
{ scope: "GLOBAL" },
{ scope: "ORGANIZATION", organizationId },
...(projectId ? [{ scope: "PROJECT" as const, projectId }] : []),
{ scope: "USER", userId },
],
},
],
},
include: {
interactions: {
where: { userId },
},
},
orderBy: [{ priority: "desc" }, { createdAt: "desc" }],
});
type InternalNotification = PlatformNotificationWithPayload & { createdAt: Date };
const result: InternalNotification[] = [];
for (const n of notifications) {
const interaction = n.interactions[0] ?? null;
if (interaction?.webappDismissedAt) continue;
const parsed = PayloadV1Schema.safeParse(n.payload);
if (!parsed.success) continue;
result.push({
id: n.id,
friendlyId: n.friendlyId,
scope: n.scope,
priority: n.priority,
createdAt: n.createdAt,
payload: parsed.data,
isRead: !!interaction,
});
}
result.sort(compareNotifications);
const unreadCount = result.filter((n) => !n.isRead).length;
const notifications_out: PlatformNotificationWithPayload[] = result.map(
({ createdAt: _, ...rest }) => rest
);
return { notifications: notifications_out, unreadCount };
}
function compareNotifications(
a: { priority: number; createdAt: Date },
b: { priority: number; createdAt: Date }
) {
const priorityDiff = b.priority - a.priority;
if (priorityDiff !== 0) return priorityDiff;
return b.createdAt.getTime() - a.createdAt.getTime();
}
// --- Write: upsert interaction ---
async function upsertInteraction({
notificationId,
userId,
onUpdate,
onCreate,
}: {
notificationId: string;
userId: string;
onUpdate: Record<string, unknown>;
onCreate: Record<string, unknown>;
}) {
await prisma.platformNotificationInteraction.upsert({
where: { notificationId_userId: { notificationId, userId } },
update: onUpdate,
create: {
notificationId,
userId,
firstSeenAt: new Date(),
showCount: 1,
...onCreate,
},
});
}
export async function recordNotificationSeen({
notificationId,
userId,
}: {
notificationId: string;
userId: string;
}) {
return upsertInteraction({
notificationId,
userId,
onUpdate: { showCount: { increment: 1 } },
onCreate: {},
});
}
export async function dismissNotification({
notificationId,
userId,
}: {
notificationId: string;
userId: string;
}) {
const now = new Date();
return upsertInteraction({
notificationId,
userId,
onUpdate: { webappDismissedAt: now },
onCreate: { webappDismissedAt: now },
});
}
export async function recordNotificationClicked({
notificationId,
userId,
}: {
notificationId: string;
userId: string;
}) {
const now = new Date();
return upsertInteraction({
notificationId,
userId,
onUpdate: { webappClickedAt: now },
onCreate: { webappClickedAt: now },
});
}
// --- Read: recent changelogs (for Help & Feedback) ---
export async function getRecentChangelogs({ limit = 2 }: { limit?: number } = {}) {
// NOTE: Intentionally not filtering by archivedAt, startsAt, or endsAt.
// We want to show archived and expired changelogs in the "What's new" section
// so users can still find recent release notes.
const notifications = await prisma.platformNotification.findMany({
where: {
surface: "WEBAPP",
payload: { path: ["data", "type"], equals: "changelog" },
},
orderBy: [{ createdAt: "desc" }],
take: limit,
});
return notifications
.map((n) => {
const parsed = PayloadV1Schema.safeParse(n.payload);
if (!parsed.success) return null;
return { id: n.id, title: parsed.data.data.title, actionUrl: parsed.data.data.actionUrl };
})
.filter(Boolean) as Array<{ id: string; title: string; actionUrl?: string }>;
}
// --- CLI: next notification for CLI surface ---
function isCliNotificationExpired(
interaction: {
userId: string;
firstSeenAt: Date;
showCount: number;
cliDismissedAt: Date | null;
} | null,
notification: {
id: string;
cliMaxDaysAfterFirstSeen: number | null;
cliMaxShowCount: number | null;
}
): boolean {
if (!interaction) return false;
let expired = false;
if (
notification.cliMaxShowCount !== null &&
interaction.showCount >= notification.cliMaxShowCount
) {
expired = true;
}
if (!expired && notification.cliMaxDaysAfterFirstSeen !== null) {
const daysSinceFirstSeen =
(Date.now() - interaction.firstSeenAt.getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceFirstSeen > notification.cliMaxDaysAfterFirstSeen) {
expired = true;
}
}
// For time-based expiration, persist the dismiss on the next request
// (showCount-based dismissal is handled inline at display time)
if (expired && !interaction.cliDismissedAt) {
void prisma.platformNotificationInteraction.update({
where: {
notificationId_userId: {
notificationId: notification.id,
userId: interaction.userId,
},
},
data: { cliDismissedAt: new Date() },
});
}
return expired;
}
export async function getNextCliNotification({
userId,
projectRef,
}: {
userId: string;
projectRef?: string;
}): Promise<{
id: string;
payload: PayloadV1;
showCount: number;
firstSeenAt: string;
} | null> {
const now = new Date();
// Resolve organizationId and projectId from projectRef if provided
let organizationId: string | undefined;
let projectId: string | undefined;
if (projectRef) {
const project = await prisma.project.findFirst({
where: {
externalRef: projectRef,
deletedAt: null,
organization: {
deletedAt: null,
members: { some: { userId } },
},
},
select: { id: true, organizationId: true },
});
if (project) {
projectId = project.id;
organizationId = project.organizationId;
}
}
// If no projectRef or project not found, get org from membership
if (!organizationId) {
const membership = await prisma.orgMember.findFirst({
where: { userId },
select: { organizationId: true },
});
if (membership) {
organizationId = membership.organizationId;
}
}
const scopeFilter: Array<Record<string, unknown>> = [
{ scope: "GLOBAL" },
{ scope: "USER", userId },
];
if (organizationId) {
scopeFilter.push({ scope: "ORGANIZATION", organizationId });
}
if (projectId) {
scopeFilter.push({ scope: "PROJECT", projectId });
}
const notifications = await prisma.platformNotification.findMany({
where: {
surface: "CLI",
archivedAt: null,
startsAt: { lte: now },
endsAt: { gt: now },
AND: [{ OR: scopeFilter }],
},
include: {
interactions: {
where: { userId },
},
},
orderBy: [{ priority: "desc" }, { createdAt: "desc" }],
});
const sorted = [...notifications].sort(compareNotifications);
// Global per-user request counter stored in Redis, used for cliShowEvery modulo.
// This is independent of per-notification showCount so that cliMaxShowCount
// correctly tracks actual displays, not API encounters.
const requestCounter = await incrementCliRequestCounter(userId);
for (const n of sorted) {
const interaction = n.interactions[0] ?? null;
if (interaction?.cliDismissedAt) continue;
if (isCliNotificationExpired(interaction, n)) continue;
const parsed = PayloadV1Schema.safeParse(n.payload);
if (!parsed.success) continue;
// Check cliShowEvery using the global request counter
if (n.cliShowEvery !== null && requestCounter % n.cliShowEvery !== 0) {
continue;
}
// Only increment showCount when the notification will actually be displayed.
// If this display reaches cliMaxShowCount, also set cliDismissedAt now
// so it's recorded immediately rather than waiting for a future request.
const reachedMaxShows =
n.cliMaxShowCount !== null &&
((interaction?.showCount ?? 0) + 1) >= n.cliMaxShowCount;
const updated = await prisma.platformNotificationInteraction.upsert({
where: { notificationId_userId: { notificationId: n.id, userId } },
update: {
showCount: { increment: 1 },
...(reachedMaxShows ? { cliDismissedAt: now } : {}),
},
create: {
notificationId: n.id,
userId,
firstSeenAt: now,
showCount: 1,
...(reachedMaxShows ? { cliDismissedAt: now } : {}),
},
});
return {
id: n.id,
payload: parsed.data,
showCount: updated.showCount,
firstSeenAt: updated.firstSeenAt.toISOString(),
};
}
return null;
}
// --- Create: admin endpoint support ---
const SCOPE_REQUIRED_FK: Record<string, "userId" | "organizationId" | "projectId"> = {
USER: "userId",
ORGANIZATION: "organizationId",
PROJECT: "projectId",
};
const ALL_FK_FIELDS = ["userId", "organizationId", "projectId"] as const;
const CLI_ONLY_FIELDS = ["cliMaxDaysAfterFirstSeen", "cliMaxShowCount", "cliShowEvery"] as const;
export const CreatePlatformNotificationSchema = z
.object({
title: z.string().min(1),
payload: PayloadV1Schema,
surface: z.enum(["WEBAPP", "CLI"]),
scope: z.enum(["USER", "PROJECT", "ORGANIZATION", "GLOBAL"]),
userId: z.string().optional(),
organizationId: z.string().optional(),
projectId: z.string().optional(),
startsAt: z
.string()
.datetime()
.transform((s) => new Date(s))
.optional(),
endsAt: z
.string()
.datetime()
.transform((s) => new Date(s)),
priority: z.number().int().default(0),
cliMaxDaysAfterFirstSeen: z.number().int().positive().optional(),
cliMaxShowCount: z.number().int().positive().optional(),
cliShowEvery: z.number().int().min(2).optional(),
})
.superRefine((data, ctx) => {
validateScopeForeignKeys(data, ctx);
validateSurfaceFields(data, ctx);
validatePayloadTypeForSurface(data, ctx);
validateStartsAt(data, ctx);
validateEndsAt(data, ctx);
});
function validateScopeForeignKeys(
data: { scope: string; userId?: string; organizationId?: string; projectId?: string },
ctx: z.RefinementCtx
) {
const requiredFk = SCOPE_REQUIRED_FK[data.scope];
if (requiredFk && !data[requiredFk]) {
ctx.addIssue({
code: "custom",
message: `${requiredFk} is required when scope is ${data.scope}`,
path: [requiredFk],
});
}
const forbiddenFks = ALL_FK_FIELDS.filter((fk) => fk !== requiredFk);
for (const fk of forbiddenFks) {
if (data[fk]) {
ctx.addIssue({
code: "custom",
message: `${fk} must not be set when scope is ${data.scope}`,
path: [fk],
});
}
}
}
function validateSurfaceFields(
data: {
surface: string;
cliMaxDaysAfterFirstSeen?: number;
cliMaxShowCount?: number;
cliShowEvery?: number;
},
ctx: z.RefinementCtx
) {
if (data.surface !== "WEBAPP") return;
for (const field of CLI_ONLY_FIELDS) {
if (data[field] !== undefined) {
ctx.addIssue({
code: "custom",
message: `${field} is not allowed for WEBAPP surface`,
path: [field],
});
}
}
}
function validateStartsAt(data: { startsAt?: Date }, ctx: z.RefinementCtx) {
if (!data.startsAt) return;
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
if (data.startsAt < oneHourAgo) {
ctx.addIssue({
code: "custom",
message: "startsAt must be within the last hour or in the future",
path: ["startsAt"],
});
}
}
const CLI_TYPES = new Set(["info", "warn", "error", "success"]);
const WEBAPP_TYPES = new Set(["card", "changelog"]);
function validatePayloadTypeForSurface(
data: { surface: string; payload: PayloadV1 },
ctx: z.RefinementCtx
) {
const allowedTypes = data.surface === "CLI" ? CLI_TYPES : WEBAPP_TYPES;
if (!allowedTypes.has(data.payload.data.type)) {
ctx.addIssue({
code: "custom",
message: `payload.data.type "${data.payload.data.type}" is not allowed for ${data.surface} surface`,
path: ["payload", "data", "type"],
});
}
}
function validateEndsAt(data: { startsAt?: Date; endsAt: Date }, ctx: z.RefinementCtx) {
const effectiveStart = data.startsAt ?? new Date();
if (data.endsAt <= effectiveStart) {
ctx.addIssue({
code: "custom",
message: "endsAt must be after startsAt",
path: ["endsAt"],
});
}
}
export type CreatePlatformNotificationInput = z.input<typeof CreatePlatformNotificationSchema>;
type CreateError =
| { type: "validation"; issues: z.ZodIssue[] }
| { type: "db"; message: string };
export function createPlatformNotification(
input: CreatePlatformNotificationInput
): ResultAsync<{ id: string; friendlyId: string }, CreateError> {
const parseResult = CreatePlatformNotificationSchema.safeParse(input);
if (!parseResult.success) {
return errAsync({ type: "validation", issues: parseResult.error.issues });
}
const data = parseResult.data;
return fromPromise(
prisma.platformNotification.create({
data: {
title: data.title,
payload: data.payload,
surface: data.surface as PlatformNotificationSurface,
scope: data.scope as PlatformNotificationScope,
userId: data.userId,
organizationId: data.organizationId,
projectId: data.projectId,
startsAt: data.startsAt ?? new Date(),
endsAt: data.endsAt,
priority: data.priority,
cliMaxDaysAfterFirstSeen: data.cliMaxDaysAfterFirstSeen,
cliMaxShowCount: data.cliMaxShowCount,
cliShowEvery: data.cliShowEvery,
},
select: { id: true, friendlyId: true },
}),
(e): CreateError => ({
type: "db",
message: e instanceof Error ? e.message : String(e),
})
);
}
+1
View File
@@ -191,6 +191,7 @@
"react-dom": "^18.2.0",
"react-grid-layout": "^2.2.2",
"react-hotkeys-hook": "^4.4.1",
"react-markdown": "^10.1.0",
"react-popper": "^2.3.0",
"react-resizable": "^3.1.3",
"react-resizable-panels": "^2.0.9",
@@ -0,0 +1,69 @@
-- CreateEnum
CREATE TYPE "public"."PlatformNotificationSurface" AS ENUM ('WEBAPP', 'CLI');
-- CreateEnum
CREATE TYPE "public"."PlatformNotificationScope" AS ENUM ('USER', 'PROJECT', 'ORGANIZATION', 'GLOBAL');
-- CreateTable
CREATE TABLE "public"."PlatformNotification" (
"id" TEXT NOT NULL,
"friendlyId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"surface" "public"."PlatformNotificationSurface" NOT NULL,
"scope" "public"."PlatformNotificationScope" NOT NULL,
"userId" TEXT,
"organizationId" TEXT,
"projectId" TEXT,
"startsAt" TIMESTAMP(3) NOT NULL,
"endsAt" TIMESTAMP(3) NOT NULL,
"cliMaxDaysAfterFirstSeen" INTEGER,
"cliMaxShowCount" INTEGER,
"cliShowEvery" INTEGER,
"priority" INTEGER NOT NULL DEFAULT 0,
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PlatformNotification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."PlatformNotificationInteraction" (
"id" TEXT NOT NULL,
"notificationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"showCount" INTEGER NOT NULL DEFAULT 1,
"webappDismissedAt" TIMESTAMP(3),
"webappClickedAt" TIMESTAMP(3),
"cliDismissedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PlatformNotificationInteraction_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PlatformNotification_friendlyId_key" ON "public"."PlatformNotification"("friendlyId");
-- CreateIndex
CREATE INDEX "PlatformNotification_surface_scope_startsAt_idx" ON "public"."PlatformNotification"("surface", "scope", "startsAt");
-- CreateIndex
CREATE UNIQUE INDEX "PlatformNotificationInteraction_notificationId_userId_key" ON "public"."PlatformNotificationInteraction"("notificationId", "userId");
-- AddForeignKey
ALTER TABLE "public"."PlatformNotification" ADD CONSTRAINT "PlatformNotification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."PlatformNotification" ADD CONSTRAINT "PlatformNotification_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."PlatformNotification" ADD CONSTRAINT "PlatformNotification_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."PlatformNotificationInteraction" ADD CONSTRAINT "PlatformNotificationInteraction_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "public"."PlatformNotification"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."PlatformNotificationInteraction" ADD CONSTRAINT "PlatformNotificationInteraction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -65,6 +65,9 @@ model User {
impersonationsReceived ImpersonationAuditLog[] @relation("ImpersonationTarget")
customerQueries CustomerQuery[]
metricsDashboards MetricsDashboard[]
platformNotifications PlatformNotification[]
platformNotificationInteractions PlatformNotificationInteraction[]
}
model MfaBackupCode {
@@ -229,6 +232,8 @@ model Organization {
customerQueries CustomerQuery[]
metricsDashboards MetricsDashboard[]
prompts Prompt[]
platformNotifications PlatformNotification[]
}
model OrgMember {
@@ -421,6 +426,8 @@ model Project {
metricsDashboards MetricsDashboard[]
llmModels LlmModel[]
prompts Prompt[]
platformNotifications PlatformNotification[]
}
enum ProjectVersion {
@@ -2719,3 +2726,102 @@ model LlmPrice {
@@unique([modelId, usageType, pricingTierId])
@@map("llm_prices")
}
enum PlatformNotificationSurface {
WEBAPP
CLI
}
enum PlatformNotificationScope {
USER
PROJECT
ORGANIZATION
GLOBAL
}
/// Admin-created notification definitions
model PlatformNotification {
id String @id @default(cuid())
friendlyId String @unique @default(cuid())
/// Admin-facing title for identification in admin tools
title String
/// Versioned JSON rendering content (see payload schema in spec)
payload Json
surface PlatformNotificationSurface
scope PlatformNotificationScope
/// Set when scope = USER
user User? @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
userId String?
/// Set when scope = ORGANIZATION
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
organizationId String?
/// Set when scope = PROJECT
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String?
/// When notification becomes active
startsAt DateTime
/// When notification expires
endsAt DateTime
/// CLI: auto-expire N days after user first saw it
cliMaxDaysAfterFirstSeen Int?
/// CLI: auto-expire after shown N times to user
cliMaxShowCount Int?
/// CLI: only show every N-th request (e.g. cliShowEvery=3 means show on 3rd, 6th, 9th…)
cliShowEvery Int?
/// Ordering within same scope level (higher = more important)
priority Int @default(0)
/// Soft delete
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
interactions PlatformNotificationInteraction[]
@@index([surface, scope, startsAt])
}
/// Per-user tracking of notification views and dismissals
model PlatformNotificationInteraction {
id String @id @default(cuid())
notification PlatformNotification @relation(fields: [notificationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
notificationId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
userId String
/// Set by beacon/CLI GET on first impression
firstSeenAt DateTime @default(now())
/// Times shown (incremented per beacon/CLI view)
showCount Int @default(1)
/// User dismissed in webapp
webappDismissedAt DateTime?
/// User clicked a link in webapp
webappClickedAt DateTime?
/// Auto-dismissed or expired in CLI
cliDismissedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([notificationId, userId])
}
+46
View File
@@ -58,6 +58,33 @@ import { z } from "zod";
import { logger } from "./utilities/logger.js";
import { VERSION } from "./version.js";
const CliPlatformNotificationResponseSchema = z.object({
notification: z
.object({
id: z.string(),
payload: z.object({
version: z.string(),
data: z.object({
type: z.enum(["info", "warn", "error", "success"]),
title: z.string(),
description: z.string(),
actionLabel: z.string().optional(),
actionUrl: z.string().optional(),
discovery: z
.object({
filePatterns: z.array(z.string()),
contentPattern: z.string().optional(),
matchBehavior: z.enum(["show-if-found", "show-if-not-found"]),
})
.optional(),
}),
}),
showCount: z.number(),
firstSeenAt: z.string(),
})
.nullable(),
});
export class CliApiClient {
private engineURL: string;
private source: "cli" | "mcp";
@@ -540,6 +567,25 @@ export class CliApiClient {
);
}
async getCliPlatformNotification(projectRef?: string, signal?: AbortSignal) {
if (!this.accessToken) {
return { success: true as const, data: { notification: null } };
}
const url = new URL("/api/v1/platform-notifications", this.apiURL);
if (projectRef) {
url.searchParams.set("projectRef", projectRef);
}
return wrapZodFetch(CliPlatformNotificationResponseSchema, url.href, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
},
signal,
});
}
async triggerTaskRun(taskId: string, body?: TriggerTaskRequestBody) {
if (!this.accessToken) {
throw new Error("triggerTaskRun: No access token");
+21
View File
@@ -1,6 +1,7 @@
import { ResolvedConfig } from "@trigger.dev/core/v3/build";
import { Command, Option as CommandOption } from "commander";
import { z } from "zod";
import { CliApiClient } from "../apiClient.js";
import { CommonCommandOptions, commonOptions, wrapCommandAction } from "../cli/common.js";
import { watchConfig } from "../config.js";
import { DevSessionInstance, startDevSession } from "../dev/devSession.js";
@@ -9,6 +10,10 @@ import { chalkError } from "../utilities/cliOutput.js";
import { resolveLocalEnvVars } from "../utilities/localEnvVars.js";
import { printDevBanner, printStandloneInitialBanner } from "../utilities/initialBanner.js";
import { logger } from "../utilities/logger.js";
import {
awaitAndDisplayPlatformNotification,
fetchPlatformNotification,
} from "../utilities/platformNotifications.js";
import { runtimeChecks } from "../utilities/runtimeCheck.js";
import { getProjectClient, LoginResultOk } from "../utilities/session.js";
import { login } from "./login.js";
@@ -28,6 +33,7 @@ const DevCommandOptions = CommonCommandOptions.extend({
config: z.string().optional(),
projectRef: z.string().optional(),
skipUpdateCheck: z.boolean().default(false),
skipPlatformNotifications: z.boolean().default(false),
envFile: z.string().optional(),
keepTmpFiles: z.boolean().default(false),
maxConcurrentRuns: z.coerce.number().optional(),
@@ -97,6 +103,12 @@ export function configureDevCommand(program: Command) {
).hideHelp()
)
.addOption(new CommandOption("--disable-warnings", "Suppress warnings output").hideHelp())
.addOption(
new CommandOption(
"--skip-platform-notifications",
"Skip showing platform notifications"
).hideHelp()
)
).action(async (options) => {
wrapCommandAction("dev", DevCommandOptions, options, async (opts) => {
await devCommand(opts);
@@ -205,8 +217,17 @@ async function startDev(options: StartDevOptions) {
logger.loggerLevel = options.logLevel;
}
const notificationPromise = options.skipPlatformNotifications
? undefined
: fetchPlatformNotification({
apiClient: new CliApiClient(options.login.auth.apiUrl, options.login.auth.accessToken),
projectRef: options.projectRef,
});
await printStandloneInitialBanner(true, options.profile);
await awaitAndDisplayPlatformNotification(notificationPromise);
let displayedUpdateMessage = false;
if (!options.skipUpdateCheck) {
+17
View File
@@ -20,6 +20,10 @@ import {
writeAuthConfigCurrentProfileName,
} from "../utilities/configFiles.js";
import { printInitialBanner } from "../utilities/initialBanner.js";
import {
awaitAndDisplayPlatformNotification,
fetchPlatformNotification,
} from "../utilities/platformNotifications.js";
import { LoginResult } from "../utilities/session.js";
import { whoAmI } from "./whoami.js";
import { logger } from "../utilities/logger.js";
@@ -285,6 +289,17 @@ export async function login(options?: LoginOptions): Promise<LoginResult> {
options?.profile
);
// Only fetch notifications for standalone login, not when embedded in dev
// (dev.ts handles its own notification fetch to avoid double counting)
const notificationPromise = opts.embedded
? undefined
: fetchPlatformNotification({
apiClient: new CliApiClient(
authConfig?.apiUrl ?? opts.defaultApiUrl,
indexResult.token
),
});
const whoAmIResult = await whoAmI(
{
profile: options?.profile ?? "default",
@@ -309,6 +324,8 @@ export async function login(options?: LoginOptions): Promise<LoginResult> {
outro("Logged in successfully");
}
await awaitAndDisplayPlatformNotification(notificationPromise);
span.end();
return {
@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import chalk from "chalk";
import { applyColorMarkup } from "./colorMarkup.js";
// Force chalk to produce color codes in tests
chalk.level = 3;
describe("applyColorMarkup", () => {
it("returns plain text when no markup is present", () => {
expect(applyColorMarkup("Hello world")).toBe("Hello world");
});
it("applies fallback style to plain text when provided", () => {
expect(applyColorMarkup("Hello", chalk.bold)).toBe(chalk.bold("Hello"));
});
it("applies a single color tag", () => {
expect(applyColorMarkup("{red}error{/red}")).toBe(chalk.red("error"));
});
it("applies color to a portion of text", () => {
const result = applyColorMarkup("Status: {green}OK{/green}");
expect(result).toBe("Status: " + chalk.green("OK"));
});
it("applies multiple color regions", () => {
const result = applyColorMarkup("{red}bad{/red} and {green}good{/green}");
expect(result).toBe(chalk.red("bad") + " and " + chalk.green("good"));
});
it("applies bold tag", () => {
expect(applyColorMarkup("{bold}important{/bold}")).toBe(chalk.bold("important"));
});
it("applies fallback to non-tagged text only", () => {
const result = applyColorMarkup("Please {red}upgrade{/red} now", chalk.bold);
expect(result).toBe(chalk.bold("Please ") + chalk.red("upgrade") + chalk.bold(" now"));
});
it("passes through invalid tag names as literal text", () => {
expect(applyColorMarkup("Hello {foo}world{/foo}")).toBe("Hello {foo}world{/foo}");
});
it("passes through braces with non-tag content", () => {
expect(applyColorMarkup("Use {curly} braces")).toBe("Use {curly} braces");
});
it("falls back on unclosed tag", () => {
const result = applyColorMarkup("{red}oops", chalk.bold);
expect(result).toBe(chalk.bold("{red}oops"));
});
it("falls back on mismatched close tag", () => {
const result = applyColorMarkup("{red}text{/green}", chalk.bold);
expect(result).toBe(chalk.bold("{red}text{/green}"));
});
it("falls back on nested tags", () => {
const result = applyColorMarkup("{red}{bold}nested{/bold}{/red}", chalk.bold);
expect(result).toBe(chalk.bold("{red}{bold}nested{/bold}{/red}"));
});
it("falls back on close tag without open", () => {
const result = applyColorMarkup("text{/red}");
expect(result).toBe("text{/red}");
});
it("handles empty string", () => {
expect(applyColorMarkup("")).toBe("");
});
it("handles tag with empty content", () => {
expect(applyColorMarkup("{red}{/red}")).toBe(chalk.red(""));
});
it("handles all valid color tags", () => {
const tags = [
"red", "green", "yellow", "blue", "magenta", "cyan", "white", "gray",
"redBright", "greenBright", "yellowBright", "blueBright",
"magentaBright", "cyanBright", "whiteBright",
];
for (const tag of tags) {
const result = applyColorMarkup(`{${tag}}test{/${tag}}`);
const expected = (chalk as Record<string, (s: string) => string>)[tag]("test");
expect(result).toBe(expected);
}
});
it("handles text with only braces but no valid tags", () => {
expect(applyColorMarkup("{} and {/}")).toBe("{} and {/}");
});
});
@@ -0,0 +1,139 @@
import chalk from "chalk";
const VALID_TAGS = new Set([
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white",
"gray",
"redBright",
"greenBright",
"yellowBright",
"blueBright",
"magentaBright",
"cyanBright",
"whiteBright",
"bold",
]);
type Token = { type: "text"; value: string } | { type: "styled"; tag: string; value: string };
/**
* Parse `{tag}text{/tag}` markup and apply chalk colors.
* On malformed input (unclosed, mismatched, or nested tags), returns the entire
* string styled with `fallbackStyle` (or unstyled if no fallback).
*/
export function applyColorMarkup(
text: string,
fallbackStyle?: (t: string) => string
): string {
const tokens = tokenize(text);
if (!tokens) {
// Malformed markup — apply fallback to entire string
return fallbackStyle ? fallbackStyle(text) : text;
}
if (tokens.length === 0) return "";
return tokens
.map((token) => {
if (token.type === "text") {
return fallbackStyle ? fallbackStyle(token.value) : token.value;
}
const colorFn = (chalk as unknown as Record<string, unknown>)[token.tag];
if (typeof colorFn === "function") {
return (colorFn as (t: string) => string)(token.value);
}
return fallbackStyle ? fallbackStyle(token.value) : token.value;
})
.join("");
}
/**
* Tokenize a string with `{tag}...{/tag}` markup.
* Returns null if the markup is malformed (unclosed, mismatched, or nested tags).
* Braces with unrecognized tag names pass through as literal text.
*/
function tokenize(text: string): Token[] | null {
const tokens: Token[] = [];
let pos = 0;
let currentText = "";
let insideTag: string | null = null;
while (pos < text.length) {
const braceIdx = text.indexOf("{", pos);
if (braceIdx === -1) {
currentText += text.slice(pos);
break;
}
const closeIdx = text.indexOf("}", braceIdx);
if (closeIdx === -1) {
// No closing brace — treat rest as literal
currentText += text.slice(pos);
break;
}
const tagContent = text.slice(braceIdx + 1, closeIdx);
// Check for closing tag
if (tagContent.startsWith("/")) {
const closingName = tagContent.slice(1);
if (VALID_TAGS.has(closingName)) {
if (insideTag === null) {
// Close tag without open — malformed
return null;
}
if (insideTag !== closingName) {
// Mismatched close — malformed
return null;
}
// Add text before this brace to the styled content
currentText += text.slice(pos, braceIdx);
tokens.push({ type: "styled", tag: insideTag, value: currentText });
currentText = "";
insideTag = null;
pos = closeIdx + 1;
continue;
}
}
// Check for opening tag
if (VALID_TAGS.has(tagContent)) {
if (insideTag !== null) {
// Nesting — malformed
return null;
}
currentText += text.slice(pos, braceIdx);
if (currentText) {
tokens.push({ type: "text", value: currentText });
currentText = "";
}
insideTag = tagContent;
pos = closeIdx + 1;
continue;
}
// Not a recognized tag — treat braces as literal text
currentText += text.slice(pos, closeIdx + 1);
pos = closeIdx + 1;
}
// If we're still inside a tag at the end, that's malformed
if (insideTag !== null) {
return null;
}
if (currentText) {
tokens.push({ type: "text", value: currentText });
}
return tokens;
}
@@ -0,0 +1,212 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import path from "node:path";
import fs from "node:fs/promises";
import os from "node:os";
import { evaluateDiscovery, type DiscoverySpec } from "./discoveryCheck.js";
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "discovery-test-"));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe("evaluateDiscovery", () => {
describe("show-if-found with file existence", () => {
it("returns true when file exists", async () => {
await fs.writeFile(path.join(tmpDir, "trigger.config.ts"), "export default {}");
const spec: DiscoverySpec = {
filePatterns: ["trigger.config.ts"],
matchBehavior: "show-if-found",
};
expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
});
it("returns false when file does not exist", async () => {
const spec: DiscoverySpec = {
filePatterns: ["trigger.config.ts"],
matchBehavior: "show-if-found",
};
expect(await evaluateDiscovery(spec, tmpDir)).toBe(false);
});
});
describe("show-if-not-found with file existence", () => {
it("returns false when file exists", async () => {
await fs.writeFile(path.join(tmpDir, ".mcp.json"), "{}");
const spec: DiscoverySpec = {
filePatterns: [".mcp.json"],
matchBehavior: "show-if-not-found",
};
expect(await evaluateDiscovery(spec, tmpDir)).toBe(false);
});
it("returns true when file does not exist", async () => {
const spec: DiscoverySpec = {
filePatterns: [".mcp.json"],
matchBehavior: "show-if-not-found",
};
expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
});
});
describe("content pattern matching", () => {
it("show-if-found: returns true when content matches", async () => {
await fs.writeFile(
path.join(tmpDir, "trigger.config.ts"),
'import { syncVercelEnvVars } from "@trigger.dev/build";'
);
const spec: DiscoverySpec = {
filePatterns: ["trigger.config.ts"],
contentPattern: "syncVercelEnvVars",
matchBehavior: "show-if-found",
};
expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
});
it("show-if-found: returns false when file exists but content does not match", async () => {
await fs.writeFile(path.join(tmpDir, "trigger.config.ts"), "export default {}");
const spec: DiscoverySpec = {
filePatterns: ["trigger.config.ts"],
contentPattern: "syncVercelEnvVars",
matchBehavior: "show-if-found",
};
expect(await evaluateDiscovery(spec, tmpDir)).toBe(false);
});
it("show-if-not-found: returns true when file exists but content does not match", async () => {
await fs.writeFile(path.join(tmpDir, ".mcp.json"), '{"mcpServers": {}}');
const spec: DiscoverySpec = {
filePatterns: [".mcp.json"],
contentPattern: "trigger",
matchBehavior: "show-if-not-found",
};
expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
});
it("show-if-not-found: returns false when content matches", async () => {
await fs.writeFile(
path.join(tmpDir, ".mcp.json"),
'{"mcpServers": {"trigger": {"url": "https://mcp.trigger.dev"}}}'
);
const spec: DiscoverySpec = {
filePatterns: [".mcp.json"],
contentPattern: "trigger",
matchBehavior: "show-if-not-found",
};
expect(await evaluateDiscovery(spec, tmpDir)).toBe(false);
});
it("supports regex content patterns", async () => {
await fs.writeFile(path.join(tmpDir, "config.ts"), "syncVercelEnvVars({ foo: true })");
const spec: DiscoverySpec = {
filePatterns: ["config.ts"],
contentPattern: "syncVercel\\w+",
matchBehavior: "show-if-found",
};
expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
});
});
describe("glob patterns", () => {
it("matches files with glob patterns", async () => {
await fs.writeFile(path.join(tmpDir, "trigger.config.ts"), "export default {}");
const spec: DiscoverySpec = {
filePatterns: ["trigger.config.*"],
matchBehavior: "show-if-found",
};
expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
});
it("matches files in subdirectories with glob", async () => {
await fs.mkdir(path.join(tmpDir, ".cursor"), { recursive: true });
await fs.writeFile(path.join(tmpDir, ".cursor", "mcp.json"), "{}");
const spec: DiscoverySpec = {
filePatterns: [".cursor/mcp.json"],
matchBehavior: "show-if-found",
};
expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
});
});
describe("multiple file patterns", () => {
it("returns true if any pattern matches (show-if-found)", async () => {
await fs.writeFile(path.join(tmpDir, "trigger.config.js"), "module.exports = {}");
const spec: DiscoverySpec = {
filePatterns: ["trigger.config.ts", "trigger.config.js"],
matchBehavior: "show-if-found",
};
expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
});
it("returns true only if no pattern matches (show-if-not-found)", async () => {
const spec: DiscoverySpec = {
filePatterns: [".mcp.json", ".cursor/mcp.json"],
matchBehavior: "show-if-not-found",
};
expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
});
it("content match short-circuits on first matching file", async () => {
await fs.writeFile(path.join(tmpDir, "a.ts"), "no match here");
await fs.writeFile(path.join(tmpDir, "b.ts"), "syncVercelEnvVars found");
const spec: DiscoverySpec = {
filePatterns: ["a.ts", "b.ts"],
contentPattern: "syncVercelEnvVars",
matchBehavior: "show-if-found",
};
expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
});
});
describe("error handling (fail closed)", () => {
it("returns false when file cannot be read for content check", async () => {
// Create a file then make it unreadable
const filePath = path.join(tmpDir, "unreadable.ts");
await fs.writeFile(filePath, "content");
await fs.chmod(filePath, 0o000);
const spec: DiscoverySpec = {
filePatterns: ["unreadable.ts"],
contentPattern: "content",
matchBehavior: "show-if-found",
};
// On some systems (e.g., running as root), chmod may not restrict reads
// So we just verify it doesn't throw
const result = await evaluateDiscovery(spec, tmpDir);
expect(typeof result).toBe("boolean");
// Restore permissions for cleanup
await fs.chmod(filePath, 0o644);
});
});
});
@@ -0,0 +1,168 @@
import { glob, isDynamicPattern } from "tinyglobby";
import { tryCatch } from "@trigger.dev/core/utils";
import { expandTilde, pathExists, readFile } from "./fileSystem.js";
import { logger } from "./logger.js";
import path from "node:path";
export type DiscoverySpec = {
filePatterns: string[];
contentPattern?: string;
matchBehavior: "show-if-found" | "show-if-not-found";
};
const REGEX_METACHARACTERS = /[\\^$.|?*+(){}[\]]/;
/**
* Evaluates a discovery spec against the local filesystem.
* Returns `true` if the notification should be shown, `false` otherwise.
* Fails closed: any error returns `false` (suppress notification).
*/
export async function evaluateDiscovery(
spec: DiscoverySpec,
projectRoot: string
): Promise<boolean> {
const [error, result] = await tryCatch(doEvaluate(spec, projectRoot));
if (error) {
logger.debug("Discovery check failed, suppressing notification", { error });
return false;
}
return result;
}
async function doEvaluate(spec: DiscoverySpec, projectRoot: string): Promise<boolean> {
logger.debug("Discovery: starting evaluation", {
filePatterns: spec.filePatterns,
contentPattern: spec.contentPattern,
matchBehavior: spec.matchBehavior,
projectRoot,
});
const matchedFiles = await resolveFilePatterns(spec.filePatterns, projectRoot);
const hasFileMatch = matchedFiles.length > 0;
if (!hasFileMatch) {
const result = spec.matchBehavior === "show-if-not-found";
logger.debug("Discovery: no files matched any pattern", { result });
return result;
}
// Files matched — if no content pattern, decide based on file match alone
if (!spec.contentPattern) {
const result = spec.matchBehavior === "show-if-found";
logger.debug("Discovery: files matched, no content pattern to check", {
matchedFiles,
result,
});
return result;
}
// Check content in matched files
const hasContentMatch = await checkContentPattern(matchedFiles, spec.contentPattern);
const result =
spec.matchBehavior === "show-if-found" ? hasContentMatch : !hasContentMatch;
logger.debug("Discovery: evaluation complete", {
matchedFiles,
contentPattern: spec.contentPattern,
hasContentMatch,
result,
});
return result;
}
async function resolveFilePatterns(
patterns: string[],
projectRoot: string
): Promise<string[]> {
const matched: string[] = [];
for (const pattern of patterns) {
const isHomeDirPattern = pattern.startsWith("~/");
const resolvedPattern = isHomeDirPattern ? expandTilde(pattern) : pattern;
const cwd = isHomeDirPattern ? "/" : projectRoot;
const isGlob = isDynamicPattern(resolvedPattern);
logger.debug("Discovery: resolving pattern", {
pattern,
resolvedPattern,
cwd,
isGlob,
isHomeDirPattern,
});
if (isGlob) {
const files = await glob({
patterns: [resolvedPattern],
cwd,
absolute: true,
dot: true,
});
if (files.length > 0) {
logger.debug("Discovery: glob matched files", { pattern, files });
}
matched.push(...files);
} else {
const absolutePath = isHomeDirPattern
? resolvedPattern
: path.resolve(projectRoot, resolvedPattern);
const exists = await pathExists(absolutePath);
logger.debug("Discovery: literal path check", { pattern, absolutePath, exists });
if (exists) {
matched.push(absolutePath);
}
}
}
return matched;
}
async function checkContentPattern(
files: string[],
contentPattern: string
): Promise<boolean> {
const useFastPath = !REGEX_METACHARACTERS.test(contentPattern);
// Pre-compile regex once outside the loop to avoid repeated compilation
// and to catch invalid patterns early
let regex: RegExp | undefined;
if (!useFastPath) {
try {
regex = new RegExp(contentPattern);
} catch (error) {
logger.debug("Discovery: invalid regex pattern, skipping content check", {
contentPattern,
error,
});
return false;
}
}
logger.debug("Discovery: checking content pattern", {
contentPattern,
useFastPath,
fileCount: files.length,
});
for (const filePath of files) {
const [error, content] = await tryCatch(readFile(filePath));
if (error) {
logger.debug("Discovery: failed to read file, skipping", { filePath, error });
continue;
}
const matches = useFastPath ? content.includes(contentPattern) : regex!.test(content);
logger.debug("Discovery: content check result", { filePath, matches });
if (matches) {
return true;
}
}
return false;
}
@@ -0,0 +1,126 @@
import { log } from "@clack/prompts";
import chalk from "chalk";
import { tryCatch } from "@trigger.dev/core/utils";
import { CliApiClient } from "../apiClient.js";
import { chalkGrey } from "./cliOutput.js";
import { applyColorMarkup } from "./colorMarkup.js";
import { evaluateDiscovery } from "./discoveryCheck.js";
import { logger } from "./logger.js";
import { spinner } from "./windows.js";
type CliLogLevel = "info" | "warn" | "error" | "success";
type PlatformNotification = {
level: CliLogLevel;
title: string;
description: string;
actionUrl?: string;
};
type FetchNotificationOptions = {
apiClient: CliApiClient;
projectRef?: string;
projectRoot?: string;
};
export async function fetchPlatformNotification(
options: FetchNotificationOptions
): Promise<PlatformNotification | undefined> {
const [error, result] = await tryCatch(
options.apiClient.getCliPlatformNotification(
options.projectRef,
AbortSignal.timeout(7000)
)
);
if (error) {
logger.debug("Platform notifications failed silently", { error });
return undefined;
}
if (!result.success) {
logger.debug("Platform notification fetch failed", { result });
return undefined;
}
const notification = result.data.notification;
if (!notification) return undefined;
const { type, discovery, title, description, actionUrl } = notification.payload.data;
if (discovery) {
const root = options.projectRoot ?? process.cwd();
const shouldShow = await evaluateDiscovery(discovery, root);
if (!shouldShow) {
logger.debug("Notification suppressed by discovery check", {
notificationId: notification.id,
discovery,
});
return undefined;
}
}
return { level: type, title, description, actionUrl };
}
function displayPlatformNotification(
notification: PlatformNotification | undefined
): void {
if (!notification) return;
const message = formatNotificationMessage(notification);
log[notification.level](message);
}
function formatNotificationMessage(notification: PlatformNotification): string {
const { title, description, actionUrl } = notification;
const styledTitle = applyColorMarkup(title, (t) => chalk.bold(t));
const styledDescription = applyColorMarkup(description, chalkGrey);
const lines = [styledTitle, styledDescription];
if (actionUrl) {
lines.push(chalk.underline(chalkGrey(actionUrl)));
}
return lines.join("\n");
}
const SPINNER_DELAY_MS = 200;
/**
* Awaits a notification promise, showing a loading spinner if the fetch
* takes longer than 200ms. The spinner is replaced by the notification
* content, or removed cleanly if there's nothing to show.
*/
export async function awaitAndDisplayPlatformNotification(
notificationPromise: Promise<PlatformNotification | undefined> | undefined
): Promise<void> {
if (!notificationPromise) return;
try {
// Race against a short delay — if the promise resolves quickly, skip the spinner
const pending = Symbol("pending");
const raceResult = await Promise.race([
notificationPromise,
new Promise<typeof pending>((resolve) =>
setTimeout(() => resolve(pending), SPINNER_DELAY_MS)
),
]);
if (raceResult !== pending) {
displayPlatformNotification(raceResult);
return;
}
// Still pending after delay — show a spinner while waiting
const $spinner = spinner();
$spinner.start("Checking for notifications");
const notification = await notificationPromise;
if (notification) {
$spinner.stop(formatNotificationMessage(notification));
} else {
$spinner.stop("No new notifications");
}
} catch (error) {
logger.debug("Platform notification display failed silently", { error });
}
}
+3
View File
@@ -710,6 +710,9 @@ importers:
react-hotkeys-hook:
specifier: ^4.4.1
version: 4.4.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@18.2.69)(react@18.2.0)
react-popper:
specifier: ^2.3.0
version: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)