diff --git a/.changeset/openclaw-agent-integration.md b/.changeset/openclaw-agent-integration.md new file mode 100644 index 00000000000..6070c27fb7c --- /dev/null +++ b/.changeset/openclaw-agent-integration.md @@ -0,0 +1,8 @@ +--- +"@trigger.dev/core": patch +"@trigger.dev/sdk": patch +--- + +feat: Add OpenClaw agent integration with Slack webhooks + +Implements Phase 1 MVP for AI agent platform allowing users to create agents through setup form (/agents/setup). Agents are stored in database with configuration (model, platform, tools). Slack webhook receives messages and triggers agent responses. Includes agent management UI and webhook integration infrastructure. diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index 0d32265c251..4b20e99f760 100644 --- a/apps/webapp/app/components/AskAI.tsx +++ b/apps/webapp/app/components/AskAI.tsx @@ -11,12 +11,11 @@ import { useSearchParams } from "@remix-run/react"; import DOMPurify from "dompurify"; import { motion } from "framer-motion"; import { marked } from "marked"; -import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; +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 { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { type loader } from "~/root"; import { Button } from "./primitives/Buttons"; import { Callout } from "./primitives/Callout"; @@ -39,104 +38,6 @@ function useKapaWebsiteId() { return routeMatch?.kapa.websiteId; } -/** Open/close state for the Ask AI dialog, including the `?aiHelp=` deep-link handling. */ -function useAskAIState() { - 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 { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI }; -} - -/** - * Hosts Ask AI (Kapa provider, ⌘I shortcut, dialog) for a menu that renders its own trigger. Wrap - * it around the popover, not inside, so the dialog and shortcut survive the popover closing. - * `children` receives the open function, or undefined when Ask AI is unavailable (self-hosted, no - * Kapa website id, or SSR). - */ -export function AskAIRoot({ - children, -}: { - children: (openAskAI: (() => void) | undefined) => ReactNode; -}) { - const { isManagedCloud } = useFeatures(); - const websiteId = useKapaWebsiteId(); - - if (!isManagedCloud || !websiteId) { - return <>{children(undefined)}; - } - - return ( - {children(undefined)}}> - {() => {children}} - - ); -} - -function AskAIRootProvider({ - websiteId, - children, -}: { - websiteId: string; - children: (openAskAI: () => void) => ReactNode; -}) { - const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState(); - - useShortcutKeys({ - shortcut: { modifiers: ["mod"], key: "i", enabledOnInputElements: true }, - action: () => openAskAI(), - }); - - return ( - openAskAI(), - onAnswerGenerationCompleted: () => openAskAI(), - }, - }} - botProtectionMechanism="hcaptcha" - > - {children(() => openAskAI())} - - - ); -} - export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) { const { isManagedCloud } = useFeatures(); const websiteId = useKapaWebsiteId(); @@ -171,7 +72,37 @@ type AskAIProviderProps = { }; function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) { - const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState(); + 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 ( - + Ask AI @@ -242,7 +177,7 @@ function AskAIDialog({ initialQuery, isOpen, onOpenChange, closeAskAI }: AskAIDi return ( - +
Ask AI @@ -295,7 +230,7 @@ function ChatMessages({ ]; return ( -
+
{conversation.length === 0 ? ( ( onExampleClick(question)} variants={{ hidden: { @@ -537,7 +472,7 @@ function ChatInterface({ initialQuery }: { initialQuery?: string }) { error={error} addFeedback={addFeedback} /> -
+
} variant="primary/large" - className="size-10 min-w-10 rounded-full group-disabled/button:border-border-brighter group-disabled/button:bg-surface-control" + className="size-10 min-w-10 rounded-full group-disabled/button:border-charcoal-550 group-disabled/button:bg-charcoal-600" /> )}
@@ -600,11 +535,11 @@ function GradientSpinnerBackground({ }) { return (
{children} diff --git a/apps/webapp/app/components/ErrorDisplay.tsx b/apps/webapp/app/components/ErrorDisplay.tsx index 374f427c504..83080556d21 100644 --- a/apps/webapp/app/components/ErrorDisplay.tsx +++ b/apps/webapp/app/components/ErrorDisplay.tsx @@ -60,13 +60,13 @@ type DisplayOptionsProps = { export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) { return ( -
-
+
+
{title} {message && {message}} diff --git a/apps/webapp/app/components/LogoIcon.tsx b/apps/webapp/app/components/LogoIcon.tsx index 365c7c90c63..0da161c4b08 100644 --- a/apps/webapp/app/components/LogoIcon.tsx +++ b/apps/webapp/app/components/LogoIcon.tsx @@ -1,32 +1,20 @@ export function LogoIcon({ className }: { className?: string }) { return ( + - - - - - - + ); } diff --git a/apps/webapp/app/components/LogoType.tsx b/apps/webapp/app/components/LogoType.tsx index 76a88fce1a1..75d4942ef5d 100644 --- a/apps/webapp/app/components/LogoType.tsx +++ b/apps/webapp/app/components/LogoType.tsx @@ -1,190 +1,32 @@ export function LogoType({ className }: { className?: string }) { return ( - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + {/* Icon */} + + + + {/* Text */} + + Trigger.dev + ); } diff --git a/apps/webapp/app/components/code/AIQueryInput.tsx b/apps/webapp/app/components/code/AIQueryInput.tsx index f9ceb3384ab..c81362e7e30 100644 --- a/apps/webapp/app/components/code/AIQueryInput.tsx +++ b/apps/webapp/app/components/code/AIQueryInput.tsx @@ -257,7 +257,7 @@ export function AIQueryInput({ onChange={(e) => setPrompt(e.target.value)} disabled={isLoading} rows={8} - className="m-0 min-h-10 w-full resize-none border-0 bg-background-bright px-3 py-2.5 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-text-dimmed focus:border-0 focus:outline-hidden focus:ring-0 focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50" + className="m-0 min-h-10 w-full resize-none border-0 bg-background-bright px-3 py-2.5 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-gray-300 file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-text-dimmed focus:border-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50" onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && prompt.trim() && !isLoading) { e.preventDefault(); @@ -342,7 +342,7 @@ export function AIQueryInput({ className="overflow-hidden" >
-
+
{isLoading ? ( @@ -356,10 +356,10 @@ export function AIQueryInput({ {isLoading ? "AI is thinking…" : lastResult === "success" - ? "Query generated" - : lastResult === "error" - ? "Generation failed" - : "AI response"} + ? "Query generated" + : lastResult === "error" + ? "Generation failed" + : "AI response"}
{isLoading ? ( @@ -390,7 +390,7 @@ export function AIQueryInput({ )}
-
+
{thinking}

}> {thinking}
diff --git a/apps/webapp/app/components/code/TSQLEditor.tsx b/apps/webapp/app/components/code/TSQLEditor.tsx index 3b4bf202954..9ab93569d0e 100644 --- a/apps/webapp/app/components/code/TSQLEditor.tsx +++ b/apps/webapp/app/components/code/TSQLEditor.tsx @@ -1,23 +1,23 @@ -import { autocompletion, startCompletion } from "@codemirror/autocomplete"; import { sql, StandardSQL } from "@codemirror/lang-sql"; +import { autocompletion, startCompletion } from "@codemirror/autocomplete"; import { linter, lintGutter } from "@codemirror/lint"; -import type { ViewUpdate } from "@codemirror/view"; import { EditorView, keymap } from "@codemirror/view"; -import { CheckIcon, ClipboardIcon, TrashIcon } from "@heroicons/react/20/solid"; -import type { TableSchema } from "@internal/tsql"; +import type { ViewUpdate } from "@codemirror/view"; +import { CheckIcon, ClipboardIcon, SparklesIcon, TrashIcon } from "@heroicons/react/20/solid"; import { type ReactCodeMirrorProps, type UseCodeMirror, useCodeMirror, } from "@uiw/react-codemirror"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { format as formatSQL } from "sql-formatter"; +import { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { cn } from "~/utils/cn"; import { Button } from "../primitives/Buttons"; import { getEditorSetup } from "./codeMirrorSetup"; import { darkTheme } from "./codeMirrorTheme"; import { createTSQLCompletion } from "./tsql/tsqlCompletion"; import { createTSQLLinter } from "./tsql/tsqlLinter"; +import type { TableSchema } from "@internal/tsql"; +import { format as formatSQL } from "sql-formatter"; export interface TSQLEditorProps extends Omit { /** Initial value for the editor */ @@ -271,7 +271,7 @@ export function TSQLEditor(opts: TSQLEditorProps) { >
{ @@ -284,7 +284,7 @@ export function TSQLEditor(opts: TSQLEditorProps) { }} /> {showButtons && ( -
+
{additionalActions && additionalActions} {showFormatButton && (
@@ -1043,8 +1047,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({ // Create TanStack Table column definitions from OutputColumnMetadata // Calculate column widths based on content const visibleColumns = useMemo( - () => - hiddenColumns?.length ? columns.filter((col) => !hiddenColumns.includes(col.name)) : columns, + () => hiddenColumns?.length ? columns.filter((col) => !hiddenColumns.includes(col.name)) : columns, [columns, hiddenColumns] ); const columnDefs = useMemo[]>( @@ -1110,7 +1113,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({ return (
@@ -1165,7 +1168,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({ return (
@@ -1219,7 +1222,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({ className={cn( "absolute right-0 top-0 h-full w-0.5 cursor-col-resize touch-none select-none", "opacity-0 group-hover/header:opacity-100", - "bg-surface-control hover:bg-indigo-500", + "bg-charcoal-600 hover:bg-indigo-500", header.column.getIsResizing() && "bg-indigo-500 opacity-100" )} /> @@ -1249,7 +1252,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({ height: `${rowVirtualizer.getTotalSize()}px`, position: "relative", }} - className="divide-y divide-grid-bright bg-background-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:z-1 after:h-px after:bg-grid-bright" + className="divide-y divide-charcoal-700 bg-background-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:z-[1] after:h-px after:bg-grid-bright" > {rowVirtualizer.getVirtualItems().map((virtualRow) => { const row = tableRows[virtualRow.index]; @@ -1257,7 +1260,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({ -
+
diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index 9034ce906ea..9cfd5043d93 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -1,18 +1,20 @@ -import { ArrowPathIcon } from "@heroicons/react/20/solid"; +import { ArrowPathIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; +import { Link } from "@remix-run/react"; import { useEffect, useRef, useState } from "react"; -import { RunsIcon } from "~/assets/icons/RunsIcon"; -import { LogLevelTooltipInfo } from "~/components/LogLevelTooltipInfo"; +import { cn } from "~/utils/cn"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server"; -import { cn } from "~/utils/cn"; import { highlightSearchText } from "~/utils/logUtils"; import { v3RunSpanPath } from "~/utils/pathBuilder"; import { DateTimeAccurate } from "../primitives/DateTime"; import { Paragraph } from "../primitives/Paragraph"; import { Spinner } from "../primitives/Spinner"; +import { LogLevel } from "./LogLevel"; +import { TruncatedCopyableValue } from "../primitives/TruncatedCopyableValue"; +import { LogLevelTooltipInfo } from "~/components/LogLevelTooltipInfo"; import { Table, TableBlankRow, @@ -24,7 +26,7 @@ import { TableRow, type TableVariant, } from "../primitives/Table"; -import { LogLevel } from "./LogLevel"; +import { RunsIcon } from "~/assets/icons/RunsIcon"; type LogsTableProps = { logs: LogEntry[]; @@ -113,7 +115,7 @@ export function LogsTable({ }, [hasMore, isLoadingMore, onLoadMore]); return ( -
+
@@ -151,7 +153,7 @@ export function LogsTable({ key={log.id} className={cn( "cursor-pointer transition-colors", - isSelected ? "bg-background-hover" : "hover:bg-background-dimmed" + isSelected ? "bg-charcoal-750" : "hover:bg-charcoal-850" )} isSelected={isSelected} > @@ -187,7 +189,7 @@ export function LogsTable({ variant="minimal/small" TrailingIcon={RunsIcon} trailingIconClassName="text-text-bright" - className="h-5.5 pl-1.5 pr-2" + className="h-[1.375rem] pl-1.5 pr-2" > View run diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index 0f51b225457..3a0d05b9b68 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -1,8 +1,6 @@ import { ChevronRightIcon, Cog8ToothIcon } from "@heroicons/react/20/solid"; -import { DEFAULT_DEV_BRANCH } from "@trigger.dev/core/v3/utils/gitBranch"; -import { isBranchableEnvironment } from "~/utils/branchableEnvironment"; import { DropdownIcon } from "~/assets/icons/DropdownIcon"; -import { useNavigation, useRevalidator } from "@remix-run/react"; +import { useNavigation } from "@remix-run/react"; import { useEffect, useRef, useState } from "react"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { useEnvironment } from "~/hooks/useEnvironment"; @@ -11,14 +9,8 @@ import { useFeatures } from "~/hooks/useFeatures"; import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { cn } from "~/utils/cn"; -import { branchesPath, branchesDevPath, docsPath, v3BillingPath } from "~/utils/pathBuilder"; -import { - EnvironmentCombo, - EnvironmentIcon, - EnvironmentLabel, - environmentFullTitle, - environmentTextClassName, -} from "../environments/EnvironmentLabel"; +import { branchesPath, docsPath, v3BillingPath } from "~/utils/pathBuilder"; +import { EnvironmentCombo, EnvironmentIcon, EnvironmentLabel, environmentFullTitle } from "../environments/EnvironmentLabel"; import { ButtonContent } from "../primitives/Buttons"; import { Header2 } from "../primitives/Headers"; import { Paragraph } from "../primitives/Paragraph"; @@ -35,108 +27,76 @@ import { V4Badge } from "../V4Badge"; import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu"; import { Badge } from "../primitives/Badge"; -// Size this Env popover's items to match the Project popover (SIDE_MENU_POPOVER_ITEM_* in -// SideMenu.tsx). Only at these call sites, so shared EnvironmentLabel/EnvironmentCombo defaults stay. -const ENV_POPOVER_ITEM_ICON = "size-5"; -const ENV_POPOVER_ITEM_LABEL = "text-[0.90625rem] font-medium tracking-[-0.01em]"; - export function EnvironmentSelector({ organization, project, environment, className, isCollapsed = false, - isDragging = false, }: { organization: MatchedOrganization; project: SideMenuProject; environment: SideMenuEnvironment; className?: string; isCollapsed?: boolean; - /** True while the side menu is being drag-resized; keeps the row in its expanded arrangement. */ - isDragging?: boolean; }) { const { isManagedCloud } = useFeatures(); const [isMenuOpen, setIsMenuOpen] = useState(false); const navigation = useNavigation(); const { urlForEnvironment } = useEnvironmentSwitcher(); - const revalidator = useRevalidator(); useEffect(() => { setIsMenuOpen(false); }, [navigation.location?.pathname]); - // Fetch immediately on open so the list is fresh right away - useEffect(() => { - if (isMenuOpen && revalidator.state !== "loading") { - revalidator.revalidate(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isMenuOpen]); - const hasStaging = project.environments.some((env) => env.type === "STAGING"); + return ( setIsMenuOpen(open)} open={isMenuOpen}> - {/* - In the side menu, opacity + max-width follow --sm-label-opacity (1 → 0): the label - fades in place and scales its width to 0 so it never holds width mid-drag. The - selector is also reused outside the side menu (BlankStatePanels, limits) where the var - is unset — the 0.2 max-width fallback pins a ~200px cap (0.2 * 1000px) so long names - ellipsis-truncate there instead of widening the control, while opacity stays 1. - */} - {/* - Chevron's 16px width follows --sm-label-opacity so an invisible span never holds width - mid-drag and pushes the row's clip edge into the icon. - */} - + } - content={`${environmentFullTitle(environment)} environment`} + content={environmentFullTitle(environment)} side="right" sideOffset={8} - // Tooltip only on the collapsed rail (expanded shows the label; this selector is also reused - // outside the side menu, where a hover tooltip is unwanted). hidden={!isCollapsed} - delayDuration={0} - buttonClassName="h-8!" + buttonClassName="!h-8" asChild - tabbable disableHoverableContent />
{project.environments - .filter((env) => env.parentEnvironmentId === null) + .filter((env) => env.branchName === null) .map((env) => { - const renderAsBranchable = isBranchableEnvironment(env); - - if (renderAsBranchable) { - const branchEnvironments = project.environments.filter( - (e) => e.parentEnvironmentId === env.id - ); - const allBranchEnvironments = - env.type === "DEVELOPMENT" ? [env, ...branchEnvironments] : branchEnvironments; - return ( - - ); - } - - return ( - e.parentEnvironmentId === env.id + ); + return ( + - } - isSelected={env.id === environment.id} - /> - ); + ); + } + case false: + return ( + + } + isSelected={env.id === environment.id} + /> + ); + } })}
{!hasStaging && isManagedCloud && ( @@ -192,12 +146,8 @@ export function EnvironmentSelector({ )} title={
- - Upgrade + + Upgrade
} isSelected={false} @@ -210,12 +160,8 @@ export function EnvironmentSelector({ )} title={
- - Upgrade + + Upgrade
} isSelected={false} @@ -237,6 +183,10 @@ function Branches({ branchEnvironments: SideMenuEnvironment[]; currentEnvironment: SideMenuEnvironment; }) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const { urlForEnvironment } = useEnvironmentSwitcher(); const navigation = useNavigation(); const [isMenuOpen, setMenuOpen] = useState(false); const timeoutRef = useRef(null); @@ -268,27 +218,33 @@ function Branches({ }, 150); }; + const activeBranches = branchEnvironments.filter((env) => env.archivedAt === null); + const state = + branchEnvironments.length === 0 + ? "no-branches" + : activeBranches.length === 0 + ? "no-active-branches" + : "has-branches"; + + const currentBranchIsArchived = environment.archivedAt !== null; + return ( setMenuOpen(open)} open={isMenuOpen}>
- + - - -
-
- ); -} - -/** - * Inner content of the branches popover (list, empty states, "Manage branches" footer). Shared by - * the `Branches` hover submenu and the side-menu Preview popover. - */ -export function BranchesPopoverContent({ - parentEnvironment, - branchEnvironments, - currentEnvironment, -}: { - parentEnvironment: SideMenuEnvironment; - branchEnvironments: SideMenuEnvironment[]; - currentEnvironment: SideMenuEnvironment; -}) { - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - const { urlForEnvironment } = useEnvironmentSwitcher(); - - const activeBranches = branchEnvironments.filter((env) => env.archivedAt === null); - const state = - branchEnvironments.length === 0 - ? "no-branches" - : activeBranches.length === 0 - ? "no-active-branches" - : "has-branches"; - - // Show the archived-branch item only in the submenu it belongs to: both Development and Preview - // render this, so without the parent check an archived dev branch leaks into Preview (and vice-versa). - const currentBranchIsArchived = - environment.archivedAt !== null && environment.parentEnvironmentId === parentEnvironment.id; - - const envTextClassName = environmentTextClassName(parentEnvironment); - - return ( - <> -
- {parentEnvironment.type === "DEVELOPMENT" ? ( - } - leadingIconClassName="text-text-dimmed" - className={ENV_POPOVER_ITEM_LABEL} - /> - ) : ( - } - leadingIconClassName="text-text-dimmed" - className={ENV_POPOVER_ITEM_LABEL} - /> - )} -
-
- {currentBranchIsArchived && ( - - - {environment.branchName} - - Archived - - } - icon={ - + {currentBranchIsArchived && ( + + {environment.branchName} + Archived + + } + icon={} + isSelected={environment.id === currentEnvironment.id} /> - } - isSelected={environment.id === currentEnvironment.id} - /> - )} - {state === "has-branches" ? ( - <> - {branchEnvironments - .filter((env) => env.archivedAt === null) - .map((env) => ( - - {env.branchName ?? DEFAULT_DEV_BRANCH} - - } - icon={ - + {branchEnvironments + .filter((env) => env.archivedAt === null) + .map((env) => ( + {env.branchName}} + icon={} + isSelected={env.id === currentEnvironment.id} /> - } - isSelected={env.id === currentEnvironment.id} - /> - ))} - - ) : state === "no-branches" ? ( -
-
- - Create your first branch -
- - Branches are a way to test new features in isolation before merging them into the main - environment. - - - Branches are only available when using or above. Read our{" "} - v4 upgrade guide to learn more. - + ))} + + ) : state === "no-branches" ? ( +
+
+ + Create your first branch +
+ + Branches are a way to test new features in isolation before merging them into the + main environment. + + + Branches are only available when using or above. Read our{" "} + v4 upgrade guide to learn + more. + +
+ ) : ( +
+ All branches are archived. +
+ )}
- ) : ( -
- All branches are archived. +
+ } + leadingIconClassName="text-text-dimmed" + />
- )} +
- + ); } diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index 8790e479421..cf81abf40e9 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -1,10 +1,10 @@ import { ArrowLeftIcon } from "@heroicons/react/24/solid"; import { BellIcon } from "~/assets/icons/BellIcon"; -import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon"; import { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; import { PadlockIcon } from "~/assets/icons/PadlockIcon"; import { UsageIcon } from "~/assets/icons/UsageIcon"; import { RolesIcon } from "~/assets/icons/RolesIcon"; +import { ShieldLockIcon } from "~/assets/icons/ShieldLockIcon"; import { SlackIcon } from "~/assets/icons/SlackIcon"; import { SlidersIcon } from "~/assets/icons/SlidersIcon"; import { UserGroupIcon } from "~/assets/icons/UserGroupIcon"; @@ -17,11 +17,10 @@ import { organizationRolesPath, organizationSettingsPath, organizationSlackIntegrationPath, - organizationSsoPath, organizationTeamPath, organizationVercelIntegrationPath, rootPath, - v3BillingLimitsPath, + v3BillingAlertsPath, v3BillingPath, v3PrivateConnectionsPath, v3UsagePath, @@ -35,6 +34,7 @@ import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; import { Paragraph } from "../primitives/Paragraph"; import { Badge } from "../primitives/Badge"; import { useHasAdminAccess } from "~/hooks/useUser"; +import { AskAI } from "../AskAI"; export type BuildInfo = { appVersion: string | undefined; @@ -48,12 +48,10 @@ export function OrganizationSettingsSideMenu({ organization, buildInfo, isUsingPlugin, - isSsoUsingPlugin, }: { organization: MatchedOrganization; buildInfo: BuildInfo; isUsingPlugin: boolean; - isSsoUsingPlugin: boolean; }) { const { isManagedCloud } = useFeatures(); const featureFlags = useFeatureFlags(); @@ -79,19 +77,11 @@ export function OrganizationSettingsSideMenu({ Back to app
-
+
- {isManagedCloud && ( <> {showSelfServe ? ( ) : null} @@ -138,7 +128,7 @@ export function OrganizationSettingsSideMenu({ {featureFlags.hasPrivateConnections && ( )} - {isSsoUsingPlugin && ( - - )} +
@@ -236,6 +224,7 @@ export function OrganizationSettingsSideMenu({
+
); diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 31001d05f75..90544998742 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -2,84 +2,61 @@ import { ArrowTopRightOnSquareIcon, ChevronRightIcon, ExclamationTriangleIcon, + PencilSquareIcon, } from "@heroicons/react/24/outline"; -import { useFetcher, useNavigation, useSubmit } from "@remix-run/react"; +import { Link, useFetcher, useNavigation } from "@remix-run/react"; +import { BugIcon } from "~/assets/icons/BugIcon"; import { LayoutGroup, motion } from "framer-motion"; -import { - type CSSProperties, - type PointerEvent as ReactPointerEvent, - type ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from "react"; +import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import simplur from "simplur"; import { AIChatIcon } from "~/assets/icons/AIChatIcon"; import { AIPenIcon } from "~/assets/icons/AIPenIcon"; import { ArrowLeftRightIcon } from "~/assets/icons/ArrowLeftRightIcon"; import { ArrowRightSquareIcon } from "~/assets/icons/ArrowRightSquareIcon"; import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon"; +import { HomeIcon } from "~/assets/icons/HomeIcon"; +import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; import { BatchesIcon } from "~/assets/icons/BatchesIcon"; -import { BellIcon } from "~/assets/icons/BellIcon"; import { Box3DIcon } from "~/assets/icons/Box3DIcon"; -import { BugIcon } from "~/assets/icons/BugIcon"; -import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon"; import { ChartBarIcon } from "~/assets/icons/ChartBarIcon"; -import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon"; -import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; import { DeploymentsIcon } from "~/assets/icons/DeploymentsIcon"; -import { DialIcon } from "~/assets/icons/DialIcon"; -import { DropdownIcon } from "~/assets/icons/DropdownIcon"; -import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { FolderClosedIcon } from "~/assets/icons/FolderClosedIcon"; import { FolderOpenIcon } from "~/assets/icons/FolderOpenIcon"; -import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon"; -import { HomeIcon } from "~/assets/icons/HomeIcon"; import { IDIcon } from "~/assets/icons/IDIcon"; +import { DialIcon } from "~/assets/icons/DialIcon"; +import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon"; import { IntegrationsIcon } from "~/assets/icons/IntegrationsIcon"; import { KeyIcon } from "~/assets/icons/KeyIcon"; -import { LeftSideMenuCollapsedIcon } from "~/assets/icons/LeftSideMenuCollapsedIcon"; -import { LeftSideMenuIcon } from "~/assets/icons/LeftSideMenuIcon"; +import { DropdownIcon } from "~/assets/icons/DropdownIcon"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { LogsIcon } from "~/assets/icons/LogsIcon"; import { PlusIcon } from "~/assets/icons/PlusIcon"; +import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon"; import { QueuesIcon } from "~/assets/icons/QueuesIcon"; -import { RunsIcon } from "~/assets/icons/RunsIcon"; -import { ShieldIcon } from "~/assets/icons/ShieldIcon"; import { SlidersIcon } from "~/assets/icons/SlidersIcon"; +import { RunsIcon } from "~/assets/icons/RunsIcon"; +import { TaskIcon } from "~/assets/icons/TaskIcon"; import { TasksIcon } from "~/assets/icons/TasksIcon"; +import { BellIcon } from "~/assets/icons/BellIcon"; import { UsageIcon } from "~/assets/icons/UsageIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; -import { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; -import { UserCrossIcon } from "~/assets/icons/UserCrossIcon"; -import { UserGroupIcon } from "~/assets/icons/UserGroupIcon"; -import { RolesIcon } from "~/assets/icons/RolesIcon"; -import { PadlockIcon } from "~/assets/icons/PadlockIcon"; -import { SlackIcon } from "~/assets/icons/SlackIcon"; -import { VercelLogo } from "~/components/integrations/VercelLogo"; import { Avatar } from "~/components/primitives/Avatar"; -import { UserProfilePhoto } from "~/components/UserProfilePhoto"; import { type MatchedEnvironment } from "~/hooks/useEnvironment"; import { useFeatureFlags } from "~/hooks/useFeatureFlags"; import { useFeatures } from "~/hooks/useFeatures"; import { type MatchedOrganization } from "~/hooks/useOrganizations"; import { type MatchedProject } from "~/hooks/useProject"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; -import { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { useHasAdminAccess } from "~/hooks/useUser"; import { type UserWithDashboardPreferences } from "~/models/user.server"; -import { - useCurrentPlan, - useIsUsingRbacPlugin, - useIsUsingSsoPlugin, -} from "~/routes/_app.orgs.$organizationSlug/route"; +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, - accountSecurityPath, - personalAccessTokensPath, adminPath, branchesPath, concurrencyPath, @@ -88,54 +65,43 @@ import { newOrganizationPath, newProjectPath, organizationPath, - organizationRolesPath, organizationSettingsPath, - organizationSlackIntegrationPath, - organizationSsoPath, organizationTeamPath, - organizationVercelIntegrationPath, queryPath, regionsPath, v3ApiKeysPath, v3BatchesPath, - v3BillingLimitsPath, v3BillingPath, - v3PrivateConnectionsPath, - v3BulkActionsPath, v3DashboardsLandingPath, + v3BulkActionsPath, v3DeploymentsPath, v3EnvironmentPath, v3EnvironmentVariablesPath, v3ErrorsPath, v3LogsPath, + v3PromptsPath, v3ModelsPath, v3ProjectAlertsPath, v3ProjectPath, v3ProjectSettingsGeneralPath, v3ProjectSettingsIntegrationsPath, - v3PromptsPath, v3QueuesPath, v3RunsPath, v3SessionsPath, v3UsagePath, v3WaitpointTokensPath, } from "~/utils/pathBuilder"; +import { AlphaBadge, NewBadge } from "../FeatureBadges"; +import { AskAI } from "../AskAI"; import { FreePlanUsage } from "../billing/FreePlanUsage"; import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; -import { AlphaBadge, NewBadge } from "../FeatureBadges"; +import { ImpersonationBanner } from "../ImpersonationBanner"; import { Button, ButtonContent, LinkButton } from "../primitives/Buttons"; import { Dialog, DialogTrigger } from "../primitives/Dialog"; -import { type RenderIcon } from "../primitives/Icon"; import { Paragraph } from "../primitives/Paragraph"; -import { Badge } from "../primitives/Badge"; -import { - Popover, - PopoverContent, - PopoverMenuItem, - PopoverSectionHeader, - PopoverTrigger, -} from "../primitives/Popover"; +import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover"; import { ShortcutKey } from "../primitives/ShortcutKey"; +import { TextLink } from "../primitives/TextLink"; import { SimpleTooltip, Tooltip, @@ -148,10 +114,10 @@ import { CreateDashboardButton } from "./DashboardDialogs"; import { DashboardList } from "./DashboardList"; import { EnvironmentSelector } from "./EnvironmentSelector"; import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; -import { NotificationPanel } from "./NotificationPanel"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; +import { TreeConnectorBranch, TreeConnectorEnd } from "./TreeConnectors"; import { type SideMenuSectionId } from "./sideMenuTypes"; /** Get the collapsed state for a specific side menu section from user preferences */ @@ -162,109 +128,6 @@ function getSectionCollapsed( return sideMenu?.collapsedSections?.[sectionId] ?? false; } -// Size popover items (org/project menus) to match the side-menu items, overriding the smaller -// small-menu-item defaults via tailwind-merge; icon carries the default dimmed color. -const SIDE_MENU_POPOVER_ITEM_ICON = "h-5 w-5 text-text-dimmed"; -const SIDE_MENU_POPOVER_ITEM_LABEL = "text-[0.90625rem] font-medium tracking-[-0.01em]"; - -// Impersonation accent (menu border + "Stop impersonating"). Full class strings so Tailwind's -// static scanner picks them up. -const IMPERSONATION_ACCENT = { - border: "border-yellow-500/80", - text: "text-yellow-500/80", -}; - -// --- Resizable side menu ----------------------------------------------------- -// A drag handle on the right edge resizes the menu. Width-driven visuals read two CSS variables -// written to the root each frame (no React re-render, so drags stay smooth): -// --sm-collapse: 0 (>= default width) → 1 (collapsed) -// --sm-label-opacity: 1 → 0, a faster fade curve of --sm-collapse - -/** Collapsed rail width in px (matches the previous `w-11`). */ -const COLLAPSED_WIDTH = 44; -/** The default/again-expanded width in px (matches the previous `w-56`). */ -const DEFAULT_WIDTH = 224; -/** The widest the menu can be dragged, in px. */ -const MAX_WIDTH = 400; -/** Duration of the collapse/expand/snap animation, in ms. */ -const COLLAPSE_ANIM_MS = 200; -/** Fraction of the collapse range over which labels fade to 0 (0.6 = fully faded at 60% collapsed). */ -const LABEL_FADE_FRACTION = 0.6; -/** - * Snap thresholds as collapse progress (0 = default, 1 = collapsed): release at <= threshold springs - * open, past it collapses. Separate per direction so releasing early continues the gesture. - */ -const COLLAPSE_SNAP_THRESHOLD = 0.25; -const EXPAND_SNAP_THRESHOLD = 0.9; -/** Pointer travel (px) below which a press on the handle counts as a click (toggle), not a drag. */ -const DRAG_CLICK_THRESHOLD = 4; - -/** Left/right padding of the pinned top section + scroll body, interpolated 10px → 4px by --sm-collapse. */ -const SIDE_MENU_PAD_X = `calc(0.625rem - 0.375rem * var(--sm-collapse, 0))`; -/** - * Scroll-body right padding DURING a transition (settled-open uses the reserved gutter instead). - * Interpolates from the measured gutter width (seamless handoff) to 4px collapsed; the 8px fallback - * is only for the first paint before `--sm-sb-gutter` is measured. - */ -const SIDE_MENU_SCROLL_PAD_RIGHT = `calc(var(--sm-sb-gutter, 8px) - (var(--sm-sb-gutter, 8px) - 0.25rem) * var(--sm-collapse, 0))`; -/** - * Hover chevron: its 16px width follows --sm-label-opacity so an invisible chevron never holds width - * mid-drag and pushes the row's clip edge into the icon. Opacity stays class-driven (hover-only). - */ -const SIDE_MENU_CHEVRON_STYLE = { - maxWidth: "calc(var(--sm-label-opacity, 1) * 16px)", -} as const; -/** - * Selector row label (org/project/env): opacity follows --sm-label-opacity to fade both directions - * without popping in on drag-open. The generous max-width cap fades the text in place rather than - * truncating it, but still scales to 0 so an invisible label never holds width and clips the icon. - */ -const SIDE_MENU_SELECTOR_LABEL_STYLE = { - maxWidth: "calc(var(--sm-label-opacity, 1) * 1000px)", - opacity: "var(--sm-label-opacity, 1)", -} as const; - -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} - -/** Collapse progress (0 = at/above default width, 1 = collapsed) for a given px width. */ -function widthToProgress(width: number) { - return clamp((DEFAULT_WIDTH - width) / (DEFAULT_WIDTH - COLLAPSED_WIDTH), 0, 1); -} - -/** Label opacity (1 → 0) for a given collapse progress, using the faster fade curve. */ -function progressToLabelOpacity(progress: number) { - return clamp((LABEL_FADE_FRACTION - progress) / LABEL_FADE_FRACTION, 0, 1); -} - -/** cubic-bezier(0.4, 0, 0.2, 1) — standard easing for the rAF tween, matching the CSS transitions. */ -function easeStandard(t: number) { - // Solve the bezier for x = t, then return y. Control points: p1 = (0.4, 0), p2 = (0.2, 1). - const x1 = 0.4; - const y1 = 0; - const x2 = 0.2; - const y2 = 1; - const cx = 3 * x1; - const bx = 3 * (x2 - x1) - cx; - const ax = 1 - cx - bx; - const cy = 3 * y1; - const by = 3 * (y2 - y1) - cy; - const ay = 1 - cy - by; - const sampleX = (u: number) => ((ax * u + bx) * u + cx) * u; - const sampleY = (u: number) => ((ay * u + by) * u + cy) * u; - const sampleDerivativeX = (u: number) => (3 * ax * u + 2 * bx) * u + cx; - // Newton-Raphson to invert x(u) = t. - let u = t; - for (let i = 0; i < 6; i++) { - const x = sampleX(u) - t; - const dx = sampleDerivativeX(u); - if (Math.abs(x) < 1e-4 || Math.abs(dx) < 1e-6) break; - u -= x / dx; - } - return sampleY(clamp(u, 0, 1)); -} - type SideMenuUser = Pick< UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences" @@ -294,49 +157,14 @@ export function SideMenu({ organization, organizations, }: SideMenuProps) { + const borderRef = useRef(null); + const [showHeaderDivider, setShowHeaderDivider] = useState(false); const [isCollapsed, setIsCollapsed] = useState( user.dashboardPreferences.sideMenu?.isCollapsed ?? false ); - const [isDragging, setIsDragging] = useState(false); - // True during a click/⌘B/release-snap animation. With isDragging, marks any in-flight transition - // (the gutter is only reserved once settled — see `showReservedGutter`). - const [isAnimating, setIsAnimating] = useState(false); - // Direction of an in-flight drag, for the Free-plan banner slide. A drag that started expanded is a - // close (the banner tracks it down); one that started collapsed is an open (the banner stays hidden - // until fully open, then rises). Only meaningful while `isDragging`. - const [dragStartedCollapsed, setDragStartedCollapsed] = useState(false); - - // --- Resize state (see the module constants above) --- - const rootRef = useRef(null); - const rafRef = useRef(null); - // Mirror of `isCollapsed` for the drag handlers (outside React's render cycle; no stale closures). - const isCollapsedRef = useRef(isCollapsed); - // The last-committed expanded width; animation targets and re-expansion read from here. - const expandedWidthRef = useRef( - clamp(user.dashboardPreferences.sideMenu?.width ?? DEFAULT_WIDTH, DEFAULT_WIDTH, MAX_WIDTH) - ); - // Frozen first-paint width; never changes, so React never fights the imperative width writes. - const initialWidthRef = useRef( - (user.dashboardPreferences.sideMenu?.isCollapsed ?? false) - ? COLLAPSED_WIDTH - : expandedWidthRef.current - ); - const widthRef = useRef(initialWidthRef.current); - const progressRef = useRef((user.dashboardPreferences.sideMenu?.isCollapsed ?? false) ? 1 : 0); - // Frozen initial style (incl. CSS vars) so the SSR HTML has the right collapsed/expanded visuals - // (no pre-hydration flash). Stable identity, so React never rewrites it after writeVisual. - const initialStyleRef = useRef({ - width: initialWidthRef.current, - "--sm-collapse": String(progressRef.current), - "--sm-label-opacity": String(progressToLabelOpacity(progressRef.current)), - } as CSSProperties); - // Removes an in-flight drag's window listeners (set on pointerdown; cleared on finish/unmount). - const dragCleanupRef = useRef<(() => void) | null>(null); - const preferencesFetcher = useFetcher(); const pendingPreferencesRef = useRef<{ isCollapsed?: boolean; - width?: number; sectionId?: SideMenuSectionId; sectionCollapsed?: boolean; }>({}); @@ -353,7 +181,6 @@ export function SideMenu({ const persistSideMenuPreferences = useCallback( (data: { isCollapsed?: boolean; - width?: number; sectionId?: SideMenuSectionId; sectionCollapsed?: boolean; }) => { @@ -377,9 +204,6 @@ export function SideMenu({ if (pending.isCollapsed !== undefined) { formData.append("isCollapsed", String(pending.isCollapsed)); } - if (pending.width !== undefined) { - formData.append("width", String(pending.width)); - } if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) { formData.append("sectionId", pending.sectionId); formData.append("sectionCollapsed", String(pending.sectionCollapsed)); @@ -394,243 +218,41 @@ export function SideMenu({ [user.isImpersonating, preferencesFetcher] ); - // Flush routine in a ref so the unmount effect can have empty deps. `useFetcher` returns a fresh - // object each render, so depending on it would fire the cleanup (flushing the debounce) every - // render — and drags re-render constantly — instead of only on unmount. - const flushPendingPreferencesRef = useRef<() => void>(); - flushPendingPreferencesRef.current = () => { - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - } - if (user.isImpersonating) return; - const pending = pendingPreferencesRef.current; - const hasPendingChanges = - pending.isCollapsed !== undefined || - pending.width !== undefined || - (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined); - if (!hasPendingChanges) return; - - const formData = new FormData(); - if (pending.isCollapsed !== undefined) { - formData.append("isCollapsed", String(pending.isCollapsed)); - } - if (pending.width !== undefined) { - formData.append("width", String(pending.width)); - } - if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) { - formData.append("sectionId", pending.sectionId); - formData.append("sectionCollapsed", String(pending.sectionCollapsed)); - } - preferencesFetcher.submit(formData, { - method: "POST", - action: "/resources/preferences/sidemenu", - }); - pendingPreferencesRef.current = {}; - }; - - // Flush pending preferences on unmount. Empty deps so cleanup runs only on a real unmount - // (see flushPendingPreferencesRef). + // Flush pending preferences on unmount to avoid losing the last toggle useEffect(() => { - return () => flushPendingPreferencesRef.current?.(); - }, []); - - // Measure the reserved scrollbar-gutter width once and expose it as `--sm-sb-gutter` (the padding - // hands off to it seamlessly; platform-dependent, so it must be measured). A probe with the same - // scrollbar classes is measured so the value matches what that styling reserves. - useEffect(() => { - const el = rootRef.current; - if (!el) return; - const probe = document.createElement("div"); - probe.className = "scrollbar-gutter-stable scrollbar-thumb-on-hover"; - probe.style.cssText = - "position:absolute;top:-9999px;left:-9999px;width:100px;height:100px;overflow-y:auto;visibility:hidden;"; - document.body.appendChild(probe); - const gutter = probe.offsetWidth - probe.clientWidth; - document.body.removeChild(probe); - if (gutter > 0) el.style.setProperty("--sm-sb-gutter", `${gutter}px`); - }, []); - - // Write width + collapse vars straight to the DOM (no re-render) so drags stay smooth; all - // width-driven visuals (labels, headers, padding, dividers) read them. - const writeVisual = useCallback((width: number, progress: number) => { - widthRef.current = width; - progressRef.current = progress; - const el = rootRef.current; - if (!el) return; - el.style.width = `${width}px`; - el.style.setProperty("--sm-collapse", String(progress)); - el.style.setProperty("--sm-label-opacity", String(progressToLabelOpacity(progress))); - }, []); - - // Animate width + progress over COLLAPSE_ANIM_MS (toggle button, ⌘B shortcut, release-snap). - const animateTo = useCallback( - (targetWidth: number, targetProgress: number) => { - if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); - const startWidth = widthRef.current; - const startProgress = progressRef.current; - if (startWidth === targetWidth && startProgress === targetProgress) { - setIsAnimating(false); - return; - } - setIsAnimating(true); - const startTime = performance.now(); - const step = (now: number) => { - const t = clamp((now - startTime) / COLLAPSE_ANIM_MS, 0, 1); - const eased = easeStandard(t); - writeVisual( - startWidth + (targetWidth - startWidth) * eased, - startProgress + (targetProgress - startProgress) * eased - ); - if (t < 1) { - rafRef.current = requestAnimationFrame(step); - } else { - rafRef.current = null; - writeVisual(targetWidth, targetProgress); - setIsAnimating(false); - } - }; - rafRef.current = requestAnimationFrame(step); - }, - [writeVisual] - ); - - // Collapse/expand to a resting state and remember it. - const applyCollapsed = useCallback( - (next: boolean) => { - isCollapsedRef.current = next; - setIsCollapsed(next); - persistSideMenuPreferences({ isCollapsed: next }); - animateTo(next ? COLLAPSED_WIDTH : expandedWidthRef.current, next ? 1 : 0); - }, - [animateTo, persistSideMenuPreferences] - ); - - const handleToggleCollapsed = useCallback(() => { - applyCollapsed(!isCollapsedRef.current); - }, [applyCollapsed]); - - // Drag runs on window-level listeners so releasing anywhere finalizes it. (Pointer capture alone - // was unreliable: if the browser drops it mid-drag, the release never fires and the menu strands.) - const onHandlePointerDown = useCallback( - (e: ReactPointerEvent) => { - if (e.button !== 0) return; - e.preventDefault(); - // Capture just quiets hover states while dragging; the drag doesn't depend on it. - try { - e.currentTarget.setPointerCapture(e.pointerId); - } catch {} - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current); - rafRef.current = null; + return () => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); } - // Grabbing the handle interrupts any in-flight collapse/expand; clear the flag so a drag that - // rests via writeVisual (not animateTo) doesn't strand it true and keep the gutter hidden. - setIsAnimating(false); - // Never allow two concurrent drags. - dragCleanupRef.current?.(); - - const drag = { - startX: e.clientX, - startWidth: rootRef.current?.getBoundingClientRect().width ?? widthRef.current, - startedCollapsed: isCollapsedRef.current, - didDrag: false, - }; - - const cleanup = () => { - window.removeEventListener("pointermove", onMove); - window.removeEventListener("pointerup", onUp); - window.removeEventListener("pointercancel", onCancel); - window.removeEventListener("blur", onCancel); - document.body.style.userSelect = ""; - document.body.style.cursor = ""; - dragCleanupRef.current = null; - }; - - const onMove = (ev: PointerEvent) => { - const dx = ev.clientX - drag.startX; - if (!drag.didDrag) { - // Ignore tiny movement so a click still reads as a click (toggle), not a drag. - if (Math.abs(dx) < DRAG_CLICK_THRESHOLD) return; - drag.didDrag = true; - setIsDragging(true); - setDragStartedCollapsed(drag.startedCollapsed); - document.body.style.userSelect = "none"; - document.body.style.cursor = "col-resize"; - } - const width = clamp(drag.startWidth + dx, COLLAPSED_WIDTH, MAX_WIDTH); - writeVisual(width, widthToProgress(width)); - }; - - const onUp = () => { - cleanup(); - setIsDragging(false); + if (user.isImpersonating) return; + const pending = pendingPreferencesRef.current; + const hasPendingChanges = + pending.isCollapsed !== undefined || + (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined); - // A press with no meaningful drag toggles the menu. - if (!drag.didDrag) { - applyCollapsed(!isCollapsedRef.current); - return; + if (hasPendingChanges) { + const formData = new FormData(); + if (pending.isCollapsed !== undefined) { + formData.append("isCollapsed", String(pending.isCollapsed)); } - - const width = widthRef.current; - // A drag that started collapsed is an opening gesture: flip the snap zone so an early - // release keeps opening. - const snapThreshold = drag.startedCollapsed - ? EXPAND_SNAP_THRESHOLD - : COLLAPSE_SNAP_THRESHOLD; - if (width >= DEFAULT_WIDTH) { - // Rest at the dragged width. - const rounded = Math.round(width); - expandedWidthRef.current = rounded; - isCollapsedRef.current = false; - setIsCollapsed(false); - persistSideMenuPreferences({ isCollapsed: false, width: rounded }); - writeVisual(rounded, 0); - } else if (widthToProgress(width) <= snapThreshold) { - // Released near the default width — spring back open. - expandedWidthRef.current = DEFAULT_WIDTH; - isCollapsedRef.current = false; - setIsCollapsed(false); - persistSideMenuPreferences({ isCollapsed: false, width: DEFAULT_WIDTH }); - animateTo(DEFAULT_WIDTH, 0); - } else { - // Released deeper in (or over-dragged past min width) — collapse the rest of the way. - isCollapsedRef.current = true; - setIsCollapsed(true); - persistSideMenuPreferences({ isCollapsed: true }); - animateTo(COLLAPSED_WIDTH, 1); + if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) { + formData.append("sectionId", pending.sectionId); + formData.append("sectionCollapsed", String(pending.sectionCollapsed)); } - }; - - const onCancel = () => { - cleanup(); - setIsDragging(false); - if (!drag.didDrag) return; - // Settle back to the current resting state. - animateTo( - isCollapsedRef.current ? COLLAPSED_WIDTH : expandedWidthRef.current, - isCollapsedRef.current ? 1 : 0 - ); - }; - - window.addEventListener("pointermove", onMove); - window.addEventListener("pointerup", onUp); - window.addEventListener("pointercancel", onCancel); - window.addEventListener("blur", onCancel); - dragCleanupRef.current = cleanup; - }, - [animateTo, applyCollapsed, persistSideMenuPreferences, writeVisual] - ); - - // Keep the drag handlers' collapsed mirror in sync; tear down any in-flight animation/drag on unmount. - useEffect(() => { - isCollapsedRef.current = isCollapsed; - }, [isCollapsed]); - useEffect(() => { - return () => { - if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); - dragCleanupRef.current?.(); + preferencesFetcher.submit(formData, { + method: "POST", + action: "/resources/preferences/sidemenu", + }); + pendingPreferencesRef.current = {}; + } }; - }, []); + }, [preferencesFetcher, user.isImpersonating]); + + const handleToggleCollapsed = () => { + const newIsCollapsed = !isCollapsed; + setIsCollapsed(newIsCollapsed); + persistSideMenuPreferences({ isCollapsed: newIsCollapsed }); + }; /** Generic handler for any collapsible section - just pass the section ID */ const handleSectionToggle = useCallback( @@ -645,84 +267,95 @@ export function SideMenu({ action: handleToggleCollapsed, }); - // Reserve the scrollbar gutter only when fully settled open (stops the list shifting as it starts/ - // stops overflowing). Dropped mid-transition so the right padding can animate instead of a fixed - // gutter snapping away (see SIDE_MENU_SCROLL_PAD_RIGHT). - const showReservedGutter = !isCollapsed && !isDragging && !isAnimating; + useEffect(() => { + const handleScroll = () => { + if (borderRef.current) { + const shouldShowHeaderDivider = borderRef.current.scrollTop > 1; + if (showHeaderDivider !== shouldShowHeaderDivider) { + setShowHeaderDivider(shouldShowHeaderDivider); + } + } + }; - // Free-plan banner slide (see FreePlanBanner). "tracking" = a close in progress, so the banner - // follows --sm-collapse down and is gone by the halfway point; "hidden" = collapsed or an open in - // progress (stays off-screen); "shown" = settled open, so it rises back up. Drag and click/⌘B share - // this: a click drives --sm-collapse through the same animation, with isAnimating standing in for - // isDragging and isCollapsed giving the direction. - const bannerPhase: "shown" | "tracking" | "hidden" = isDragging - ? dragStartedCollapsed - ? "hidden" - : "tracking" - : isAnimating - ? isCollapsed - ? "tracking" - : "hidden" - : isCollapsed - ? "hidden" - : "shown"; + borderRef.current?.addEventListener("scroll", handleScroll); + return () => borderRef.current?.removeEventListener("scroll", handleScroll); + }, [showHeaderDivider]); return (
- -
-
-
- +
+
+
+
- - - + {isAdmin && !user.isImpersonating ? ( + + + + + + + + Admin dashboard + + + + + ) : isAdmin && user.isImpersonating ? ( + + + + ) : null}
-
- -
- +
+
{environment.type === "DEVELOPMENT" && project.engine === "V2" && ( - + @@ -732,13 +365,6 @@ export function SideMenu({
-
-
-
-
+
+
@@ -1082,17 +699,16 @@ export function SideMenu({ > {isFreeUser && ( - + + + )}
@@ -1148,7 +764,7 @@ function V3DeprecationPanel({ > + } @@ -1160,7 +776,7 @@ function V3DeprecationPanel({ />
- + @@ -1187,7 +803,7 @@ function V3DeprecationContent() { fullWidth TrailingIcon={ArrowTopRightOnSquareIcon} trailingIconClassName="text-amber-300" - className="border-amber-500/30 bg-amber-500/15 hover:border-amber-500/50! hover:bg-amber-500/25!" + className="border-amber-500/30 bg-amber-500/15 hover:!border-amber-500/50 hover:!bg-amber-500/25" > View migration guide @@ -1195,34 +811,30 @@ function V3DeprecationContent() { ); } -function OrgSelector({ +function ProjectSelector({ + project, organization, organizations, + user, isCollapsed = false, - isDragging = false, - isAdmin, - isImpersonating, }: { + project: SideMenuProject; organization: MatchedOrganization; organizations: MatchedOrganization[]; + user: SideMenuUser; isCollapsed?: boolean; - /** True while the menu is being drag-resized; keeps the row in its expanded arrangement. */ - isDragging?: boolean; - /** Account context, only used to render the collapsed-rail "Account" submenu (see below). */ - isAdmin: boolean; - isImpersonating: boolean; }) { const currentPlan = useCurrentPlan(); const [isOrgMenuOpen, setOrgMenuOpen] = useState(false); const navigation = useNavigation(); const { isManagedCloud } = useFeatures(); - const featureFlags = useFeatureFlags(); - const showSelfServe = useShowSelfServe(); - const isUsingRbacPlugin = useIsUsingRbacPlugin(); - const isUsingSsoPlugin = useIsUsingSsoPlugin(); - const isPaying = currentPlan?.v3Subscription?.isPaying === true; - const planTitle = currentPlan?.v3Subscription?.plan?.title; + let plan: string | undefined = undefined; + if (currentPlan?.v3Subscription?.isPaying === false) { + plan = "Free"; + } else if (currentPlan?.v3Subscription?.isPaying === true) { + plan = currentPlan.v3Subscription.plan?.title; + } useEffect(() => { setOrgMenuOpen(false); @@ -1234,368 +846,106 @@ function OrgSelector({ button={ - - {organization.title} + + + {project.name ?? "Select a project"} - + } - content={organization.title} + content={`${organization.title} / ${project.name ?? "Select a project"}`} side="right" sideOffset={8} hidden={!isCollapsed} - buttonClassName="h-8!" + buttonClassName="!h-8" asChild - tabbable disableHoverableContent /> - {!isCollapsed && } -
- - {isManagedCloud && ( - - )} - {isManagedCloud && ( - - Billing - {isPaying && planTitle ? {planTitle} : null} -
- } - icon={CreditCardIcon} - leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} - className={SIDE_MENU_POPOVER_ITEM_LABEL} - /> - )} - {isManagedCloud && showSelfServe && ( - - )} - - {featureFlags.hasPrivateConnections && ( - - )} - {isUsingRbacPlugin && ( - - )} - {isUsingSsoPlugin && ( - - )} - -
-
- {organizations.length > 1 ? ( - - ) : ( - - )} -
- {/* Collapsed: the account button is hidden, so surface Account as a submenu here (the only - always-reachable menu on the rail). */} - {isCollapsed && ( -
- }> - - +
+
+ + +
+ +
+ +
+ {organization.title} +
+ {plan && ( + + {plan} plan + + )} + {simplur`${organization.membersCount} member[|s]`} +
+
- )} - - - ); -} - -/** - * Account menu entries, shared by the standalone account popover (expanded rail) and the "Account" - * submenu in the org popover (collapsed rail). - */ -function AccountMenuItems({ - isAdmin, - isImpersonating, -}: { - isAdmin: boolean; - isImpersonating: boolean; -}) { - const submit = useSubmit(); - const stopImpersonating = () => - submit(null, { action: "/resources/impersonation", method: "delete" }); - - return ( - <> - {isAdmin && ( -
- {isImpersonating ? ( - - Stop impersonating - -
- } - icon={UserCrossIcon} - onClick={stopImpersonating} - leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, IMPERSONATION_ACCENT.text)} - className={SIDE_MENU_POPOVER_ITEM_LABEL} - /> - ) : ( - - Admin dashboard - -
- } - icon={HomeIcon} - leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} - className={SIDE_MENU_POPOVER_ITEM_LABEL} - /> - )} -
- )} -
- - - -
-
- -
- - ); -} - -function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImpersonating: boolean }) { - const [isOpen, setIsOpen] = useState(false); - const navigation = useNavigation(); - - useEffect(() => { - setIsOpen(false); - }, [navigation.location?.pathname]); - - // The admin shortcut lives in so it works everywhere, not just where this menu is. - return ( - setIsOpen(open)} open={isOpen}> - - - - } - content="Account" - side="bottom" - sideOffset={8} - asChild - tabbable - disableHoverableContent - /> - - - - - - ); -} - -function ProjectSelector({ - project, - organization, - environment, - isCollapsed = false, - isDragging = false, - className, -}: { - project: SideMenuProject; - organization: MatchedOrganization; - environment: SideMenuEnvironment; - isCollapsed?: boolean; - /** True while the menu is being drag-resized; keeps the row in its expanded arrangement. */ - isDragging?: boolean; - className?: string; -}) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const navigation = useNavigation(); - - useEffect(() => { - setIsMenuOpen(false); - }, [navigation.location?.pathname]); - - return ( - setIsMenuOpen(open)} open={isMenuOpen}> - - - - - - {project.name ?? "Select a project"} - - - - + - - - - } - content={project.name ?? "Select a project"} - side="right" - sideOffset={8} - hidden={!isCollapsed} - buttonClassName="h-8!" - asChild - tabbable - disableHoverableContent - /> - -
- - + + Settings + + {isManagedCloud && ( + + + Usage + + )} +
-
+
{organization.projects.map((p) => { const isSelected = p.id === project.id; return ( @@ -1609,264 +959,206 @@ function ProjectSelector({ } isSelected={isSelected} icon={isSelected ? FolderOpenIcon : FolderClosedIcon} - leadingIconClassName="h-5 w-5 text-indigo-500" - className={SIDE_MENU_POPOVER_ITEM_LABEL} + leadingIconClassName="text-indigo-500" /> ); })} + +
+
+ {organizations.length > 1 ? ( + + ) : ( + + )} +
+
+ +
+
+
); } -/** - * Hover-expandable submenu row for side-menu popovers (Account, Switch organization, Integrations): - * a menu item with a trailing chevron that reveals `children` in a popover to the right, with a - * short close delay so the pointer can cross the gap. - */ -function SideMenuPopoverSubMenu({ - title, - icon, - leadingIconClassName, - children, +function SwitchOrganizations({ + organizations, + organization, }: { - title: string; - icon: RenderIcon; - leadingIconClassName?: string; - children: ReactNode; + organizations: MatchedOrganization[]; + organization: MatchedOrganization; }) { const navigation = useNavigation(); - const [isOpen, setIsOpen] = useState(false); + const [isMenuOpen, setMenuOpen] = useState(false); const timeoutRef = useRef(null); + // Clear timeout on unmount useEffect(() => { return () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } }; }, []); - // Close the submenu on navigation (the parent popover closes too). useEffect(() => { - setIsOpen(false); + setMenuOpen(false); }, [navigation.location?.pathname]); - const openNow = () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - setIsOpen(true); + const handleMouseEnter = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + setMenuOpen(true); }; - const closeSoon = () => { - // Small delay before closing so the pointer can move onto the content. - timeoutRef.current = setTimeout(() => setIsOpen(false), 150); + + const handleMouseLeave = () => { + // Small delay before closing to allow moving to the content + timeoutRef.current = setTimeout(() => { + setMenuOpen(false); + }, 150); }; return ( - setIsOpen(open)} open={isOpen}> -
+ setMenuOpen(open)} open={isMenuOpen}> +
- {title} + Switch organization - {children} +
+ {organizations.map((org) => ( + } + leadingIconClassName="text-text-dimmed" + isSelected={org.id === organization.id} + /> + ))} +
+
+ +
); } -function SwitchOrganizations({ - organizations, - organization, -}: { - organizations: MatchedOrganization[]; - organization: MatchedOrganization; -}) { - return ( - -
- -
-
- {organizations.map((org) => ( - } - leadingIconClassName="text-text-dimmed" - className={SIDE_MENU_POPOVER_ITEM_LABEL} - isSelected={org.id === organization.id} - /> - ))} -
-
- ); -} - -function Integrations({ organization }: { organization: MatchedOrganization }) { +function SelectorDivider() { return ( - -
- - -
-
+ + + ); } -/** - * Fades out and collapses to 0 width via the menu's `--sm-label-opacity` variable, tracking a drag - * in real time (no CSS opacity transition — it would lag the per-frame variable writes). - */ +/** Helper component that fades out but preserves width (collapses to 0 width) */ function CollapsibleElement({ - isDragging = false, + isCollapsed, children, className, }: { - /** Only blocks clicks on the fading button mid-drag; the hiding is width+opacity below. */ - isDragging?: boolean; + isCollapsed: boolean; children: ReactNode; className?: string; }) { - // Width AND opacity follow --sm-label-opacity: opacity alone would leave the invisible button - // holding 32px of row width, pushing the primary item's clip edge into its icon ("masked" mid-drag). - // Shrinking width on the same curve hands that space back. No CSS transition (it would lag the writes). return (
{children}
); } -/** - * The Free-plan banner at the foot of the menu. On close it doesn't collapse or slide on its own: its - * reserved height collapses (tracking --sm-collapse, gone by the halfway point) and, because the bottom - * section is pinned to the bottom, that pushes the whole section (Help & Feedback + this banner) down so - * the full-height banner slides off the bottom edge. On open it lags, waiting for the settled "shown" - * phase, then rises back up via translateY. Height is measured so the reclaimed space matches the banner. - */ -function FreePlanBanner({ - to, - percentage, - phase, +/** Helper component that fades out and collapses height completely */ +function CollapsibleHeight({ + isCollapsed, + children, + className, }: { - to: string; - percentage: number; - phase: "shown" | "tracking" | "hidden"; + isCollapsed: boolean; + children: ReactNode; + className?: string; }) { - const contentRef = useRef(null); - const [height, setHeight] = useState(0); - - useEffect(() => { - const el = contentRef.current; - if (!el) return; - const measure = () => setHeight(el.offsetHeight); - measure(); - const ro = new ResizeObserver(measure); - ro.observe(el); - return () => ro.disconnect(); - }, []); - - // Close progress, doubled + clamped so the banner is fully gone by the time the menu is halfway shut. - const closeProgress = "min(var(--sm-collapse, 0) * 2, 1)"; - // Slide a little past its own height to clear the section padding + the viewport edge. - const offset = height + 24; - - const maxHeight = - phase === "shown" - ? height - ? `${height}px` - : "none" - : phase === "hidden" - ? "0px" - : `calc((1 - ${closeProgress}) * ${height}px)`; - // On close the banner no longer slides itself: its reserved height collapses (maxHeight) and, because - // the bottom section is pinned to the bottom, that drops Help & Feedback down while the full-height - // banner overflows off the bottom edge. Only the pop-up-from-hidden rise uses translateY, so "hidden" - // parks it below the edge; "shown" and "tracking" both sit at 0. - const translateY = phase === "hidden" ? `${offset}px` : "0px"; - // Fade out as it slides off (tracking --sm-collapse) and fade back in on the settled-open rise. - const opacity = phase === "shown" ? 1 : phase === "hidden" ? 0 : `calc(1 - ${closeProgress})`; - return (
-
- -
+
{children}
); } function HelpAndAI({ isCollapsed, - isDragging, organizationId, projectId, - onToggleCollapsed, }: { isCollapsed: boolean; - isDragging: boolean; organizationId: string; projectId: string; - onToggleCollapsed: () => void; }) { return ( @@ -1882,77 +1174,126 @@ function HelpAndAI({ organizationId={organizationId} projectId={projectId} /> - +
); } -function CollapseMenuButton({ +function AnimatedChevron({ + isHovering, isCollapsed, - isDragging = false, - onToggle, }: { + isHovering: boolean; isCollapsed: boolean; - isDragging?: boolean; - onToggle: () => void; }) { - const [isHovering, setIsHovering] = useState(false); + // When hovering and expanded: left chevron (pointing left to collapse) + // When hovering and collapsed: right chevron (pointing right to expand) + // When not hovering: straight vertical line + + const getRotation = () => { + if (!isHovering) return { top: 0, bottom: 0 }; + if (isCollapsed) { + // Right chevron + return { top: -17, bottom: 17 }; + } else { + // Left chevron + return { top: 17, bottom: -17 }; + } + }; + + const { top, bottom } = getRotation(); + + // Calculate horizontal offset to keep chevron centered when rotated + // Left chevron: translate left (-1.5px) + // Right chevron: translate right (+1.5px) + const getTranslateX = () => { + if (!isHovering) return 0; + return isCollapsed ? 1.5 : -1.5; + }; return ( - // Shrink-and-fade only while dragging CLOSED, where this sits beside Help & Feedback and would - // overlap it as the row narrows. Dragging OPEN it stays put: collapsed, this IS the expand - // affordance, and the 0->1 variable would make the icon grow from nothing. At rest: natural size. -
+ {/* Top segment */} + + {/* Bottom segment */} + + + ); +} + +function CollapseToggle({ isCollapsed, onToggle }: { isCollapsed: boolean; onToggle: () => void }) { + const [isHovering, setIsHovering] = useState(false); + + return ( +
+ {/* Vertical line to mask the side menu border */} +
- + - setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} + className={cn( + "group flex h-12 w-6 items-center justify-center rounded-md text-text-dimmed transition-all duration-200 focus-custom", + isHovering + ? "border border-grid-bright bg-background-bright shadow-md hover:bg-charcoal-750 hover:text-text-bright" + : "border border-transparent bg-transparent" + )} > - - + + - + {isCollapsed ? "Expand" : "Collapse"} @@ -1964,71 +1305,3 @@ function CollapseMenuButton({
); } - -/** - * Resize affordance straddling the menu's right border: hover reveals an indigo line, drag resizes, - * click toggles collapsed/expanded, and the tooltip follows the pointer's Y. The strip extends 4px - * past the edge, so the menu root deliberately has no overflow-hidden (only its inner grid does). - */ -function ResizeHandle({ - isCollapsed, - isDragging, - onPointerDown, -}: { - isCollapsed: boolean; - isDragging: boolean; - onPointerDown: (e: ReactPointerEvent) => void; -}) { - // Fully controlled so open never flips controlled/uncontrolled mid-interaction; open requests - // during a drag are dropped. - const [isTooltipOpen, setTooltipOpen] = useState(false); - // Pointer Y within the strip — anchors the tooltip beside the cursor, not the strip's center. - const [anchorY, setAnchorY] = useState(0); - - return ( - - setTooltipOpen(open && !isDragging)} - > - -
{ - if (isDragging) return; - setAnchorY(Math.round(e.clientY - e.currentTarget.getBoundingClientRect().top)); - }} - className="group/resize absolute inset-y-0 -right-1 z-30 w-2 cursor-col-resize touch-none" - > -
-
- - - Drag to resize - - {isCollapsed ? "Click to expand" : "Click to collapse"} - - - - - - - - - ); -} diff --git a/apps/webapp/app/components/navigation/SideMenuHeader.tsx b/apps/webapp/app/components/navigation/SideMenuHeader.tsx index 8ccbfa7de70..2ac6abf120f 100644 --- a/apps/webapp/app/components/navigation/SideMenuHeader.tsx +++ b/apps/webapp/app/components/navigation/SideMenuHeader.tsx @@ -40,17 +40,24 @@ export function SideMenuHeader({

{visiblePart} {fadingPart && ( - // --sm-label-opacity morphs "Project" → "Proj" as the menu narrows (unset elsewhere → 1). - {fadingPart} + + {fadingPart} + )}

{children !== undefined ? ( setHeaderMenuOpen(open)} open={isHeaderMenuOpen}> - +
{children}
diff --git a/apps/webapp/app/components/onboarding/TechnologyPicker.tsx b/apps/webapp/app/components/onboarding/TechnologyPicker.tsx index 7236f9fa8b9..169056573f4 100644 --- a/apps/webapp/app/components/onboarding/TechnologyPicker.tsx +++ b/apps/webapp/app/components/onboarding/TechnologyPicker.tsx @@ -281,7 +281,7 @@ export function TechnologyPicker({ }} virtualFocus > - +
Select your technologies… @@ -293,22 +293,22 @@ export function TechnologyPicker({ gutter={5} unmountOnHide className={cn( - "z-50 flex flex-col overflow-clip rounded border border-grid-bright bg-background-bright shadow-md outline-hidden animate-in fade-in-40", + "z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40", "min-w-[max(180px,var(--popover-anchor-width))]", "max-w-[min(480px,var(--popover-available-width))]", "max-h-[min(400px,var(--popover-available-height))]" )} > -
+
- + {filteredOptions.map((option) => ( -
+
{showOtherInput ? ( -
+
setOtherInputValue(e.target.value)} onKeyDown={handleOtherKeyDown} placeholder="Type and press Enter to add" - className="flex-1 border-none bg-transparent pl-2 text-2sm text-text-bright shadow-none outline-hidden ring-0 placeholder:text-text-dimmed focus:border-none focus:outline-hidden focus:ring-0" + className="flex-1 border-none bg-transparent pl-2 text-2sm text-text-bright shadow-none outline-none ring-0 placeholder:text-text-dimmed focus:border-none focus:outline-none focus:ring-0" autoFocus /> (data: TItem[] | Section[]): data is Section = TItemOrSection extends Section ? U : TItemOrSection; -export interface SelectProps extends Omit< - Ariakit.SelectProps, - "children" -> { +export interface SelectProps + extends Omit { icon?: React.ReactNode; text?: React.ReactNode | ((value: TValue) => React.ReactNode); placeholder?: React.ReactNode; @@ -110,8 +108,6 @@ export interface SelectProps extends Om allowItemShortcuts?: boolean; clearSearchOnSelection?: boolean; dropdownIcon?: boolean | React.ReactNode; - popoverClassName?: string; - placement?: Ariakit.SelectProviderProps["placement"]; } export function Select({ @@ -137,8 +133,6 @@ export function Select({ disabled, clearSearchOnSelection = true, dropdownIcon, - popoverClassName, - placement, ...props }: SelectProps) { const [searchValue, setSearchValue] = useState(""); @@ -195,7 +189,6 @@ export function Select({ open={open} setOpen={setOpen} virtualFocus={searchable} - placement={placement} value={value} setValue={(v) => { if (clearSearchOnSelection) { @@ -220,7 +213,7 @@ export function Select({ dropdownIcon={dropdownIcon} {...props} /> - + {!searchable && showHeading && heading && {heading}} />} {searchable && } @@ -228,9 +221,11 @@ export function Select({ {typeof children === "function" ? ( matches.length > 0 ? ( isSection(matches) ? ( - - {children} - + ) : ( children(matches as ItemFromSection[], { shortcutsEnabled: enableItemShortcuts, @@ -319,10 +314,10 @@ export function SelectTrigger({ {(value) => ( <> {typeof value === "string" - ? (value ?? placeholder) + ? value ?? placeholder : value.length === 0 - ? placeholder - : value.join(", ")} + ? placeholder + : value.join(", ")} )} @@ -363,7 +358,7 @@ export function SelectTrigger({ {showTooltip && (
{tooltipTitle ?? "Open menu"} @@ -381,9 +376,8 @@ export function SelectTrigger({ ); } -export interface SelectProviderProps< - TValue extends string | string[], -> extends Ariakit.SelectProviderProps {} +export interface SelectProviderProps + extends Ariakit.SelectProviderProps {} export function SelectProvider( props: SelectProviderProps ) { @@ -442,7 +436,7 @@ export function SelectList(props: SelectListProps) { @@ -454,9 +448,6 @@ export interface SelectItemProps extends Ariakit.SelectItemProps { checkIcon?: React.ReactNode; checkPosition?: "left" | "right"; shortcut?: ShortcutDefinition; - // Allow the item to grow to multiple lines and wrap its content instead of - // being locked to a single truncated line. Use for options with a subtitle. - wrap?: boolean; } const selectItemClasses = @@ -469,7 +460,6 @@ export function SelectItem({ checkIcon = , checkPosition = "right", shortcut, - wrap = false, ...props }: SelectItemProps) { const combobox = Ariakit.useComboboxContext(); @@ -479,7 +469,9 @@ export function SelectItem({ // SelectLinkItem (which uses render to swap in a ) get their // render prop silently dropped, which is why those rows looked // clickable but didn't navigate. - const render = combobox ? : props.render; + const render = combobox + ? + : props.render; const ref = React.useRef(null); const select = Ariakit.useSelectContext(); const selectValue = select?.useState("value"); @@ -517,22 +509,17 @@ export function SelectItem({ >
{checkPosition === "left" && } {icon} -
- {props.children || props.value} -
+
{props.children || props.value}
{checkPosition === "right" && checkIcon} {shortcut && ( @@ -617,7 +604,7 @@ export function shortcutFromIndex( export interface SelectSeparatorProps extends React.ComponentProps<"div"> {} export function SelectSeparator(props: SelectSeparatorProps) { - return
; + return
; } export interface SelectGroupProps extends Ariakit.SelectGroupProps {} @@ -633,7 +620,7 @@ export function SelectGroupLabel(props: SelectGroupLabelProps) { @@ -643,7 +630,7 @@ export function SelectGroupLabel(props: SelectGroupLabelProps) { export interface SelectHeadingProps extends Ariakit.SelectHeadingProps {} export function SelectHeading({ render, ...props }: SelectHeadingProps) { return ( -
+
); @@ -663,11 +650,11 @@ export function SelectPopover({ shift={shift} unmountOnHide={unmountOnHide} className={cn( - "z-50 flex flex-col overflow-clip rounded border border-grid-bright bg-background-bright shadow-md outline-hidden animate-in fade-in-40", + "z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40", "min-w-[max(180px,var(--popover-anchor-width))]", "max-w-[min(480px,var(--popover-available-width))]", "max-h-[min(600px,var(--popover-available-height))]", - "origin-(--popover-transform-origin)", + "origin-[var(--popover-transform-origin)]", className )} {...props} @@ -692,11 +679,11 @@ export function ComboBox({ ...props }: ComboBoxProps) { return ( -
+
} - className="flex-1 bg-transparent text-xs text-text-dimmed outline-hidden" + className="flex-1 bg-transparent text-xs text-text-dimmed outline-none" {...props} /> {shortcut && ( diff --git a/apps/webapp/app/components/primitives/Sheet.tsx b/apps/webapp/app/components/primitives/Sheet.tsx index b7376245f4c..3c77229aa92 100644 --- a/apps/webapp/app/components/primitives/Sheet.tsx +++ b/apps/webapp/app/components/primitives/Sheet.tsx @@ -25,7 +25,8 @@ const portalVariants = cva("fixed inset-0 z-50 flex", { }); interface SheetPortalProps - extends SheetPrimitive.DialogPortalProps, VariantProps {} + extends SheetPrimitive.DialogPortalProps, + VariantProps {} const SheetPortal = ({ position, children, ...props }: SheetPortalProps) => ( @@ -138,8 +139,7 @@ const sheetVariants = cva( ); export interface DialogContentProps - extends - React.ComponentPropsWithoutRef, + extends React.ComponentPropsWithoutRef, VariantProps {} const SheetContent = React.forwardRef< @@ -155,7 +155,7 @@ const SheetContent = React.forwardRef< >
- + Close @@ -171,7 +171,7 @@ SheetContent.displayName = SheetPrimitive.Content.displayName; export const SheetBody = ({ className, ...props }: React.HTMLAttributes) => (
= { @@ -87,7 +92,7 @@ export function TreeView({ } }} className={cn( - "w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control focus-within:outline-hidden", + "w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-gray-300 focus-within:outline-none", parentClassName )} layoutScroll @@ -227,10 +232,7 @@ export function useTree({ if (selectedId === undefined) { dispatch({ type: "DESELECT_ALL_NODES" }); } else { - dispatch({ - type: "SELECT_NODE", - payload: { id: selectedId, scrollToNode: false, scrollToNodeFn }, - }); + dispatch({ type: "SELECT_NODE", payload: { id: selectedId, scrollToNode: false, scrollToNodeFn } }); } } }, [selectedId]); @@ -621,13 +623,10 @@ export function createTreeFromFlatItems( rootId: string ): Tree | undefined { // Index items by id - const indexedItems: { [id: string]: Tree } = withoutChildren.reduce( - (acc, item) => { - acc[item.id] = { id: item.id, runId: item.runId, data: item.data, children: [] }; - return acc; - }, - {} as { [id: string]: Tree } - ); + const indexedItems: { [id: string]: Tree } = withoutChildren.reduce((acc, item) => { + acc[item.id] = { id: item.id, runId: item.runId, data: item.data, children: [] }; + return acc; + }, {} as { [id: string]: Tree }); // Add items to parent's children array withoutChildren.forEach((item) => { diff --git a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx index b5c562e0f70..daaa99e20a2 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx @@ -61,8 +61,7 @@ export function ChartLegendCompound({ const totals = useSeriesTotal(aggregation); // Derive the effective label from the aggregation type when no explicit label is provided - const effectiveTotalLabel = - totalLabel ?? (aggregation ? aggregationLabels[aggregation] : "Total"); + const effectiveTotalLabel = totalLabel ?? (aggregation ? aggregationLabels[aggregation] : "Total"); // Calculate grand total by aggregating across all per-series values const grandTotal = useMemo(() => { @@ -84,7 +83,9 @@ export function ChartLegendCompound({ const rawValues = dataKeys.map((key) => dataRow[key]); - const values = rawValues.filter((v): v is number => v != null).map((v) => Number(v) || 0); + const values = rawValues + .filter((v): v is number => v != null) + .map((v) => Number(v) || 0); // All null → gap-filled point, return null to show dash if (values.length === 0) return null; @@ -169,11 +170,7 @@ export function ChartLegendCompound({ return (
{/* Total row */}
{/* Separator */} -
+
{/* Legend items - scrollable when scrollable prop is true */}
{legendItems.visible.map((item) => { @@ -251,7 +248,7 @@ export function ChartLegendCompound({ content={item.label} side="top" disableHoverableContent - className="max-w-xs wrap-break-word" + className="max-w-xs break-words" buttonClassName="cursor-default min-w-0" />
-
+
{remainingCount} more…
View all @@ -332,12 +329,7 @@ type HoveredHiddenItemRowProps = { valueFormatter?: (value: number) => string; }; -function HoveredHiddenItemRow({ - item, - value, - remainingCount, - valueFormatter, -}: HoveredHiddenItemRowProps) { +function HoveredHiddenItemRow({ item, value, remainingCount, valueFormatter }: HoveredHiddenItemRowProps) { return (
{/* Active highlight background */} diff --git a/apps/webapp/app/components/primitives/charts/ChartZoom.tsx b/apps/webapp/app/components/primitives/charts/ChartZoom.tsx index 375a133f445..f8a40e34a15 100644 --- a/apps/webapp/app/components/primitives/charts/ChartZoom.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartZoom.tsx @@ -127,7 +127,7 @@ export function ZoomTooltip({ "absolute whitespace-nowrap rounded border px-2 py-1 text-xxs tabular-nums", invalidSelection ? "border-amber-800 bg-amber-950 text-amber-400" - : "border-blue-800 bg-[#1B2334] text-blue-400" + : "border-blue-800 bg-blue-50 text-blue-400" )} style={{ left: coordinate?.x, @@ -141,7 +141,7 @@ export function ZoomTooltip({ "absolute top-[-5px] left-1/2 h-2 w-2 -translate-x-1/2 rotate-45", invalidSelection ? "border-l border-t border-amber-800 bg-amber-950" - : "border-l border-t border-blue-800 bg-[#1B2334]" + : "border-l border-t border-blue-800 bg-blue-50" )} />
diff --git a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx index 86747f9e277..e729675cedd 100644 --- a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx +++ b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx @@ -1,15 +1,12 @@ -import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react"; -import { parseWithZod } from "@conform-to/zod"; -import { RectangleStackIcon } from "@heroicons/react/20/solid"; +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; import { DialogClose } from "@radix-ui/react-dialog"; import { Form, useActionData, useNavigation, useParams, useSubmit } from "@remix-run/react"; -import { MachinePresetName } from "@trigger.dev/core/v3"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type UseDataFunctionReturn, useTypedFetcher } from "remix-typedjson"; import { TaskIcon } from "~/assets/icons/TaskIcon"; import { JSONEditor } from "~/components/code/JSONEditor"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; -import { Badge } from "~/components/primitives/Badge"; import { Button } from "~/components/primitives/Buttons"; import { DialogContent, DialogHeader } from "~/components/primitives/Dialog"; import { DurationPicker } from "~/components/primitives/DurationPicker"; @@ -20,6 +17,7 @@ import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues"; import { ResizableHandle, ResizablePanel, @@ -29,12 +27,15 @@ import { Select, SelectItem } from "~/components/primitives/Select"; import { Spinner, SpinnerWhite } from "~/components/primitives/Spinner"; import { TabButton, TabContainer } from "~/components/primitives/Tabs"; import { TextLink } from "~/components/primitives/TextLink"; -import { InfoIconTooltip } from "~/components/primitives/Tooltip"; -import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues"; import { type loader } from "~/routes/resources.taskruns.$runParam.replay"; import { docsPath } from "~/utils/pathBuilder"; import { ReplayRunData } from "~/v3/replayTask"; +import { RectangleStackIcon } from "@heroicons/react/20/solid"; +import { Badge } from "~/components/primitives/Badge"; import { RunTagInput } from "./RunTagInput"; +import { MachinePresetName } from "@trigger.dev/core/v3"; +import { InfoIconTooltip } from "~/components/primitives/Tooltip"; +import { divide } from "effect/Duration"; type ReplayRunDialogProps = { runFriendlyId: string; @@ -183,47 +184,49 @@ function ReplayForm({ })); const lastSubmission = useActionData(); - const [form, fields] = useForm({ + const [ + form, + { + environment, + payload, + metadata, + delaySeconds, + ttlSeconds, + idempotencyKey, + idempotencyKeyTTLSeconds, + queue, + concurrencyKey, + maxAttempts, + maxDurationSeconds, + tags, + version, + machine, + region, + prioritySeconds, + }, + ] = useForm({ id: "replay-task", - lastResult: lastSubmission as any, + lastSubmission: lastSubmission as any, onSubmit(event, { formData }) { event.preventDefault(); if (editablePayload) { - formData.set(fields.payload.name, currentPayloadJson.current); + formData.set(payload.name, currentPayloadJson.current); } - formData.set(fields.metadata.name, currentMetadataJson.current); + formData.set(metadata.name, currentMetadataJson.current); submit(formData, { method: "POST", action: formAction }); }, onValidate({ formData }) { - return parseWithZod(formData, { schema: ReplayRunData }); + return parse(formData, { schema: ReplayRunData }); }, }); - const { - environment, - payload: _payload, - metadata: _metadata, - delaySeconds, - ttlSeconds, - idempotencyKey, - idempotencyKeyTTLSeconds, - queue, - concurrencyKey, - maxAttempts, - maxDurationSeconds, - tags, - version, - machine, - region, - prioritySeconds, - } = fields; return ( @@ -236,7 +239,7 @@ function ReplayForm({ className="-mx-3 mt-3 w-auto flex-1 border-b border-t border-grid-dimmed" > -
+
-
+
Options enable you to control the execution behavior of your task.{" "} @@ -310,7 +313,7 @@ function ReplayForm({ Machine Runs task on a specific version. )} - {version.errors} + {version.error} {replayData.regions.length > 1 && ( @@ -361,7 +364,7 @@ function ReplayForm({ Region @@ -411,7 +414,7 @@ function ReplayForm({ @@ -434,7 +437,7 @@ function ReplayForm({ )} Assign run to a specific queue. - {queue.errors} + {queue.error} Retries failed runs up to the specified number of attempts. - {maxAttempts.errors} + {maxAttempts.error} @@ -483,14 +486,14 @@ function ReplayForm({ defaultValueSeconds={replayData.maxDurationSeconds ?? undefined} /> Overrides the maximum compute time limit for the run. - {maxDurationSeconds.errors} + {maxDurationSeconds.error} - - {idempotencyKey.errors} + + {idempotencyKey.error} Specify an idempotency key to ensure that a task is only triggered once with the same key. @@ -504,7 +507,7 @@ function ReplayForm({ /> Keys expire after 30 days by default. - {idempotencyKeyTTLSeconds.errors} + {idempotencyKeyTTLSeconds.error} @@ -512,26 +515,26 @@ function ReplayForm({ Concurrency key Limits concurrency by creating a separate queue for each value of the key. - {concurrencyKey.errors} + {concurrencyKey.error} Delays run by a specific duration. - {delaySeconds.errors} + {delaySeconds.error} Sets the priority of the run. Higher values mean higher priority. - {prioritySeconds.errors} + {prioritySeconds.error} @@ -541,9 +544,9 @@ function ReplayForm({ defaultValueSeconds={replayData.ttlSeconds} /> Expires the run if it hasn't started within the TTL. - {ttlSeconds.errors} + {ttlSeconds.error} - {form.errors} + {form.error}
@@ -557,7 +560,7 @@ function ReplayForm({
@@ -627,7 +618,7 @@ function LogsDisplay({
0 ? "bg-error/80" : "bg-surface-control" + errorCount > 0 ? "bg-error/80" : "bg-charcoal-600" )} /> @@ -638,7 +629,7 @@ function LogsDisplay({
0 ? "bg-warning/80" : "bg-surface-control" + warningCount > 0 ? "bg-warning/80" : "bg-charcoal-600" )} /> @@ -701,7 +692,7 @@ function LogsDisplay({
@@ -725,7 +716,7 @@ function LogsDisplay({ "flex w-full gap-x-2.5 border-l-2 px-2.5 py-1", log.level === "error" && "border-error/60 bg-error/15 hover:bg-error/25", log.level === "warn" && "border-warning/60 bg-warning/20 hover:bg-warning/30", - log.level === "info" && "border-transparent hover:bg-background-hover" + log.level === "info" && "border-transparent hover:bg-charcoal-750" )} >
{collapsed && ( -
+
)}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index 02d6ef00c4d..7cfa8fcc26e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -1,5 +1,12 @@ -import { getFormProps, useForm, type FieldMetadata, type FormMetadata } from "@conform-to/react"; -import { parseWithZod } from "@conform-to/zod"; +import { + type FieldConfig, + list, + requestIntent, + useFieldList, + useFieldset, + useForm, +} from "@conform-to/react"; +import { parse } from "@conform-to/zod"; import { LockClosedIcon, LockOpenIcon, @@ -10,7 +17,7 @@ import { import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react"; import { json } from "@remix-run/server-runtime"; import dotenv from "dotenv"; -import { useCallback, useState } from "react"; +import { type RefObject, useCallback, useRef, useState } from "react"; import { redirect } from "remix-typedjson"; import invariant from "tiny-invariant"; import { z } from "zod"; @@ -26,7 +33,6 @@ import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { Select, SelectItem } from "~/components/primitives/Select"; import { Switch } from "~/components/primitives/Switch"; import { TextLink } from "~/components/primitives/TextLink"; import { @@ -42,12 +48,12 @@ import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useTypedMatchesData } from "~/hooks/useTypedMatchData"; import { resolveOrgIdFromSlug } from "~/models/organization.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; +import { cn } from "~/utils/cn"; import { environmentVariablesRouteId, type loader as environmentVariablesLoader, } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route"; -import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; -import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, v3BillingPath, @@ -55,7 +61,7 @@ import { } from "~/utils/pathBuilder"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository"; -import { findUnauthorizedEnvironmentId } from "~/v3/writableEnvironments"; +import { Select, SelectItem } from "~/components/primitives/Select"; const Variable = z.object({ key: EnvironmentVariableKey, @@ -74,22 +80,19 @@ const schema = z.object({ if (i === "true") return true; return false; }, z.boolean()), - environmentIds: z.preprocess( - (i) => { - if (typeof i === "string") return [i]; - - if (Array.isArray(i)) { - const ids = i.filter((v) => typeof v === "string" && v !== ""); - if (ids.length === 0) { - return; - } - return ids; + environmentIds: z.preprocess((i) => { + if (typeof i === "string") return [i]; + + if (Array.isArray(i)) { + const ids = i.filter((v) => typeof v === "string" && v !== ""); + if (ids.length === 0) { + return; } + return ids; + } - return; - }, - z.array(z.string(), { required_error: "At least one environment is required" }) - ), + return; + }, z.array(z.string(), { required_error: "At least one environment is required" })), variables: z.preprocess((i) => { if (!Array.isArray(i)) { return []; @@ -118,10 +121,10 @@ export const action = dashboardAction( } const formData = await request.formData(); - const submission = parseWithZod(formData, { schema }); + const submission = parse(formData, { schema }); - if (submission.status !== "success") { - return json(submission.reply()); + if (!submission.value) { + return json(submission); } // Enforce env-tier write:envvars for every targeted environment, so a role @@ -135,15 +138,10 @@ export const action = dashboardAction( (env) => !ability.can("write", { type: "envvars", envType: env.type }) ); if (hasDeniedEnvironment) { - return json( - submission.reply({ - fieldErrors: { - environmentIds: [ - "You don't have permission to manage environment variables in one of the selected environments.", - ], - }, - }) - ); + submission.error.environmentIds = [ + "You don't have permission to manage environment variables in one of the selected environments.", + ]; + return json(submission); } const project = await prisma.project.findUnique({ @@ -162,32 +160,8 @@ export const action = dashboardAction( }, }); if (!project) { - return json(submission.reply({ formErrors: ["Project not found"] })); - } - - // The submitted `environmentIds` are user-supplied. Shared env types are - // writable by any member; a DEV env only by its owner. See - // findUnauthorizedEnvironmentId. - const submittedEnvs = await prisma.runtimeEnvironment.findMany({ - where: { - projectId: project.id, - id: { in: submission.value.environmentIds }, - }, - select: { id: true, type: true, orgMember: { select: { userId: true } } }, - }); - const unauthorizedEnvironmentId = findUnauthorizedEnvironmentId( - submittedEnvs, - submission.value.environmentIds, - userId - ); - if (unauthorizedEnvironmentId) { - return json( - submission.reply({ - fieldErrors: { - environmentIds: ["One or more of the selected environments is not writable by you."], - }, - }) - ); + submission.error.key = ["Project not found"]; + return json(submission); } const repository = new EnvironmentVariablesRepository(prisma); @@ -200,20 +174,19 @@ export const action = dashboardAction( }); if (!result.success) { - const fieldErrors: Record = {}; if (result.variableErrors) { for (const { key, error } of result.variableErrors) { const index = submission.value.variables.findIndex((v) => v.key === key); if (index !== -1) { - fieldErrors[`variables[${index}].key`] = [error]; + submission.error[`variables[${index}].key`] = [error]; } } } else { - fieldErrors.variables = [result.error]; + submission.error.variables = [result.error]; } - return json(submission.reply({ fieldErrors })); + return json(submission); } return redirect( @@ -227,7 +200,7 @@ export const action = dashboardAction( ); export default function Page() { - const [isOpen, _setIsOpen] = useState(true); + const [isOpen, setIsOpen] = useState(true); const parentData = useTypedMatchesData({ id: environmentVariablesRouteId, }); @@ -247,30 +220,27 @@ export default function Page() { const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState>(new Set()); const [selectedBranchId, setSelectedBranchId] = useState(undefined); - // TODO for no we only support branch-specific env vars for Preview environments - // Mostly to keep the UX for setting consistent env-vars across Dev/Staging/Prod easier - const previewBranches = environments.filter( - (env) => env.type === "PREVIEW" && env.parentEnvironmentId !== null - ); - const nonBranchEnvironments = environments.filter((env) => env.parentEnvironmentId === null); + const branchEnvironments = environments.filter((env) => env.branchName); + const nonBranchEnvironments = environments.filter((env) => !env.branchName); const selectedEnvironments = environments.filter((env) => selectedEnvironmentIds.has(env.id)); - const previewIsSelected = selectedEnvironments.some((env) => env.type === "PREVIEW"); + const previewIsSelected = selectedEnvironments.some( + (env) => env.branchName !== null || env.type === "PREVIEW" + ); const isLoading = navigation.state !== "idle" && navigation.formMethod === "post"; - const [form, fields] = useForm>({ + const [form, { environmentIds, variables }] = useForm({ id: "create-environment-variables", // TODO: type this - lastResult: lastSubmission as any, + lastSubmission: lastSubmission as any, onValidate({ formData }) { - return parseWithZod(formData, { schema }); + return parse(formData, { schema }); }, shouldRevalidate: "onSubmit", defaultValue: { variables: [{ key: "", value: "" }], }, }); - const { environmentIds, variables } = fields; const handleEnvironmentChange = ( environmentId: string, @@ -324,8 +294,8 @@ export default function Page() { > New environment variables - -
+ +
{selectedBranchId ? ( @@ -382,9 +352,9 @@ export default function Page() { - + - + )}
- {environmentIds.errors} + {environmentIds.error} Dev environment variables specified here will be overridden by ones in your .env file when running locally. @@ -436,7 +406,7 @@ export default function Page() { value={selectedBranchId ?? "all"} setValue={handleBranchChange} placeholder="All branches" - items={[{ id: "all", branchName: "All branches" }, ...previewBranches]} + items={[{ id: "all", branchName: "All branches" }, ...branchEnvironments]} className="w-fit min-w-52" filter={{ keys: [ @@ -444,7 +414,7 @@ export default function Page() { ], }} text={(val) => - val ? previewBranches.find((b) => b.id === val)?.branchName : null + val ? branchEnvironments.find((b) => b.id === val)?.branchName : null } dropdownIcon > @@ -498,13 +468,13 @@ export default function Page() { - {variables.errors} + {variables.error} - {form.errors} + {form.error} ; - form: FormMetadata; + variablesFields: FieldConfig; + formRef: RefObject; }) { const { items, @@ -586,13 +556,13 @@ function VariableFields({ const [firstPair, ...rest] = keyValuePairs; update(index, firstPair); - for (const _pair of rest) { - form.insert({ name: variablesFields.name }); + for (const pair of rest) { + requestIntent(formRef.current ?? undefined, list.append(variablesFields.name)); } insertAfter(index, rest); }, []); - const fields = variablesFields.getFieldList(); + const fields = useFieldList(formRef, variablesFields); return ( <> @@ -608,7 +578,10 @@ function VariableFields({ onChange={(value) => update(index, value)} onPaste={(e) => handlePaste(index, e)} onDelete={() => { - form.remove({ name: variablesFields.name, index }); + requestIntent( + formRef.current ?? undefined, + list.remove(variablesFields.name, { index }) + ); remove(index); }} showDeleteButton={items.length > 1} @@ -626,7 +599,7 @@ function VariableFields({ className="w-fit" type="button" onClick={() => { - form.insert({ name: variablesFields.name }); + requestIntent(formRef.current ?? undefined, list.append(variablesFields.name)); append([{ key: "", value: "" }]); }} LeadingIcon={PlusIcon} @@ -657,13 +630,14 @@ function VariableField({ onDelete: () => void; showDeleteButton: boolean; showValue: boolean; - config: FieldMetadata; + config: FieldConfig; }) { - const fields = config.getFieldset(); + const ref = useRef(null); + const fields = useFieldset(ref, config); const baseFieldName = `variables[${index}]`; return ( -
+
- {fields.key.errors} + {fields.key.error}
@@ -687,7 +661,7 @@ function VariableField({ value={value.value} onChange={(e) => onChange({ ...value, value: e.currentTarget.value })} /> - {fields.value.errors} + {fields.value.error}
{showDeleteButton && (
+ + + Message + Response + Time (ms) + Date + + + + {agentConfig.executions.map((exec) => ( + + {exec.message} + {exec.response} + {exec.executionTimeMs}ms + {new Date(exec.createdAt).toLocaleString()} + + ))} + +
+
+ )} + + {/* Health History */} + {agentConfig.healthChecks.length > 0 && ( +
+ Health Checks + + + + Status + Response Time + Date + + + + {agentConfig.healthChecks.map((check) => ( + + + + {check.isHealthy ? "✓ Healthy" : "✗ Unhealthy"} + + + {check.responseTimeMs}ms + {new Date(check.createdAt).toLocaleString()} + + ))} + +
+
+ )} + + + ); +} diff --git a/apps/webapp/app/routes/agents.setup.tsx b/apps/webapp/app/routes/agents.setup.tsx new file mode 100644 index 00000000000..efc6d70171c --- /dev/null +++ b/apps/webapp/app/routes/agents.setup.tsx @@ -0,0 +1,246 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { Form, useActionData, useNavigation } from "@remix-run/react"; +import { useState } from "react"; +import { z } from "zod"; +import { Button } from "~/components/primitives/Buttons"; +import { Header1, Header2 } from "~/components/primitives/Headers"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { prisma } from "~/db.server"; +import { requireUser } from "~/services/auth.server"; +import { logger } from "~/services/logger.server"; + +const SetupSchema = z.object({ + agentName: z.string().min(1, "Agent name is required"), + model: z.enum(["claude-3.5-sonnet", "claude-3-opus", "gpt-4-turbo"]), + messagingPlatform: z.enum(["slack", "discord", "telegram"]), + tools: z.string(), // JSON string array + slackWorkspaceId: z.string().optional(), + slackWebhookToken: z.string().optional(), +}); + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await requireUser(request); + return json({ user }); +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + if (request.method !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const user = await requireUser(request); + const formData = await request.formData(); + + try { + const data = SetupSchema.parse({ + agentName: formData.get("agentName"), + model: formData.get("model"), + messagingPlatform: formData.get("messagingPlatform"), + tools: formData.get("tools"), + slackWorkspaceId: formData.get("slackWorkspaceId"), + slackWebhookToken: formData.get("slackWebhookToken"), + }); + + // Parse tools JSON + const tools = JSON.parse(data.tools || "[]"); + + // Create agent config in database + const agentConfig = await prisma.agentConfig.create({ + data: { + name: data.agentName, + model: data.model, + messagingPlatform: data.messagingPlatform, + tools: tools, + slackWorkspaceId: data.slackWorkspaceId || null, + slackWebhookToken: data.slackWebhookToken || null, + userId: user.id, + status: "provisioning", + }, + }); + + logger.info("Agent created", { + agentId: agentConfig.id, + userId: user.id, + name: data.agentName, + }); + + // Trigger provisioning endpoint to spin up container + try { + const provisionResponse = await fetch("http://localhost:3000/api/agents/provision", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agentId: agentConfig.id }), + }); + + if (!provisionResponse.ok) { + logger.error("Provisioning failed", { + agentId: agentConfig.id, + status: provisionResponse.status, + }); + } + } catch (error) { + logger.error("Failed to call provisioning endpoint", { error }); + } + + return redirect(`/agents/${agentConfig.id}/status`); + } catch (error) { + logger.error("Failed to create agent", { error, userId: user.id }); + return json( + { error: error instanceof Error ? error.message : "Failed to create agent" }, + { status: 400 } + ); + } +}; + +export default function AgentSetup() { + const navigation = useNavigation(); + const actionData = useActionData(); + const [selectedTools, setSelectedTools] = useState([]); + + const toolOptions = [ + { id: "web-search", label: "Web Search" }, + { id: "code-execution", label: "Code Execution" }, + { id: "file-operations", label: "File Operations" }, + { id: "api-calls", label: "API Calls" }, + ]; + + const handleToolChange = (toolId: string, checked: boolean) => { + if (checked) { + setSelectedTools([...selectedTools, toolId]); + } else { + setSelectedTools(selectedTools.filter((t) => t !== toolId)); + } + }; + + return ( + + + Create a New Agent + Set up your AI agent with model, messaging, and tools + + + {actionData?.error && ( +
+ {actionData.error} +
+ )} + + {/* Agent Name */} +
+ + +
+ + {/* Model Selection */} +
+ + +
+ + {/* Messaging Platform */} +
+ + +
+ + {/* Tools Selection */} +
+ Select Tools +
+ {toolOptions.map((tool) => ( + + ))} +
+ +
+ + {/* Slack Integration (conditional) */} + {/* This would be conditional based on messagingPlatform selection */} +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+ ); +} diff --git a/apps/webapp/app/routes/api.agents.provision.ts b/apps/webapp/app/routes/api.agents.provision.ts new file mode 100644 index 00000000000..777fda0ca5a --- /dev/null +++ b/apps/webapp/app/routes/api.agents.provision.ts @@ -0,0 +1,92 @@ +import type { ActionFunctionArgs } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import { spawn } from "child_process"; +import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; + +/** + * POST /api/agents/provision + * Provisions an OpenClaw container for a given agent config + */ +export const action = async ({ request }: ActionFunctionArgs) => { + if (request.method !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const { agentId } = await request.json() as { agentId: string }; + + if (!agentId) { + return json({ error: "agentId is required" }, { status: 400 }); + } + + try { + // Get agent config + const agentConfig = await prisma.agentConfig.findUnique({ + where: { id: agentId }, + }); + + if (!agentConfig) { + return json({ error: "Agent not found" }, { status: 404 }); + } + + // Find the next available port (starting at 8001) + const lastAgent = await prisma.agentConfig.findFirst({ + where: { + containerPort: { not: null }, + }, + orderBy: { containerPort: "desc" }, + }); + + const nextPort = (lastAgent?.containerPort || 8000) + 1; + + // Generate container name + const containerName = `openclaw-${agentConfig.userId.slice(0, 8)}-${agentConfig.id.slice(0, 8)}`; + + logger.info("Provisioning OpenClaw container", { + agentId, + containerName, + port: nextPort, + }); + + // TODO: Implement actual Docker provisioning + // For now, just update the database with port info + // Production would SSH to VPS and run: docker run -d --name $containerName -p $nextPort:8000 openclaw:latest + + const updatedAgent = await prisma.agentConfig.update({ + where: { id: agentId }, + data: { + containerName, + containerPort: nextPort, + status: "provisioning", + }, + }); + + // Log provisioning start (not health check yet since container doesn't exist) + await prisma.agentHealthCheck.create({ + data: { + agentId, + isHealthy: false, + errorMessage: "Container provisioning started - awaiting actual deployment", + }, + }); + + logger.info("Agent provisioned successfully", { + agentId, + containerName, + port: nextPort, + }); + + return json({ + success: true, + agentId, + containerName, + containerPort: nextPort, + }); + } catch (error) { + logger.error("Failed to provision agent", { error, agentId }); + return json( + { error: error instanceof Error ? error.message : "Provisioning failed" }, + { status: 500 } + ); + } +}; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx index 43381613433..75c5b30b02f 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx @@ -26,8 +26,6 @@ import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.s import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { v3RunStreamParamsSchema } from "~/utils/pathBuilder"; -import { runStore } from "~/v3/runStore.server"; -import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; type ViewMode = "list" | "compact"; @@ -60,49 +58,42 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { throw new Response("Not Found", { status: 404 }); } - const runWhere = { friendlyId: runParam, projectId: project.id }; - const runArgs = { - select: { - id: true, - friendlyId: true, - realtimeStreamsVersion: true, - streamBasinName: true, - runtimeEnvironmentId: true, + const run = await $replica.taskRun.findFirst({ + where: { + friendlyId: runParam, + projectId: project.id, + }, + include: { + runtimeEnvironment: { + include: { + project: true, + organization: true, + orgMember: true, + }, + }, }, - }; - // Client-less findRun defaults to the read replica; replica lag can null out a live run and 404 a - // valid stream-viewer request (useRealtimeStream surfaces the error, no auto-retry). Re-read the - // owning primary on a replica miss. - const run = - (await runStore.findRun(runWhere, runArgs)) ?? - (await runStore.findRunOnPrimary(runWhere, runArgs)); + }); if (!run) { throw new Response("Not Found", { status: 404 }); } - const environment = await controlPlaneResolver.resolveAuthenticatedEnv(run.runtimeEnvironmentId); - - if (!environment || environment.slug !== envParam) { + if (run.runtimeEnvironment.slug !== envParam) { throw new Response("Not Found", { status: 404 }); } // Get Last-Event-ID header for resuming from a specific position const lastEventId = request.headers.get("Last-Event-ID") || undefined; - const realtimeStream = getRealtimeStreamInstance(environment, run.realtimeStreamsVersion, { - run: { streamBasinName: run.streamBasinName }, - }); - - return realtimeStream.streamResponse( - request, - run.friendlyId, - streamKey, - getRequestAbortSignal(), - { - lastEventId, - } + const realtimeStream = getRealtimeStreamInstance( + run.runtimeEnvironment, + run.realtimeStreamsVersion, + { run } ); + + return realtimeStream.streamResponse(request, run.friendlyId, streamKey, getRequestAbortSignal(), { + lastEventId, + }); }; export function RealtimeStreamViewer({ @@ -210,6 +201,7 @@ export function RealtimeStreamViewer({ const handleScroll = () => { if (!scrollElement || !bottomElement) return; + // Clear any existing timeout if (scrollTimeout) { clearTimeout(scrollTimeout); } @@ -342,8 +334,8 @@ export function RealtimeStreamViewer({ chunks.length === 0 ? "cursor-not-allowed opacity-50" : copied - ? "text-success hover:cursor-pointer" - : "text-text-dimmed hover:cursor-pointer hover:text-text-bright" + ? "text-success hover:cursor-pointer" + : "text-text-dimmed hover:cursor-pointer hover:text-text-bright" )} > {copied ? ( @@ -395,7 +387,7 @@ export function RealtimeStreamViewer({ {/* Content */}
{error && (
@@ -507,7 +499,7 @@ function StreamChunkLine({ return (
{/* Line number */}
{lineNumber}
{/* Timestamp */} -
{timestamp}
+
{timestamp}
{/* Content */}
{formattedData}
@@ -558,6 +550,7 @@ export function useRealtimeStream(resourcePath: string, startIndex?: number) { reader = stream.getReader(); + // Read from the stream while (true) { const { done, value } = await reader.read(); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index 42631d5ff45..3211dbb0518 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -1,4 +1,4 @@ -import { parseWithZod } from "@conform-to/zod"; +import { parse } from "@conform-to/zod"; import { ArrowPathIcon, InformationCircleIcon } from "@heroicons/react/20/solid"; import { XCircleIcon } from "@heroicons/react/24/outline"; import { Form } from "@remix-run/react"; @@ -39,7 +39,6 @@ import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton"; -import { Select, SelectItem } from "~/components/primitives/Select"; import { type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; @@ -51,9 +50,7 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/m import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; import { CreateBulkActionPresenter } from "~/presenters/v3/CreateBulkActionPresenter.server"; -import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server"; import { RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/shouldRevalidateRunsList"; import { logger } from "~/services/logger.server"; import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; @@ -85,24 +82,12 @@ export const loader = dashboardLoader( } const presenter = new CreateBulkActionPresenter(); - const [data, regionsResult] = await Promise.all([ - presenter.call({ - organizationId: project.organizationId, - projectId: project.id, - environmentId: environment.id, - request, - }), - tryCatch( - new RegionsPresenter().call({ - userId: user.id, - projectSlug: projectParam, - isAdmin: user.admin || user.isImpersonating, - }) - ), - ]); - - const [regionsError, regionsData] = regionsResult; - const regions = regionsError ? [] : regionsData.regions; + const data = await presenter.call({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + request, + }); // Display flag for the inspector's Cancel/Replay controls — the action // below enforces write:runs independently. @@ -110,7 +95,7 @@ export const loader = dashboardLoader( canCreateBulkAction: { action: "write", resource: { type: "runs" } }, }); - return typedjson({ ...data, regions, canCreateBulkAction }); + return typedjson({ ...data, canCreateBulkAction }); } ); @@ -119,10 +104,6 @@ export const CreateBulkActionSearchParams = z.object({ action: BulkActionAction.default("cancel"), }); -// Sentinel for the "Override region" dropdown meaning "keep each run's original -// region". Normalized to `undefined` in the action so the service never sees it. -const REPLAY_REGION_NO_OVERRIDE_VALUE = "__no_override__"; - export const CreateBulkActionPayload = z.discriminatedUnion("mode", [ z.object({ mode: z.literal("selected"), @@ -133,7 +114,6 @@ export const CreateBulkActionPayload = z.discriminatedUnion("mode", [ return []; }, z.array(z.string())), title: z.string().optional(), - region: z.string().optional(), failedRedirect: z.string(), emailNotification: z.preprocess((value) => value === "on", z.boolean()), }), @@ -141,7 +121,6 @@ export const CreateBulkActionPayload = z.discriminatedUnion("mode", [ mode: z.literal("filter"), action: BulkActionAction, title: z.string().optional(), - region: z.string().optional(), failedRedirect: z.string(), emailNotification: z.preprocess((value) => value === "on", z.boolean()), }), @@ -171,9 +150,9 @@ export const action = dashboardAction( } const formData = await request.formData(); - const submission = parseWithZod(formData, { schema: CreateBulkActionPayload }); + const submission = parse(formData, { schema: CreateBulkActionPayload }); - if (submission.status !== "success") { + if (!submission.value) { logger.error("Invalid bulk action", { submission, formData: Object.fromEntries(formData), @@ -181,33 +160,16 @@ export const action = dashboardAction( return redirectWithErrorMessage("/", request, "Invalid bulk action"); } - // "Don't override" keeps each run's original region — drop it so it isn't - // stored as a real override. - if (submission.value.region === REPLAY_REGION_NO_OVERRIDE_VALUE) { - submission.value.region = undefined; - } - const service = new BulkActionService(); const [error, result] = await tryCatch( - (async () => { - const filters = - submission.value.mode === "selected" - ? { runId: submission.value.selectedRunIds } - : await getRunFiltersFromRequest(request); - - return service.create({ - organizationId: project.organizationId, - projectId: project.id, - environmentId: environment.id, - userId: user.id, - action: submission.value.action, - title: submission.value.title, - region: submission.value.region, - emailNotification: submission.value.emailNotification, - filters, - triggerSource: "dashboard", - }); - })() + service.create( + project.organizationId, + project.id, + environment.id, + user.id, + submission.value, + request + ) ); if (error) { @@ -276,23 +238,6 @@ export function CreateBulkActionInspector({ const impactedCountElement = mode === "selected" ? selectedItems.size : ; - // Region is a replay-only override and only applies to deployed environments. - // The default keeps each run in its original region so a bulk action spanning - // multiple regions doesn't silently re-route runs. - const regions = data?.regions ?? []; - const showRegion = - action === "replay" && environment.type !== "DEVELOPMENT" && regions.length > 1; - const regionItems = [ - { value: REPLAY_REGION_NO_OVERRIDE_VALUE, label: "Don't override", isDefault: false }, - ...regions.map((r) => ({ - // masterQueue is the region routing key the replay resolves against - // (WorkerGroupService matches regionOverride on masterQueue); name is display only. - value: r.masterQueue, - label: r.description ? `${r.name} — ${r.description}` : r.name, - isDefault: r.isDefault, - })), - ]; - return (
-
+
- {showRegion && ( - - - {/* Our Select primitive uses Ariakit, which treats value={undefined} - as uncontrolled and keeps stale state when switching environments. - The key forces a remount so it reinitializes with the default value. */} - - - By default each run is replayed in its original region. Select a region to run - them all there instead. - - - )} { const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); const formData = await request.formData(); - const submission = parseWithZod(formData, { schema: UpsertSchedule }); + const submission = parse(formData, { schema: UpsertSchedule }); - if (submission.status !== "success") { - return json(submission.reply()); + if (!submission.value) { + return json(submission); } // `_format=json` → return JSON instead of redirecting; caller toasts. @@ -157,10 +162,10 @@ export function UpsertScheduleForm({ submitFetcher?: FetcherWithComponents; }) { const actionData = useActionData(); - // Only feed conform-shaped data (`status`) to `useForm` — `{ ok, message }` - // envelopes lack it and crash conform. + // Only feed conform-shaped data (`intent`) to `useForm` — `{ ok, message }` + // envelopes lack `payload` and crash conform. const fetcherSubmission = - submitFetcher?.data && typeof submitFetcher.data === "object" && "status" in submitFetcher.data + submitFetcher?.data && typeof submitFetcher.data === "object" && "intent" in submitFetcher.data ? submitFetcher.data : undefined; const lastSubmission = submitFetcher ? fetcherSubmission : actionData; @@ -180,10 +185,10 @@ export function UpsertScheduleForm({ // coexist without duplicate DOM ids breaking `htmlFor` / conform. id: schedule?.friendlyId ? `edit-schedule-${schedule.friendlyId}` : "create-schedule", // TODO: type this - lastResult: lastSubmission as any, + lastSubmission: lastSubmission as any, shouldRevalidate: "onSubmit", onValidate({ formData }) { - return parseWithZod(formData, { schema: UpsertSchedule }); + return parse(formData, { schema: UpsertSchedule }); }, }); @@ -228,7 +233,7 @@ export function UpsertScheduleForm({
@@ -236,11 +241,11 @@ export function UpsertScheduleForm({ {schedule?.friendlyId ? "Edit schedule" : defaultTaskIdentifier - ? `New schedule for ${defaultTaskIdentifier}` - : "New schedule"} + ? `New schedule for ${defaultTaskIdentifier}` + : "New schedule"}
-
+
{submitFetcher ? : null} {schedule && } @@ -254,7 +259,7 @@ export function UpsertScheduleForm({ - {taskIdentifier.errors} + {taskIdentifier.error} ); })()} @@ -294,7 +299,7 @@ export function UpsertScheduleForm({ CRON pattern (UTC) @@ -417,14 +422,14 @@ export function UpsertScheduleForm({ run function of your task. This allows you to have per-user CRON tasks.{" "} Read the docs. - {externalId.errors} + {externalId.error} - {deduplicationKey.errors} + {deduplicationKey.error} - {form.errors} + {form.error}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx index e1ed1b2cc70..d0180c1a636 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx @@ -1,8 +1,8 @@ -import { parseWithZod } from "@conform-to/zod"; +import { env } from "~/env.server"; +import { parse } from "@conform-to/zod"; import { Form, useLocation, useNavigation, useSubmit } from "@remix-run/react"; import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; -import type { WaitpointTokenStatus } from "@trigger.dev/core/v3"; -import { stringifyIO, timeoutError } from "@trigger.dev/core/v3"; +import { stringifyIO, timeoutError, WaitpointTokenStatus } from "@trigger.dev/core/v3"; import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; import type { Waitpoint } from "@trigger.dev/database"; import { useCallback, useRef } from "react"; @@ -12,13 +12,9 @@ import { JSONEditor } from "~/components/code/JSONEditor"; import { Button } from "~/components/primitives/Buttons"; import { DateTime } from "~/components/primitives/DateTime"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { SpinnerWhite } from "~/components/primitives/Spinner"; import { InfoIconTooltip } from "~/components/primitives/Tooltip"; import { LiveCountdown } from "~/components/runs/v3/LiveTimer"; import { $replica } from "~/db.server"; -import { runStore } from "~/v3/runStore.server"; -import { env } from "~/env.server"; -import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; @@ -26,8 +22,10 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { processWaitpointCompletionPacket } from "~/runEngine/concerns/waitpointCompletionPacket.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema, v3RunsPath } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema, ProjectParamSchema, v3RunsPath } from "~/utils/pathBuilder"; import { engine } from "~/v3/runEngine.server"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; +import { useEnvironment } from "~/hooks/useEnvironment"; const CompleteWaitpointFormData = z.discriminatedUnion("type", [ z.object({ @@ -53,10 +51,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const { organizationSlug, projectParam, envParam, waitpointFriendlyId } = Params.parse(params); const formData = await request.formData(); - const submission = parseWithZod(formData, { schema: CompleteWaitpointFormData }); + const submission = parse(formData, { schema: CompleteWaitpointFormData }); - if (submission.status !== "success") { - return json(submission.reply()); + if (!submission.value) { + return json(submission); } try { @@ -81,7 +79,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const waitpointId = WaitpointId.toId(waitpointFriendlyId); - let waitpoint = await runStore.findWaitpoint({ + const waitpoint = await $replica.waitpoint.findFirst({ select: { projectId: true, environmentId: true, @@ -90,14 +88,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { id: waitpointId, }, }); - if (!waitpoint) { - // Read-your-writes: a just-minted token may not have replicated. Re-read the owning primary - // before the auth guard / "No waitpoint found" (mirrors the token complete/callback routes). - waitpoint = await runStore.findWaitpointOnPrimary({ - select: { projectId: true, environmentId: true }, - where: { id: waitpointId }, - }); - } if (waitpoint?.projectId !== project.id) { return redirectWithErrorMessage( @@ -109,7 +99,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { switch (submission.value.type) { case "DATETIME": { - const _result = await engine.completeWaitpoint({ + const result = await engine.completeWaitpoint({ id: waitpointId, }); @@ -122,7 +112,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { case "MANUAL": { if (submission.value.isTimeout) { try { - const _result = await engine.completeWaitpoint({ + const result = await engine.completeWaitpoint({ id: waitpointId, output: { type: "application/json", @@ -136,7 +126,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { request, "Waitpoint timed out" ); - } catch (_e) { + } catch (e) { return redirectWithErrorMessage( submission.value.failureRedirect, request, @@ -182,7 +172,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { `${WaitpointId.toFriendlyId(waitpointId)}/token` ); - const _result = await engine.completeWaitpoint({ + const result = await engine.completeWaitpoint({ id: waitpointId, output: finalData.data ? { type: finalData.dataType, value: finalData.data, isError: false } @@ -194,7 +184,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { request, "Waitpoint completed" ); - } catch (_e) { + } catch (e) { return redirectWithErrorMessage( submission.value.failureRedirect, request, @@ -369,8 +359,8 @@ function CompleteManualWaitpointForm({ waitpoint }: { waitpoint: { id: string } contentClassName="normal-case tracking-normal max-w-xs" />
-
-
+
+
setText(e.target.value)} rows={3} - className="m-0 min-h-10 w-full border-0 bg-background-bright px-3 py-2 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control file:border-0 file:bg-transparent file:text-base file:font-medium focus:border-0 focus:outline-hidden focus:ring-0 focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50" + className="m-0 min-h-10 w-full border-0 bg-background-bright px-3 py-2 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-gray-300 file:border-0 file:bg-transparent file:text-base file:font-medium focus:border-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50" />
-
+
diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index 012d47827de..6107f5c2218 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -3,6 +3,7 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { Fragment } from "react"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { AppContainer } from "~/components/layout/AppLayout"; +import { env } from "~/env.server"; import { requireUser } from "~/services/session.server"; import { cn } from "~/utils/cn"; @@ -155,12 +156,6 @@ const stories: Story[] = [ name: "Usage", slug: "usage", }, - // Dashboard agent section - { - sectionTitle: "Dashboard agent", - name: "Agent UI", - slug: "agent-ui", - }, // Forms section { sectionTitle: "Forms", @@ -244,7 +239,7 @@ function SideMenu({ stories }: { stories: Story[] }) { )} >
-
+
{stories.map((story) => { return ( diff --git a/apps/webapp/app/routes/webhooks.slack.ts b/apps/webapp/app/routes/webhooks.slack.ts new file mode 100644 index 00000000000..5529e45759d --- /dev/null +++ b/apps/webapp/app/routes/webhooks.slack.ts @@ -0,0 +1,140 @@ +import type { ActionFunctionArgs } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; + +/** + * POST /webhooks/slack + * Receives Slack messages and routes them to the correct OpenClaw agent + */ +export const action = async ({ request }: ActionFunctionArgs) => { + if (request.method !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + try { + const event = await request.json() as any; + + // Handle Slack URL verification + if (event.type === "url_verification") { + return json({ challenge: event.challenge }); + } + + // Handle message events + if (event.type === "event_callback" && event.event.type === "message") { + const slackEvent = event.event; + const workspaceId = event.team_id; + const channel = slackEvent.channel; + const text = slackEvent.text; + const userId = slackEvent.user; + + logger.info("Received Slack message", { + workspaceId, + channel, + userId, + text: text?.substring(0, 100), + }); + + // Find the agent for this workspace + const agent = await prisma.agentConfig.findFirst({ + where: { + slackWorkspaceId: workspaceId, + messagingPlatform: "slack", + status: "healthy", + }, + }); + + if (!agent) { + logger.warn("No agent found for workspace", { workspaceId }); + return json({ ok: true }); // Don't error, just ignore + } + + if (!agent.containerPort) { + logger.warn("Agent has no container port", { agentId: agent.id }); + return json({ ok: true }); + } + + // Route message to OpenClaw container (on VPS) + const containerUrl = `http://178.128.150.129:${agent.containerPort}`; + + try { + const containerResponse = await fetch(`${containerUrl}/api/message`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text, + userId, + channel, + metadata: { + slackUserId: userId, + slackChannel: channel, + timestamp: new Date().toISOString(), + }, + }), + }); + + const containerData = await containerResponse.json(); + const agentResponse = containerData?.response || "I couldn't process that"; + + // Log execution + await prisma.agentExecution.create({ + data: { + agentId: agent.id, + message: text, + response: agentResponse, + executionTimeMs: 0, // TODO: Measure actual execution time + inputTokens: containerData?.inputTokens, + outputTokens: containerData?.outputTokens, + }, + }); + + // Send response back to Slack + if (agent.slackWebhookToken) { + await fetch(`https://hooks.slack.com/services/${agent.slackWebhookToken}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + channel, + text: agentResponse, + reply_broadcast: false, + thread_ts: slackEvent.thread_ts || slackEvent.ts, + }), + }); + } + + logger.info("Message processed successfully", { + agentId: agent.id, + responseLength: agentResponse.length, + }); + } catch (containerError) { + logger.error("Failed to route message to container", { + agentId: agent.id, + containerPort: agent.containerPort, + error: containerError, + }); + + // Mark agent as unhealthy + await prisma.agentConfig.update({ + where: { id: agent.id }, + data: { status: "unhealthy" }, + }); + + // Log health check failure + await prisma.agentHealthCheck.create({ + data: { + agentId: agent.id, + isHealthy: false, + errorMessage: containerError instanceof Error ? containerError.message : "Unknown error", + }, + }); + + return json({ ok: true }); // Don't fail the webhook, just mark agent unhealthy + } + } + + return json({ ok: true }); + } catch (error) { + logger.error("Webhook processing error", { error }); + return json({ ok: true }, { status: 200 }); // Always return 200 to Slack + } +}; diff --git a/internal-packages/database/prisma/migrations/20260325122458_add_openclaw_agents/migration.sql b/internal-packages/database/prisma/migrations/20260325122458_add_openclaw_agents/migration.sql new file mode 100644 index 00000000000..61c239b1b77 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260325122458_add_openclaw_agents/migration.sql @@ -0,0 +1,121 @@ +-- DropIndex +DROP INDEX "public"."SecretStore_key_idx"; + +-- DropIndex +DROP INDEX "public"."TaskRun_runtimeEnvironmentId_createdAt_idx"; + +-- DropIndex +DROP INDEX "public"."TaskRun_runtimeEnvironmentId_id_idx"; + +-- AlterTable +ALTER TABLE "public"."FeatureFlag" ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "public"."IntegrationDeployment" ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_WaitpointRunConnections_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_completedWaitpoints_AB_unique"; + +-- CreateTable +CREATE TABLE "public"."AgentConfig" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "model" TEXT NOT NULL, + "messagingPlatform" TEXT NOT NULL, + "tools" JSONB NOT NULL, + "containerName" TEXT, + "containerPort" INTEGER, + "slackWorkspaceId" TEXT, + "slackWebhookToken" TEXT, + "apiKeys" JSONB, + "status" TEXT NOT NULL DEFAULT 'provisioning', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AgentConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."AgentExecution" ( + "id" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "message" TEXT NOT NULL, + "response" TEXT NOT NULL, + "toolsUsed" JSONB, + "executionTimeMs" INTEGER NOT NULL, + "inputTokens" INTEGER, + "outputTokens" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AgentExecution_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."AgentHealthCheck" ( + "id" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "responseTimeMs" INTEGER, + "isHealthy" BOOLEAN NOT NULL, + "errorMessage" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AgentHealthCheck_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AgentConfig_userId_createdAt_idx" ON "public"."AgentConfig"("userId", "createdAt" DESC); + +-- CreateIndex +CREATE INDEX "AgentConfig_slackWorkspaceId_idx" ON "public"."AgentConfig"("slackWorkspaceId"); + +-- CreateIndex +CREATE INDEX "AgentExecution_agentId_createdAt_idx" ON "public"."AgentExecution"("agentId", "createdAt" DESC); + +-- CreateIndex +CREATE INDEX "AgentHealthCheck_agentId_createdAt_idx" ON "public"."AgentHealthCheck"("agentId", "createdAt" DESC); + +-- CreateIndex +CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops); + +-- CreateIndex +CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC); + +-- CreateIndex +CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC); + +-- AddForeignKey +ALTER TABLE "public"."AgentConfig" ADD CONSTRAINT "AgentConfig_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."AgentExecution" ADD CONSTRAINT "AgentExecution_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "public"."AgentConfig"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."AgentHealthCheck" ADD CONSTRAINT "AgentHealthCheck_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "public"."AgentConfig"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 7d6f4ac5493..e48e2240c6c 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -79,6 +79,7 @@ model User { metricsDashboards MetricsDashboard[] platformNotifications PlatformNotification[] platformNotificationInteractions PlatformNotificationInteraction[] + agentConfigs AgentConfig[] } model MfaBackupCode { @@ -3197,3 +3198,104 @@ model OrganizationDataStore { @@index([kind]) } + +// ==================================================== +// OpenClaw Agent Models +// ==================================================== + +/// OpenClaw Agent Configuration +model AgentConfig { + id String @id @default(cuid()) + + /// Owner of this agent + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + /// Agent display name + name String + + /// Model selected (e.g., claude-3.5-sonnet) + model String + + /// Messaging platform (slack, discord, telegram) + messagingPlatform String + + /// Tools/skills enabled (JSON array) + tools Json + + /// Container name on VPS + containerName String? + + /// Container port (e.g., 8001, 8002) + containerPort Int? + + /// Slack workspace ID for routing + slackWorkspaceId String? + + /// Webhook token for Slack + slackWebhookToken String? + + /// User's API keys (encrypted) + apiKeys Json? + + /// Provisioning state + status String @default("provisioning") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + executions AgentExecution[] + healthChecks AgentHealthCheck[] + + @@index([userId, createdAt(sort: Desc)]) + @@index([slackWorkspaceId]) +} + +/// Execution history +model AgentExecution { + id String @id @default(cuid()) + + agent AgentConfig @relation(fields: [agentId], references: [id], onDelete: Cascade) + agentId String + + /// Input message + message String + + /// Output response + response String + + /// Tools used in this execution + toolsUsed Json? + + /// Execution time in ms + executionTimeMs Int + + /// Token usage + inputTokens Int? + outputTokens Int? + + createdAt DateTime @default(now()) + + @@index([agentId, createdAt(sort: Desc)]) +} + +/// Health check history +model AgentHealthCheck { + id String @id @default(cuid()) + + agent AgentConfig @relation(fields: [agentId], references: [id], onDelete: Cascade) + agentId String + + /// Response time in ms + responseTimeMs Int? + + /// Healthy or not + isHealthy Boolean + + /// Error message if unhealthy + errorMessage String? + + createdAt DateTime @default(now()) + + @@index([agentId, createdAt(sort: Desc)]) +} diff --git a/internal-packages/emails/emails/components/Footer.tsx b/internal-packages/emails/emails/components/Footer.tsx index 00f128c8bea..7c722e70694 100644 --- a/internal-packages/emails/emails/components/Footer.tsx +++ b/internal-packages/emails/emails/components/Footer.tsx @@ -8,7 +8,7 @@ export function Footer() {
©Trigger.dev, 1111B S Governors Ave STE 6433, Dover, DE 19904 |{" "} - + Trigger.dev