diff --git a/web/oss/src/components/ModelRegistry/Drawers/ConfigureProviderDrawer/assets/ConfigureProviderDrawerContent.tsx b/web/oss/src/components/ModelRegistry/Drawers/ConfigureProviderDrawer/assets/ConfigureProviderDrawerContent.tsx index 693894aa68..ec773e66bf 100644 --- a/web/oss/src/components/ModelRegistry/Drawers/ConfigureProviderDrawer/assets/ConfigureProviderDrawerContent.tsx +++ b/web/oss/src/components/ModelRegistry/Drawers/ConfigureProviderDrawer/assets/ConfigureProviderDrawerContent.tsx @@ -8,7 +8,7 @@ import {useWatch} from "antd/lib/form/Form" import {useVaultSecret} from "@/oss/hooks/useVaultSecret" import {LlmProvider} from "@/oss/lib/helpers/llmProviders" -import {isAppNameInputValid} from "@/oss/lib/helpers/utils" +import {isSlugInputValid} from "@/oss/lib/helpers/utils" import {PROVIDER_KINDS, PROVIDER_LABELS, SecretDTOProvider} from "@/oss/lib/Types" import LabelInput from "../../../assets/LabelInput" @@ -239,7 +239,7 @@ const ConfigureProviderDrawerContent = ({ return Promise.reject( "Please enter name", ) - if (!isAppNameInputValid(value)) { + if (!isSlugInputValid(value)) { return Promise.reject( "Name must contain only letters, numbers, underscore, or dash without any spaces.", ) diff --git a/web/oss/src/components/Playground/Components/Modals/CommitVariantChangesModal/index.tsx b/web/oss/src/components/Playground/Components/Modals/CommitVariantChangesModal/index.tsx index 4c0fad58c6..8388fc7103 100644 --- a/web/oss/src/components/Playground/Components/Modals/CommitVariantChangesModal/index.tsx +++ b/web/oss/src/components/Playground/Components/Modals/CommitVariantChangesModal/index.tsx @@ -23,6 +23,7 @@ import {selectedAppIdAtom} from "@/oss/state/app" import {CommitVariantChangesModalProps} from "./assets/types" const EVALUATOR_CREATE_FIELDS: CommitCreateFieldsConfig = {nameLabel: "Evaluator name"} +const APP_CREATE_FIELDS: CommitCreateFieldsConfig = {nameLabel: "App name"} const VARIANT_CREATE_FIELDS: CommitCreateFieldsConfig = { modes: ["variant"], nameLabel: "Variant name", @@ -38,6 +39,7 @@ const CommitVariantChangesModal: React.FC = ({ const runnableData = useAtomValue(workflowMolecule.selectors.data(variantId || "")) const isEphemeral = useAtomValue(workflowMolecule.selectors.isEphemeral(variantId || "")) const isEvaluator = useAtomValue(workflowMolecule.selectors.isEvaluator(variantId || "")) + const isApplication = useAtomValue(workflowMolecule.selectors.isApplication(variantId || "")) const appId = useAtomValue(selectedAppIdAtom) const commitRevision = useSetAtom(playgroundController.actions.commitRevision) @@ -226,8 +228,23 @@ const CommitVariantChangesModal: React.FC = ({ [isEvaluator], ) - // For ephemeral entities, render a simplified "Create" modal with editable name + // For ephemeral entities, render a simplified "Create" modal with editable name. + // Branch the labels on the entity's type flag — evaluator-create flows show + // "Evaluator name", app-create flows show "App name", everything else + // (variant-from-base) keeps the evaluator default for backward compat. if (isEphemeral) { + const createFields = isEvaluator + ? EVALUATOR_CREATE_FIELDS + : isApplication + ? APP_CREATE_FIELDS + : EVALUATOR_CREATE_FIELDS + // The drawer wrapper (`useDrawerCreateCommitCallback`) toasts + // "App created successfully" / "Evaluator created successfully" + // on its `onNewRevision` hook. Letting the modal also toast + // would surface two identical notifications. For unrecognized + // ephemeral flows (no evaluator / no application flag) we keep + // the modal toast as a fallback. + const successMessage = isEvaluator || isApplication ? null : "Created successfully" return ( = ({ }} onSubmit={handleSubmit} actionLabel="Create" - createEntityFields={EVALUATOR_CREATE_FIELDS} - successMessage="Evaluator created successfully" + createEntityFields={createFields} + successMessage={successMessage} /> ) } diff --git a/web/oss/src/components/SharedDrawers/AnnotateDrawer/assets/CreateEvaluator/assets/CreateNewMetric/index.tsx b/web/oss/src/components/SharedDrawers/AnnotateDrawer/assets/CreateEvaluator/assets/CreateNewMetric/index.tsx index 987b586bcf..834df92aac 100644 --- a/web/oss/src/components/SharedDrawers/AnnotateDrawer/assets/CreateEvaluator/assets/CreateNewMetric/index.tsx +++ b/web/oss/src/components/SharedDrawers/AnnotateDrawer/assets/CreateEvaluator/assets/CreateNewMetric/index.tsx @@ -4,7 +4,7 @@ import {Plus, Trash} from "@phosphor-icons/react" import {Button, Form, FormListFieldData, Input, InputNumber, Select, Switch, Typography} from "antd" import dynamic from "next/dynamic" -import {isAppNameInputValid} from "@/oss/lib/helpers/utils" +import {isSlugInputValid} from "@/oss/lib/helpers/utils" import {EVALUATOR_OPTIONS, NUMERIC_METRIC_TYPES} from "../../../constants" @@ -131,7 +131,7 @@ const CreateNewMetric = ({ validator(_, value) { if (!value) { return Promise.resolve() - } else if (!isAppNameInputValid(value)) { + } else if (!isSlugInputValid(value)) { return Promise.reject( "Slug must contain only letters, numbers, underscore, or dash without any spaces.", ) diff --git a/web/oss/src/components/SharedDrawers/AnnotateDrawer/assets/CreateEvaluator/index.tsx b/web/oss/src/components/SharedDrawers/AnnotateDrawer/assets/CreateEvaluator/index.tsx index e7e637efae..720499bfb4 100644 --- a/web/oss/src/components/SharedDrawers/AnnotateDrawer/assets/CreateEvaluator/index.tsx +++ b/web/oss/src/components/SharedDrawers/AnnotateDrawer/assets/CreateEvaluator/index.tsx @@ -13,7 +13,7 @@ import deepEqual from "fast-deep-equal" import {useSetAtom} from "jotai" import {useDebounceValue} from "usehooks-ts" -import {isAppNameInputValid} from "@/oss/lib/helpers/utils" +import {isSlugInputValid} from "@/oss/lib/helpers/utils" import {recordWidgetEventAtom} from "@/oss/lib/onboarding" import {EvaluatorPreviewDto} from "@/oss/services/evaluations/api/evaluatorTypes" @@ -326,7 +326,7 @@ const CreateEvaluator = ({ validator(_, value) { if (!value) { return Promise.resolve() - } else if (!isAppNameInputValid(value)) { + } else if (!isSlugInputValid(value)) { return Promise.reject( "Slug must contain only letters, numbers, underscore, or dash without any spaces.", ) diff --git a/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx b/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx index ce3712600f..d4fe13b239 100644 --- a/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx +++ b/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx @@ -18,6 +18,7 @@ import { parseEvaluatorKeyFromUri, evaluatorTemplatesMapAtom, workflowMolecule, + discardLocalServerDataAtom, } from "@agenta/entities/workflow" import {EntityPicker} from "@agenta/entity-ui" import {PlaygroundConfigSection} from "@agenta/entity-ui/drill-in" @@ -36,7 +37,9 @@ import { import {type PlaygroundUIProviders} from "@agenta/playground-ui" import { DrawerProvidersProvider, + isCreateContext, workflowRevisionDrawerAtom, + workflowRevisionDrawerContextAtom, closeWorkflowRevisionDrawerAtom, workflowRevisionDrawerCallbackAtom, workflowRevisionDrawerEntityIdAtom, @@ -51,6 +54,7 @@ import {Rocket} from "@phosphor-icons/react" import {Button, Typography, message} from "antd" import {getDefaultStore, useAtom, useAtomValue, useSetAtom} from "jotai" import dynamic from "next/dynamic" +import {useRouter} from "next/router" import OSSdrillInUIProvider from "@/oss/components/DrillInView/OSSdrillInUIProvider" import SimpleSharedEditor from "@/oss/components/EditorViews/SimpleSharedEditor" @@ -62,12 +66,14 @@ import { } from "@/oss/components/Evaluators/components/ConfigureEvaluator/atoms" import EvaluatorPlaygroundHeader from "@/oss/components/Evaluators/components/ConfigureEvaluator/EvaluatorPlaygroundHeader" import {clearEvaluatorWorkflowCache} from "@/oss/components/Evaluators/store/evaluatorsPaginatedStore" +import {invalidateAppManagementWorkflowQueries} from "@/oss/components/pages/app-management/store" import CommitVariantChangesButton from "@/oss/components/Playground/Components/Modals/CommitVariantChangesModal/assets/CommitVariantChangesButton" import DeployVariantButton from "@/oss/components/Playground/Components/Modals/DeployVariantModal/assets/DeployVariantButton" import PlaygroundTestcaseEditor from "@/oss/components/Playground/Components/PlaygroundTestcaseEditor" import {OSSPlaygroundShell} from "@/oss/components/Playground/OSSPlaygroundShell" import SharedGenerationResultUtils from "@/oss/components/SharedGenerationResultUtils" import {usePlaygroundNavigation} from "@/oss/hooks/usePlaygroundNavigation" +import useURL from "@/oss/hooks/useURL" import {useQueryParamState} from "@/oss/state/appState" const PlaygroundMainView = dynamic( @@ -397,10 +403,19 @@ const DrawerPlayground = memo(({entityId}: {entityId: string}) => { }) // ================================================================ -// COMMIT CALLBACK (evaluator create mode) +// COMMIT CALLBACK (evaluator + app create modes) +// +// Fires on commit-success inside the drawer for: +// - evaluator-create: closes drawer, callback receives new revision ID +// - evaluator-view: updates drawer entityId to the newly committed revision +// - app-create: closes drawer FIRST (sync atom resets), then callback +// receives {newAppId, newRevisionId} so the dropdown +// handler can router.push to /apps//playground. +// Order: close → navigate (avoids drawer flicker on +// destination page during Next.js async transition). // ================================================================ -const useEvaluatorCommitCallback = () => { +const useDrawerCreateCommitCallback = () => { const {context} = useAtomValue(workflowRevisionDrawerAtom) const drawerCallback = useAtomValue(workflowRevisionDrawerCallbackAtom) const drawerCallbackRef = useRef(drawerCallback) @@ -410,21 +425,65 @@ const useEvaluatorCommitCallback = () => { const closeDrawerRef = useRef(closeDrawer) closeDrawerRef.current = closeDrawer + const router = useRouter() + const routerRef = useRef(router) + routerRef.current = router + + const {baseAppURL} = useURL() + const baseAppURLRef = useRef(baseAppURL) + baseAppURLRef.current = baseAppURL + const isEvaluator = context === "evaluator-create" || context === "evaluator-view" const isEvaluatorCreate = context === "evaluator-create" + const isAppCreate = context === "app-create" useEffect(() => { - if (!isEvaluator) return + if (!isEvaluator && !isAppCreate) return const previousOnNewRevision = getWorkflowCommitCallbacks().onNewRevision registerWorkflowCommitCallbacks({ onNewRevision: async (result, params) => { - clearEvaluatorWorkflowCache() + if (isEvaluator) { + clearEvaluatorWorkflowCache() + } await previousOnNewRevision?.(result, params) if (isEvaluatorCreate) { - drawerCallbackRef.current?.(result.newRevisionId) + drawerCallbackRef.current?.({ + configId: result.newRevisionId, + newRevisionId: result.newRevisionId, + }) + closeDrawerRef.current() + } else if (isAppCreate) { + const newWorkflow = result.workflow as + | {workflow_id?: string; id?: string} + | undefined + const newAppId = newWorkflow?.workflow_id ?? newWorkflow?.id ?? undefined + const newRevisionId = result.newRevisionId + + // Refresh the apps-page paginated table + count caches so + // the new app shows up immediately on /apps when the user + // navigates back. The shared workflow-list invalidation + // (commit.ts:590) doesn't cover the app-management + // paginated store. + void invalidateAppManagementWorkflowQueries() + + // Fire the user callback first so any analytics/hooks + // see the result. Then handle navigation + close inside + // the wrapper itself — owning the routing here keeps the + // contract simple for callers and avoids brittleness from + // a callback closure capturing stale router references. + drawerCallbackRef.current?.({ + newAppId, + newRevisionId, + }) + + if (newAppId && newRevisionId) { + routerRef.current.push( + `${baseAppURLRef.current}/${newAppId}/playground?revisions=${newRevisionId}`, + ) + } closeDrawerRef.current() } else { // In evaluator-view mode, the selection change callback @@ -437,9 +496,11 @@ const useEvaluatorCommitCallback = () => { } message.success( - isEvaluatorCreate - ? "Evaluator created successfully" - : "Evaluator committed successfully", + isAppCreate + ? "App created successfully" + : isEvaluatorCreate + ? "Evaluator created successfully" + : "Evaluator committed successfully", ) }, }) @@ -449,19 +510,105 @@ const useEvaluatorCommitCallback = () => { onNewRevision: previousOnNewRevision, }) } - }, [isEvaluator, isEvaluatorCreate]) + }, [isEvaluator, isEvaluatorCreate, isAppCreate]) } // ================================================================ // MAIN WRAPPER // ================================================================ +// ================================================================ +// CROSS-CONTEXT CLEANUP — release local-* on close (idle, no commit) +// +// Wires `closeWorkflowRevisionDrawerAtom` to also dispatch +// `discardLocalServerDataAtom` for any local-* entity. Applies to all +// drawer-create contexts (app-create, evaluator-create, trace-replay). +// +// Commit-in-flight gate: if a commit just succeeded, the close was +// triggered BY the commit handler (above), and the entity has already +// been promoted to a real ID. Releasing the local-* entry then is +// safe — the new real entity is in a different atom family. +// +// If a commit is in-flight at the moment of close (e.g., user clicks +// the X mid-commit), the existing close-handler runs synchronously +// before the commit settles. Acceptable for v1: the commit will still +// complete on the server and the user can find the new app in the +// list. The orphan local-* would be cleared by the close, but the +// commit's discardWorkflowDraftAtom call already clears the draft +// layer; the local server data is dead either way. +// ================================================================ + +const useDrawerCloseCleanup = () => { + const isOpen = useAtomValue(workflowRevisionDrawerOpenAtom) + const entityIdRef = useRef(null) + const entityId = useAtomValue(workflowRevisionDrawerEntityIdAtom) + + // `closeWorkflowRevisionDrawerAtom` resets `isOpen` and `entityId` + // atomically in one Jotai write. React batches both updates into the + // same render, so a naive `entityIdRef.current = entityId` during + // render would clobber the ref with `null` BEFORE the cleanup effect + // fires. Only update the ref while the drawer is open, so we keep + // the last truthy ID around to discard on close. + if (isOpen && entityId && entityIdRef.current !== entityId) { + entityIdRef.current = entityId + } + + const discard = useSetAtom(discardLocalServerDataAtom) + const discardRef = useRef(discard) + discardRef.current = discard + + const prevOpenRef = useRef(isOpen) + useEffect(() => { + if (prevOpenRef.current && !isOpen) { + // Drawer just closed — release the entity that was open. + // Read from the ref captured BEFORE the close (since close + // resets entityId to null in the same render). + const id = entityIdRef.current + if (id) discardRef.current(id) + entityIdRef.current = null + } + prevOpenRef.current = isOpen + }, [isOpen]) +} + +// ================================================================ +// REFRESH WARNING — beforeunload guard for unsaved drawer edits +// +// Fires the standard browser "you have unsaved changes" warning when +// the user tries to refresh / close tab while a drawer-create context +// is open AND the entity has unsaved edits. Cross-context: covers +// app-create, evaluator-create, trace-replay. +// ================================================================ + +const useUnsavedDrawerWarning = () => { + const isOpen = useAtomValue(workflowRevisionDrawerOpenAtom) + const context = useAtomValue(workflowRevisionDrawerContextAtom) + const entityId = useAtomValue(workflowRevisionDrawerEntityIdAtom) + const isDirty = useAtomValue( + useMemo(() => workflowMolecule.atoms.isDirty(entityId ?? "__none__"), [entityId]), + ) + + useEffect(() => { + if (!isOpen || !isCreateContext(context) || !isDirty) return + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault() + // Modern browsers ignore the message, but setting returnValue + // is required to trigger the prompt at all. + e.returnValue = "" + } + window.addEventListener("beforeunload", handler) + return () => window.removeEventListener("beforeunload", handler) + }, [isOpen, context, isDirty]) +} + const WorkflowRevisionDrawerWrapper = () => { const isOpen = useAtomValue(workflowRevisionDrawerOpenAtom) const entityId = useAtomValue(workflowRevisionDrawerEntityIdAtom) const [, setQueryRevision] = useQueryParamState("revisionId") - useEvaluatorCommitCallback() + useDrawerCreateCommitCallback() + useDrawerCloseCleanup() + useUnsavedDrawerWarning() // Clear revisionId from URL when drawer closes const prevOpenRef = useRef(isOpen) diff --git a/web/oss/src/components/pages/app-management/components/ApplicationManagementSection.tsx b/web/oss/src/components/pages/app-management/components/ApplicationManagementSection.tsx index 64452308dd..8a986aacbe 100644 --- a/web/oss/src/components/pages/app-management/components/ApplicationManagementSection.tsx +++ b/web/oss/src/components/pages/app-management/components/ApplicationManagementSection.tsx @@ -1,9 +1,8 @@ -import {type SetStateAction, useCallback, useEffect, useMemo} from "react" +import {useCallback, useEffect, useMemo} from "react" import {workflowMolecule} from "@agenta/entities/workflow" import {extractApiErrorMessage} from "@agenta/shared/utils" import {InfiniteVirtualTableFeatureShell, useTableManager} from "@agenta/ui/table" -import {PlusOutlined} from "@ant-design/icons" import {Tray} from "@phosphor-icons/react" import {Button, Empty, Space, Typography, message} from "antd" import {useAtomValue, useSetAtom} from "jotai" @@ -24,20 +23,17 @@ import { import type {AppWorkflowRow} from "../store" import {createAppWorkflowColumns, type AppWorkflowColumnActions} from "./appWorkflowColumns" +import CreateAppDropdown from "./CreateAppDropdown" import EmptyAppView from "./EmptyAppView" interface ApplicationManagementSectionProps { - setIsAddAppFromTemplatedModal?: (value: SetStateAction) => void mode?: "active" | "archived" } const {Title} = Typography const PAGE_SIZE = 10 -const ApplicationManagementSection = ({ - setIsAddAppFromTemplatedModal, - mode = "active", -}: ApplicationManagementSectionProps) => { +const ApplicationManagementSection = ({mode = "active"}: ApplicationManagementSectionProps) => { const tableState = getAppWorkflowTableState(mode) const isArchived = tableState.mode === "archived" const router = useRouter() @@ -132,16 +128,10 @@ const ApplicationManagementSection = ({ > Archived - + ), - [baseAppURL, isArchived, router, setIsAddAppFromTemplatedModal], + [baseAppURL, isArchived, router], ) const emptyState = useMemo(() => { @@ -153,12 +143,8 @@ const ApplicationManagementSection = ({ ) } - return setIsAddAppFromTemplatedModal ? ( - - ) : ( - - ) - }, [isArchived, setIsAddAppFromTemplatedModal]) + return + }, [isArchived]) return (
diff --git a/web/oss/src/components/pages/app-management/components/CreateAppDropdown/index.tsx b/web/oss/src/components/pages/app-management/components/CreateAppDropdown/index.tsx new file mode 100644 index 0000000000..fdc6300665 --- /dev/null +++ b/web/oss/src/components/pages/app-management/components/CreateAppDropdown/index.tsx @@ -0,0 +1,209 @@ +import {memo, useCallback, useMemo, useRef, useState, useTransition} from "react" + +import { + appTemplatesQueryAtom, + createEphemeralAppFromTemplate, + type AppType, +} from "@agenta/entities/workflow" +import {openWorkflowRevisionDrawerAtom} from "@agenta/playground-ui/workflow-revision-drawer" +import {cn, textColors, bgColors, borderColors} from "@agenta/ui" +import {PlusOutlined} from "@ant-design/icons" +import {ArrowRight} from "@phosphor-icons/react" +import {Button, Popover, Typography, message} from "antd" +import {useAtomValue, useSetAtom} from "jotai" + +import {getAppTypeIcon} from "../../../prompts/assets/iconHelpers" + +interface CreateAppDropdownItem { + type: AppType + label: string + description: string + testId: string +} + +const ITEMS: CreateAppDropdownItem[] = [ + { + type: "chat", + label: "Chat", + description: "Conversational app with message history.", + testId: "create-app-dropdown-chat", + }, + { + type: "completion", + label: "Completion", + description: "Single-shot prompt completion.", + testId: "create-app-dropdown-completion", + }, +] + +interface CreateAppDropdownProps { + /** Custom trigger element (defaults to "Create New Prompt" button) */ + trigger?: React.ReactNode + /** Additional class name for the trigger wrapper */ + className?: string +} + +/** + * Dropdown for creating a new app. Replaces the legacy `AddAppFromTemplateModal`. + * + * Chat / Completion: mints a `local-*` ephemeral via `createEphemeralAppFromTemplate`, + * opens `WorkflowRevisionDrawer` with `context: "app-create"`. Commit promotes + * ephemeral → real app + variant + v1 in one server call, drawer closes, user + * lands on `/apps//playground?revisions=`. + * + * Custom workflow has its own entry point ("Set up workflow" in the prompts + * breadcrumb / table action menu) and is intentionally not surfaced here. + * + * Race guard: rapid double-click is handled with `useTransition` + `AbortController`. + * While a factory call is in-flight, dropdown items are disabled and any newer + * click aborts the prior request before the drawer opens for the wrong type. + */ +const CreateAppDropdown = ({trigger, className}: CreateAppDropdownProps) => { + const [open, setOpen] = useState(false) + const [isPending, startTransition] = useTransition() + const inflightRef = useRef(null) + + const setOpenDrawer = useSetAtom(openWorkflowRevisionDrawerAtom) + + // Pre-fetch the catalog templates as soon as the dropdown mounts so the + // factory has data ready when the user clicks Chat / Completion. Without + // this subscription, the templates query is lazy and the first click + // pays the full fetch latency before the drawer can open. + useAtomValue(appTemplatesQueryAtom) + + const handleSelect = useCallback( + (item: CreateAppDropdownItem) => { + if (isPending) return + setOpen(false) + + // Cancel any prior in-flight request (rapid double-click pre-pending). + inflightRef.current?.abort() + const controller = new AbortController() + inflightRef.current = controller + const appType: AppType = item.type + + startTransition(async () => { + try { + const entityId = await createEphemeralAppFromTemplate({ + type: appType, + signal: controller.signal, + }) + if (controller.signal.aborted) return + if (!entityId) { + message.error("Couldn't start app creation — please retry") + return + } + // The drawer wrapper owns navigation for `app-create` + // (see `useDrawerCreateCommitCallback`) — it closes the + // drawer and pushes to /apps//playground in one + // transition. Avoid passing `onWorkflowCreated` here so + // we don't double-navigate. + setOpenDrawer({ + entityId, + context: "app-create", + }) + } finally { + if (inflightRef.current === controller) inflightRef.current = null + } + }) + }, + [isPending, setOpenDrawer], + ) + + const popoverContent = useMemo( + () => ( +
+
+ + Select app type + +
+
+ {ITEMS.map((item) => { + const disabled = isPending + return ( + + ) + })} +
+
+ ), + [handleSelect, isPending], + ) + + const defaultTrigger = ( + + ) + + return ( + + {trigger ?? defaultTrigger} + + ) +} + +export default memo(CreateAppDropdown) diff --git a/web/oss/src/components/pages/app-management/components/EmptyAppView.tsx b/web/oss/src/components/pages/app-management/components/EmptyAppView.tsx index 22f2dda125..bbbea3d15c 100644 --- a/web/oss/src/components/pages/app-management/components/EmptyAppView.tsx +++ b/web/oss/src/components/pages/app-management/components/EmptyAppView.tsx @@ -1,15 +1,10 @@ -import {SetStateAction} from "react" - -import {PlusOutlined} from "@ant-design/icons" -import {Button, Typography} from "antd" +import {Typography} from "antd" import Image from "next/image" import {createUseStyles} from "react-jss" import {JSSTheme} from "@/oss/lib/Types" -interface EmptyAppViewProps { - setIsAddAppFromTemplatedModal: (value: SetStateAction) => void -} +import CreateAppDropdown from "./CreateAppDropdown" const useStyles = createUseStyles((theme: JSSTheme) => ({ container: { @@ -35,21 +30,14 @@ const useStyles = createUseStyles((theme: JSSTheme) => ({ }, })) -const EmptyAppView = ({setIsAddAppFromTemplatedModal}: EmptyAppViewProps) => { +const EmptyAppView = () => { const classes = useStyles() return (
not-found Click here to create your first prompt - +
) diff --git a/web/oss/src/components/pages/app-management/components/appWorkflowColumns.tsx b/web/oss/src/components/pages/app-management/components/appWorkflowColumns.tsx index 4e567aac29..4fb02ecf97 100644 --- a/web/oss/src/components/pages/app-management/components/appWorkflowColumns.tsx +++ b/web/oss/src/components/pages/app-management/components/appWorkflowColumns.tsx @@ -83,14 +83,14 @@ export function createAppWorkflowColumns( ), }, ...(isArchived - ? ([ + ? [ { - type: "date", + type: "date" as const, key: "deletedAt", title: "Archived At", }, { - type: "text", + type: "text" as const, key: "deletedById", title: "Archived By", render: (_: unknown, record: AppWorkflowRow) => ( @@ -104,7 +104,7 @@ export function createAppWorkflowColumns(
), }, - ] as const) + ] : []), { type: "actions", diff --git a/web/oss/src/components/pages/app-management/index.tsx b/web/oss/src/components/pages/app-management/index.tsx index e754af6818..90dad3fdca 100644 --- a/web/oss/src/components/pages/app-management/index.tsx +++ b/web/oss/src/components/pages/app-management/index.tsx @@ -1,6 +1,6 @@ -import {useEffect, useState} from "react" +import {useCallback, useEffect, useState} from "react" -import {workflowMolecule} from "@agenta/entities/workflow" +import {appTemplatesQueryAtom} from "@agenta/entities/workflow" import {PageLayout} from "@agenta/ui" import {Typography} from "antd" import {useAtomValue, useSetAtom} from "jotai" @@ -9,35 +9,20 @@ import dynamic from "next/dynamic" import {useAppTheme} from "@/oss/components/Layout/ThemeContextProvider" import {welcomeCardsDismissedAtom} from "@/oss/components/pages/app-management/components/WelcomeCardsSection/assets/store/welcomeCards" import ResultComponent from "@/oss/components/ResultComponent/ResultComponent" -import {useVaultSecret} from "@/oss/hooks/useVaultSecret" -import {usePostHogAg} from "@/oss/lib/helpers/analytics/hooks/usePostHogAg" -import {type LlmProvider} from "@/oss/lib/helpers/llmProviders" -import {isDemo} from "@/oss/lib/helpers/utils" import { onboardingWidgetActivationAtom, - recordWidgetEventAtom, setOnboardingWidgetActivationAtom, } from "@/oss/lib/onboarding" import {StyleProps} from "@/oss/lib/Types" -import {waitForAppToStart} from "@/oss/services/api" -import {createAppWithTemplate} from "@/oss/services/app-selector/api" import {useAppsData} from "@/oss/state/app" -import {appCreationStatusAtom, resetAppCreationAtom} from "@/oss/state/appCreation/status" -import {useProfileData} from "@/oss/state/profile" -import {getProjectValues} from "@/oss/state/project" -import {timeout} from "./assets/helpers" import {useStyles} from "./assets/styles" import ApplicationManagementSection from "./components/ApplicationManagementSection" import HelpAndSupportSection from "./components/HelpAndSupportSection" import WelcomeCardsSection from "./components/WelcomeCardsSection" -import {invalidateAppManagementWorkflowQueries} from "./store" -const CreateAppStatusModal: any = dynamic( - () => import("@/oss/components/pages/app-management/modals/CreateAppStatusModal"), -) -const AddAppFromTemplatedModal: any = dynamic( - () => import("@/oss/components/pages/app-management/modals/AddAppFromTemplateModal"), +const CreateAppTypeModal: any = dynamic( + () => import("@/oss/components/pages/app-management/modals/CreateAppTypeModal"), ) const SetupTracingModal: any = dynamic( @@ -49,71 +34,36 @@ const ObservabilityDashboardSection: any = dynamic( ) const AppManagement: React.FC = () => { - const statusData = useAtomValue(appCreationStatusAtom) - const setStatusData = useSetAtom(appCreationStatusAtom) - const resetAppCreation = useSetAtom(resetAppCreationAtom) - const [statusModalOpen, setStatusModalOpen] = useState(false) const onboardingWidgetActivation = useAtomValue(onboardingWidgetActivationAtom) - const recordWidgetEvent = useSetAtom(recordWidgetEventAtom) const setOnboardingWidgetActivation = useSetAtom(setOnboardingWidgetActivationAtom) const welcomeCardsDismissed = useAtomValue(welcomeCardsDismissedAtom) - const posthog = usePostHogAg() const {appTheme} = useAppTheme() const classes = useStyles({themeMode: appTheme} as StyleProps) - const {user} = useProfileData() - const [templateKey, setTemplateKey] = useState(undefined) - const [isAddAppFromTemplatedModal, setIsAddAppFromTemplatedModal] = useState(false) + const [isCreateAppTypeModalOpen, setIsCreateAppTypeModalOpen] = useState(false) const [isSetupTracingModal, setIsSetupTracingModal] = useState(false) - const [appName, setAppName] = useState("") - const [appSlug, setAppSlug] = useState(undefined) - const {error, mutate} = useAppsData() - - const {secrets} = useVaultSecret() - - const handleTemplateCardClick = async ( - templateId: string, - submittedAppName: string, - submittedAppSlug?: string, - ) => { - setAppName(submittedAppName) - setAppSlug(submittedAppSlug) - setTemplateKey(templateId) - setIsAddAppFromTemplatedModal(false) - setStatusModalOpen(true) - resetAppCreation() - - // attempt to create and start the template, notify user of the progress - const apiKeys = secrets - await createAppWithTemplate({ - appName: submittedAppName, - slug: submittedAppSlug, - templateKey: templateId, - providerKey: isDemo() && apiKeys?.length === 0 ? [] : (apiKeys as LlmProvider[]), - onStatusChange: async (status, details, appId) => { - if (["error", "bad_request", "timeout", "success"].includes(status)) - if (status === "success") { - await mutate?.() - await invalidateAppManagementWorkflowQueries() - posthog?.capture?.("app_deployment", { - properties: { - app_id: appId, - environment: "UI", - deployed_by: user?.id, - }, - }) - recordWidgetEvent("prompt_created") - } - - setStatusData((prev) => ({...prev, status, details, appId: appId || prev.appId})) - }, - }) - } + const {error} = useAppsData() + + // Pre-fetch the catalog templates on page mount so the welcome-card + // "Create a prompt" shortcut and the apps-table dropdown both have + // data ready. This avoids the first-click latency cliff when the + // factory falls back to a synchronous fetch. + useAtomValue(appTemplatesQueryAtom) + + /** + * "Create a prompt" welcome-card shortcut: opens the CreateAppTypeModal + * so the user explicitly picks Chat or Completion before we mint the + * ephemeral app. The modal handles drawer navigation; we only own + * opening the modal here. + */ + const handleCreatePrompt = useCallback(() => { + setIsCreateAppTypeModalOpen(true) + }, []) useEffect(() => { if (onboardingWidgetActivation !== "open-create-prompt") return - setIsAddAppFromTemplatedModal(true) + handleCreatePrompt() setOnboardingWidgetActivation(null) - }, [onboardingWidgetActivation, setOnboardingWidgetActivation]) + }, [handleCreatePrompt, onboardingWidgetActivation, setOnboardingWidgetActivation]) useEffect(() => { if (onboardingWidgetActivation !== "tracing-snippet") return @@ -121,36 +71,6 @@ const AppManagement: React.FC = () => { setOnboardingWidgetActivation(null) }, [onboardingWidgetActivation, setOnboardingWidgetActivation]) - const onErrorRetry = async () => { - if (statusData.appId) { - setStatusData((prev) => ({...prev, status: "cleanup", details: undefined})) - const {projectId} = getProjectValues() - await workflowMolecule.lifecycle - .archive(statusData.appId, {projectId}) - .catch(console.error) - await mutate?.() - await invalidateAppManagementWorkflowQueries() - } - handleTemplateCardClick(templateKey as string, appName, appSlug) - } - - const onTimeoutRetry = async () => { - if (!statusData.appId) return - setStatusData((prev) => ({...prev, status: "configuring_app", details: undefined})) - try { - await waitForAppToStart({appId: statusData.appId, timeout}) - } catch (error: any) { - if (error.message === "timeout") { - setStatusData((prev) => ({...prev, status: "timeout", details: undefined})) - } else { - setStatusData((prev) => ({...prev, status: "error", details: error})) - } - } - setStatusData((prev) => ({...prev, status: "success", details: undefined})) - await mutate?.() - await invalidateAppManagementWorkflowQueries() - } - return ( <> @@ -165,15 +85,13 @@ const AppManagement: React.FC = () => { )} setIsAddAppFromTemplatedModal(true)} + onCreatePrompt={handleCreatePrompt} onSetupTracing={() => setIsSetupTracingModal(true)} /> - + @@ -185,23 +103,9 @@ const AppManagement: React.FC = () => { onCancel={() => setIsSetupTracingModal(false)} /> - setIsAddAppFromTemplatedModal(false)} - handleTemplateCardClick={handleTemplateCardClick} - /> - - { - setStatusModalOpen(false) - resetAppCreation() - }} - statusData={statusData} - appName={appName} + setIsCreateAppTypeModalOpen(false)} /> ) diff --git a/web/oss/src/components/pages/app-management/modals/AddAppFromTemplateModal/assets/styles.ts b/web/oss/src/components/pages/app-management/modals/AddAppFromTemplateModal/assets/styles.ts deleted file mode 100644 index 1a1aed6fa1..0000000000 --- a/web/oss/src/components/pages/app-management/modals/AddAppFromTemplateModal/assets/styles.ts +++ /dev/null @@ -1,71 +0,0 @@ -import {createUseStyles} from "react-jss" - -import type {JSSTheme} from "@/oss/lib/Types" - -export const useStyles = createUseStyles((theme: JSSTheme) => ({ - modalContainer: { - transition: "width 0.3s ease", - "& .ant-modal-content": { - overflow: "hidden", - borderRadius: 16, - "& > .ant-modal-close": { - top: 16, - }, - }, - }, - modal: { - display: "flex", - flexDirection: "column", - gap: 16, - }, - modalError: { - color: theme.colorError, - marginTop: 2, - }, - headerText: { - "& .ant-typography": { - lineHeight: theme.lineHeightLG, - fontSize: theme.fontSizeHeading4, - fontWeight: theme.fontWeightStrong, - }, - }, - title: { - fontSize: theme.fontSizeLG, - fontWeight: theme.fontWeightMedium, - lineHeight: theme.lineHeightLG, - }, - label: { - fontWeight: theme.fontWeightMedium, - }, - card: { - width: 208, - height: 180, - cursor: "pointer", - transitionDuration: "0.3s", - "&:hover": { - boxShadow: theme.boxShadow, - }, - "& > .ant-card-head": { - minHeight: 0, - padding: theme.paddingSM, - - "& .ant-card-head-title": { - fontSize: theme.fontSize, - fontWeight: theme.fontWeightMedium, - lineHeight: theme.lineHeight, - }, - }, - "& > .ant-card-body": { - padding: theme.paddingSM, - "& > .ant-typography": { - color: theme.colorTextSecondary, - }, - }, - }, - inputName: { - borderColor: `${theme.colorError} !important`, - "& .ant-input-clear-icon": { - color: theme.colorError, - }, - }, -})) diff --git a/web/oss/src/components/pages/app-management/modals/AddAppFromTemplateModal/components/AddAppFromTemplateModalContent.tsx b/web/oss/src/components/pages/app-management/modals/AddAppFromTemplateModal/components/AddAppFromTemplateModalContent.tsx deleted file mode 100644 index 3b251969b9..0000000000 --- a/web/oss/src/components/pages/app-management/modals/AddAppFromTemplateModal/components/AddAppFromTemplateModalContent.tsx +++ /dev/null @@ -1,337 +0,0 @@ -import {useCallback, useEffect, useMemo, useRef, useState} from "react" - -import { - generateSlugWithExistingSuffix, - generateSlugWithSuffix, - getSlugSuffix, - isValidSlug, - regenerateSlugSuffix, -} from "@agenta/shared/utils" -import {ArrowClockwise} from "@phosphor-icons/react" -import {Button, Card, Flex, Input, notification, Radio, Tag, Typography} from "antd" -import clsx from "clsx" - -import {isAppNameInputValid} from "@/oss/lib/helpers/utils" -import {GenericObject} from "@/oss/lib/Types" -import {useAppsData, useTemplates} from "@/oss/state/app" - -import {getTemplateKey} from "../../../assets/helpers" -import {useStyles} from "../assets/styles" - -const {Text} = Typography - -interface AddAppFromTemplateModalContentProps { - handleTemplateCardClick: ( - templateId: string, - appName: string, - appSlug?: string, - ) => Promise -} - -const AddAppFromTemplateModalContent = ({ - handleTemplateCardClick, -}: AddAppFromTemplateModalContentProps) => { - const classes = useStyles() - - const [newApp, setNewApp] = useState("") - const [newAppSlug, setNewAppSlug] = useState(null) - const [slugEditing, setSlugEditing] = useState(false) - const [templateKey, setTemplateKey] = useState(undefined) - const generatedSlugSuffixRef = useRef(null) - const slugManuallyEditedRef = useRef(false) - - const {apps} = useAppsData() - const [{data: allTemplates = [], isLoading: fetchingTemplate}, noTemplateMessage] = - useTemplates() - - const templates = useMemo( - () => - allTemplates.filter( - (t) => - !t.data?.uri?.startsWith("agenta:custom:") && - !t.data?.uri?.startsWith("agenta:builtin:llm:"), - ), - [allTemplates], - ) - - const appNameExist = useMemo( - () => - apps.some( - (app: GenericObject) => - ((app?.name ?? app?.slug) || "").toLowerCase() === newApp.toLowerCase(), - ), - [apps, newApp], - ) - - const appSlugExist = useMemo(() => { - const slug = newAppSlug?.trim().toLowerCase() - if (!slug) return false - - return apps.some((app: GenericObject) => { - const appSlug = typeof app?.slug === "string" ? app.slug.trim().toLowerCase() : "" - return appSlug === slug - }) - }, [apps, newAppSlug]) - - const isError = appNameExist || (newApp.length > 0 && !isAppNameInputValid(newApp)) - const slugValidationError = - newAppSlug && !isValidSlug(newAppSlug) - ? "Slug may only contain a-z, 0-9, hyphens, underscores, and periods." - : appSlugExist - ? "App slug already exists" - : null - - useEffect(() => { - if (!newApp.trim()) { - setNewAppSlug(null) - setSlugEditing(false) - generatedSlugSuffixRef.current = null - slugManuallyEditedRef.current = false - return - } - - if (slugManuallyEditedRef.current) return - - const generatedSlug = generateSlugWithExistingSuffix(newApp, generatedSlugSuffixRef.current) - generatedSlugSuffixRef.current = getSlugSuffix(generatedSlug) - setNewAppSlug(generatedSlug) - }, [newApp]) - - const handleAppNameChange = useCallback( - (value: string) => { - setNewApp(value) - - if (!value.trim()) { - setNewAppSlug(null) - setSlugEditing(false) - generatedSlugSuffixRef.current = null - slugManuallyEditedRef.current = false - return - } - - if (!slugManuallyEditedRef.current && !newAppSlug?.trim() && value.trim()) { - const generatedSlug = generateSlugWithExistingSuffix( - value, - generatedSlugSuffixRef.current, - ) - generatedSlugSuffixRef.current = getSlugSuffix(generatedSlug) - setNewAppSlug(generatedSlug) - } - }, - [newAppSlug, slugEditing], - ) - - const handleSlugInputChange = useCallback((value: string) => { - slugManuallyEditedRef.current = true - setNewAppSlug(value) - }, []) - - const handleRegenerateSlug = useCallback(() => { - const generatedSlug = regenerateSlugSuffix( - newAppSlug || newApp, - generatedSlugSuffixRef.current, - ) - generatedSlugSuffixRef.current = getSlugSuffix(generatedSlug) - setNewAppSlug(generatedSlug) - }, [newApp, newAppSlug]) - - const handleEditSlug = useCallback(() => { - if (!newAppSlug && newApp.trim()) { - const generatedSlug = generateSlugWithSuffix(newApp) - generatedSlugSuffixRef.current = getSlugSuffix(generatedSlug) - setNewAppSlug(generatedSlug) - } - setSlugEditing(true) - }, [newApp, newAppSlug]) - - const handleCreateApp = useCallback(() => { - if (appNameExist) { - notification.warning({ - message: "Template Selection", - description: "App name already exists. Please choose a different name.", - duration: 3, - }) - } else if (slugValidationError) { - notification.warning({ - message: "Template Selection", - description: slugValidationError, - duration: 3, - }) - } else if (fetchingTemplate && newApp.length > 0 && isAppNameInputValid(newApp)) { - notification.info({ - message: "Template Selection", - description: "The template image is currently being fetched. Please wait...", - duration: 3, - }) - } else if ( - !fetchingTemplate && - newApp.length > 0 && - isAppNameInputValid(newApp) && - newAppSlug && - !slugValidationError - ) { - handleTemplateCardClick(templateKey as string, newApp, newAppSlug) - } else { - notification.warning({ - message: "Template Selection", - description: "Please provide a valid app name to choose a template.", - duration: 3, - }) - } - }, [ - appNameExist, - fetchingTemplate, - handleTemplateCardClick, - newApp, - newAppSlug, - slugValidationError, - templateKey, - ]) - - const handleEnterKeyPress = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === "Enter" && templateKey) { - handleCreateApp() - } - }, - [handleCreateApp, templateKey], - ) - - const onCardClick = useCallback((template: (typeof templates)[number]) => { - const key = getTemplateKey(template) - if (key) { - setTemplateKey(key) - } - }, []) - - return ( -
-
- Create New Prompt -
- -
- Provide the name of the application - handleAppNameChange(e.target.value)} - onKeyDown={handleEnterKeyPress} - className={`${isError && classes.inputName}`} - allowClear - /> - - {appNameExist && ( - - App name already exists - - )} - {newApp.length > 0 && !isAppNameInputValid(newApp) && ( - - App name must contain only letters, numbers, underscore, or dash without any - spaces. - - )} - -
- {slugEditing ? ( - <> - Slug - handleSlugInputChange(e.target.value)} - status={slugValidationError ? "error" : undefined} - suffix={ - - - )} -
- )} - {!slugEditing && slugValidationError && ( - - {slugValidationError} - - )} -
- - -
- Choose the prompt type - - {noTemplateMessage ? ( - - {noTemplateMessage} - - ) : ( - templates.map((temp) => ( - } - className={clsx(classes.card, "capitalize")} - onClick={() => onCardClick(temp)} - > - {temp.description ?? ""} - - )) - )} - -
- -
- -
-
- ) -} - -export default AddAppFromTemplateModalContent diff --git a/web/oss/src/components/pages/app-management/modals/AddAppFromTemplateModal/index.tsx b/web/oss/src/components/pages/app-management/modals/AddAppFromTemplateModal/index.tsx deleted file mode 100644 index ea6298551f..0000000000 --- a/web/oss/src/components/pages/app-management/modals/AddAppFromTemplateModal/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import {EnhancedModal} from "@agenta/ui" - -import AddAppFromTemplateModalContent from "./components/AddAppFromTemplateModalContent" -import {AddAppFromTemplatedModalProps} from "./types" - -const AddAppFromTemplatedModal = ({ - open, - onCancel, - handleTemplateCardClick, -}: AddAppFromTemplatedModalProps) => { - return ( - - - - ) -} - -export default AddAppFromTemplatedModal diff --git a/web/oss/src/components/pages/app-management/modals/AddAppFromTemplateModal/types.ts b/web/oss/src/components/pages/app-management/modals/AddAppFromTemplateModal/types.ts deleted file mode 100644 index f19e70607d..0000000000 --- a/web/oss/src/components/pages/app-management/modals/AddAppFromTemplateModal/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface AddAppFromTemplatedModalProps { - open: boolean - onCancel: () => void - handleTemplateCardClick: ( - templateId: string, - appName: string, - appSlug?: string, - ) => Promise -} diff --git a/web/oss/src/components/pages/app-management/modals/CreateAppTypeModal/index.tsx b/web/oss/src/components/pages/app-management/modals/CreateAppTypeModal/index.tsx new file mode 100644 index 0000000000..9f07b6b354 --- /dev/null +++ b/web/oss/src/components/pages/app-management/modals/CreateAppTypeModal/index.tsx @@ -0,0 +1,179 @@ +/** + * CreateAppTypeModal + * + * Onboarding modal that surfaces the two built-in app types (Chat / + * Completion) as large, equal-weight choices. Used by the welcome-card + * "Create a prompt" entry on the home page so first-time users explicitly + * pick a type rather than landing on a Chat default. + * + * The repeat-user dropdown next to the apps table (`CreateAppDropdown`) + * keeps a compact list-style picker; this modal is intentionally heavier + * for the onboarding context. + * + * On selection: mints a `local-*` ephemeral via + * `createEphemeralAppFromTemplate` and opens the unified + * `WorkflowRevisionDrawer` with `context: "app-create"`. Navigation on + * commit is owned by the drawer wrapper — no `onWorkflowCreated` callback + * needed here. + */ +import {memo, useCallback, useRef, useState, useTransition} from "react" + +import {createEphemeralAppFromTemplate, type AppType} from "@agenta/entities/workflow" +import {openWorkflowRevisionDrawerAtom} from "@agenta/playground-ui/workflow-revision-drawer" +import {cn, textColors, borderColors} from "@agenta/ui" +import {ArrowRight} from "@phosphor-icons/react" +import {Typography, message} from "antd" +import {useSetAtom} from "jotai" + +import EnhancedModal from "@/oss/components/EnhancedUIs/Modal" + +import {getAppTypeIcon} from "../../../prompts/assets/iconHelpers" + +const {Title, Text} = Typography + +interface CreateAppTypeOption { + type: AppType + label: string + description: string + testId: string +} + +const OPTIONS: CreateAppTypeOption[] = [ + { + type: "chat", + label: "Chat", + description: "Conversational app with message history.", + testId: "create-app-type-modal-chat", + }, + { + type: "completion", + label: "Completion", + description: "Single-shot prompt completion.", + testId: "create-app-type-modal-completion", + }, +] + +interface CreateAppTypeModalProps { + open: boolean + onCancel: () => void +} + +const CreateAppTypeModal = ({open, onCancel}: CreateAppTypeModalProps) => { + const [isPending, startTransition] = useTransition() + const inflightRef = useRef(null) + const [activeType, setActiveType] = useState(null) + + const setOpenDrawer = useSetAtom(openWorkflowRevisionDrawerAtom) + + const handleSelect = useCallback( + (option: CreateAppTypeOption) => { + if (isPending) return + + // Cancel any prior in-flight request (rapid double-click). + inflightRef.current?.abort() + const controller = new AbortController() + inflightRef.current = controller + setActiveType(option.type) + + startTransition(async () => { + try { + const entityId = await createEphemeralAppFromTemplate({ + type: option.type, + signal: controller.signal, + }) + if (controller.signal.aborted) return + if (!entityId) { + message.error("Couldn't start app creation — please retry") + return + } + onCancel() + setOpenDrawer({ + entityId, + context: "app-create", + }) + } finally { + if (inflightRef.current === controller) inflightRef.current = null + setActiveType(null) + } + }) + }, + [isPending, onCancel, setOpenDrawer], + ) + + return ( + +
+ + Create a new prompt + + Choose the type of app you want to build. +
+ +
+ {OPTIONS.map((option) => { + const disabled = isPending + const isActive = activeType === option.type + return ( + + ) + })} +
+
+ ) +} + +export default memo(CreateAppTypeModal) diff --git a/web/oss/src/components/pages/app-management/modals/CustomWorkflowModal/components/CustomWorkflowModalContent.tsx b/web/oss/src/components/pages/app-management/modals/CustomWorkflowModal/components/CustomWorkflowModalContent.tsx index 68de3585a6..f1931d2367 100644 --- a/web/oss/src/components/pages/app-management/modals/CustomWorkflowModal/components/CustomWorkflowModalContent.tsx +++ b/web/oss/src/components/pages/app-management/modals/CustomWorkflowModal/components/CustomWorkflowModalContent.tsx @@ -9,7 +9,6 @@ import {Scroll} from "@phosphor-icons/react" import {Typography, Space, Button, notification} from "antd" import {useAtom, useAtomValue} from "jotai" -import {isAppNameInputValid} from "@/oss/lib/helpers/utils" import {updateVariant} from "@/oss/services/app-selector/api" import {useAppsData} from "@/oss/state/app" import { @@ -214,10 +213,7 @@ const CustomWorkflowModalContent = ({ editorType="border" placeholder="Enter app name" editorClassName={`!border-none !shadow-none px-0 ${ - appNameExist || - (values.appName.length > 0 && !isAppNameInputValid(values.appName)) - ? "border-red-500 !border" - : "" + appNameExist ? "border-red-500 !border" : "" }`} className="py-1 px-[11px] !w-auto" useAntdInput @@ -237,19 +233,6 @@ const CustomWorkflowModalContent = ({ App name already exists )} - {values.appName.length > 0 && !isAppNameInputValid(values.appName) && ( - - App name must contain only letters, numbers, underscore, or dash without any - spaces. - - )} { @@ -132,12 +129,6 @@ const CustomWorkflowModalFooter = ({ "App name already exists. Please choose a different name.", duration: 3, }) - } else if (!isAppNameInputValid(customWorkflowAppValues.appName)) { - notification.warning({ - message: "Custom Workflow", - description: "Please provide a valid app name.", - duration: 3, - }) } else { handleCreateApp() } diff --git a/web/oss/src/components/pages/app-management/modals/EditAppModal/index.tsx b/web/oss/src/components/pages/app-management/modals/EditAppModal/index.tsx index 594ae78fc6..b62423bd1f 100644 --- a/web/oss/src/components/pages/app-management/modals/EditAppModal/index.tsx +++ b/web/oss/src/components/pages/app-management/modals/EditAppModal/index.tsx @@ -7,7 +7,6 @@ import clsx from "clsx" import {useAtomValue, useSetAtom} from "jotai" import {createUseStyles} from "react-jss" -import {isAppNameInputValid} from "@/oss/lib/helpers/utils" import {GenericObject, JSSTheme} from "@/oss/lib/Types" import {useAppsData} from "@/oss/state/app" import {getProjectValues} from "@/oss/state/project" @@ -92,11 +91,6 @@ const EditAppModal = () => { {appNameExist && (
App name already exists
)} - {appNameInput?.length > 0 && !isAppNameInputValid(appNameInput) && ( -
- App name must contain only letters, numbers, underscore, or dash -
- )} ) diff --git a/web/oss/src/components/pages/app-management/store/appWorkflowStore.ts b/web/oss/src/components/pages/app-management/store/appWorkflowStore.ts index 0ab3e17d4f..02300456fc 100644 --- a/web/oss/src/components/pages/app-management/store/appWorkflowStore.ts +++ b/web/oss/src/components/pages/app-management/store/appWorkflowStore.ts @@ -420,6 +420,10 @@ export const appWorkflowCountAtom = atom((get) => { return query.data ?? 0 }) +// ============================================================================ +// ARCHIVED STORE +// ============================================================================ + const archivedAppWorkflowSearchTermAtom = atom("") const archivedAppWorkflowMetaAtom = atom((get) => ({ @@ -431,14 +435,18 @@ const archivedAppWorkflowMetaAtom = atom((get) => ({ const archivedAppWorkflowPaginatedStore = createPaginatedEntityStore< AppWorkflowRow, - Workflow, + EnrichedWorkflow, AppWorkflowQueryMeta >({ entityName: "archivedAppWorkflow", metaAtom: archivedAppWorkflowMetaAtom, - fetchPage: async ({meta, limit, cursor}): Promise> => { + fetchPage: async ({ + meta, + limit, + cursor, + }): Promise> => { if (!meta.projectId) { - return emptyFetchResult() + return emptyFetchResult() } const archivedWorkflows = await fetchArchivedAppWorkflows(meta) @@ -569,5 +577,3 @@ export async function invalidateAppManagementWorkflowQueries() { queryClient.invalidateQueries({queryKey: ["archivedAppWorkflowCount"], exact: false}), ]) } - -export {appWorkflowSearchTermAtom} diff --git a/web/oss/src/components/pages/app-management/store/index.ts b/web/oss/src/components/pages/app-management/store/index.ts index 12ef6e7cc6..d27bfdb43a 100644 --- a/web/oss/src/components/pages/app-management/store/index.ts +++ b/web/oss/src/components/pages/app-management/store/index.ts @@ -2,13 +2,13 @@ export { getAppWorkflowTableState, appWorkflowPaginatedStore, workflowPaginatedStore, - appWorkflowSearchTermAtom, appWorkflowCountAtom, appWorkflowTotalCountAtom, invalidateAppManagementWorkflowQueries, type AppWorkflowRow, } from "./appWorkflowStore" export { + appWorkflowSearchTermAtom, workflowInvokableOnlyAtom, workflowTypeFilterAtom, type WorkflowTypeFilter, diff --git a/web/oss/src/components/pages/prompts/PromptsPage.tsx b/web/oss/src/components/pages/prompts/PromptsPage.tsx index 278d3bbc08..f7c12e303c 100644 --- a/web/oss/src/components/pages/prompts/PromptsPage.tsx +++ b/web/oss/src/components/pages/prompts/PromptsPage.tsx @@ -1,6 +1,11 @@ import {useCallback, useEffect, useMemo, useState} from "react" -import {workflowMolecule} from "@agenta/entities/workflow" +import { + appTemplatesQueryAtom, + createEphemeralAppFromTemplate, + type AppType, +} from "@agenta/entities/workflow" +import {openWorkflowRevisionDrawerAtom} from "@agenta/playground-ui/workflow-revision-drawer" import {PageLayout} from "@agenta/ui" import type { InfiniteVirtualTableRowSelection, @@ -17,18 +22,12 @@ import {timeout} from "@/oss/components/pages/app-management/assets/helpers" import useCustomWorkflowConfig from "@/oss/components/pages/app-management/modals/CustomWorkflowModal/hooks/useCustomWorkflowConfig" import {openDeleteAppModalAtom} from "@/oss/components/pages/app-management/modals/DeleteAppModal/store/deleteAppModalStore" import useURL from "@/oss/hooks/useURL" -import {useVaultSecret} from "@/oss/hooks/useVaultSecret" -import {usePostHogAg} from "@/oss/lib/helpers/analytics/hooks/usePostHogAg" -import {LlmProvider} from "@/oss/lib/helpers/llmProviders" -import {isDemo} from "@/oss/lib/helpers/utils" import {useBreadcrumbsEffect} from "@/oss/lib/hooks/useBreadcrumbs" import {waitForAppToStart} from "@/oss/services/api" -import {createAppWithTemplate, updateAppFolder} from "@/oss/services/app-selector/api" +import {updateAppFolder} from "@/oss/services/app-selector/api" import {createFolder, deleteFolder, editFolder} from "@/oss/services/folders" import {Folder, FolderKind} from "@/oss/services/folders/types" import {appCreationStatusAtom, resetAppCreationAtom} from "@/oss/state/appCreation/status" -import {useProfileData} from "@/oss/state/profile" -import {getProjectValues} from "@/oss/state/project" import {useProjectData} from "@/oss/state/project" import {type FolderTreeItem, slugify} from "./assets/utils" @@ -58,10 +57,6 @@ const CreateAppStatusModal: any = dynamic( () => import("@/oss/components/pages/app-management/modals/CreateAppStatusModal"), ) -const AddAppFromTemplatedModal: any = dynamic( - () => import("@/oss/components/pages/app-management/modals/AddAppFromTemplateModal"), -) - const INITIAL_FOLDER_MODAL_STATE: FolderModalState = { name: "", modalOpen: false, @@ -71,14 +66,16 @@ const INITIAL_FOLDER_MODAL_STATE: FolderModalState = { const PromptsPage = () => { const {projectId} = useProjectData() - const {secrets} = useVaultSecret() - const posthog = usePostHogAg() const router = useRouter() const {baseAppURL} = useURL() - const {user} = useProfileData() const statusData = useAtomValue(appCreationStatusAtom) const setStatusData = useSetAtom(appCreationStatusAtom) const resetAppCreation = useSetAtom(resetAppCreationAtom) + const setOpenDrawer = useSetAtom(openWorkflowRevisionDrawerAtom) + + // Pre-fetch the catalog templates on page mount so the breadcrumb + // "+ New prompt" shortcut has data ready. Same rationale as on /apps. + useAtomValue(appTemplatesQueryAtom) // Entity-based data (scoped to current folder, or all when searching) const folders = useAtomValue(foldersAtom) @@ -96,13 +93,9 @@ const PromptsPage = () => { const [moveModalOpen, setMoveModalOpen] = useState(false) const [statusModalOpen, setStatusModalOpen] = useState(false) - const [isAddAppFromTemplatedModal, setIsAddAppFromTemplatedModal] = useState(false) const [deleteModalOpen, setDeleteModalOpen] = useState(false) const [deleteFolderId, setDeleteFolderId] = useState(null) const [moveSelection, setMoveSelection] = useState(null) - const [templateKey, setTemplateKey] = useState(undefined) - const [appName, setAppName] = useState("") - const [appSlug, setAppSlug] = useState(undefined) const [fetchingCustomWorkflow, setFetchingCustomWorkflow] = useState(false) const [moveEntity, setMoveEntity] = useState<{ type: "folder" | "app" @@ -374,59 +367,47 @@ const PromptsPage = () => { } } - const handleTemplateCardClick = async ( - templateId: string, - submittedAppName: string, - submittedAppSlug?: string, - ) => { - setAppName(submittedAppName) - setAppSlug(submittedAppSlug) - setTemplateKey(templateId) - setIsAddAppFromTemplatedModal(false) - setStatusModalOpen(true) - resetAppCreation() + /** + * "+ New prompt" entry in the breadcrumb / table-section menus. The menu + * surfaces a Chat / Completion submenu so the type is chosen explicitly + * before we mint the ephemeral app. Custom workflow has its own entry + * (`handleSetupWorkflow`). + */ + const handleOpenNewPromptModal = useCallback( + async (type: AppType) => { + const entityId = await createEphemeralAppFromTemplate({type}) + if (!entityId) { + message.error("Couldn't start prompt creation — please retry") + return + } + // Navigation is owned by the drawer wrapper for `app-create` + // (see `useDrawerCreateCommitCallback`). Don't pass + // `onWorkflowCreated` — it would push to the same URL the + // wrapper already pushes to. + setOpenDrawer({ + entityId, + context: "app-create", + }) + }, + [setOpenDrawer], + ) - const apiKeys = secrets - - await createAppWithTemplate({ - appName: submittedAppName, - slug: submittedAppSlug, - templateKey: templateId, - folderId: currentFolderId ?? null, - providerKey: isDemo() && apiKeys?.length === 0 ? [] : (apiKeys as LlmProvider[]), - onStatusChange: async (status, details, appId) => { - if (["error", "bad_request", "timeout", "success"].includes(status)) - if (status === "success") { - refetchWorkflows() - posthog?.capture?.("app_deployment", { - properties: { - app_id: appId, - environment: "UI", - deployed_by: user?.id, - }, - }) - } - - setStatusData((prev) => ({...prev, status, details, appId: appId || prev.appId})) - }, - }) + const handleSetupWorkflow = () => { + openCustomWorkflowModal() } - const onErrorRetry = async () => { - if (statusData.appId) { - setStatusData((prev) => ({...prev, status: "cleanup", details: undefined})) - const {projectId} = getProjectValues() - await workflowMolecule.lifecycle - .archive(statusData.appId, {projectId}) - .catch(console.error) - refetchWorkflows() - } - if (templateKey) { - await handleTemplateCardClick(templateKey, appName, appSlug) - } - } + /** + * Status modal is mounted for the Custom workflow path — Custom is still + * eager-create with progress states. Retry handlers are no-ops here: + * Custom errors require the user to fix the form and resubmit, not a + * blind retry of the same payload. + */ + const onErrorRetry = useCallback(() => { + setStatusModalOpen(false) + resetAppCreation() + }, [resetAppCreation]) - const onTimeoutRetry = async () => { + const onTimeoutRetry = useCallback(async () => { if (!statusData.appId) return setStatusData((prev) => ({...prev, status: "configuring_app", details: undefined})) try { @@ -440,15 +421,7 @@ const PromptsPage = () => { } setStatusData((prev) => ({...prev, status: "success", details: undefined})) refetchWorkflows() - } - - const handleOpenNewPromptModal = () => { - setIsAddAppFromTemplatedModal(true) - } - - const handleSetupWorkflow = () => { - openCustomWorkflowModal() - } + }, [refetchWorkflows, setStatusData, statusData.appId]) const handleOpenAppOverview = (workflowId: string) => { router.push(`${baseAppURL}/${workflowId}/overview`) @@ -837,12 +810,6 @@ const PromptsPage = () => { okText={isRenameMode ? "Save" : "Create"} /> - setIsAddAppFromTemplatedModal(false)} - handleTemplateCardClick={handleTemplateCardClick} - /> - { resetAppCreation() }} statusData={statusData} - appName={appName} + appName="" />
) diff --git a/web/oss/src/components/pages/prompts/components/PromptsBreadcrumb.tsx b/web/oss/src/components/pages/prompts/components/PromptsBreadcrumb.tsx index aadefb96b9..5617934f79 100644 --- a/web/oss/src/components/pages/prompts/components/PromptsBreadcrumb.tsx +++ b/web/oss/src/components/pages/prompts/components/PromptsBreadcrumb.tsx @@ -1,5 +1,6 @@ import React, {useMemo} from "react" +import type {AppType} from "@agenta/entities/workflow" import { CaretDownIcon, FolderDashedIcon, @@ -15,6 +16,7 @@ import {createUseStyles} from "react-jss" import {JSSTheme} from "@/oss/lib/Types" +import {getAppTypeIcon} from "../assets/iconHelpers" import {FolderTreeNode} from "../assets/utils" import PromptsHouseIcon from "./PromptsHouseIcon" @@ -24,7 +26,7 @@ interface PromptsBreadcrumbProps { foldersById: Record currentFolderId: string | null onFolderChange?: (folderId: string | null) => void - onNewPrompt?: () => void + onNewPrompt?: (type: AppType) => void onSetupWorkflow?: () => void onNewFolder?: () => void onMoveFolder?: (folderId: string | null) => void @@ -125,7 +127,28 @@ const PromptsBreadcrumb = ({ key: "new_prompt", icon: , label: "New prompt", - onClick: () => onNewPrompt?.(), + children: [ + { + key: "new_prompt_chat", + label: ( + + {getAppTypeIcon("chat")} + Chat + + ), + onClick: () => onNewPrompt?.("chat"), + }, + { + key: "new_prompt_completion", + label: ( + + {getAppTypeIcon("completion")} + Completion + + ), + onClick: () => onNewPrompt?.("completion"), + }, + ], }, { key: "new_folder", diff --git a/web/oss/src/components/pages/prompts/components/PromptsTableSection.tsx b/web/oss/src/components/pages/prompts/components/PromptsTableSection.tsx index 5afbfc872b..d74fc91d72 100644 --- a/web/oss/src/components/pages/prompts/components/PromptsTableSection.tsx +++ b/web/oss/src/components/pages/prompts/components/PromptsTableSection.tsx @@ -1,5 +1,6 @@ import {useMemo} from "react" +import type {AppType} from "@agenta/entities/workflow" import {InfiniteVirtualTableFeatureShell} from "@agenta/ui/table" import type { InfiniteVirtualTableRowSelection, @@ -11,6 +12,7 @@ import {Button, Dropdown, Input, Space} from "antd" import type {MenuProps} from "antd" import type {ColumnsType, TableProps} from "antd/es/table" +import {getAppTypeIcon} from "../assets/iconHelpers" import type {FolderTreeItem} from "../assets/utils" import type {PromptsTableRow} from "../types" @@ -26,7 +28,7 @@ interface PromptsTableSectionProps { searchTerm: string onSearchChange: (value: string) => void onDeleteSelected: () => void - onOpenNewPrompt: () => void + onOpenNewPrompt: (type: AppType) => void onOpenNewFolder: () => void onSetupWorkflow: () => void selectedRow: FolderTreeItem | null @@ -55,10 +57,42 @@ export const PromptsTableSection = ({ key: "new_prompt", icon: , label: "New prompt", - onClick: ({domEvent}: {domEvent: React.MouseEvent | React.KeyboardEvent}) => { - domEvent.stopPropagation() - onOpenNewPrompt() - }, + children: [ + { + key: "new_prompt_chat", + label: ( + + {getAppTypeIcon("chat")} + Chat + + ), + onClick: ({ + domEvent, + }: { + domEvent: React.MouseEvent | React.KeyboardEvent + }) => { + domEvent.stopPropagation() + onOpenNewPrompt("chat") + }, + }, + { + key: "new_prompt_completion", + label: ( + + {getAppTypeIcon("completion")} + Completion + + ), + onClick: ({ + domEvent, + }: { + domEvent: React.MouseEvent | React.KeyboardEvent + }) => { + domEvent.stopPropagation() + onOpenNewPrompt("completion") + }, + }, + ], }, { key: "new_folder", diff --git a/web/oss/src/lib/helpers/utils.ts b/web/oss/src/lib/helpers/utils.ts index 579d57400c..3463974dc4 100644 --- a/web/oss/src/lib/helpers/utils.ts +++ b/web/oss/src/lib/helpers/utils.ts @@ -27,14 +27,21 @@ export const capitalize = (s: string) => { const URL_SAFE = /^[a-zA-Z0-9_-]+$/ +// App names are free-form display labels (per AGE-3754). Only reject empty / +// whitespace-only input here; URL safety belongs on slug fields, not names. export const isAppNameInputValid = (input: string) => { - return URL_SAFE.test(input) + return typeof input === "string" && input.trim().length > 0 } export const isVariantNameInputValid = (input: string) => { return URL_SAFE.test(input) } +// Slugs go into URLs / identifiers and stay constrained to [a-zA-Z0-9_-]. +export const isSlugInputValid = (input: string) => { + return URL_SAFE.test(input) +} + export const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)) export const snakeToCamel = (str: string) => diff --git a/web/oss/src/state/url/routeMatchers.ts b/web/oss/src/state/url/routeMatchers.ts index fc7bcce475..a4ae97983c 100644 --- a/web/oss/src/state/url/routeMatchers.ts +++ b/web/oss/src/state/url/routeMatchers.ts @@ -2,6 +2,7 @@ const TRACE_ENABLED_PATH_MATCHERS = [ "/observability", "/traces", "/playground", + "/prompts", "/evaluations", "/annotations", "/evaluators", diff --git a/web/oss/src/state/url/trace.ts b/web/oss/src/state/url/trace.ts index c3d581c076..57aa4d47e7 100644 --- a/web/oss/src/state/url/trace.ts +++ b/web/oss/src/state/url/trace.ts @@ -15,6 +15,15 @@ const isBrowser = typeof window !== "undefined" export const traceIdAtom = atom(undefined) +// Tracks whether the current trace drawer was opened via URL (trace param). +// Only URL-driven drawers should be closed by URL sync. Drawers opened +// programmatically (e.g. from execution-result trace buttons inside the +// WorkflowRevisionDrawer "+ New prompt" flow on /apps) must survive route +// changes and URL syncs that don't natively support trace context — otherwise +// `syncTraceStateFromUrl` strips `?span=...` while the drawer's tree-click +// handler re-adds it, producing a tight URL change loop. +let drawerOpenedViaUrl = false + export const clearTraceDrawerState = () => { const store = getDefaultStore() const current = store.get(traceDrawerAtom) @@ -26,6 +35,7 @@ export const clearTraceDrawerState = () => { store.set(traceIdAtom, undefined) store.set(selectedTraceIdAtom, "") store.set(selectedNodeAtom, "") + drawerOpenedViaUrl = false } export const syncTraceStateFromUrl = (nextUrl?: string) => { @@ -41,6 +51,13 @@ export const syncTraceStateFromUrl = (nextUrl?: string) => { const currentDrawerState = store.get(traceDrawerAtom) if (!routeSupportsTrace) { + // Programmatic opens (drawer already open without being URL-driven) + // must survive on non-trace routes. Stripping `?span=...` while the + // drawer is open would race the tree-click `setSpanQueryParam` and + // loop indefinitely. + if (currentDrawerState.open && !drawerOpenedViaUrl) { + return + } if (traceParam || url.searchParams.has("span")) { if (traceParam) { url.searchParams.delete("trace") @@ -53,13 +70,16 @@ export const syncTraceStateFromUrl = (nextUrl?: string) => { console.error("Failed to remove unsupported trace query params:", error) }) } - if (currentTraceId !== undefined) { + if (currentDrawerState.open && drawerOpenedViaUrl) { clearTraceDrawerState() } return } if (!traceParam) { + if (currentDrawerState.open && !drawerOpenedViaUrl) { + return + } if (currentTraceId !== undefined) { clearTraceDrawerState() } @@ -70,6 +90,12 @@ export const syncTraceStateFromUrl = (nextUrl?: string) => { return } + // The drawer is being opened by URL sync (rather than programmatically + // by a button handler that already set `drawerState.open = true`). + if (!currentDrawerState.open) { + drawerOpenedViaUrl = true + } + store.set(traceIdAtom, traceParam) store.set(selectedTraceIdAtom, traceParam) store.set(selectedNodeAtom, spanParam ?? "") @@ -109,5 +135,8 @@ export const clearTraceQueryParam = () => { } export const clearTraceParamAtom = atom(null, (_get, _set) => { + // Reset the URL-driven flag so the next open (whether URL-driven or + // programmatic) starts from a clean state. + drawerOpenedViaUrl = false clearTraceQueryParam() }) diff --git a/web/oss/tests/playwright/acceptance/app/index.ts b/web/oss/tests/playwright/acceptance/app/index.ts index 6d5226d204..96daf89023 100644 --- a/web/oss/tests/playwright/acceptance/app/index.ts +++ b/web/oss/tests/playwright/acceptance/app/index.ts @@ -9,6 +9,8 @@ import { TestSpeedType, TestLicenseType, } from "@agenta/web-tests/playwright/config/testTags" +import {expect} from "@agenta/web-tests/utils" + import {AppType} from "./assets/types" import {test as baseTest} from "./test" import {expectAuthenticatedSession} from "../utils/auth" @@ -94,6 +96,72 @@ const tests = () => { }) }, ) + + baseTest( + `closing the create-app drawer without committing fires no /workflows POST`, + {tag: tags}, + async ({page, navigateToApps}) => { + await scenarios.given("the user is authenticated", async () => { + await expectAuthenticatedSession(page) + }) + + await scenarios.and("the user is on the Prompts page", async () => { + await navigateToApps() + }) + + // Track every POST /workflows request that fires after the drawer + // opens. The lazy-create-before-commit shift means closing the + // drawer pre-commit must not hit the create endpoint. + const workflowPosts: string[] = [] + page.on("request", (request) => { + if ( + request.method() === "POST" && + request.url().includes("/workflows") && + !request.url().includes("/query") + ) { + workflowPosts.push(request.url()) + } + }) + + await scenarios.when( + 'the user opens the create-app dropdown and picks "Chat"', + async () => { + const trigger = page.getByTestId("create-app-dropdown-trigger").first() + await expect(trigger).toBeVisible({timeout: 15000}) + await trigger.click() + + const chatItem = page.getByTestId("create-app-dropdown-chat").first() + await expect(chatItem).toBeVisible({timeout: 15000}) + await chatItem.click() + + const drawer = page.getByRole("dialog").last() + await expect(drawer).toBeVisible({timeout: 15000}) + // Confirm the editable name input is present (drawer fully mounted) + await expect(page.getByTestId("app-create-name-input").first()).toBeVisible({ + timeout: 15000, + }) + }, + ) + + await scenarios.and("the user closes the drawer without committing", async () => { + const closeButton = page.getByTestId("workflow-revision-drawer-close").first() + await expect(closeButton).toBeVisible() + await closeButton.click() + // Wait long enough that any (incorrect) commit request would have + // fired. The factory's inspect call may hit /workflows/inspect or + // similar but the create endpoint is /workflows POST with a body. + await page.waitForTimeout(800) + }) + + await scenarios.then("no /workflows POST request was made", async () => { + expect(workflowPosts).toEqual([]) + }) + + await scenarios.and("the user remains on the apps page", async () => { + await expect(page).toHaveURL(/\/apps$/) + }) + }, + ) } export default tests diff --git a/web/oss/tests/playwright/acceptance/app/test.ts b/web/oss/tests/playwright/acceptance/app/test.ts index a06ec467de..0b6490cdb3 100644 --- a/web/oss/tests/playwright/acceptance/app/test.ts +++ b/web/oss/tests/playwright/acceptance/app/test.ts @@ -1,51 +1,29 @@ import {test as baseTest} from "@agenta/web-tests/tests/fixtures/base.fixture" import {expect} from "@agenta/web-tests/utils" -import type {Locator} from "@playwright/test" -import {APP_TYPE_LABELS} from "./assets/types" +import {AppType} from "./assets/types" import type {AppFixtures, CreateAppResponse} from "./assets/types" -const selectCreatePromptType = async (dialog: Locator, appTypeLabel: string) => { - await expect(dialog.getByText("Choose the prompt type", {exact: true})).toBeVisible({ - timeout: 15000, - }) - - const appTypeCards = dialog.locator(".ant-card") - await expect(appTypeCards.first()).toBeVisible({timeout: 15000}) - - const matchingCards = appTypeCards.filter({hasText: new RegExp(appTypeLabel, "i")}) - await expect.poll(async () => await matchingCards.count(), {timeout: 15000}).toBeGreaterThan(0) - - const appTypeCard = matchingCards.first() - await expect(appTypeCard).toBeVisible({timeout: 15000}) - - const appTypeRadio = appTypeCard.locator('input[type="radio"]').first() - const isSelected = async () => { - if ((await appTypeRadio.count().catch(() => 0)) > 0) { - return await appTypeRadio.isChecked().catch(() => false) - } - - const checkedRadio = appTypeCard.locator(".ant-radio-checked").first() - return await checkedRadio.isVisible().catch(() => false) - } - - if (!(await isSelected())) { - await appTypeCard.click() - } - - await expect.poll(isSelected, {timeout: 15000}).toBe(true) -} - /** * App-specific test fixtures extending the base test fixture. * Provides high-level actions for app management tests. + * + * NOTE: As of the app-create drawer alignment redesign, app creation + * goes through: + * 1. Click the "Create New Prompt" dropdown trigger on /apps + * 2. Pick "Chat" or "Completion" from the dropdown menu + * 3. The drawer opens with an ephemeral local-* entity + * 4. (optionally) edit the name in the drawer header + * 5. Click Commit inside the drawer to create the app + * 6. Drawer closes and user lands on /apps//playground + * + * Custom workflow uses a separate path (not covered by this fixture today). */ const testWithAppFixtures = baseTest.extend({ /** * Navigates to the apps dashboard and verifies page load. - * Uses base fixture's page navigation and text validation. */ - navigateToApps: async ({page, uiHelpers}, use) => { + navigateToApps: async ({page, uiHelpers: _uiHelpers}, use) => { await use(async () => { await page.goto("/apps") await page.waitForURL("**/apps", {waitUntil: "domcontentloaded"}) @@ -59,37 +37,44 @@ const testWithAppFixtures = baseTest.extend({ /** * Creates a new app and validates both UI flow and API response. * - * @param appName - Name for the new app - * @returns CreateAppResponse containing app details from API + * Drives the lazy-create-via-drawer flow: + * open dropdown → pick type → drawer opens → set name → commit → wait for nav * - * Flow: - * 1. Setup API response listener - * 2. Execute UI interactions for app creation - * 3. Validate API response - * 4. Confirm navigation to playground + * @param appName - Name for the new app (set inline in the drawer header) + * @param appType - Chat or Completion + * @returns CreateAppResponse with the created workflow's id + name */ createNewApp: async ({page, uiHelpers}, use) => { - await use(async (appName: string, appType) => { - await uiHelpers.clickButton("Create New Prompt") - - let dialog = page.getByRole("dialog").last() - - // Wait for dialog with a short timeout - const isDialogVisible = await dialog.isVisible().catch(() => false) - - // If dialog is not visible, click the button and wait for it - if (!isDialogVisible) { - await uiHelpers.clickButton("Create New Prompt") - dialog = page.getByRole("dialog").last() - await expect(dialog).toBeVisible() - } - const input = dialog.getByRole("textbox", {name: "Enter a name"}) - await expect(input).toBeVisible() - const dialogTitle = dialog.getByText("Create New Prompt").first() - await expect(dialogTitle).toBeVisible() - await uiHelpers.typeWithDelay('input[placeholder="Enter a name"]', appName) - const appTypeLabel = APP_TYPE_LABELS[appType] - await selectCreatePromptType(dialog, appTypeLabel) + await use(async (appName: string, appType: AppType) => { + // 1. Open the dropdown + const trigger = page.getByTestId("create-app-dropdown-trigger").first() + await expect(trigger).toBeVisible({timeout: 15000}) + await trigger.click() + + // 2. Pick the matching menu item + const itemTestId = + appType === AppType.CHAT_PROMPT + ? "create-app-dropdown-chat" + : "create-app-dropdown-completion" + const menuItem = page.getByTestId(itemTestId).first() + await expect(menuItem).toBeVisible({timeout: 15000}) + await menuItem.click() + + // 3. Drawer opens with the ephemeral entity. Find the inline + // name input in the drawer header and replace its value. + const drawer = page.getByRole("dialog").last() + await expect(drawer).toBeVisible({timeout: 15000}) + + const nameInput = page.getByTestId("app-create-name-input").first() + await expect(nameInput).toBeVisible({timeout: 15000}) + await nameInput.click() + await nameInput.fill(appName) + // Blur so the workflow draft picks up the new name (the input + // commits on blur via the onBlur handler). + await nameInput.blur() + + // 4. Set up the network listener BEFORE clicking commit, so we + // capture the workflow create POST. const createAppPromise = page.waitForResponse((response) => { if ( !response.url().includes("/workflows") || @@ -97,11 +82,23 @@ const testWithAppFixtures = baseTest.extend({ ) { return false } - - const payload = response.request().postData() || "" + const payload = response.request().postData() ?? "" return payload.includes(appName) }) - await uiHelpers.clickButton("Create New Prompt", dialog) + + // 5. Click the Commit button. The drawer's commit flow promotes + // the ephemeral local-* entity to a real workflow. The + // drawer typically opens a confirmation modal — accept it. + await uiHelpers.clickButton("Commit", drawer) + // Some drawer commit buttons open a confirmation modal first. + const confirmDialog = page.getByRole("dialog").last() + const confirmButton = confirmDialog.getByRole("button", {name: /commit|create/i}) + const confirmVisible = await confirmButton.isVisible().catch(() => false) + if (confirmVisible) { + await confirmButton.click() + } + + // 6. Wait for the response and the navigation. const createAppResponse = await createAppPromise expect(createAppResponse.ok()).toBe(true) @@ -116,13 +113,6 @@ const testWithAppFixtures = baseTest.extend({ /** * Verifies successful app creation in the UI. - * - * @param appName - Name of the created app to verify - * - * Checks: - * 1. Loading state appears and disappears - * 2. App name is visible in the UI - * 3. Loading indicator is gone */ verifyAppCreation: async ({uiHelpers}, use) => { await use(async (appName: string) => { @@ -133,7 +123,4 @@ const testWithAppFixtures = baseTest.extend({ }, }) -// Then create auth-enabled test -// export const test = testWithAppFixtures -// createAuthTest(testWithAppFixtures); export {expect, testWithAppFixtures as test} diff --git a/web/packages/agenta-entities/src/workflow/index.ts b/web/packages/agenta-entities/src/workflow/index.ts index 16dce1cbfd..d5ff7554f7 100644 --- a/web/packages/agenta-entities/src/workflow/index.ts +++ b/web/packages/agenta-entities/src/workflow/index.ts @@ -223,6 +223,8 @@ export { // Ephemeral workflows (from trace data) createEphemeralWorkflow, type CreateEphemeralWorkflowParams, + // Cross-context ephemeral cleanup (drawer-create flows) + discardLocalServerDataAtom, // Latest revision (derived from already-fetched data) workflowLatestRevisionIdAtomFamily, workflowAppTypeAtomFamily, @@ -309,6 +311,12 @@ export { // Selection config evaluatorSelectionConfig, type EvaluatorSelectionConfig, + // App templates + ephemeral factory (app-create drawer flow) + appTemplatesQueryAtom, + appTemplatesDataAtom, + createEphemeralAppFromTemplate, + type AppType, + type CreateEphemeralAppFromTemplateParams, } from "./state" // ============================================================================ diff --git a/web/packages/agenta-entities/src/workflow/state/appUtils.ts b/web/packages/agenta-entities/src/workflow/state/appUtils.ts new file mode 100644 index 0000000000..eb4a1b40f6 --- /dev/null +++ b/web/packages/agenta-entities/src/workflow/state/appUtils.ts @@ -0,0 +1,229 @@ +/** + * App Utilities for Workflow Store + * + * Convenience atoms for application-type workflows. + * Apps are workflows with `flags.is_application === true`. + * + * Provides: + * - App template definitions query (chat / completion / custom catalog) + * - Ephemeral app factory (local-* entity from a template, used by the + * new app-create drawer flow that mirrors evaluator-create) + * + * @packageDocumentation + */ + +import {projectIdAtom, sessionAtom} from "@agenta/shared/state" +import {atom, getDefaultStore} from "jotai" +import {atomWithQuery} from "jotai-tanstack-query" + +import {generateLocalId} from "../../shared" +import type {WorkflowCatalogTemplate, WorkflowCatalogTemplatesResponse} from "../api" +import {fetchWorkflowCatalogTemplates, inspectWorkflow} from "../api" +import type {Workflow} from "../core" +import {buildWorkflowUri, parseWorkflowKeyFromUri} from "../core" + +import {buildServiceUrlFromUri} from "./helpers" +import {workflowLocalServerDataAtomFamily} from "./store" + +// ============================================================================ +// TEMPLATES QUERY +// ============================================================================ + +/** + * Query atom for application template definitions (chat, completion, custom). + * Templates are static data (built-in app types), cached for 5 minutes. + */ +export const appTemplatesQueryAtom = atomWithQuery((get) => { + const projectId = get(projectIdAtom) + return { + queryKey: ["appTemplates", projectId], + queryFn: async (): Promise => { + if (!projectId) return {count: 0, templates: []} + return fetchWorkflowCatalogTemplates({isApplication: true}) + }, + enabled: get(sessionAtom) && !!projectId, + staleTime: 5 * 60_000, + refetchOnWindowFocus: false, + } +}) + +/** + * Derived atom for the application templates data array. + */ +export const appTemplatesDataAtom = atom((get) => { + const query = get(appTemplatesQueryAtom) + return query.data?.templates ?? [] +}) + +// ============================================================================ +// EPHEMERAL APP FACTORY +// ============================================================================ + +/** + * App types supported by the drawer flow. "custom" routes through the + * existing CustomWorkflowModal and does NOT use this factory. + */ +export type AppType = "chat" | "completion" + +export interface CreateEphemeralAppFromTemplateParams { + type: AppType + defaultName?: string + /** Optional abort signal — superseded by a newer click cancels the inflight call */ + signal?: AbortSignal +} + +const capitalize = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s) + +/** + * Match a template to the requested app type. The catalog returns templates + * with `data.uri` like `agenta:builtin:chat:v0` (provider:kind:key:version). + * We extract the key segment via `parseWorkflowKeyFromUri` and compare + * against the requested type. Falls back to comparing `t.key` directly + * (which may be `"chat"`, `"SERVICE:chat"`, or the catalog key). + */ +function matchTemplateForType( + templates: WorkflowCatalogTemplate[], + type: AppType, +): WorkflowCatalogTemplate | null { + const lowerType = type.toLowerCase() + return ( + templates.find((t) => { + const uriKey = parseWorkflowKeyFromUri(t.data?.uri)?.toLowerCase() ?? null + if (uriKey === lowerType) return true + const rawKey = t.key?.toLowerCase() ?? "" + // Strip a `service:` prefix if present (e.g. `SERVICE:chat` → `chat`). + const normalizedKey = rawKey.startsWith("service:") ? rawKey.slice(8) : rawKey + return normalizedKey === lowerType + }) ?? null + ) +} + +/** + * Create a local-only application workflow entity from a built-in catalog + * template (chat or completion). Mirrors `createEvaluatorFromTemplate` — + * fetches the parameter schema via the inspect endpoint, merges with template + * defaults, and stores the entity in the local atom family. + * + * The returned `local-*` ID is immediately usable via `workflowEntityAtomFamily(id)`. + * On commit (via `createWorkflowFromEphemeralAtom`), the ephemeral is promoted + * to a real app + variant + v1 in one server call — flags flow transitively + * (`flags.is_application: true` is set here, read at commit time). + * + * Pure entity-lifecycle function — no UI/router dependencies. + * + * @returns The local entity ID, or null if the template was not found, + * the project is not set, or the call was aborted via the signal. + */ +export async function createEphemeralAppFromTemplate({ + type, + defaultName, + signal, +}: CreateEphemeralAppFromTemplateParams): Promise { + if (signal?.aborted) return null + + const store = getDefaultStore() + const projectId = store.get(projectIdAtom) + + if (!projectId) return null + + // Read cached templates first (fast path — atom may already be populated + // by a mounted dropdown). Fall back to a direct fetch if empty. + let templates = store.get(appTemplatesDataAtom) + if (templates.length === 0) { + try { + const response = await fetchWorkflowCatalogTemplates({isApplication: true}) + if (signal?.aborted) return null + templates = response.templates ?? [] + } catch { + return null + } + } + + const template = matchTemplateForType(templates, type) + if (!template) return null + + if (signal?.aborted) return null + + // Fall back to building the URI from the requested `type` (e.g. "chat", + // "completion") rather than `template.key`. The catalog can return keys + // like `SERVICE:chat` (matched via `matchTemplateForType`) which would + // produce an invalid builtin URI when fed straight into `buildWorkflowUri`. + const uri = template.data?.uri ?? buildWorkflowUri(type) + const localId = generateLocalId("local") + const resolvedName = defaultName ?? `${capitalize(type)} prompt` + + const catalogSchemas = template.data?.schemas as + | Record | null | undefined> + | undefined + let schemas: { + inputs?: Record | null + outputs?: Record | null + parameters?: Record | null + } = { + inputs: (catalogSchemas?.inputs as Record | undefined) ?? null, + outputs: (catalogSchemas?.outputs as Record | undefined) ?? null, + parameters: (catalogSchemas?.parameters as Record | undefined) ?? null, + } + + // Resolve schemas from inspect — best-effort. If it fails or aborts, + // fall back to catalog schemas above. + try { + const serviceUrl = buildServiceUrlFromUri(uri) + const inspectData = await inspectWorkflow(uri, projectId, serviceUrl) + if (signal?.aborted) return null + const inspectSchemas = inspectData?.revision?.schemas ?? inspectData?.interface?.schemas + if (inspectSchemas) { + schemas = { + inputs: inspectSchemas.inputs ?? schemas.inputs, + outputs: inspectSchemas.outputs ?? schemas.outputs, + parameters: inspectSchemas.parameters ?? schemas.parameters, + } + } + } catch { + // Inspect failed — proceed with catalog schemas (or empty). + } + + if (signal?.aborted) return null + + const parameters: Record = { + ...((template.data?.parameters as Record | undefined) ?? {}), + } + + const workflow: Workflow = { + id: localId, + name: resolvedName, + slug: null, + version: null, + flags: { + is_managed: false, + is_custom: false, + is_llm: true, + is_hook: false, + is_code: false, + is_match: false, + is_feedback: false, + is_chat: type === "chat", + has_url: false, + has_script: false, + has_handler: false, + is_application: true, + is_evaluator: false, + is_snippet: false, + is_base: false, + }, + data: { + uri, + parameters, + schemas, + }, + meta: { + __ephemeral: true, + templateKey: template.key, + defaultName: resolvedName, + }, + } as Workflow + + store.set(workflowLocalServerDataAtomFamily(localId), workflow) + + return localId +} diff --git a/web/packages/agenta-entities/src/workflow/state/index.ts b/web/packages/agenta-entities/src/workflow/state/index.ts index 6720709ec1..63e65d6769 100644 --- a/web/packages/agenta-entities/src/workflow/state/index.ts +++ b/web/packages/agenta-entities/src/workflow/state/index.ts @@ -63,6 +63,8 @@ export { // Ephemeral workflows (from trace data) createEphemeralWorkflow, type CreateEphemeralWorkflowParams, + // Cross-context ephemeral cleanup (drawer-create flows) + discardLocalServerDataAtom, // Latest revision (derived from already-fetched data) workflowLatestRevisionIdAtomFamily, workflowAppTypeAtomFamily, @@ -191,3 +193,17 @@ export { evaluatorSelectionConfig, type EvaluatorSelectionConfig, } from "./evaluatorUtils" + +// ============================================================================ +// APP UTILITIES (for application-type workflows) +// ============================================================================ + +export { + // Templates + appTemplatesQueryAtom, + appTemplatesDataAtom, + // Create ephemeral app from template (entity lifecycle) + createEphemeralAppFromTemplate, + type AppType, + type CreateEphemeralAppFromTemplateParams, +} from "./appUtils" diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index 6c0977a1e7..664ff766d9 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -1959,6 +1959,29 @@ export function createEphemeralWorkflow(params: CreateEphemeralWorkflowParams): return {id, data: workflow} } +/** + * Release a `local-*` ephemeral entity from the local atom family. + * + * Discards both the local server data (the ephemeral entity itself) and + * the draft layer (any in-progress edits). Used by drawer-create flows + * (`app-create`, `evaluator-create`, `trace-replay`) when the user closes + * the drawer without committing. + * + * Safe to call with non-local IDs — it's a no-op for those (the helper + * checks the prefix internally). + * + * **Caller is responsible for gating on commit-not-in-flight.** Releasing + * during an active commit can tear state mid-mutation. The drawer wrapper + * owns this gate. + */ +export const discardLocalServerDataAtom = atom(null, (_get, set, localId: string) => { + if (!localId || !localId.startsWith("local-")) return + set(workflowLocalServerDataAtomFamily(localId), null) + workflowLocalServerDataAtomFamily.remove(localId) + set(workflowDraftAtomFamily(localId), null) + workflowDraftAtomFamily.remove(localId) +}) + // ============================================================================ // CACHE INVALIDATION // ============================================================================ diff --git a/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/DrawerContent.tsx b/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/DrawerContent.tsx index 1328596a6d..11a1482d12 100644 --- a/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/DrawerContent.tsx +++ b/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/DrawerContent.tsx @@ -17,7 +17,11 @@ import {atom, useAtomValue} from "jotai" import {atomFamily} from "jotai/utils" import MetadataSidebar from "./MetadataSidebar" -import {workflowRevisionDrawerContextAtom, workflowRevisionDrawerExpandedAtom} from "./store" +import { + isCreateContext, + workflowRevisionDrawerContextAtom, + workflowRevisionDrawerExpandedAtom, +} from "./store" const EMPTY_ID = "__workflow-drawer-empty__" @@ -51,7 +55,7 @@ const DrawerContent = ({entityId, playgroundContent}: DrawerContentProps) => { const isExpanded = useAtomValue(workflowRevisionDrawerExpandedAtom) const context = useAtomValue(workflowRevisionDrawerContextAtom) - const showMetadata = !isExpanded && context !== "evaluator-create" + const showMetadata = !isExpanded && !isCreateContext(context) return (
diff --git a/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/DrawerHeader.tsx b/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/DrawerHeader.tsx index c30fdd872b..66510a20fc 100644 --- a/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/DrawerHeader.tsx +++ b/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/DrawerHeader.tsx @@ -14,16 +14,18 @@ * - Navigation arrows appear when navigationIds has > 1 entry (except evaluator-create) * - Info popover (metadata) shows in expanded mode only */ -import {memo, useCallback, useMemo} from "react" +import {memo, useCallback, useEffect, useMemo, useState} from "react" +import {workflowMolecule} from "@agenta/entities/workflow" import {ArrowsIn, ArrowsOut, CaretDown, CaretUp, Info, X} from "@phosphor-icons/react" -import {Button, Popover, Typography} from "antd" +import {Button, Input, Popover, Typography} from "antd" import {useAtomValue, useSetAtom} from "jotai" import {useDrawerProviders} from "./DrawerContext" import MetadataSidebar from "./MetadataSidebar" import { closeWorkflowRevisionDrawerAtom, + isCreateContext, navigateWorkflowRevisionDrawerAtom, workflowRevisionDrawerContextAtom, workflowRevisionDrawerEntityIdAtom, @@ -104,7 +106,7 @@ const VariantActionButtons = memo(({entityId}: {entityId: string}) => { const MetadataPopover = memo(({entityId}: {entityId: string}) => { const context = useAtomValue(workflowRevisionDrawerContextAtom) - if (context === "evaluator-create") return null + if (isCreateContext(context)) return null return ( = { deployment: "Deployment", "evaluator-view": "Evaluator", "evaluator-create": "New Evaluator", + "app-create": "New App", } +// ================================================================ +// APP-CREATE NAME INPUT (editable, inline in header) +// ================================================================ + +/** + * Inline editable name input shown in the drawer header for `app-create`. + * Reads the current name from `workflowMolecule.selectors.name(entityId)` + * (draft-merged), writes via `workflowMolecule.actions.update`. The latest + * value is what `createWorkflowFromEphemeralAtom` reads at commit time + * (commit.ts:550 — `workflowName = name || entity.name || "Workflow"`). + */ +const AppCreateNameInput = memo(({entityId}: {entityId: string}) => { + const currentName = useAtomValue(workflowMolecule.selectors.name(entityId)) + const updateWorkflow = useSetAtom(workflowMolecule.actions.update) + const [localValue, setLocalValue] = useState(currentName ?? "") + + // Keep input in sync if the entity's name changes externally (e.g. on + // initial entity hydration). Don't clobber while the user is typing. + useEffect(() => { + setLocalValue(currentName ?? "") + }, [currentName]) + + const handleChange = useCallback((e: React.ChangeEvent) => { + setLocalValue(e.target.value) + }, []) + + const handleBlur = useCallback(() => { + const trimmed = localValue.trim() + // Persist the trimmed value whenever it differs from the current + // (also-trimmed) name — including the empty-string case. Without + // this, deleting the name and blurring would leave the workflow + // entity holding the previous name silently while the input + // appears blank. + if (trimmed !== (currentName ?? "").trim()) { + updateWorkflow(entityId, {name: trimmed}) + } + }, [localValue, currentName, updateWorkflow, entityId]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.currentTarget.blur() + } + }, []) + + return ( + + ) +}) + // ================================================================ // MAIN HEADER // ================================================================ @@ -150,19 +212,33 @@ const DrawerHeader = () => { [isExpanded, setExpanded], ) - const isEvaluatorCreate = context === "evaluator-create" + const isCreate = isCreateContext(context) + const isAppCreate = context === "app-create" const isEvaluator = context === "evaluator-view" || context === "evaluator-create" const title = DRAWER_TITLES[context] ?? "Workflow Revision" return ( -
+
{/* Left: close + title + nav */}
-
@@ -170,7 +246,7 @@ const DrawerHeader = () => {
{isExpanded ? entityId && - : isEvaluatorCreate + : isCreate ? null : isEvaluator ? null diff --git a/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/WorkflowRevisionDrawer.tsx b/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/WorkflowRevisionDrawer.tsx index 7fffa0b2b9..36f700f1be 100644 --- a/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/WorkflowRevisionDrawer.tsx +++ b/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/WorkflowRevisionDrawer.tsx @@ -45,6 +45,10 @@ const WorkflowRevisionDrawer = ({playgroundContent}: WorkflowRevisionDrawerProps const closeDrawer = useSetAtom(closeWorkflowRevisionDrawerAtom) const [shouldRender, setShouldRender] = useState(!!isOpen) const isEvaluatorDrawer = context === "evaluator-view" || context === "evaluator-create" + // Create-style flows (evaluator + app create) and the evaluator viewer use a + // blurred backdrop. antd's built-in `maskClosable` (default true) drives + // close-on-outside-click for these — see `onClose` below. + const showBlurredMask = isEvaluatorDrawer || isStacked || context === "app-create" useEffect(() => { if (isOpen) { @@ -101,7 +105,8 @@ const WorkflowRevisionDrawer = ({playgroundContent}: WorkflowRevisionDrawerProps + context === "evaluator-create" || context === "app-create" export interface OpenDrawerParams { entityId: string context: DrawerContext /** List of entity IDs for prev/next navigation */ navigationIds?: string[] - /** Callback after successful evaluator creation/commit */ + /** + * Callback after successful workflow creation/commit. Fires for both + * `evaluator-create` and `app-create` contexts. + * + * For `evaluator-create`, called with the new config ID. + * For `app-create`, called with `{newAppId, newRevisionId}` so the caller + * can navigate to the app-scoped playground. + */ + onWorkflowCreated?: (result: { + configId?: string + newAppId?: string + newRevisionId?: string + }) => void + /** + * @deprecated Use `onWorkflowCreated` instead. Kept for backward compatibility + * with existing evaluator-create call sites; will be removed in a follow-up. + */ onEvaluatorCreated?: (configId?: string) => void /** * Override the drawer's initial expanded state. When omitted, evaluator @@ -70,10 +100,14 @@ export const workflowRevisionDrawerViewModeAtom = atomWithReset( /** List of entity IDs for prev/next navigation */ export const workflowRevisionDrawerNavigationIdsAtom = atomWithReset([]) -/** Callback ref for onEvaluatorCreated */ -export const workflowRevisionDrawerCallbackAtom = atom<((configId?: string) => void) | undefined>( - undefined, -) +/** + * Callback ref fired post-commit by the drawer. Stores the new + * `onWorkflowCreated` shape; old `onEvaluatorCreated` callers are bridged + * inside `openWorkflowRevisionDrawerAtom`. + */ +export const workflowRevisionDrawerCallbackAtom = atom< + ((result: {configId?: string; newAppId?: string; newRevisionId?: string}) => void) | undefined +>(undefined) // ================================================================ // DERIVED @@ -96,7 +130,9 @@ export const workflowRevisionDrawerAtom = atom((get) => ({ export const openWorkflowRevisionDrawerAtom = atom(null, (get, set, params: OpenDrawerParams) => { const opensExpanded = params.expanded ?? - (params.context === "evaluator-view" || params.context === "evaluator-create") + (params.context === "evaluator-view" || + params.context === "evaluator-create" || + params.context === "app-create") set(workflowRevisionDrawerEntityIdAtom, params.entityId) set(workflowRevisionDrawerOpenAtom, true) @@ -106,7 +142,23 @@ export const openWorkflowRevisionDrawerAtom = atom(null, (get, set, params: Open if (params.navigationIds !== undefined) { set(workflowRevisionDrawerNavigationIdsAtom, params.navigationIds) } - set(workflowRevisionDrawerCallbackAtom, params.onEvaluatorCreated) + + // Prefer the new callback shape; bridge the deprecated one. + // + // Wrap the callback in an updater (`() => fn`). Jotai's primitive atoms + // treat a function value passed to `set` as an updater and invoke it with + // the current value — storing a callback directly would fire it once with + // `undefined` and persist the return value instead of the callback itself. + if (params.onWorkflowCreated) { + const cb = params.onWorkflowCreated + set(workflowRevisionDrawerCallbackAtom, () => cb) + } else if (params.onEvaluatorCreated) { + const legacy = params.onEvaluatorCreated + const bridged = (result: {configId?: string}) => legacy(result?.configId) + set(workflowRevisionDrawerCallbackAtom, () => bridged) + } else { + set(workflowRevisionDrawerCallbackAtom, undefined) + } }) /** Close the drawer and clean up */