diff --git a/apps/webapp/app/assets/icons/AISparkleIcon.tsx b/apps/webapp/app/assets/icons/AISparkleIcon.tsx index ee0924bbfd..46f7429e77 100644 --- a/apps/webapp/app/assets/icons/AISparkleIcon.tsx +++ b/apps/webapp/app/assets/icons/AISparkleIcon.tsx @@ -1,53 +1,31 @@ export function AISparkleIcon({ className }: { className?: string }) { return ( - + - - - - - - - - - - - - - - ); } diff --git a/apps/webapp/app/assets/icons/KeyboardEnterIcon.tsx b/apps/webapp/app/assets/icons/KeyboardEnterIcon.tsx new file mode 100644 index 0000000000..b634191272 --- /dev/null +++ b/apps/webapp/app/assets/icons/KeyboardEnterIcon.tsx @@ -0,0 +1,12 @@ +export function KeyboardEnterIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index d44359bb4e..8349ed970f 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -11,6 +11,8 @@ import { } from "./primitives/SheetV3"; import { ShortcutKey } from "./primitives/ShortcutKey"; import { Button } from "./primitives/Buttons"; +import { useState } from "react"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; export function Shortcuts() { return ( @@ -23,121 +25,147 @@ export function Shortcuts() { data-action="shortcuts" fullWidth textAlignLeft - shortcut={{ modifiers: ["shift"], key: "?" }} + shortcut={{ modifiers: ["shift"], key: "?", enabled: false }} className="gap-x-0 pl-0.5" iconSpacing="gap-x-0.5" > Shortcuts - - - -
- - - Keyboard shortcuts - -
- -
-
- General - - - - - - - - - - - - - - to - - - - - - - - - - - - -
-
- Runs page - - - - - - - - - -
-
- Run page - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - to - - - -
-
- Schedules page - - - -
-
- Alerts page - - - -
-
- - + ); } +export function ShortcutsAutoOpen() { + const [isOpen, setIsOpen] = useState(false); + + useShortcutKeys({ + shortcut: { modifiers: ["shift"], key: "?" }, + action: () => { + setIsOpen(true); + }, + }); + + return ( + + + + ); +} + +function ShortcutContent() { + return ( + + + +
+ + + Keyboard shortcuts + +
+
+
+
+ General + + + + + + + + + + + + + + + + + to + + + + + + + + + + + + +
+
+ Runs page + + + + + + + + + +
+
+ Run page + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + to + + + +
+
+ Schedules page + + + +
+
+ Alerts page + + + +
+
+
+
+ ); +} + function Shortcut({ children, name }: { children: React.ReactNode; name: string }) { return (
diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index f595ea1dbd..a788e74233 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -2,9 +2,9 @@ import { ArrowUpRightIcon, BookOpenIcon, CalendarDaysIcon, - ChatBubbleLeftEllipsisIcon, EnvelopeIcon, LightBulbIcon, + QuestionMarkCircleIcon, SignalIcon, StarIcon, } from "@heroicons/react/20/solid"; @@ -12,6 +12,7 @@ import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons"; import { Fragment, useState } from "react"; import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; import { Feedback } from "../Feedback"; +import { Shortcuts } from "../Shortcuts"; import { StepContentContainer } from "../StepContentContainer"; import { Button } from "../primitives/Buttons"; import { ClipboardField } from "../primitives/ClipboardField"; @@ -21,16 +22,21 @@ import { Paragraph } from "../primitives/Paragraph"; import { Popover, PopoverContent, PopoverSideMenuTrigger } from "../primitives/Popover"; import { StepNumber } from "../primitives/StepNumber"; import { MenuCount, SideMenuItem } from "./SideMenuItem"; -import { Shortcuts } from "../Shortcuts"; -export function HelpAndFeedback() { + +export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?: boolean }) { const [isHelpMenuOpen, setHelpMenuOpen] = useState(false); const currentPlan = useCurrentPlan(); return ( setHelpMenuOpen(open)}> - +
- + Help & Feedback
diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 6a56281873..05bc0cddaa 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -76,6 +76,14 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; +import { ShortcutKey } from "../primitives/ShortcutKey"; +import { useFeatures } from "~/hooks/useFeatures"; +import { useKapaConfig } from "~/root"; +import { useShortcuts } from "~/components/primitives/ShortcutsProvider"; +import { useKapaWidget } from "../../hooks/useKapaWidget"; +import { ShortcutsAutoOpen } from "../Shortcuts"; type SideMenuUser = Pick & { isImpersonating: boolean }; export type SideMenuProject = Pick< @@ -276,7 +284,9 @@ export function SideMenu({
- +
+ +
{isFreeUser && ( ); } + +function HelpAndAI() { + const { isKapaEnabled, openKapa, isKapaOpen } = useKapaWidget(); + + return ( + <> + + + {isKapaEnabled && ( + + + +
+ +
+
+ + Ask AI + + +
+
+ )} + + ); +} diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 2adddb40cc..ed94d46f95 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -177,6 +177,7 @@ export type ButtonContentPropsType = { shortcutPosition?: "before-trailing-icon" | "after-trailing-icon"; tooltip?: ReactNode; iconSpacing?: string; + hideShortcutKey?: boolean; }; export function ButtonContent(props: ButtonContentPropsType) { @@ -192,6 +193,7 @@ export function ButtonContent(props: ButtonContentPropsType) { className, tooltip, iconSpacing, + hideShortcutKey, } = props; const variation = allVariants.variant[props.variant]; @@ -202,7 +204,8 @@ export function ButtonContent(props: ButtonContentPropsType) { const textColorClassName = variation.textColor; const renderShortcutKey = () => - shortcut && ( + shortcut && + !hideShortcutKey && ( Enter; + return isMac ? ( + + ) : ( + Enter + ); case "esc": return Esc; case "del": diff --git a/apps/webapp/app/components/primitives/ShortcutsProvider.tsx b/apps/webapp/app/components/primitives/ShortcutsProvider.tsx new file mode 100644 index 0000000000..bcbed9b562 --- /dev/null +++ b/apps/webapp/app/components/primitives/ShortcutsProvider.tsx @@ -0,0 +1,39 @@ +import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from "react"; + +type ShortcutsContextType = { + areShortcutsEnabled: boolean; + disableShortcuts: () => void; + enableShortcuts: () => void; +}; + +const ShortcutsContext = createContext(null); + +type ShortcutsProviderProps = { + children: ReactNode; +}; + +export function ShortcutsProvider({ children }: ShortcutsProviderProps) { + const [areShortcutsEnabled, setAreShortcutsEnabled] = useState(true); + + const disableShortcuts = useCallback(() => setAreShortcutsEnabled(false), []); + const enableShortcuts = useCallback(() => setAreShortcutsEnabled(true), []); + + const value = useMemo( + () => ({ + areShortcutsEnabled, + disableShortcuts, + enableShortcuts, + }), + [areShortcutsEnabled, disableShortcuts, enableShortcuts] + ); + + return {children}; +} + +const throwIfNoProvider = () => { + throw new Error("useShortcuts must be used within a ShortcutsProvider"); +}; + +export const useShortcuts = () => { + return useContext(ShortcutsContext) ?? throwIfNoProvider(); +}; diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 66ea233fec..25dddd221c 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -710,6 +710,9 @@ const EnvironmentSchema = z.object({ QUEUE_SSE_AUTORELOAD_INTERVAL_MS: z.coerce.number().int().default(5_000), QUEUE_SSE_AUTORELOAD_TIMEOUT_MS: z.coerce.number().int().default(60_000), + + // kapa.ai + KAPA_AI_WEBSITE_ID: z.string().optional(), }); export type Environment = z.infer; diff --git a/apps/webapp/app/hooks/useKapaWidget.tsx b/apps/webapp/app/hooks/useKapaWidget.tsx new file mode 100644 index 0000000000..7c038030b5 --- /dev/null +++ b/apps/webapp/app/hooks/useKapaWidget.tsx @@ -0,0 +1,98 @@ +import { useKapaConfig } from "~/root"; +import { useShortcuts } from "../components/primitives/ShortcutsProvider"; +import { useFeatures } from "~/hooks/useFeatures"; +import { useCallback, useEffect, useState } from "react"; + +export function useKapaWidget() { + const kapa = useKapaConfig(); + const features = useFeatures(); + const { disableShortcuts, enableShortcuts, areShortcutsEnabled } = useShortcuts(); + const [isKapaOpen, setIsKapaOpen] = useState(false); + + useEffect(() => { + if (!features.isManagedCloud || !kapa?.websiteId) return; + + loadScriptIfNotExists(kapa.websiteId); + + // Define the handler function + const handleModalClose = () => { + setIsKapaOpen(false); + enableShortcuts(); + }; + + const kapaInterval = setInterval(() => { + if (typeof window.Kapa === "function") { + clearInterval(kapaInterval); + window.Kapa("render"); + window.Kapa("onModalClose", handleModalClose); + + // Register onModalOpen handler + window.Kapa("onModalOpen", () => { + setIsKapaOpen(true); + disableShortcuts(); + }); + } + }, 100); + + // Clear interval on unmount to prevent memory leaks + return () => { + clearInterval(kapaInterval); + if (typeof window.Kapa === "function") { + window.Kapa("unmount"); + } + }; + }, [features.isManagedCloud, kapa?.websiteId, disableShortcuts, enableShortcuts]); + + //todo remove listeners + + const openKapa = useCallback(() => { + if (!features.isManagedCloud || !kapa?.websiteId) return; + + if (typeof window.Kapa === "function") { + window.Kapa("open"); + setIsKapaOpen(true); + disableShortcuts(); + } + }, [disableShortcuts, features.isManagedCloud, kapa?.websiteId]); + + return { + isKapaEnabled: features.isManagedCloud && kapa?.websiteId, + openKapa, + isKapaOpen, + }; +} + +function loadScriptIfNotExists(websiteId: string) { + const scriptSrc = "https://widget.kapa.ai/kapa-widget.bundle.js"; + + if (document.querySelector(`script[src="${scriptSrc}"]`)) { + return; + } + + const script = document.createElement("script"); + script.async = true; + script.src = scriptSrc; + + const attributes = { + "data-website-id": websiteId, + "data-project-name": "Trigger.dev", + "data-project-color": "#C7D2FE", + "data-project-logo": "https://content.trigger.dev/trigger-logo-circle.png", + "data-render-on-load": "false", + "data-button-hide": "true", + "data-modal-disclaimer-bg-color": "#1A1B1F", + "data-modal-disclaimer-text-color": "#878C99", + "data-modal-header-bg-color": "#2C3034", + "data-modal-body-bg-color": "#4D525B", + "data-query-input-text-color": "#15171A", + "data-query-input-placeholder-text-color": "#878C99", + "data-modal-title-color": "#D7D9DD", + "data-button-text-color": "#D7D9DD", + }; + + Object.entries(attributes).forEach(([key, value]) => { + script.setAttribute(key, value); + }); + + document.head.appendChild(script); +} diff --git a/apps/webapp/app/hooks/useShortcutKeys.tsx b/apps/webapp/app/hooks/useShortcutKeys.tsx index 721dbeea18..0674b5bc0b 100644 --- a/apps/webapp/app/hooks/useShortcutKeys.tsx +++ b/apps/webapp/app/hooks/useShortcutKeys.tsx @@ -1,5 +1,6 @@ import { useHotkeys } from "react-hotkeys-hook"; import { useOperatingSystem } from "~/components/primitives/OperatingSystemProvider"; +import { useShortcuts } from "~/components/primitives/ShortcutsProvider"; export type Modifier = "alt" | "ctrl" | "meta" | "shift" | "mod"; @@ -7,6 +8,7 @@ export type Shortcut = { key: string; modifiers?: Modifier[]; enabledOnInputElements?: boolean; + enabled?: boolean; }; export type ShortcutDefinition = @@ -30,20 +32,26 @@ export function useShortcutKeys({ enabledOnInputElements, }: useShortcutKeysProps) { const { platform } = useOperatingSystem(); + const { areShortcutsEnabled } = useShortcuts(); const isMac = platform === "mac"; const relevantShortcut = shortcut && "mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut; const keys = createKeysFromShortcut(relevantShortcut); + + const isEnabled = !disabled && areShortcutsEnabled && relevantShortcut?.enabled !== false; + useHotkeys( keys, (event, hotkeysEvent) => { action(event); }, { - enabled: !disabled, - enableOnFormTags: enabledOnInputElements ?? relevantShortcut?.enabledOnInputElements, - enableOnContentEditable: enabledOnInputElements ?? relevantShortcut?.enabledOnInputElements, + enabled: isEnabled, + enableOnFormTags: + isEnabled && (enabledOnInputElements ?? relevantShortcut?.enabledOnInputElements), + enableOnContentEditable: + isEnabled && (enabledOnInputElements ?? relevantShortcut?.enabledOnInputElements), } ); } diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index e1418968de..6dc437fd9a 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -1,20 +1,36 @@ import type { LinksFunction, LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; import type { ShouldRevalidateFunction } from "@remix-run/react"; -import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react"; -import { UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useMatches, +} from "@remix-run/react"; +import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; import { ExternalScripts } from "remix-utils/external-scripts"; import type { ToastMessage } from "~/models/message.server"; import { commitSession, getSession } from "~/models/message.server"; import tailwindStylesheetUrl from "~/tailwind.css"; import { RouteErrorDisplay } from "./components/ErrorDisplay"; import { AppContainer, MainCenteredContainer } from "./components/layout/AppLayout"; +import { ShortcutsProvider } from "./components/primitives/ShortcutsProvider"; import { Toast } from "./components/primitives/Toast"; import { env } from "./env.server"; import { featuresForRequest } from "./features.server"; import { usePostHog } from "./hooks/usePostHog"; +import { useTypedMatchesData } from "./hooks/useTypedMatchData"; import { getUser } from "./services/session.server"; import { appEnvTitleTag } from "./utils"; +declare global { + interface Window { + Kapa: (command: string, options?: (() => void) | { onRender?: () => void }) => void; + } +} + export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: tailwindStylesheetUrl }]; }; @@ -34,12 +50,25 @@ export const meta: MetaFunction = ({ data }) => { ]; }; +export function useKapaConfig() { + const matches = useMatches(); + const routeMatch = useTypedMatchesData({ + id: "root", + matches, + }); + return routeMatch?.kapa; +} + export const loader = async ({ request }: LoaderFunctionArgs) => { const session = await getSession(request.headers.get("cookie")); const toastMessage = session.get("toastMessage") as ToastMessage; const posthogProjectKey = env.POSTHOG_PROJECT_KEY; const features = featuresForRequest(request); + const kapa = { + websiteId: env.KAPA_AI_WEBSITE_ID, + }; + return typedjson( { user: await getUser(request), @@ -48,6 +77,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { features, appEnv: env.APP_ENV, appOrigin: env.APP_ORIGIN, + kapa, }, { headers: { "Set-Cookie": await commitSession(session) } } ); @@ -73,7 +103,7 @@ export function ErrorBoundary() { - + @@ -86,7 +116,7 @@ export function ErrorBoundary() { ); } -function App() { +export default function App() { const { posthogProjectKey } = useTypedLoaderData(); usePostHog(posthogProjectKey); @@ -97,9 +127,11 @@ function App() { - - - + + + + + @@ -109,5 +141,3 @@ function App() { ); } - -export default App; diff --git a/apps/webapp/app/routes/resources.kapa-widget.ts b/apps/webapp/app/routes/resources.kapa-widget.ts new file mode 100644 index 0000000000..4c4be3216e --- /dev/null +++ b/apps/webapp/app/routes/resources.kapa-widget.ts @@ -0,0 +1,13 @@ +import { type LoaderFunctionArgs } from "@remix-run/node"; + +export async function loader({ request }: LoaderFunctionArgs) { + const response = await fetch("https://widget.kapa.ai/kapa-widget.bundle.js"); + const script = await response.text(); + + return new Response(script, { + headers: { + "Content-Type": "application/javascript", + "Cache-Control": "public, max-age=86400", // Cache for 1 day + }, + }); +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.schedules.new.natural-language.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.schedules.new.natural-language.tsx index 5abfdfe79d..3f119b8ae7 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.schedules.new.natural-language.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.schedules.new.natural-language.tsx @@ -83,14 +83,14 @@ export function AIGeneratedCronField({ onSuccess }: AIGeneratedCronFieldProps) { return (
-
+