diff --git a/.changeset/small-birds-arrive.md b/.changeset/small-birds-arrive.md new file mode 100644 index 0000000000..cf1039b83e --- /dev/null +++ b/.changeset/small-birds-arrive.md @@ -0,0 +1,16 @@ +--- +"@trigger.dev/react-hooks": patch +--- + +Added the ability to specify a "createdAt" filter when subscribing to tags in our useRealtime hooks: + +```tsx +// Only subscribe to runs created in the last 10 hours +useRealtimeRunWithTags("my-tag", { createdAt: "10h" }) +``` + +You can also now choose to skip subscribing to specific columns by specifying the `skipColumns` option: + +```tsx +useRealtimeRun(run.id, { skipColumns: ["usageDurationMs"] }); +``` diff --git a/.changeset/wicked-ads-walk.md b/.changeset/wicked-ads-walk.md new file mode 100644 index 0000000000..c9190c709f --- /dev/null +++ b/.changeset/wicked-ads-walk.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/react-hooks": patch +"@trigger.dev/core": patch +--- + +Fixes an issue with realtime when re-subscribing to a run, that would temporarily display stale data and the changes. Now when re-subscribing to a run only the latest changes will be vended diff --git a/.gitignore b/.gitignore index 260bfed29c..9bee46fc27 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ apps/**/public/build /packages/core/src/package.json /packages/trigger-sdk/src/package.json /packages/python/src/package.json +.claude \ No newline at end of file diff --git a/apps/webapp/app/assets/icons/SparkleListIcon.tsx b/apps/webapp/app/assets/icons/SparkleListIcon.tsx new file mode 100644 index 0000000000..264fc227c8 --- /dev/null +++ b/apps/webapp/app/assets/icons/SparkleListIcon.tsx @@ -0,0 +1,14 @@ +export function SparkleListIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx new file mode 100644 index 0000000000..39cc4cdaaf --- /dev/null +++ b/apps/webapp/app/components/AskAI.tsx @@ -0,0 +1,535 @@ +import { + ArrowPathIcon, + ArrowUpIcon, + HandThumbDownIcon, + HandThumbUpIcon, + StopIcon, +} from "@heroicons/react/20/solid"; +import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk"; +import { useSearchParams } from "@remix-run/react"; +import DOMPurify from "dompurify"; +import { motion } from "framer-motion"; +import { marked } from "marked"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTypedRouteLoaderData } from "remix-typedjson"; +import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; +import { SparkleListIcon } from "~/assets/icons/SparkleListIcon"; +import { useFeatures } from "~/hooks/useFeatures"; +import { type loader } from "~/root"; +import { Button } from "./primitives/Buttons"; +import { Callout } from "./primitives/Callout"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./primitives/Dialog"; +import { Header2 } from "./primitives/Headers"; +import { Paragraph } from "./primitives/Paragraph"; +import { ShortcutKey } from "./primitives/ShortcutKey"; +import { Spinner } from "./primitives/Spinner"; +import { + SimpleTooltip, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "./primitives/Tooltip"; +import { ClientOnly } from "remix-utils/client-only"; + +function useKapaWebsiteId() { + const routeMatch = useTypedRouteLoaderData("root"); + return routeMatch?.kapa.websiteId; +} + +export function AskAI() { + const { isManagedCloud } = useFeatures(); + const websiteId = useKapaWebsiteId(); + + if (!isManagedCloud || !websiteId) { + return null; + } + + return ( + + + + } + > + {() => } + + ); +} + +type AskAIProviderProps = { + websiteId: string; +}; + +function AskAIProvider({ websiteId }: AskAIProviderProps) { + const [isOpen, setIsOpen] = useState(false); + const [initialQuery, setInitialQuery] = useState(); + const [searchParams, setSearchParams] = useSearchParams(); + + const openAskAI = useCallback((question?: string) => { + if (question) { + setInitialQuery(question); + } else { + setInitialQuery(undefined); + } + setIsOpen(true); + }, []); + + const closeAskAI = useCallback(() => { + setIsOpen(false); + setInitialQuery(undefined); + }, []); + + // Handle URL param functionality + useEffect(() => { + const aiHelp = searchParams.get("aiHelp"); + if (aiHelp) { + // Delay to avoid hCaptcha bot detection + window.setTimeout(() => openAskAI(aiHelp), 1000); + + // Clone instead of mutating in place + const next = new URLSearchParams(searchParams); + next.delete("aiHelp"); + setSearchParams(next); + } + }, [searchParams, openAskAI]); + + return ( + openAskAI(), + onAnswerGenerationCompleted: () => openAskAI(), + }, + }} + botProtectionMechanism="hcaptcha" + > + + + +
+ +
+
+ + Ask AI + + +
+
+ +
+ ); +} + +type AskAIDialogProps = { + initialQuery?: string; + isOpen: boolean; + onOpenChange: (open: boolean) => void; + closeAskAI: () => void; +}; + +function AskAIDialog({ initialQuery, isOpen, onOpenChange, closeAskAI }: AskAIDialogProps) { + const handleOpenChange = (open: boolean) => { + if (!open) { + closeAskAI(); + } else { + onOpenChange(open); + } + }; + + return ( + + + +
+ + Ask AI +
+
+ +
+
+ ); +} + +function ChatMessages({ + conversation, + isPreparingAnswer, + isGeneratingAnswer, + onReset, + onExampleClick, + error, + addFeedback, +}: { + conversation: QA[]; + isPreparingAnswer: boolean; + isGeneratingAnswer: boolean; + onReset: () => void; + onExampleClick: (question: string) => void; + error: string | null; + addFeedback: ( + questionAnswerId: string, + reaction: "upvote" | "downvote", + comment?: FeedbackComment + ) => void; +}) { + const [feedbackGivenForQAs, setFeedbackGivenForQAs] = useState>(new Set()); + + // Reset feedback state when conversation is reset + useEffect(() => { + if (conversation.length === 0) { + setFeedbackGivenForQAs(new Set()); + } + }, [conversation.length]); + + // Check if feedback has been given for the latest QA + const latestQA = conversation[conversation.length - 1]; + const hasFeedbackForLatestQA = latestQA?.id ? feedbackGivenForQAs.has(latestQA.id) : false; + + const exampleQuestions = [ + "How do I increase my concurrency limit?", + "How do I debug errors in my task?", + "How do I deploy my task?", + ]; + + return ( +
+ {conversation.length === 0 ? ( + + + I'm trained on docs, examples, and other content. Ask me anything about Trigger.dev. + + {exampleQuestions.map((question, index) => ( + onExampleClick(question)} + variants={{ + hidden: { + opacity: 0, + x: 20, + }, + visible: { + opacity: 1, + x: 0, + transition: { + opacity: { + duration: 0.5, + ease: "linear", + }, + x: { + type: "spring", + stiffness: 300, + damping: 25, + }, + }, + }, + }} + > + + + {question} + + + ))} + + ) : ( + conversation.map((qa) => ( +
+ {qa.question} +
+
+ )) + )} + {conversation.length > 0 && + !isPreparingAnswer && + !isGeneratingAnswer && + !error && + !latestQA?.id && ( +
+ + Answer generation was stopped + + +
+ )} + {conversation.length > 0 && + !isPreparingAnswer && + !isGeneratingAnswer && + !error && + latestQA?.id && ( +
+ {hasFeedbackForLatestQA ? ( + + + Thanks for your feedback! + + + ) : ( +
+ + Was this helpful? + +
+ + +
+
+ )} + +
+ )} + {isPreparingAnswer && ( +
+ + Preparing answer… +
+ )} + {error && ( +
+ + Error generating answer: + + {error} If the problem persists after retrying, please contact support. + + +
+ +
+
+ )} +
+ ); +} + +function ChatInterface({ initialQuery }: { initialQuery?: string }) { + const [message, setMessage] = useState(""); + const [isExpanded, setIsExpanded] = useState(false); + const hasSubmittedInitialQuery = useRef(false); + const { + conversation, + submitQuery, + isGeneratingAnswer, + isPreparingAnswer, + resetConversation, + stopGeneration, + error, + addFeedback, + } = useChat(); + + useEffect(() => { + if (initialQuery && !hasSubmittedInitialQuery.current) { + hasSubmittedInitialQuery.current = true; + setIsExpanded(true); + submitQuery(initialQuery); + } + }, [initialQuery, submitQuery]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (message.trim()) { + setIsExpanded(true); + submitQuery(message); + setMessage(""); + } + }; + + const handleExampleClick = (question: string) => { + setIsExpanded(true); + submitQuery(question); + }; + + const handleReset = () => { + resetConversation(); + setIsExpanded(false); + }; + + return ( + + +
+
+ setMessage(e.target.value)} + placeholder="Ask a question..." + disabled={isGeneratingAnswer} + autoFocus + className="flex-1 rounded-md border border-grid-bright bg-background-dimmed px-3 py-2 text-text-bright placeholder:text-text-dimmed focus-visible:focus-custom" + /> + {isGeneratingAnswer ? ( + stopGeneration()} + className="group relative z-10 flex size-10 min-w-10 cursor-pointer items-center justify-center" + > + + + + } + content="Stop generating" + /> + ) : isPreparingAnswer ? ( + + + + ) : ( +
+
+
+ ); +} + +function GradientSpinnerBackground({ + children, + className, + hoverEffect = false, +}: { + children?: React.ReactNode; + className?: string; + hoverEffect?: boolean; +}) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index d9170d68e9..5ee7badc35 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -21,12 +21,13 @@ import { import { useNavigation } from "@remix-run/react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import simplur from "simplur"; -import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon"; import { TaskIconSmall } from "~/assets/icons/TaskIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; import { Avatar } from "~/components/primitives/Avatar"; import { type MatchedEnvironment } from "~/hooks/useEnvironment"; +import { useFeatures } from "~/hooks/useFeatures"; import { type MatchedOrganization } from "~/hooks/useOrganizations"; import { type MatchedProject } from "~/hooks/useProject"; import { useHasAdminAccess } from "~/hooks/useUser"; @@ -61,7 +62,7 @@ import { v3UsagePath, v3WaitpointTokensPath, } from "~/utils/pathBuilder"; -import { useKapaWidget } from "../../hooks/useKapaWidget"; +import { AskAI } from "../AskAI"; import { FreePlanUsage } from "../billing/FreePlanUsage"; import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; import { ImpersonationBanner } from "../ImpersonationBanner"; @@ -75,19 +76,16 @@ import { PopoverMenuItem, PopoverTrigger, } from "../primitives/Popover"; -import { ShortcutKey } from "../primitives/ShortcutKey"; import { TextLink } from "../primitives/TextLink"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip"; import { ShortcutsAutoOpen } from "../Shortcuts"; import { UserProfilePhoto } from "../UserProfilePhoto"; +import { V4Badge } from "../V4Badge"; import { EnvironmentSelector } from "./EnvironmentSelector"; import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; -import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; -import { V4Badge } from "../V4Badge"; -import { useFeatures } from "~/hooks/useFeatures"; type SideMenuUser = Pick & { isImpersonating: boolean }; export type SideMenuProject = Pick< @@ -586,41 +584,13 @@ function SelectorDivider() { } function HelpAndAI() { - const { isKapaEnabled, isKapaOpen, openKapa } = useKapaWidget(); + const features = useFeatures(); return ( <> - - {isKapaEnabled && ( - - - -
- -
-
- - Ask AI - - -
-
- )} + + ); } diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx index 6a37c6f2bf..99664b3dc3 100644 --- a/apps/webapp/app/components/primitives/CopyableText.tsx +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -4,9 +4,17 @@ import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { useCopy } from "~/hooks/useCopy"; import { cn } from "~/utils/cn"; -export function CopyableText({ value, className }: { value: string; className?: string }) { +export function CopyableText({ + value, + copyValue, + className, +}: { + value: string; + copyValue?: string; + className?: string; +}) { const [isHovered, setIsHovered] = useState(false); - const { copy, copied } = useCopy(value); + const { copy, copied } = useCopy(copyValue ?? value); return (
{children} - -
- - - Close -
+ + + + Close diff --git a/apps/webapp/app/components/primitives/ShortcutKey.tsx b/apps/webapp/app/components/primitives/ShortcutKey.tsx index acfd599c73..04b1f36737 100644 --- a/apps/webapp/app/components/primitives/ShortcutKey.tsx +++ b/apps/webapp/app/components/primitives/ShortcutKey.tsx @@ -13,8 +13,8 @@ const medium = export const variants = { small: - "text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase", - medium, + "text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase", + medium: cn(medium, "group-hover:border-charcoal-550"), "medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"), }; diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index c90eecb423..c85963edcb 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -52,6 +52,8 @@ import { TaskRunStatusCombo, } from "./TaskRunStatus"; import { useEnvironment } from "~/hooks/useEnvironment"; +import { CopyableText } from "~/components/primitives/CopyableText"; +import { ClipboardField } from "~/components/primitives/ClipboardField"; type RunsTableProps = { total: number; @@ -134,7 +136,7 @@ export function TaskRunsTable({ )} )} - Run # + ID Task Version )} - - {formatNumber(run.number)} + + + +
+ } + asChild + disableHoverableContent + /> @@ -539,7 +554,7 @@ function BlankState({ isLoading, filters }: Pick; - const { environments, tasks, from, to, ...otherFilters } = filters; + const { tasks, from, to, ...otherFilters } = filters; if ( filters.tasks.length === 1 && diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 61c8fdff03..3297616866 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -35,6 +35,9 @@ const EnvironmentSchema = z.object({ API_ORIGIN: z.string().optional(), STREAM_ORIGIN: z.string().optional(), ELECTRIC_ORIGIN: z.string().default("http://localhost:3060"), + // A comma separated list of electric origins to shard into different electric instances by environmentId + // example: "http://localhost:3060,http://localhost:3061,http://localhost:3062" + ELECTRIC_ORIGIN_SHARDS: z.string().optional(), APP_ENV: z.string().default(process.env.NODE_ENV), SERVICE_NAME: z.string().default("trigger.dev webapp"), POSTHOG_PROJECT_KEY: z.string().default("phc_LFH7kJiGhdIlnO22hTAKgHpaKhpM8gkzWAFvHmf5vfS"), @@ -161,6 +164,11 @@ const EnvironmentSchema = z.object({ .default(process.env.REDIS_TLS_DISABLED ?? "false"), REALTIME_STREAMS_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), + REALTIME_MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS: z.coerce + .number() + .int() + .default(24 * 60 * 60 * 1000), // 1 day in milliseconds + PUBSUB_REDIS_HOST: z .string() .optional() @@ -738,6 +746,14 @@ const EnvironmentSchema = z.object({ RUN_REPLICATION_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(), RUN_REPLICATION_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10), + // Clickhouse + CLICKHOUSE_URL: z.string().optional(), + CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"), + CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(), + CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10), + CLICKHOUSE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), + CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"), + // Bootstrap TRIGGER_BOOTSTRAP_ENABLED: z.string().default("0"), TRIGGER_BOOTSTRAP_WORKER_GROUP_NAME: z.string().optional(), diff --git a/apps/webapp/app/hooks/useKapaWidget.tsx b/apps/webapp/app/hooks/useKapaWidget.tsx deleted file mode 100644 index 3f0f8eef44..0000000000 --- a/apps/webapp/app/hooks/useKapaWidget.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { useMatches, useSearchParams } from "@remix-run/react"; -import { useCallback, useEffect, useState } from "react"; -import { useFeatures } from "~/hooks/useFeatures"; -import { type loader } from "~/root"; -import { useShortcuts } from "../components/primitives/ShortcutsProvider"; -import { useTypedMatchesData } from "./useTypedMatchData"; - -type OpenOptions = { mode: string; query: string; submit: boolean }; - -declare global { - interface Window { - Kapa: ( - command: string, - options?: (() => void) | { onRender?: () => void } | OpenOptions, - remove?: string | { onRender?: () => void } - ) => void; - } -} - -export function KapaScripts({ websiteId }: { websiteId?: string }) { - if (!websiteId) return null; - - return ( - <> - -