diff --git a/web/oss/src/components/Evaluators/components/ConfigureEvaluator/atoms.ts b/web/oss/src/components/Evaluators/components/ConfigureEvaluator/atoms.ts index 0c83b594af..faf123ef56 100644 --- a/web/oss/src/components/Evaluators/components/ConfigureEvaluator/atoms.ts +++ b/web/oss/src/components/Evaluators/components/ConfigureEvaluator/atoms.ts @@ -205,9 +205,16 @@ export const connectAppToEvaluatorAtom = atom( appLabel: string evaluatorRevisionId: string evaluatorLabel: string + persistSelection?: boolean }, ) => { - const {appRevisionId, appLabel, evaluatorRevisionId, evaluatorLabel} = params + const { + appRevisionId, + appLabel, + evaluatorRevisionId, + evaluatorLabel, + persistSelection = true, + } = params // Replace primary node with the app FIRST — if the graph mutation // bails out (changePrimaryNode returns null when there's no current @@ -247,14 +254,16 @@ export const connectAppToEvaluatorAtom = atom( // Persist only after both graph mutations succeeded. The picker // display label is derived from the depth-0 node's `label` via // `selectedAppLabelAtom`, so no extra write needed here. - set(persistedAppSelectionAtom, {appRevisionId, appLabel}) - - // Pin the stored run-on mode to "app" too. While connected, - // `effectiveRunOnModeAtom` overrides to "app" regardless, but the - // stored mode is what we fall back to on disconnect — without this a - // user who connected an app from "data" mode would snap back to the - // testcase panel on disconnect instead of the "Select an app" state. - set(runOnModeAtom, "app") + if (persistSelection) { + set(persistedAppSelectionAtom, {appRevisionId, appLabel}) + + // Pin the stored run-on mode to "app" too. While connected, + // `effectiveRunOnModeAtom` overrides to "app" regardless, but the + // stored mode is what we fall back to on disconnect — without this a + // user who connected an app from "data" mode would snap back to the + // testcase panel on disconnect instead of the "Select an app" state. + set(runOnModeAtom, "app") + } // Force the node-derived display atoms to re-settle after the two // sequential `playgroundNodesAtom` writes above (changePrimaryNode → diff --git a/web/oss/src/components/Evaluators/components/EvaluatorTemplateDropdown.tsx b/web/oss/src/components/Evaluators/components/EvaluatorTemplateDropdown.tsx index 35cbd650ff..c678fa396a 100644 --- a/web/oss/src/components/Evaluators/components/EvaluatorTemplateDropdown.tsx +++ b/web/oss/src/components/Evaluators/components/EvaluatorTemplateDropdown.tsx @@ -9,6 +9,7 @@ import {cn, textColors, bgColors, borderColors} from "@agenta/ui" import {PlusOutlined} from "@ant-design/icons" import {ArrowRight} from "@phosphor-icons/react" import {Button, Empty, Popover, Skeleton, Tabs, Tag, Typography} from "antd" +import type {PopoverProps} from "antd" import {useAtomValue} from "jotai" import { @@ -30,6 +31,8 @@ interface EvaluatorTemplateDropdownProps { open?: boolean /** Callback when open state changes (required when using controlled `open`) */ onOpenChange?: (open: boolean) => void + /** Popover placement relative to the trigger. */ + placement?: PopoverProps["placement"] } /** @@ -42,6 +45,7 @@ const EvaluatorTemplateDropdown = ({ className, open: controlledOpen, onOpenChange: controlledOnOpenChange, + placement = "bottomRight", }: EvaluatorTemplateDropdownProps) => { const [activeTab, setActiveTab] = useState(DEFAULT_TAB_KEY) const [internalOpen, setInternalOpen] = useState(false) @@ -183,7 +187,7 @@ const EvaluatorTemplateDropdown = ({ onOpenChange={setOpen} trigger={["click"]} content={popoverContent} - placement="bottomRight" + placement={placement} arrow={false} styles={{container: {padding: 0}}} > diff --git a/web/oss/src/components/Playground/Components/MainLayout/index.tsx b/web/oss/src/components/Playground/Components/MainLayout/index.tsx index 92142af3b0..7bc72f9928 100644 --- a/web/oss/src/components/Playground/Components/MainLayout/index.tsx +++ b/web/oss/src/components/Playground/Components/MainLayout/index.tsx @@ -17,7 +17,7 @@ import ExecutionItems, { } from "@agenta/playground-ui/execution-items" import {Button, Splitter, Typography} from "antd" import clsx from "clsx" -import {useAtomValue} from "jotai" +import {useAtomValue, useSetAtom} from "jotai" import dynamic from "next/dynamic" import {routerAppIdAtom} from "@/oss/state/app/selectors/app" @@ -108,7 +108,7 @@ const PlaygroundMainView = ({ const status = useAtomValue(playgroundController.selectors.status()) const urlAppId = useAtomValue(routerAppIdAtom) const {open: openEntitySelector} = useEntitySelector() - const setEntityIds = playgroundController.actions.setEntityIds + const setEntityIds = useSetAtom(playgroundController.actions.setEntityIds) const isEvaluatorMode = mode === "evaluator" const layoutEntityIds = selectedEntityIds.length > 0 ? selectedEntityIds : displayedEntities @@ -137,9 +137,7 @@ const PlaygroundMainView = ({ allowedTypes: ["workflow"], }) if (selection) { - // Add the selected entity to the playground - const store = (await import("jotai")).getDefaultStore() - store.set(setEntityIds, [selection.id]) + setEntityIds([selection.id]) } }, [openEntitySelector, setEntityIds]) diff --git a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx index 681628ab8f..dd141c8830 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx @@ -7,8 +7,10 @@ import { parseWorkflowKeyFromUri, workflowMolecule, createEvaluatorFromTemplate, + evaluatorNameByRevisionAtomFamily, + evaluatorWorkflowMetaMapAtom, } from "@agenta/entities/workflow" -import type {EvaluatorCatalogTemplate, WorkflowTypeColor} from "@agenta/entities/workflow" +import type {EvaluatorCatalogTemplate, Workflow, WorkflowTypeColor} from "@agenta/entities/workflow" import {EntityPicker} from "@agenta/entity-ui" import {type WorkflowRevisionSelectionResult} from "@agenta/entity-ui/selection" import {useEnrichedEvaluatorOnlyAdapter as useEvaluatorOnlyAdapter} from "@agenta/entity-ui/selection" @@ -20,7 +22,7 @@ import {CloseOutlined, DownOutlined, MoreOutlined} from "@ant-design/icons" import {Gavel, PencilSimple, Plus} from "@phosphor-icons/react" import {Button, Divider, Dropdown, Space, Tag, Tooltip, Typography, message} from "antd" import clsx from "clsx" -import {useAtomValue, useSetAtom} from "jotai" +import {atom, useAtomValue, useSetAtom, useStore} from "jotai" import dynamic from "next/dynamic" import EvaluatorTemplateDropdown from "@/oss/components/Evaluators/components/EvaluatorTemplateDropdown" @@ -114,12 +116,18 @@ const EvaluatorTag: React.FC<{ return getWorkflowTypeColor(workflowType) ?? undefined }, [runnableData]) + // Revision entities are often named after their variant ("default"), so + // prefer the parent evaluator workflow's name for display. + const evaluatorName = useAtomValue( + useMemo(() => evaluatorNameByRevisionAtomFamily(node.entityId), [node.entityId]), + ) + const label = useMemo(() => { - const fetchedName = runnableData?.name?.trim() + const fetchedName = evaluatorName || runnableData?.name?.trim() const name = fetchedName || runnableData?.slug?.trim() || "Evaluator" const version = runnableData?.version ?? null return version != null ? `${name} v${version}` : name - }, [runnableData]) + }, [evaluatorName, runnableData]) return ( = ({className, ...divPro [connectedEvaluatorNodes], ) + // Map of workflowId → connected revisions of that workflow, for the picker's + // parent checkboxes and selected-revision chips. PlaygroundNode doesn't carry + // metadata, so the parent workflow id and version are read reactively from + // each connected revision's molecule data. + const selectedChildrenByParent = useAtomValue( + useMemo( + () => + atom((get) => { + const entries: {workflowId: string; id: string; version: number}[] = [] + for (const node of connectedEvaluatorNodes) { + const data = get(workflowMolecule.selectors.data(node.entityId)) as { + workflow_id?: string | null + version?: number | null + } | null + if (!data?.workflow_id) continue + entries.push({ + workflowId: data.workflow_id, + id: node.entityId, + version: data.version ?? 0, + }) + } + + const map = new Map() + for (const entry of entries.sort((a, b) => b.version - a.version)) { + const arr = map.get(entry.workflowId) ?? [] + arr.push({id: entry.id, label: `v${entry.version}`}) + map.set(entry.workflowId, arr) + } + return map + }), + [connectedEvaluatorNodes], + ), + ) + + // Map of workflowId → total revision count, for the indeterminate checkbox state + const workflowMetaMap = useAtomValue(evaluatorWorkflowMetaMapAtom) + const totalChildrenByParent = useMemo(() => { + const map = new Map() + for (const [workflowId, meta] of workflowMetaMap) { + if (meta.versionCount != null) map.set(workflowId, meta.versionCount) + } + return map + }, [workflowMetaMap]) + const handleDisconnectAll = useCallback(() => { disconnectDownstreamNode("workflow") }, [disconnectDownstreamNode]) @@ -196,21 +248,111 @@ const PlaygroundHeader: React.FC = ({className, ...divPro [disconnectSingleDownstreamNode], ) - // Evaluator-only adapter with colored type tags, human filtering, and custom revision labels - const evaluatorWorkflowAdapter = useEvaluatorOnlyAdapter(renderWorkflowRevisionLabel) + // Disconnect a single revision by its revision (entity) id — used by the + // picker's chips and parent checkbox uncheck. + const handleDeselectChild = useCallback( + (childId: string) => { + const node = connectedEvaluatorNodes.find((n) => n.entityId === childId) + if (node) disconnectSingleDownstreamNode(node.id) + }, + [connectedEvaluatorNodes, disconnectSingleDownstreamNode], + ) + + // Evaluator-only adapter with colored type tags, human filtering, custom revision + // labels, and workflow metadata ("N versions · date") for the picker rows. + // splitTypeTag renders the type tag in the row's suffix slot (vertically + // centered) instead of trailing the name. + const evaluatorWorkflowAdapter = useEvaluatorOnlyAdapter(renderWorkflowRevisionLabel, { + showWorkflowMeta: true, + splitTypeTag: true, + }) // Controlled state for EvaluatorTemplateDropdown const [templateDropdownOpen, setTemplateDropdownOpen] = useState(false) // Open the evaluator template dropdown (called from EntityPicker's onCreateNew) - const _handleOpenTemplateDropdown = useCallback(() => { - // Small delay to let the EntityPicker popover close first - setTimeout(() => { - setTemplateDropdownOpen(true) - }, 100) + const handleOpenTemplateDropdown = useCallback(() => { + setTemplateDropdownOpen(true) }, []) const openEvaluatorDrawer = useSetAtom(openEvaluatorDrawerAtom) + const playgroundStore = useStore() + // The root node's `label` can be a raw entity UUID (URL-hydrated nodes get + // `label: entityId`), so build the display label from entity data instead: + // "AppName / vN" — the same format the drawer's app picker produces on a + // manual selection (skip-variant adapter), so the preselected state matches. + const rootRevisionVersion = useAtomValue( + useMemo(() => { + const rootEntityId = nodes.find((node) => node.depth === 0)?.entityId + return atom((get) => { + if (!rootEntityId) return null + const data = get(workflowMolecule.selectors.data(rootEntityId)) as { + version?: number | null + } | null + return data?.version ?? null + }) + }, [nodes]), + ) + + const currentAppSelection = useMemo(() => { + if (currentWorkflowCtx.workflowKind === "evaluator") return undefined + + const rootNode = nodes.find((node) => node.depth === 0) + if (!rootNode) return undefined + + const appName = currentWorkflow?.name?.trim() || "Application" + + return { + revisionId: rootNode.entityId, + label: rootRevisionVersion != null ? `${appName} / v${rootRevisionVersion}` : appName, + } + }, [currentWorkflow?.name, currentWorkflowCtx.workflowKind, nodes, rootRevisionVersion]) + + const handleCreatedEvaluator = useCallback( + ({ + newAppId, + newRevisionId, + workflow, + }: { + newAppId?: string + newRevisionId?: string + workflow?: Workflow + }) => { + if (!newRevisionId) return + + if (workflow) { + workflowMolecule.set.seedEntity(newRevisionId, workflow, {store: playgroundStore}) + } + + const currentNodes = playgroundStore.get(playgroundController.selectors.nodes()) + const rootNode = currentNodes.find((node) => node.depth === 0) + const alreadyConnected = currentNodes.some( + (node) => node.depth > 0 && node.entityId === newRevisionId, + ) + if (!rootNode || alreadyConnected) return + + const workflowName = workflow?.name?.trim() || workflow?.slug?.trim() || "Evaluator" + const revision = workflow?.version ?? 1 + + playgroundStore.set(playgroundController.actions.connectDownstreamNode, { + sourceNodeId: rootNode.id, + entity: { + type: "workflow", + id: newRevisionId, + label: `${workflowName} / v${revision}`, + metadata: { + workflowId: newAppId ?? workflow?.workflow_id, + workflowName, + variantId: "", + variantName: "", + revision, + }, + }, + }) + workflowMolecule.cache.invalidateList() + }, + [playgroundStore], + ) // Handle template selection from EvaluatorTemplateDropdown const handleTemplateSelect = useCallback( @@ -230,9 +372,13 @@ const PlaygroundHeader: React.FC = ({className, ...divPro openEvaluatorDrawer({ entityId: localId, mode: "create", + isolatedPlayground: true, + initialAppSelection: currentAppSelection, + postCreateNavigation: "stay", + onWorkflowCreated: handleCreatedEvaluator, }) }, - [openEvaluatorDrawer], + [currentAppSelection, handleCreatedEvaluator, openEvaluatorDrawer], ) // Multi-select: toggle evaluator connection/disconnection @@ -359,49 +505,60 @@ const PlaygroundHeader: React.FC = ({className, ...divPro * playground doesn't make sense (would evaluate itself). */} {currentWorkflowCtx.workflowKind !== "evaluator" && } - - - - variant="popover-cascader" - adapter={evaluatorWorkflowAdapter} - onSelect={handleEvaluatorToggle} - size="small" - placeholder="Evaluator" - icon={} - disabled={!hasRootNode} - multiSelect - selectedChildIds={connectedRevisionIds} - selectionSummary - childItemLabelMode="simple" - panelWidth={280} - // TODO: Implement evaluator template creation in checkpoint to with different playground context - // We can scope using entity revision id - // And have multiple playgrounds - // onCreateNew={handleOpenTemplateDropdown} - // createNewLabel="New evaluator" - popupFooter={ - connectedEvaluatorNodes.length > 0 ? ( -
- -
- ) : undefined - } - /> -
-
- } - /> + + + + + variant="popover-cascader" + adapter={evaluatorWorkflowAdapter} + onSelect={handleEvaluatorToggle} + size="small" + placeholder="Evaluator" + icon={} + disabled={!hasRootNode} + multiSelect + selectedChildIds={connectedRevisionIds} + selectionSummary + childItemLabelMode="simple" + panelWidth={320} + childPanelWidth={180} + openChildOnHover + showParentCheckboxes + selectedChildrenByParent={selectedChildrenByParent} + totalChildrenByParent={totalChildrenByParent} + onDeselectChild={handleDeselectChild} + showParentDescription + showGroupHeaders + showChildSelectAll + onClearAll={handleDisconnectAll} + onCreateNew={handleOpenTemplateDropdown} + createNewLabel="Create new" + popupFooter={ + connectedEvaluatorNodes.length > 0 ? ( +
+ +
+ ) : undefined + } + /> +
+
+ } + /> +
{isProjectLevelPlayground ? ( diff --git a/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx b/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx index 2b236c2243..a95cc2d431 100644 --- a/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx +++ b/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx @@ -8,7 +8,7 @@ * PlaygroundMainView between configOnly and full viewMode — no component * tree swap, no remounting. */ -import {memo, useCallback, useEffect, useMemo, useRef} from "react" +import {memo, useCallback, useEffect, useMemo, useRef, type ReactNode} from "react" import {loadableStateAtomFamily} from "@agenta/entities/loadable" import {testcaseMolecule} from "@agenta/entities/testcase" @@ -27,7 +27,9 @@ import { clearAllRunsMutationAtom, connectedTestsetAtom, derivedLoadableIdAtom, + executionAdapterAtom, playgroundInitializedAtom, + playgroundStoreAtom, } from "@agenta/playground/state" import {type PlaygroundUIProviders} from "@agenta/playground-ui" import { @@ -39,16 +41,32 @@ import { workflowRevisionDrawerCallbackAtom, workflowRevisionDrawerEntityIdAtom, workflowRevisionDrawerExpandedAtom, + workflowRevisionDrawerInitialAppSelectionAtom, + workflowRevisionDrawerIsolatedPlaygroundAtom, workflowRevisionDrawerOpenAtom, + workflowRevisionDrawerPostCreateNavigationAtom, + workflowRevisionDrawerScopedDirtyAtom, workflowRevisionDrawerViewModeAtom, WorkflowRevisionDrawer, suppressDrawerCloseUrlCleanupAtom, type DrawerProviders, + type DrawerInitialAppSelection, } from "@agenta/playground-ui/workflow-revision-drawer" +import {projectIdAtom, sessionAtom} from "@agenta/shared/state" import {EnvironmentTag} from "@agenta/ui" import {Rocket} from "@phosphor-icons/react" import {Button, message} from "antd" -import {getDefaultStore, useAtom, useAtomValue, useSetAtom} from "jotai" +import { + Provider, + createStore, + getDefaultStore, + useAtom, + useAtomValue, + useSetAtom, + useStore, + type PrimitiveAtom, +} from "jotai" +import {queryClientAtom} from "jotai-tanstack-query" import dynamic from "next/dynamic" import {useRouter} from "next/router" @@ -68,6 +86,7 @@ import {invalidatePromptsWorkflowQueries} from "@/oss/components/pages/prompts/s 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 WebWorkerProvider from "@/oss/components/Playground/Components/WebWorkerProvider" import {OSSPlaygroundShell} from "@/oss/components/Playground/OSSPlaygroundShell" import SharedGenerationResultUtils from "@/oss/components/SharedGenerationResultUtils" import {usePlaygroundNavigation} from "@/oss/hooks/usePlaygroundNavigation" @@ -184,9 +203,30 @@ const DrawerAppPlayground = memo(({entityId}: {entityId: string}) => { * Key difference from ConfigureEvaluatorPage: no playgroundSyncAtom (URL-driven), * instead uses setEntityIds + playgroundInitializedAtom for drawer-based init. */ -const DrawerEvaluatorPlayground = memo(({entityId}: {entityId: string}) => { - const isExpanded = useAtomValue(workflowRevisionDrawerExpandedAtom) - const [configViewMode, setConfigViewMode] = useAtom(workflowRevisionDrawerViewModeAtom) +const DrawerEvaluatorPlayground = memo(function DrawerEvaluatorPlayground({ + entityId, + isExpanded, + configViewMode, + onConfigViewModeChange, + onScopedDirtyChange, + initialAppSelection, +}: { + entityId: string + isExpanded: boolean + configViewMode: "form" | "json" | "yaml" + onConfigViewModeChange: (mode: "form" | "json" | "yaml") => void + onScopedDirtyChange?: (isDirty: boolean) => void + initialAppSelection?: DrawerInitialAppSelection | null +}) { + const store = useStore() + const isDirty = useAtomValue( + useMemo(() => workflowMolecule.atoms.isDirty(entityId), [entityId]), + ) + + useEffect(() => { + onScopedDirtyChange?.(isDirty) + return () => onScopedDirtyChange?.(false) + }, [isDirty, onScopedDirtyChange]) // Initialize playground with the evaluator entity via addPrimaryNode // (same path as playgroundSyncAtom). This properly links the loadable, @@ -203,19 +243,25 @@ const DrawerEvaluatorPlayground = memo(({entityId}: {entityId: string}) => { addPrimaryNode({type: "workflow", id: entityId, label: "Evaluator"}) setInitialized(true) - const store = getDefaultStore() - - // Restore persisted app selection (survives drawer close/reopen and commits). + // Prefer the caller's current app revision, then fall back to the + // persisted evaluator selection used by existing entry points. // `selectedAppLabelAtom` is derived from the node graph now — the - // `connectApp` call below seeds the depth-0 node with the persisted + // `connectApp` call below seeds the depth-0 node with the selected // label, which the derived atom picks up automatically. - const persisted = store.get(persistedAppSelectionAtom) - if (persisted) { + const persistedAppSelection = store.get(persistedAppSelectionAtom) + const appSelection = initialAppSelection + ? { + appRevisionId: initialAppSelection.revisionId, + appLabel: initialAppSelection.label, + } + : persistedAppSelection + if (appSelection) { connectApp({ - appRevisionId: persisted.appRevisionId, - appLabel: persisted.appLabel, + appRevisionId: appSelection.appRevisionId, + appLabel: appSelection.appLabel, evaluatorRevisionId: entityId, evaluatorLabel: "Evaluator", + persistSelection: !initialAppSelection, }) } @@ -235,8 +281,6 @@ const DrawerEvaluatorPlayground = memo(({entityId}: {entityId: string}) => { } } return () => { - const store = getDefaultStore() - // Clear execution results BEFORE resetting nodes // (derivedLoadableIdAtom reads from nodes to resolve the loadableId) clearAllRuns() @@ -280,13 +324,13 @@ const DrawerEvaluatorPlayground = memo(({entityId}: {entityId: string}) => { setInitialized, setConnectedTestset, connectApp, + initialAppSelection, ]) // Save testset connection to localStorage whenever the user connects a testset const connectedTestset = useAtomValue(connectedTestsetAtom) useEffect(() => { if (!connectedTestset) return - const store = getDefaultStore() const loadableId = store.get(derivedLoadableIdAtom) if (!loadableId) return const loadableState = store.get(loadableStateAtomFamily(loadableId)) @@ -294,7 +338,7 @@ const DrawerEvaluatorPlayground = memo(({entityId}: {entityId: string}) => { const rowIds = store.get(testcaseMolecule.atoms.displayRowIds) const testcases = rowIds .map((id) => { - const entity = testcaseMolecule.get.data(id) + const entity = store.get(testcaseMolecule.data(id)) return entity ? ({...entity, id} as {id: string} & Record) : null }) .filter((t): t is {id: string} & Record => t !== null) @@ -352,7 +396,7 @@ const DrawerEvaluatorPlayground = memo(({entityId}: {entityId: string}) => { viewMode={isExpanded ? "full" : "configOnly"} embedded configViewMode={configViewMode} - onConfigViewModeChange={setConfigViewMode} + onConfigViewModeChange={onConfigViewModeChange} configEntityIdsOverride={configEntityIds} runDisabled={runDisabled} runDisabledContent={runDisabledContent} @@ -364,15 +408,94 @@ const DrawerEvaluatorPlayground = memo(({entityId}: {entityId: string}) => { const DrawerPlayground = memo(({entityId}: {entityId: string}) => { const {context} = useAtomValue(workflowRevisionDrawerAtom) + const isolatedPlayground = useAtomValue(workflowRevisionDrawerIsolatedPlaygroundAtom) + const initialAppSelection = useAtomValue(workflowRevisionDrawerInitialAppSelectionAtom) + const isExpanded = useAtomValue(workflowRevisionDrawerExpandedAtom) + const [configViewMode, setConfigViewMode] = useAtom(workflowRevisionDrawerViewModeAtom) + const setScopedDirty = useSetAtom(workflowRevisionDrawerScopedDirtyAtom) const isEvaluator = context === "evaluator-view" || context === "evaluator-create" - return isEvaluator ? ( - + if (!isEvaluator) return + + const evaluatorPlayground = ( + + ) + + return isolatedPlayground ? ( + + {evaluatorPlayground} + ) : ( - + evaluatorPlayground ) }) +const IsolatedDrawerPlaygroundSession = ({ + entityId, + initialAppSelection, + children, +}: { + entityId: string + initialAppSelection?: DrawerInitialAppSelection | null + children: ReactNode +}) => { + const parentStore = useStore() + const scopedStore = useMemo(() => { + const store = createStore() + store.set(playgroundStoreAtom, store) + store.set(projectIdAtom, parentStore.get(projectIdAtom)) + store.set(sessionAtom, parentStore.get(sessionAtom)) + store.set(queryClientAtom, parentStore.get(queryClientAtom)) + store.set(executionAdapterAtom, parentStore.get(executionAdapterAtom)) + + const entity = parentStore.get(workflowMolecule.selectors.data(entityId)) + if (entity) { + workflowMolecule.set.seedEntity(entityId, entity, {store}) + } + + if (initialAppSelection) { + const appEntity = parentStore.get( + workflowMolecule.selectors.data(initialAppSelection.revisionId), + ) + if (appEntity) { + workflowMolecule.set.seedEntity(initialAppSelection.revisionId, appEntity, {store}) + } + } + + return store + }, [entityId, initialAppSelection, parentStore]) + + useEffect(() => { + const mirrorAtom = (targetAtom: PrimitiveAtom) => + parentStore.sub(targetAtom, () => { + scopedStore.set(targetAtom, parentStore.get(targetAtom)) + }) + const unsubs = [ + mirrorAtom(projectIdAtom), + mirrorAtom(sessionAtom), + mirrorAtom(queryClientAtom), + mirrorAtom(executionAdapterAtom), + ] + return () => unsubs.forEach((unsubscribe) => unsubscribe()) + }, [parentStore, scopedStore]) + + return ( + + {children} + + ) +} + // ================================================================ // COMMIT CALLBACK (evaluator + app create modes) // @@ -388,6 +511,8 @@ const DrawerPlayground = memo(({entityId}: {entityId: string}) => { const useDrawerCreateCommitCallback = () => { const {context} = useAtomValue(workflowRevisionDrawerAtom) + const isolatedPlayground = useAtomValue(workflowRevisionDrawerIsolatedPlaygroundAtom) + const postCreateNavigation = useAtomValue(workflowRevisionDrawerPostCreateNavigationAtom) const drawerCallback = useAtomValue(workflowRevisionDrawerCallbackAtom) const drawerCallbackRef = useRef(drawerCallback) drawerCallbackRef.current = drawerCallback @@ -418,7 +543,9 @@ const useDrawerCreateCommitCallback = () => { if (isEvaluator) { clearEvaluatorWorkflowCache() } - await previousOnNewRevision?.(result, params) + if (!isolatedPlayground) { + await previousOnNewRevision?.(result, params) + } if (isEvaluatorCreate) { const newWorkflow = result.workflow as @@ -438,10 +565,16 @@ const useDrawerCreateCommitCallback = () => { configId: newRevisionId, newAppId, newRevisionId, + workflow: result.workflow, }) message.success("Evaluator created successfully") + if (postCreateNavigation === "stay") { + closeDrawerRef.current() + return + } + // Close the drawer immediately and fire-and-forget the // navigation. We pass `skipUrlCleanup: true` so the // drawer-close effect doesn't run `setQueryRevision(null)` @@ -536,7 +669,7 @@ const useDrawerCreateCommitCallback = () => { onNewRevision: previousOnNewRevision, }) } - }, [isEvaluator, isEvaluatorCreate, isAppCreate]) + }, [isEvaluator, isEvaluatorCreate, isAppCreate, isolatedPlayground, postCreateNavigation]) } // ================================================================ @@ -610,12 +743,15 @@ const useUnsavedDrawerWarning = () => { const isOpen = useAtomValue(workflowRevisionDrawerOpenAtom) const context = useAtomValue(workflowRevisionDrawerContextAtom) const entityId = useAtomValue(workflowRevisionDrawerEntityIdAtom) + const isolatedPlayground = useAtomValue(workflowRevisionDrawerIsolatedPlaygroundAtom) + const scopedDirty = useAtomValue(workflowRevisionDrawerScopedDirtyAtom) const isDirty = useAtomValue( useMemo(() => workflowMolecule.atoms.isDirty(entityId ?? "__none__"), [entityId]), ) + const effectiveDirty = isolatedPlayground ? scopedDirty : isDirty useEffect(() => { - if (!isOpen || !isCreateContext(context) || !isDirty) return + if (!isOpen || !isCreateContext(context) || !effectiveDirty) return const handler = (e: BeforeUnloadEvent) => { e.preventDefault() // Modern browsers ignore the message, but setting returnValue @@ -624,7 +760,7 @@ const useUnsavedDrawerWarning = () => { } window.addEventListener("beforeunload", handler) return () => window.removeEventListener("beforeunload", handler) - }, [isOpen, context, isDirty]) + }, [isOpen, context, effectiveDirty]) } const WorkflowRevisionDrawerWrapper = () => { diff --git a/web/oss/src/state/evaluator/evaluatorDrawerStore.ts b/web/oss/src/state/evaluator/evaluatorDrawerStore.ts index e8fda43797..1772d30489 100644 --- a/web/oss/src/state/evaluator/evaluatorDrawerStore.ts +++ b/web/oss/src/state/evaluator/evaluatorDrawerStore.ts @@ -13,6 +13,10 @@ import { workflowRevisionDrawerExpandedAtom, workflowRevisionDrawerCallbackAtom, } from "@agenta/playground-ui/workflow-revision-drawer" +import type { + DrawerInitialAppSelection, + WorkflowCreatedResult, +} from "@agenta/playground-ui/workflow-revision-drawer" import {atom} from "jotai" // ================================================================ @@ -29,11 +33,10 @@ interface OpenDrawerParams { /** @deprecated Use `onWorkflowCreated` to also receive the parent workflow id (`newAppId`). */ onEvaluatorCreated?: (configId?: string) => void /** Callback after successful evaluator creation/commit. Receives the new revision id (`configId`/`newRevisionId`) and the parent workflow id (`newAppId`). */ - onWorkflowCreated?: (result: { - configId?: string - newAppId?: string - newRevisionId?: string - }) => void + onWorkflowCreated?: (result: WorkflowCreatedResult) => void + isolatedPlayground?: boolean + initialAppSelection?: DrawerInitialAppSelection + postCreateNavigation?: "default" | "stay" } // ================================================================ @@ -57,6 +60,9 @@ export const openEvaluatorDrawerAtom = atom(null, (_get, set, params: OpenDrawer navigationIds: params.navigationIds, onWorkflowCreated: params.onWorkflowCreated, onEvaluatorCreated: params.onEvaluatorCreated, + isolatedPlayground: params.isolatedPlayground, + initialAppSelection: params.initialAppSelection, + postCreateNavigation: params.postCreateNavigation, }) }) diff --git a/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/EntityEvaluatorSelector.tsx b/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/EntityEvaluatorSelector.tsx index f2cdcdca7c..b720c1ab24 100644 --- a/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/EntityEvaluatorSelector.tsx +++ b/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/EntityEvaluatorSelector.tsx @@ -27,14 +27,17 @@ export interface EntityEvaluatorSelectorProps { buttonLabel?: string onCreate?: () => void createLabel?: string - disabledRevisionIds?: Set - disabledRevisionTooltip?: string panelMinWidth?: number panelWidth?: number + childPanelWidth?: number disabled?: boolean - selectedEvaluatorId?: string | null - selectedRevisionId?: string | null + selectedRevisionIds: Set + selectedRevisionsByEvaluator: Map + totalRevisionsByEvaluator: Map + onDeselectRevision: (revisionId: string) => void + onClearAll: () => void openVersionOnHover?: boolean + defaultOpenVersionPanel?: boolean } function getNestedValue(obj: unknown, ...keys: string[]): unknown { @@ -129,14 +132,17 @@ export function EntityEvaluatorSelector({ buttonLabel = "Add evaluator", onCreate, createLabel = "Create evaluator", - disabledRevisionIds, - disabledRevisionTooltip = "Already added", panelMinWidth = 280, panelWidth, + childPanelWidth, disabled = false, - selectedEvaluatorId, - selectedRevisionId, - openVersionOnHover = false, + selectedRevisionIds, + selectedRevisionsByEvaluator, + totalRevisionsByEvaluator, + onDeselectRevision, + onClearAll, + openVersionOnHover = true, + defaultOpenVersionPanel = false, }: EntityEvaluatorSelectorProps) { const renderRevisionLabel = useCallback((entity: unknown) => { const revision = entity as WorkflowRevisionLike @@ -164,7 +170,9 @@ export function EntityEvaluatorSelector({ ) }, []) - const evaluatorAdapter = useEnrichedHumanEvaluatorAdapter(renderRevisionLabel) + const evaluatorAdapter = useEnrichedHumanEvaluatorAdapter(renderRevisionLabel, { + showWorkflowMeta: true, + }) return (
@@ -179,12 +187,20 @@ export function EntityEvaluatorSelector({ showDropdownIcon={false} panelMinWidth={panelMinWidth} panelWidth={panelWidth} + childPanelWidth={childPanelWidth} disabled={disabled} - selectedParentId={selectedEvaluatorId} - selectedChildId={selectedRevisionId} - disabledChildIds={disabledRevisionIds} - disabledChildTooltip={disabledRevisionTooltip} openChildOnHover={openVersionOnHover} + defaultOpenChildPanel={defaultOpenVersionPanel} + multiSelect + selectedChildIds={selectedRevisionIds} + selectionSummary + showParentCheckboxes + selectedChildrenByParent={selectedRevisionsByEvaluator} + totalChildrenByParent={totalRevisionsByEvaluator} + onDeselectChild={onDeselectRevision} + showParentDescription + showChildSelectAll + onClearAll={onClearAll} size="middle" onCreateNew={onCreate} createNewLabel={createLabel} diff --git a/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/index.tsx b/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/index.tsx index 497b79bbcb..aa41f8ed18 100644 --- a/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/index.tsx +++ b/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/index.tsx @@ -5,6 +5,7 @@ import { simpleQueueMolecule, type CreateSimpleQueuePayload, } from "@agenta/entities/simpleQueue" +import {evaluatorWorkflowMetaMapAtom} from "@agenta/entities/workflow" import {type WorkflowRevisionSelectionResult} from "@agenta/entity-ui/selection" import {projectIdAtom} from "@agenta/shared/state" import {ModalContent, ModalFooter, message} from "@agenta/ui" @@ -114,7 +115,25 @@ function CreateQueueDrawerContent({ [selectedEvaluators], ) const selectedRevisionIds = useMemo(() => new Set(evaluatorRevisionIds), [evaluatorRevisionIds]) - const latestSelectedEvaluator = selectedEvaluators[selectedEvaluators.length - 1] ?? null + const selectedRevisionsByEvaluator = useMemo(() => { + const map = new Map() + + for (const evaluator of [...selectedEvaluators].sort((a, b) => b.version - a.version)) { + const selected = map.get(evaluator.evaluatorId) ?? [] + selected.push({id: evaluator.revisionId, label: `v${evaluator.version}`}) + map.set(evaluator.evaluatorId, selected) + } + + return map + }, [selectedEvaluators]) + const evaluatorWorkflowMetaMap = useAtomValue(evaluatorWorkflowMetaMapAtom) + const totalRevisionsByEvaluator = useMemo(() => { + const map = new Map() + for (const [evaluatorId, meta] of evaluatorWorkflowMetaMap) { + if (meta.versionCount != null) map.set(evaluatorId, meta.versionCount) + } + return map + }, [evaluatorWorkflowMetaMap]) useEffect(() => { form.setFieldValue("evaluatorRevisionIds", evaluatorRevisionIds) @@ -143,7 +162,7 @@ function CreateQueueDrawerContent({ setSelectedEvaluators((prev) => { if (prev.some((evaluator) => evaluator.revisionId === id)) { - return prev + return prev.filter((evaluator) => evaluator.revisionId !== id) } return [ @@ -165,6 +184,10 @@ function CreateQueueDrawerContent({ ) }, []) + const handleClearEvaluators = useCallback(() => { + setSelectedEvaluators([]) + }, []) + const handleFinish = useCallback( async (values: FormValues) => { if (!projectId) { @@ -367,14 +390,13 @@ function CreateQueueDrawerContent({ onCreate={feedbackOnCreate} createLabel={feedbackCreateLabel} disabled={isSubmitting} - disabledRevisionIds={selectedRevisionIds} - selectedEvaluatorId={ - latestSelectedEvaluator?.evaluatorId ?? null - } - selectedRevisionId={ - latestSelectedEvaluator?.revisionId ?? null + selectedRevisionIds={selectedRevisionIds} + selectedRevisionsByEvaluator={ + selectedRevisionsByEvaluator } - openVersionOnHover + totalRevisionsByEvaluator={totalRevisionsByEvaluator} + onDeselectRevision={handleRemoveEvaluator} + onClearAll={handleClearEvaluators} /> {selectedEvaluators.map((evaluator) => ( { isPending: boolean isError: boolean error: Error | null + /** Whether the backing query has completed at least once. */ + isFetched?: boolean } /** diff --git a/web/packages/agenta-entities/src/workflow/index.ts b/web/packages/agenta-entities/src/workflow/index.ts index 19989d84b1..01c635ebc3 100644 --- a/web/packages/agenta-entities/src/workflow/index.ts +++ b/web/packages/agenta-entities/src/workflow/index.ts @@ -306,6 +306,11 @@ export { evaluatorPresetsAtomFamily, // Key map evaluatorKeyMapAtom, + // Workflow display metadata (version count + last modified) + evaluatorWorkflowMetaMapAtom, + type EvaluatorWorkflowMeta, + // Parent evaluator name lookup per revision + evaluatorNameByRevisionAtomFamily, // Feedback metric schemas (observability annotation filter) evaluatorFeedbackSchemasAtom, type EvaluatorFeedbackSchema, diff --git a/web/packages/agenta-entities/src/workflow/relations.ts b/web/packages/agenta-entities/src/workflow/relations.ts index 0ea4ab7237..557829fef8 100644 --- a/web/packages/agenta-entities/src/workflow/relations.ts +++ b/web/packages/agenta-entities/src/workflow/relations.ts @@ -95,6 +95,7 @@ const revisionByWorkflowListAtomFamily = atomFamily((workflowId: string) => isPending, isError, error, + isFetched: query.isFetched, } }), ) diff --git a/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts b/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts index 7f92f4a125..e36531a10c 100644 --- a/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts +++ b/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts @@ -38,6 +38,7 @@ import { workflowProjectIdAtom, workflowLocalServerDataAtomFamily, workflowLatestRevisionQueryAtomFamily, + workflowEntityAtomFamily, invalidateWorkflowsListCache, type WorkflowListRef, toWorkflowListRef, @@ -252,11 +253,74 @@ export const evaluatorKeyMapAtom = atom>((get) => { return map }) +// ============================================================================ +// EVALUATOR WORKFLOW META MAP +// ============================================================================ + +/** + * Display metadata for an evaluator workflow row in selection UIs. + */ +export interface EvaluatorWorkflowMeta { + /** + * Number of revisions, derived from the latest revision's version number. + * Revisions are sequential and v0 is excluded from pickers, so the latest + * version number equals the revision count — no full-list fetch needed. + */ + versionCount: number | null + /** Workflow-level creation timestamp. */ + createdAt: string | null + /** Workflow-level last-modified timestamp. */ + updatedAt?: string | null +} + +/** + * Derived atom: workflowId → display metadata (version count + timestamps). + * + * Reads the same batched + cached latest-revision queries as `evaluatorKeyMapAtom`, + * so subscribing to this atom adds no extra requests. + */ +export const evaluatorWorkflowMetaMapAtom = atom>((get) => { + const evaluators = get(nonArchivedEvaluatorsAtom) + const map = new Map() + + for (const evaluator of evaluators) { + if (!evaluator.id) continue + + const revision = get(workflowLatestRevisionQueryAtomFamily(evaluator.id)).data + + map.set(evaluator.id, { + versionCount: revision?.version ?? null, + createdAt: evaluator.created_at ?? null, + updatedAt: evaluator.updated_at ?? null, + }) + } + + return map +}) + +/** + * Derived atom family: revisionId → parent evaluator workflow's display name. + * + * Evaluator revisions are frequently named after their variant (e.g. "default"), + * so displaying the revision's own `name` is misleading. This resolves the + * revision's `workflow_id` against the evaluator list and returns the parent + * workflow's name instead. Returns `null` when the revision's parent isn't an + * evaluator (e.g. app revisions) so callers can fall back to the revision name. + */ +export const evaluatorNameByRevisionAtomFamily = atomFamily((revisionId: string) => + atom((get) => { + const revision = get(workflowEntityAtomFamily(revisionId)) + const workflowId = revision?.workflow_id + if (!workflowId) return null + const evaluator = get(evaluatorsListDataAtom).find((w) => w.id === workflowId) + return evaluator?.name?.trim() || null + }), +) + /** * Per-evaluator output-metric schema, keyed for the observability annotation/feedback * filter. `properties` is the raw `{ metricKey: jsonSchema }` record from the latest - * revision's output schema — the same shape `deriveFeedbackValueType` expects, so the - * filter keeps inspecting each metric's JSON schema (incl. array `items.type`). + * revision's output schema. */ export interface EvaluatorFeedbackSchema { slug: string | null @@ -266,14 +330,6 @@ export interface EvaluatorFeedbackSchema { /** * Derived atom: every non-archived evaluator paired with its output-metric properties. - * - * Mirrors `evaluatorKeyMapAtom` — iterate the evaluator list, resolve each latest revision - * (batched + cached by `workflowLatestRevisionQueryAtomFamily`), then derive. The workflow - * LIST response carries no `data`, so the output schema must come from the revision. - * - * Reuses `resolveOutputSchemaProperties`, which unwraps the envelope shape that auto-created - * feedback evaluators store, so inferred feedback metrics (e.g. `score`, `comment`) surface - * here just like UI-created ones. */ export const evaluatorFeedbackSchemasAtom = atom((get) => { const evaluators = get(nonArchivedEvaluatorsAtom) @@ -286,8 +342,6 @@ export const evaluatorFeedbackSchemasAtom = atom((get if (!revision) continue result.push({ - // Artifact-level name/slug (revision.name is the variant name — see - // workflow name semantics); revision.data carries the output schema. slug: evaluator.slug ?? null, name: evaluator.name ?? null, properties: resolveOutputSchemaProperties(revision.data) ?? {}, diff --git a/web/packages/agenta-entities/src/workflow/state/index.ts b/web/packages/agenta-entities/src/workflow/state/index.ts index c26998cb78..08e4f50a06 100644 --- a/web/packages/agenta-entities/src/workflow/state/index.ts +++ b/web/packages/agenta-entities/src/workflow/state/index.ts @@ -176,6 +176,11 @@ export { evaluatorPresetsAtomFamily, // Key map evaluatorKeyMapAtom, + // Workflow display metadata (version count + last modified) + evaluatorWorkflowMetaMapAtom, + type EvaluatorWorkflowMeta, + // Parent evaluator name lookup per revision + evaluatorNameByRevisionAtomFamily, // Feedback metric schemas (observability annotation filter) evaluatorFeedbackSchemasAtom, type EvaluatorFeedbackSchema, diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index bd1954d898..84a9440605 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -375,7 +375,7 @@ export const workflowProjectIdAtom = projectIdAtom * - `flags` — filtering (is_evaluator, is_feedback, is_custom) * - `deleted_at` — archive filtering * - `description` — human evaluator list display - * - `created_at` — sort order in some views + * - `created_at` / `updated_at` — sort order and metadata display in some views */ export interface WorkflowListRef { id: string @@ -385,6 +385,7 @@ export interface WorkflowListRef { flags: Workflow["flags"] deleted_at: string | null created_at: string | null + updated_at: string | null } /** @@ -407,6 +408,7 @@ export function toWorkflowListRef(w: Workflow): WorkflowListRef { flags: w.flags, deleted_at: w.deleted_at ?? null, created_at: w.created_at ?? null, + updated_at: w.updated_at ?? null, } } @@ -2289,6 +2291,7 @@ export function seedCreatedWorkflowCache( flags: revision.flags, deleted_at: revision.deleted_at ?? null, created_at: revision.created_at ?? null, + updated_at: revision.updated_at ?? null, } store.set(workflowLocalServerDataAtomFamily(revision.id), revision) diff --git a/web/packages/agenta-entity-ui/src/selection/adapters/createAdapter.ts b/web/packages/agenta-entity-ui/src/selection/adapters/createAdapter.ts index 046792c790..3c3ecffbfd 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/createAdapter.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/createAdapter.ts @@ -93,6 +93,7 @@ export function createAdapter( isSelectable: level.isSelectable ?? (() => index >= resolvedSelectableLevel), isDisabled: level.isDisabled, getDescription: level.getDescription, + getSuffixNode: level.getSuffixNode, // Lifecycle callbacks onBeforeLoad: level.onBeforeLoad, // Filtering diff --git a/web/packages/agenta-entity-ui/src/selection/adapters/createAdapterFromRelations.ts b/web/packages/agenta-entity-ui/src/selection/adapters/createAdapterFromRelations.ts index 8ceff54b87..78e929e256 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/createAdapterFromRelations.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/createAdapterFromRelations.ts @@ -68,6 +68,8 @@ export interface LevelOverride { getIcon?: (entity: T) => ReactNode /** Custom description extractor */ getDescription?: (entity: T) => string | undefined + /** Custom suffix node (rendered after the label block, before the chevron) */ + getSuffixNode?: (entity: T) => ReactNode /** Override hasChildren */ hasChildren?: boolean | ((entity: T) => boolean) /** Override isSelectable */ @@ -216,6 +218,7 @@ function applyOverrides( getPlaceholderNode: overrides.getPlaceholderNode ?? baseLevel.getPlaceholderNode, getIcon: overrides.getIcon ?? baseLevel.getIcon, getDescription: overrides.getDescription ?? baseLevel.getDescription, + getSuffixNode: overrides.getSuffixNode ?? baseLevel.getSuffixNode, hasChildren: overrides.hasChildren !== undefined ? typeof overrides.hasChildren === "function" @@ -347,6 +350,7 @@ export function createAdapterFromRelations< getDescription: rootLevel.getDescription as | ((entity: unknown) => string | undefined) | undefined, + getSuffixNode: rootLevel.getSuffixNode as ((entity: unknown) => ReactNode) | undefined, hasChildren: rootLevel.hasChildren as boolean | ((entity: unknown) => boolean) | undefined, isSelectable: rootLevel.isSelectable as | boolean diff --git a/web/packages/agenta-entity-ui/src/selection/adapters/createLevelFromRelation.ts b/web/packages/agenta-entity-ui/src/selection/adapters/createLevelFromRelation.ts index c752bbe7f8..03aa0ab208 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/createLevelFromRelation.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/createLevelFromRelation.ts @@ -89,6 +89,11 @@ export interface CreateLevelFromRelationOptions { */ getDescription?: (entity: TChild) => string | undefined + /** + * Custom suffix node (rendered after the label block, before the chevron) + */ + getSuffixNode?: (entity: TChild) => React.ReactNode + /** * Whether this level has children (defaults based on position) */ @@ -238,6 +243,7 @@ export function createLevelFromRelation( getPlaceholderNode, getIcon, getDescription, + getSuffixNode, hasChildren, isSelectable, isDisabled, @@ -303,6 +309,7 @@ export function createLevelFromRelation( getPlaceholderNode, getIcon, getDescription: resolvedGetDescription, + getSuffixNode, hasChildren: hasChildrenFn, isSelectable: isSelectableFn, isDisabled, diff --git a/web/packages/agenta-entity-ui/src/selection/adapters/evaluatorLabelUtils.ts b/web/packages/agenta-entity-ui/src/selection/adapters/evaluatorLabelUtils.ts index a0fc0ce803..edc61847bf 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/evaluatorLabelUtils.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/evaluatorLabelUtils.ts @@ -29,32 +29,30 @@ interface EvaluatorWorkflowLike { // ============================================================================ /** - * Renders an evaluator workflow item with a colored type tag. + * Renders the colored type tag for an evaluator workflow item, or `undefined` + * for non-evaluator workflows / unknown types. * * Tag resolution order: * 1. Human evaluators → "Human" tag from flags * 2. Custom evaluators → "Custom Code" tag from flags * 3. Built-in evaluators → display name looked up via evaluatorKeyMap + evaluatorDefsByKey * - * Non-evaluator workflows get a plain label without a tag. - * * @param entity - The workflow entity (must have `id`, `name`, `flags`) * @param evaluatorKeyMap - Map from revision data * @param evaluatorDefsByKey - Map from template definitions */ -export function renderEvaluatorPickerLabelNode( +export function renderEvaluatorTypeTag( entity: unknown, evaluatorKeyMap: Map, evaluatorDefsByKey: Map, -): React.ReactNode { +): React.ReactNode | undefined { const w = entity as EvaluatorWorkflowLike - const name = w.name ?? "Unnamed" const evaluatorKey = evaluatorKeyMap.get(w.id) const isHumanEvaluator = Boolean(w.flags?.is_feedback) || evaluatorKey === "feedback" // Only show colored tags for evaluator-type workflows if (!w.flags?.is_evaluator && !isHumanEvaluator) { - return React.createElement(EntityListItemLabel, {label: name}) + return undefined } // Resolve tag label and color key @@ -74,26 +72,60 @@ export function renderEvaluatorPickerLabelNode( } } + if (!tagLabel) return undefined + const color = colorSource ? getWorkflowTypeColor(colorSource) : null - const tag = tagLabel - ? React.createElement( - "span", - { - className: "text-[10px] px-1.5 py-0.5 rounded", - style: color - ? { - backgroundColor: color.bg, - color: color.text, - borderColor: color.border, - borderWidth: "1px", - borderStyle: "solid", - } - : undefined, - }, - tagLabel, - ) - : undefined + return React.createElement( + "span", + { + className: "text-[10px] px-1.5 py-0.5 rounded", + style: color + ? { + backgroundColor: color.bg, + color: color.text, + borderColor: color.border, + borderWidth: "1px", + borderStyle: "solid", + } + : undefined, + }, + tagLabel, + ) +} + +/** + * Renders an evaluator workflow item's name without a type tag. Pair with + * `renderEvaluatorTypeTag` when the tag is rendered in a separate slot + * (e.g., the picker row's suffix). + */ +export function renderEvaluatorPickerNameNode(entity: unknown): React.ReactNode { + const w = entity as EvaluatorWorkflowLike + return React.createElement(EntityListItemLabel, {label: w.name ?? "Unnamed"}) +} + +/** + * Renders an evaluator workflow item with a colored type tag trailing the name. + * + * Non-evaluator workflows get a plain label without a tag. See + * `renderEvaluatorTypeTag` for the tag resolution rules. + * + * @param entity - The workflow entity (must have `id`, `name`, `flags`) + * @param evaluatorKeyMap - Map from revision data + * @param evaluatorDefsByKey - Map from template definitions + */ +export function renderEvaluatorPickerLabelNode( + entity: unknown, + evaluatorKeyMap: Map, + evaluatorDefsByKey: Map, +): React.ReactNode { + const w = entity as EvaluatorWorkflowLike + const name = w.name ?? "Unnamed" + const tag = renderEvaluatorTypeTag(entity, evaluatorKeyMap, evaluatorDefsByKey) + + if (!tag) { + return React.createElement(EntityListItemLabel, {label: name}) + } return React.createElement(EntityListItemLabel, { label: name, diff --git a/web/packages/agenta-entity-ui/src/selection/adapters/evaluatorWorkflowMetaDescription.ts b/web/packages/agenta-entity-ui/src/selection/adapters/evaluatorWorkflowMetaDescription.ts new file mode 100644 index 0000000000..557b9ede16 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/selection/adapters/evaluatorWorkflowMetaDescription.ts @@ -0,0 +1,34 @@ +import type {EvaluatorWorkflowMeta} from "@agenta/entities/workflow" + +/** + * Format an evaluator workflow's metadata as a one-line subtitle. + * + * @internal + */ +export function formatWorkflowMetaDescription( + meta: EvaluatorWorkflowMeta | undefined, +): string | undefined { + if (!meta) return undefined + + const parts: string[] = [] + + if (meta.versionCount != null && meta.versionCount > 0) { + parts.push(`${meta.versionCount} ${meta.versionCount === 1 ? "version" : "versions"}`) + } + + const lastModifiedAt = meta.updatedAt ?? meta.createdAt + if (lastModifiedAt) { + const date = new Date(lastModifiedAt) + if (!isNaN(date.getTime())) { + parts.push( + date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }), + ) + } + } + + return parts.length > 0 ? parts.join(" · ") : undefined +} diff --git a/web/packages/agenta-entity-ui/src/selection/adapters/types.ts b/web/packages/agenta-entity-ui/src/selection/adapters/types.ts index 1f54ddfb85..fa66917934 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/types.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/types.ts @@ -142,6 +142,11 @@ export interface CreateHierarchyLevelOptions { */ getDescription?: (entity: T) => string | undefined + /** + * Get a suffix node rendered after the label block, before the chevron + */ + getSuffixNode?: (entity: T) => ReactNode + /** * Callback to enable/prepare the query before loading children. * Called with the parent ID when navigating into this level. diff --git a/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts b/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts index 8333582859..f84893cf29 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts @@ -20,13 +20,19 @@ import { evaluatorTemplatesMapAtom, evaluatorTemplatesDataAtom, evaluatorConfigsQueryStateAtom, + evaluatorWorkflowMetaMapAtom, humanEvaluatorsListQueryAtom, workflowAppTypeAtomFamily, workflowsListDataAtom, } from "@agenta/entities/workflow" import {atom, getDefaultStore, useAtomValue} from "jotai" -import {renderEvaluatorPickerLabelNode} from "./evaluatorLabelUtils" +import { + renderEvaluatorPickerLabelNode, + renderEvaluatorPickerNameNode, + renderEvaluatorTypeTag, +} from "./evaluatorLabelUtils" +import {formatWorkflowMetaDescription} from "./evaluatorWorkflowMetaDescription" import { createWorkflowRevisionAdapter, type WorkflowRevisionSelectionResult, @@ -121,21 +127,34 @@ export function useEnrichedEvaluatorBrowseAdapter() { * Used by annotation pages and playground evaluator connect flow. * * @param revisionLabelOverride - Optional custom label renderer for revision level + * @param options.showWorkflowMeta - When true, evaluator rows expose a + * "N versions · date" description (consumed by pickers that opt into + * showing parent descriptions). Default false — existing callers unchanged. + * @param options.splitTypeTag - When true, the colored type tag moves out of + * the label line into the adapter's `getSuffixNode` (rendered after the + * label block, vertically centered against the whole row). Default false — + * existing callers keep the tag trailing the name. */ export function useEnrichedEvaluatorOnlyAdapter( revisionLabelOverride?: (entity: unknown) => React.ReactNode, + options?: {showWorkflowMeta?: boolean; splitTypeTag?: boolean}, ) { const {evaluatorKeyMap, evaluatorDefsByKey} = useEvaluatorEnrichedData() const templates = useAtomValue(evaluatorTemplatesDataAtom) + const workflowMetaMap = useAtomValue(evaluatorWorkflowMetaMapAtom) const evaluatorKeyMapRef = useRef(evaluatorKeyMap) const evaluatorDefsByKeyRef = useRef(evaluatorDefsByKey) const revisionLabelOverrideRef = useRef(revisionLabelOverride) + const workflowMetaMapRef = useRef(workflowMetaMap) evaluatorKeyMapRef.current = evaluatorKeyMap evaluatorDefsByKeyRef.current = evaluatorDefsByKey revisionLabelOverrideRef.current = revisionLabelOverride + workflowMetaMapRef.current = workflowMetaMap const hasRevisionLabelOverride = Boolean(revisionLabelOverride) + const showWorkflowMeta = Boolean(options?.showWorkflowMeta) + const splitTypeTag = Boolean(options?.splitTypeTag) // Build a stable Map from template data const templateCategoryMap = useMemo(() => { @@ -167,12 +186,25 @@ export function useEnrichedEvaluatorOnlyAdapter( ) return useMemo(() => { - const getLabelNode = (entity: unknown): React.ReactNode => - renderEvaluatorPickerLabelNode( - entity, - evaluatorKeyMapRef.current, - evaluatorDefsByKeyRef.current, - ) + // With splitTypeTag the colored tag renders in the row's suffix slot + // (vertically centered) instead of trailing the name. + const getLabelNode = splitTypeTag + ? renderEvaluatorPickerNameNode + : (entity: unknown): React.ReactNode => + renderEvaluatorPickerLabelNode( + entity, + evaluatorKeyMapRef.current, + evaluatorDefsByKeyRef.current, + ) + + const getSuffixNode = splitTypeTag + ? (entity: unknown): React.ReactNode => + renderEvaluatorTypeTag( + entity, + evaluatorKeyMapRef.current, + evaluatorDefsByKeyRef.current, + ) + : undefined // Resolve workflowId → evaluatorKey → primary category const getGroupKey = (entity: unknown): string | null | undefined => { @@ -192,12 +224,21 @@ export function useEnrichedEvaluatorOnlyAdapter( const getGroupLabel = (key: string): string => CATEGORY_LABELS[key] ?? key + const getDescription = showWorkflowMeta + ? (entity: unknown): string | undefined => { + const w = entity as {id: string} + return formatWorkflowMetaDescription(workflowMetaMapRef.current.get(w.id)) + } + : undefined + const options: NonNullable[0]> = { skipVariantLevel: true, excludeRevisionZero: true, workflowListAtom: autoEvaluatorsListAtom, grandparentOverrides: { getLabelNode, + getDescription, + getSuffixNode, getGroupKey, getGroupLabel, buildTabs: (entities: unknown[]) => { @@ -232,7 +273,7 @@ export function useEnrichedEvaluatorOnlyAdapter( } return createWorkflowRevisionAdapter(options) - }, [hasRevisionLabelOverride, autoEvaluatorsListAtom]) + }, [hasRevisionLabelOverride, showWorkflowMeta, splitTypeTag, autoEvaluatorsListAtom]) } type AnnotationWorkflowRevisionSelectionResult = WorkflowRevisionSelectionResult & { @@ -247,17 +288,22 @@ type AnnotationWorkflowRevisionSelectionResult = WorkflowRevisionSelectionResult */ export function useEnrichedHumanEvaluatorAdapter( revisionLabelOverride?: (entity: unknown) => React.ReactNode, + options?: {showWorkflowMeta?: boolean}, ) { const {evaluatorKeyMap, evaluatorDefsByKey} = useEvaluatorEnrichedData() + const workflowMetaMap = useAtomValue(evaluatorWorkflowMetaMapAtom) const evaluatorKeyMapRef = useRef(evaluatorKeyMap) const evaluatorDefsByKeyRef = useRef(evaluatorDefsByKey) const revisionLabelOverrideRef = useRef(revisionLabelOverride) + const workflowMetaMapRef = useRef(workflowMetaMap) evaluatorKeyMapRef.current = evaluatorKeyMap evaluatorDefsByKeyRef.current = evaluatorDefsByKey revisionLabelOverrideRef.current = revisionLabelOverride + workflowMetaMapRef.current = workflowMetaMap const hasRevisionLabelOverride = Boolean(revisionLabelOverride) + const showWorkflowMeta = Boolean(options?.showWorkflowMeta) // Stable atom that wraps humanEvaluatorsListQueryAtom into ListQueryState. // Uses a proper Jotai atom so filtering reactively updates when revision data resolves. @@ -289,6 +335,14 @@ export function useEnrichedHumanEvaluatorAdapter( workflowListAtom: humanEvaluatorsListAtom, grandparentOverrides: { getLabelNode, + getDescription: showWorkflowMeta + ? (entity: unknown): string | undefined => { + const workflow = entity as {id: string} + return formatWorkflowMetaDescription( + workflowMetaMapRef.current.get(workflow.id), + ) + } + : undefined, }, toSelection: (path, leafEntity) => { const revision = leafEntity as {id: string; version?: number} @@ -324,7 +378,7 @@ export function useEnrichedHumanEvaluatorAdapter( } return createWorkflowRevisionAdapter(options) - }, [hasRevisionLabelOverride, humanEvaluatorsListAtom]) + }, [hasRevisionLabelOverride, humanEvaluatorsListAtom, showWorkflowMeta]) } /** diff --git a/web/packages/agenta-entity-ui/src/selection/adapters/workflowRevisionRelationAdapter.ts b/web/packages/agenta-entity-ui/src/selection/adapters/workflowRevisionRelationAdapter.ts index 00d196dc69..ceb4655b5b 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/workflowRevisionRelationAdapter.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/workflowRevisionRelationAdapter.ts @@ -293,6 +293,8 @@ export interface CreateWorkflowRevisionAdapterOptions { */ grandparentOverrides?: { getLabelNode?: (entity: unknown) => React.ReactNode + getDescription?: (entity: unknown) => string | undefined + getSuffixNode?: (entity: unknown) => React.ReactNode getGroupKey?: (entity: unknown) => string | null | undefined getGroupLabel?: (key: string) => string buildTabs?: (items: unknown[]) => import("../types").TabDefinition[] @@ -502,6 +504,8 @@ export function createWorkflowRevisionAdapter( getId: (entity: unknown) => (entity as {id: string}).id, getLabel: getWorkflowDisplayName, getLabelNode: grandparentOverrides.getLabelNode ?? renderWorkflowLabelNode, + getDescription: grandparentOverrides.getDescription, + getSuffixNode: grandparentOverrides.getSuffixNode, hasChildren: true, isSelectable: false, getGroupKey: grandparentOverrides.getGroupKey, @@ -751,6 +755,8 @@ export function createWorkflowRevisionAdapter( getId: (entity: unknown) => (entity as {id: string}).id, getLabel: (entity: unknown) => getWorkflowDisplayName(entity), getLabelNode: grandparentOverrides.getLabelNode ?? renderWorkflowLabelNode, + getDescription: grandparentOverrides.getDescription, + getSuffixNode: grandparentOverrides.getSuffixNode, getGroupKey: grandparentOverrides.getGroupKey ?? getWorkflowGroupKey, getGroupLabel: grandparentOverrides.getGroupLabel ?? getWorkflowGroupLabel, hasChildren: true, diff --git a/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/shared/AutoSelectHandler.tsx b/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/shared/AutoSelectHandler.tsx index 98c72117a2..7a74ced2de 100644 --- a/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/shared/AutoSelectHandler.tsx +++ b/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/shared/AutoSelectHandler.tsx @@ -33,6 +33,11 @@ export interface AutoSelectHandlerProps { */ childLevelConfig: HierarchyLevel + /** + * Child IDs that should be skipped when resolving the latest selectable child + */ + disabledChildIds?: Set + /** * Function to create selection result */ @@ -68,6 +73,7 @@ export function AutoSelectHandler({ parentLabel, parentLevelConfig, childLevelConfig, + disabledChildIds, createSelection, onSelect, onComplete, @@ -77,6 +83,7 @@ export function AutoSelectHandler({ parentLabel, parentLevelConfig, childLevelConfig, + disabledChildIds, createSelection, onSelect, onComplete, diff --git a/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/types.ts b/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/types.ts index 7e59bc8f63..d3cd0f9c31 100644 --- a/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/types.ts +++ b/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/types.ts @@ -610,6 +610,23 @@ export interface PopoverCascaderVariantProps< */ panelWidth?: number + /** + * Fixed width of the child (right-hand) panel (px), e.g. the revision + * panel. Overrides `panelWidth` for the child panel only; the root panel + * keeps `panelWidth`/`panelMinWidth`. + */ + childPanelWidth?: number + + /** + * Opens a child panel automatically when the popover opens. + * + * When enabled, the currently selected parent is preferred; otherwise the + * first visible parent is opened. When disabled, the child panel opens only + * after the user hovers or clicks a parent. + * @default false + */ + defaultOpenChildPanel?: boolean + /** * Maximum height of item lists (px) * @default 340 @@ -679,6 +696,77 @@ export interface PopoverCascaderVariantProps< */ childItemLabelMode?: "full" | "simple" + // ======================================================================== + // PARENT (ROOT) MULTI-SELECT + // ======================================================================== + + /** + * Render a checkbox on each root row (multi-select mode only). + * Checked when the parent has at least one selected child (per + * `selectedChildrenByParent`); indeterminate when only some of its + * children are selected (requires `totalChildrenByParent`). + * @default false + */ + showParentCheckboxes?: boolean + + /** + * Consumer-provided map: parentId → currently selected children of that + * parent. Drives the parent checkbox checked state and the selected-child + * chips rendered under the parent label. + */ + selectedChildrenByParent?: Map + + /** + * Consumer-provided map: parentId → total child count. Used to render the + * parent checkbox as indeterminate when only some children are selected. + */ + totalChildrenByParent?: Map + + /** + * Called when a selected-child chip's remove (×) button is clicked. + * Also called for every selected child when its parent checkbox is unchecked. + */ + onDeselectChild?: (childId: string) => void + + // ======================================================================== + // ROOT ROW METADATA + // ======================================================================== + + /** + * Show the adapter's `getDescription()` as a subtitle on root rows. + * Replaced by the selected-child chips when the row has selections. + * @default false + */ + showParentDescription?: boolean + + // ======================================================================== + // GROUP HEADERS + // ======================================================================== + + /** + * Render group label headers with divider lines above each group when the + * "All" tab is active and the adapter provides grouping. + * @default false + */ + showGroupHeaders?: boolean + + // ======================================================================== + // BULK ACTIONS + // ======================================================================== + + /** + * Show a "Select all" link in the child panel header (multi-select only). + * Selects every enabled, not-yet-selected child of the open parent. + * @default false + */ + showChildSelectAll?: boolean + + /** + * When provided, renders a "Clear all" link next to the selection summary + * (requires `selectionSummary`). Called to clear the entire selection. + */ + onClearAll?: () => void + // ======================================================================== // SELECTION SUMMARY // ======================================================================== diff --git a/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/variants/ListPopoverVariant.tsx b/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/variants/ListPopoverVariant.tsx index b404d9aed9..e13f08f884 100644 --- a/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/variants/ListPopoverVariant.tsx +++ b/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/variants/ListPopoverVariant.tsx @@ -82,6 +82,7 @@ export function ListPopoverVariant({ handleParentClick, handleChildSelect, autoSelectingParent, + clearAutoSelectingParent, isLoadingParents, parentsError, adapter: resolvedAdapter, @@ -255,13 +256,12 @@ export function ListPopoverVariant({ parentLabel={autoSelectingParent.label} parentLevelConfig={parentLevelConfig} childLevelConfig={childLevelConfig} + disabledChildIds={disabledChildIds} createSelection={(path, entity) => resolvedAdapter.toSelection(path, entity) as TSelection } onSelect={onSelect} - onComplete={() => { - // The hook will handle clearing autoSelectingParent - }} + onComplete={clearAutoSelectingParent} /> )}
diff --git a/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/variants/PopoverCascaderVariant.tsx b/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/variants/PopoverCascaderVariant.tsx index c40fc1d044..7aaa94e42d 100644 --- a/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/variants/PopoverCascaderVariant.tsx +++ b/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/variants/PopoverCascaderVariant.tsx @@ -14,16 +14,17 @@ * Pattern: Button trigger → Popover → [Root Panel | Child Panel] */ -import React, {useCallback, useEffect, useMemo, useState, type CSSProperties} from "react" +import React, {useCallback, useEffect, useMemo, useRef, useState, type CSSProperties} from "react" import {cn} from "@agenta/ui" import {EntityListItem, SearchInput} from "@agenta/ui/components/selection" -import {CaretDown, Plus} from "@phosphor-icons/react" +import {CaretDown, Plus, X} from "@phosphor-icons/react" import {Button, Checkbox, Empty, Popover, Spin, Tabs} from "antd" import {useEntitySelectionCore} from "../../../hooks/useEntitySelectionCore" import {useLevelData} from "../../../hooks/utilities" import type {EntitySelectionResult, HierarchyLevel, SelectionPathItem} from "../../../types" +import {AutoSelectHandler} from "../shared" import type {PopoverCascaderVariantProps} from "../types" const POPOVER_CASCADER_TEST_IDS = { @@ -55,6 +56,7 @@ function ChildPanelContent({ multiSelect = false, selectedChildIds, childItemLabelMode = "full", + showSelectAll = false, }: { parentId: string parentLabel: string @@ -69,6 +71,7 @@ function ChildPanelContent({ multiSelect?: boolean selectedChildIds?: Set childItemLabelMode?: "full" | "simple" + showSelectAll?: boolean }) { const {items, query} = useLevelData({ levelConfig: childLevelConfig, @@ -101,106 +104,160 @@ function ChildPanelContent({ .length : 0 + // Select every enabled child that isn't selected yet. Only unselected items + // are passed to onSelect, which has toggle semantics in multi-select mode. + const handleSelectAll = useCallback(() => { + for (const item of enabledChildren) { + if (!selectedChildIds?.has(childLevelConfig.getId(item))) { + onSelect(item) + } + } + }, [enabledChildren, selectedChildIds, childLevelConfig, onSelect]) + + if (query.isPending) { + return ( +
+ +
+ ) + } + return (
- {query.isPending ? ( - // Render the loading spinner inside the SAME fixed-width panel chrome as the - // loaded content (panelStyle is the fixed childPanelStyle). Previously the - // pending state early-returned a separate `px-6` container, so the right - // panel's footprint could differ between loading and loaded — causing a - // layout shift when a parent item was selected. -
- + {/* Child panel header */} + {multiSelect && ( +
+
+ + {parentLabel} + + {multiSelect && ( + + {selectedCount} of {filteredItems.length} selected + + )} +
+ {showSelectAll && + enabledChildren.length > 0 && + selectedCount < enabledChildren.length && ( + + )}
- ) : ( - <> - {/* Child panel header */} - {multiSelect && ( -
-
- - {parentLabel} - - {multiSelect && ( - - {selectedCount} of {filteredItems.length} selected - + )} + + {/* Child items */} +
+ {filteredItems.length === 0 ? ( + + ) : multiSelect ? ( + filteredItems.map((item) => { + const itemId = childLevelConfig.getId(item) + const label = childLevelConfig.getLabel(item) + const labelNode = getItemLabelNode?.(item) + const isDisabled = disabledIds?.has(itemId) ?? false + const isChecked = selectedChildIds?.has(itemId) ?? false + + return ( +
{ + if (!isDisabled) onSelect(item) + }} + > + + + {labelNode ?? label} +
-
- )} - - {/* Child items */} -
- {filteredItems.length === 0 ? ( - { + const itemId = childLevelConfig.getId(item) + const label = childLevelConfig.getLabel(item) + const labelNode = getItemLabelNode?.(item) + const isSelected = itemId === selectedId + const isDisabled = disabledIds?.has(itemId) ?? false + + return ( + !isDisabled && onSelect(item)} + onSelect={() => !isDisabled && onSelect(item)} + className="!py-1.5" /> - ) : multiSelect ? ( - // Multi-select: checkboxes - filteredItems.map((item) => { - const itemId = childLevelConfig.getId(item) - const label = childLevelConfig.getLabel(item) - const labelNode = getItemLabelNode?.(item) - const isDisabled = disabledIds?.has(itemId) ?? false - const isChecked = selectedChildIds?.has(itemId) ?? false - - return ( -
{ - if (!isDisabled) onSelect(item) - }} - > - - - {labelNode ?? label} - -
- ) - }) - ) : ( - // Single-select: click items - filteredItems.map((item) => { - const itemId = childLevelConfig.getId(item) - const label = childLevelConfig.getLabel(item) - const labelNode = getItemLabelNode?.(item) - const isSelected = itemId === selectedId - const isDisabled = disabledIds?.has(itemId) ?? false - - return ( - !isDisabled && onSelect(item)} - onSelect={() => !isDisabled && onSelect(item)} - className="!py-1.5" - /> - ) - }) - )} -
- - )} + ) + }) + )} +
+
+ ) +} + +// ============================================================================ +// SELECTED CHILD CHIPS (rendered under a root item's label) +// ============================================================================ + +/** + * Compact removable chips for a root item's selected children (e.g. "v2 ×"). + * Only the "×" stops propagation (it deselects); clicks anywhere else in the + * chips row bubble up to the row so they open the child panel like the rest + * of the row body. + */ +function SelectedChildChips({ + chips, + onDeselectChild, +}: { + chips: {id: string; label: string}[] + onDeselectChild?: (childId: string) => void +}) { + return ( +
+ {chips.map((chip) => ( + + {chip.label} + { + e.stopPropagation() + onDeselectChild?.(chip.id) + }} + /> + + ))}
) } @@ -217,6 +274,13 @@ function RootItemRenderer({ selectedRootId, openChildOnHover, onRootItemClick, + showParentCheckboxes = false, + selectedChildrenByParent, + totalChildrenByParent, + onParentCheckboxChange, + onDeselectChild, + isParentSelectionPending = false, + showParentDescription = false, }: { item: unknown rootLevel: HierarchyLevel @@ -225,8 +289,56 @@ function RootItemRenderer({ selectedRootId: string | null openChildOnHover: boolean onRootItemClick: (item: unknown) => void + showParentCheckboxes?: boolean + selectedChildrenByParent?: Map + totalChildrenByParent?: Map + onParentCheckboxChange: (item: unknown, checked: boolean) => void + onDeselectChild?: (childId: string) => void + isParentSelectionPending?: boolean + showParentDescription?: boolean }) { const id = rootLevel.getId(item) + + const selectedChildren = selectedChildrenByParent?.get(id) + const selectedCount = selectedChildren?.length ?? 0 + const totalChildren = totalChildrenByParent?.get(id) + const isChecked = selectedCount > 0 + const isIndeterminate = isChecked && totalChildren != null && selectedCount < totalChildren + + // Checkbox toggles selection only; clicks must not bubble to the row + // (which opens the child panel). + const prefixNode = showParentCheckboxes ? ( + e.stopPropagation()} className="flex items-center"> + onParentCheckboxChange(item, !isChecked)} + /> + + ) : undefined + + // Subtitle metadata is replaced by the selected-child chips when present. + const description = + showParentDescription && selectedCount === 0 ? rootLevel.getDescription?.(item) : undefined + + const footerNode = + selectedCount > 0 && selectedChildren ? ( + + ) : undefined + + // When the child panel opens on hover, the row body click is free to + // toggle the parent checkbox (same as clicking the checkbox) and must not + // also drill into the panel. Otherwise the row click opens the child panel. + const handleItemClick = () => { + if (openChildOnHover && showParentCheckboxes) { + onParentCheckboxChange(item, !isChecked) + return + } + + onRootItemClick(item) + } + return (
1} isSelectable={totalLevels <= 1} isSelected={id === selectedParentId} isHovered={id === selectedRootId} - onClick={() => onRootItemClick(item)} - onSelect={() => onRootItemClick(item)} + onClick={handleItemClick} + onSelect={handleItemClick} />
) @@ -264,6 +380,8 @@ export function PopoverCascaderVariant({ placement = "bottomLeft", panelMinWidth = 220, panelWidth, + childPanelWidth, + defaultOpenChildPanel = false, maxHeight = 340, popupFooter, onCreateNew, @@ -278,6 +396,18 @@ export function PopoverCascaderVariant({ selectedChildIds, selectionSummary, childItemLabelMode = "full", + // Parent multi-select props + showParentCheckboxes = false, + selectedChildrenByParent, + totalChildrenByParent, + onDeselectChild, + // Root row metadata + showParentDescription = false, + // Group headers + showGroupHeaders = false, + // Bulk actions + showChildSelectAll = false, + onClearAll, }: PopoverCascaderVariantProps) { const {hierarchyLevels, createSelection} = useEntitySelectionCore({ adapter: adapterProp, @@ -294,6 +424,11 @@ export function PopoverCascaderVariant({ const [searchTerm, setSearchTerm] = useState("") const [selectedRootId, setSelectedRootId] = useState(null) const [selectedRootEntity, setSelectedRootEntity] = useState(null) + const [pendingParentSelection, setPendingParentSelection] = useState<{ + id: string + label: string + } | null>(null) + const pendingCreateRef = useRef(false) // Active tab state — always starts on "all", reset on close const [activeTabKey, setActiveTabKey] = useState("all") @@ -353,29 +488,55 @@ export function PopoverCascaderVariant({ return `${selectionCount} selected` }, [selectionSummary, selectedChildIds, selectedChildId]) + const resolvedPanelWidth = panelWidth ?? panelMinWidth + const resolvedChildWidth = childPanelWidth ?? resolvedPanelWidth + const isChildPanelVisible = selectedRootId !== null && totalLevels > 1 + + // Keep the total popover width stable when the child panel opens. While + // closed, the root panel occupies both configured panel widths. const panelStyle = useMemo( - () => (panelWidth != null ? {width: panelWidth} : {minWidth: panelMinWidth}), - [panelWidth, panelMinWidth], + () => ({ + width: isChildPanelVisible + ? resolvedPanelWidth + : resolvedPanelWidth + (totalLevels > 1 ? resolvedChildWidth : 0), + }), + [isChildPanelVisible, resolvedPanelWidth, resolvedChildWidth, totalLevels], ) const childPanelStyle = useMemo( - () => - panelWidth != null - ? {width: panelWidth} - : {minWidth: panelMinWidth, maxWidth: panelMinWidth}, - [panelWidth, panelMinWidth], + () => ({width: resolvedChildWidth}), + [resolvedChildWidth], ) - // Maintain auto-selection to prevent pixel shifts when searching/filtering + const childPanelOuterStyle = useMemo( + () => ({width: resolvedChildWidth}), + [resolvedChildWidth], + ) + + // Keep a user-opened child panel aligned with the filtered root list. The + // initial/default opening behavior remains opt-in. useEffect(() => { if (!open || totalLevels <= 1) return // Wait until rootItems are loaded if (rootQuery.isPending && rootItems.length === 0) return - // On open/mount, if we have a parent ID pre-selected and no root ID is selected locally yet - if (!selectedRootId && selectedParentId) { - const matchingRoot = rootItems.find( + // If something is already selected locally, ensure it's still in the filtered view + if (selectedRootId) { + const stillExists = tabFilteredRootItems.some( + (item) => rootLevel.getId(item) === selectedRootId, + ) + if (stillExists) return + + setSelectedRootId(null) + setSelectedRootEntity(null) + } + + if (!defaultOpenChildPanel) return + + // Prefer the controlled parent selection when default opening is enabled. + if (selectedParentId) { + const matchingRoot = tabFilteredRootItems.find( (item) => rootLevel.getId(item) === selectedParentId, ) if (matchingRoot) { @@ -386,15 +547,7 @@ export function PopoverCascaderVariant({ } } - // If something is already selected locally, ensure it's still in the filtered view - if (selectedRootId) { - const stillExists = tabFilteredRootItems.some( - (item) => rootLevel.getId(item) === selectedRootId, - ) - if (stillExists) return - } - - // Auto-select the first available item in the filtered view (UI ONLY, don't trigger selection) + // Otherwise open the first available item without selecting a child. if (tabFilteredRootItems.length > 0) { const firstItem = tabFilteredRootItems[0] const id = rootLevel.getId(firstItem) @@ -410,6 +563,7 @@ export function PopoverCascaderVariant({ totalLevels, selectedRootId, selectedParentId, + defaultOpenChildPanel, tabFilteredRootItems, rootLevel, hierarchyLevels, @@ -483,6 +637,36 @@ export function PopoverCascaderVariant({ ], ) + const handleParentCheckboxChange = useCallback( + (parentEntity: unknown, checked: boolean) => { + if (pendingParentSelection) return + + const parentId = rootLevel.getId(parentEntity) + if (!checked) { + selectedChildrenByParent + ?.get(parentId) + ?.forEach((child) => onDeselectChild?.(child.id)) + return + } + + const childLevel = hierarchyLevels[1] + if (!childLevel) return + + childLevel.onBeforeLoad?.(parentId) + setPendingParentSelection({ + id: parentId, + label: rootLevel.getLabel(parentEntity), + }) + }, + [ + hierarchyLevels, + onDeselectChild, + pendingParentSelection, + rootLevel, + selectedChildrenByParent, + ], + ) + // Reset state when popover closes const handleOpenChange = useCallback((newOpen: boolean) => { setOpen(newOpen) @@ -495,9 +679,18 @@ export function PopoverCascaderVariant({ }, []) const handleCreateNew = useCallback(() => { - onCreateNew?.() + pendingCreateRef.current = true setOpen(false) - }, [onCreateNew]) + }, []) + + const handleAfterOpenChange = useCallback( + (isOpen: boolean) => { + if (isOpen || !pendingCreateRef.current) return + pendingCreateRef.current = false + onCreateNew?.() + }, + [onCreateNew], + ) // Shared props for RootItemRenderer const rootItemProps = useMemo( @@ -508,6 +701,13 @@ export function PopoverCascaderVariant({ selectedRootId, openChildOnHover, onRootItemClick: handleRootItemClick, + showParentCheckboxes, + selectedChildrenByParent, + totalChildrenByParent, + onParentCheckboxChange: handleParentCheckboxChange, + onDeselectChild, + isParentSelectionPending: pendingParentSelection !== null, + showParentDescription, }), [ rootLevel, @@ -516,6 +716,13 @@ export function PopoverCascaderVariant({ selectedRootId, openChildOnHover, handleRootItemClick, + showParentCheckboxes, + selectedChildrenByParent, + totalChildrenByParent, + handleParentCheckboxChange, + onDeselectChild, + pendingParentSelection, + showParentDescription, ], ) @@ -568,10 +775,20 @@ export function PopoverCascaderVariant({ > {/* Selection summary */} {selectionSummaryText ? ( -
+
{selectionSummaryText} + {onClearAll && (selectedChildIds?.size ?? 0) > 0 && ( + + )}
) : null} @@ -593,6 +810,15 @@ export function PopoverCascaderVariant({ {Array.from(groupedItems.groups.entries()).map( ([groupKey, items]) => (
+ {showGroupHeaders && ( +
+ + {rootLevel.getGroupLabel?.(groupKey) ?? + groupKey} + +
+
+ )} {items.map((item) => ( ({
), )} - {groupedItems.ungrouped.length > 0 && - groupedItems.ungrouped.map((item) => ( - - ))} + {groupedItems.ungrouped.length > 0 && ( +
+ {showGroupHeaders && groupedItems.groups.size > 0 && ( +
+ + Other + +
+
+ )} + {groupedItems.ungrouped.map((item) => ( + + ))} +
+ )} ) : ( // Flat rendering (no grouping) @@ -630,9 +867,7 @@ export function PopoverCascaderVariant({ {/* CHILD PANEL */} {selectedRootId && totalLevels > 1 && ( - // Fixed width (childPanelStyle), not the growable panelStyle, so the - // child column never changes width between loading and loaded states. -
+
({ multiSelect={multiSelect} selectedChildIds={selectedChildIds} childItemLabelMode={childItemLabelMode} + showSelectAll={showChildSelectAll} />
)} @@ -656,28 +892,45 @@ export function PopoverCascaderVariant({ ) return ( - - - + + + ) } diff --git a/web/packages/agenta-entity-ui/src/selection/hooks/modes/autoSelectLatestChild.ts b/web/packages/agenta-entity-ui/src/selection/hooks/modes/autoSelectLatestChild.ts new file mode 100644 index 0000000000..b69eca4ffb --- /dev/null +++ b/web/packages/agenta-entity-ui/src/selection/hooks/modes/autoSelectLatestChild.ts @@ -0,0 +1,38 @@ +import type {LevelQueryState} from "../utilities" + +export type AutoSelectLatestChildDecision = + | {status: "wait"} + | {status: "select"; child: T} + | {status: "complete"} + +interface ResolveAutoSelectLatestChildOptions { + children: T[] + query: LevelQueryState + getId: (child: T) => string + disabledChildIds?: Set +} + +/** + * Resolve the next auto-selection action independently from React effects. + * + * @internal + */ +export function resolveAutoSelectLatestChild({ + children, + query, + getId, + disabledChildIds, +}: ResolveAutoSelectLatestChildOptions): AutoSelectLatestChildDecision { + if (query.isPending) return {status: "wait"} + + const firstChild = children.find((child) => !disabledChildIds?.has(getId(child))) + if (firstChild) return {status: "select", child: firstChild} + + // Legacy adapters may omit isFetched. Only an explicit false means the + // initial empty snapshot is still waiting for the lazy query to settle. + if (!query.isError && query.isFetched === false && children.length === 0) { + return {status: "wait"} + } + + return {status: "complete"} +} diff --git a/web/packages/agenta-entity-ui/src/selection/hooks/modes/useListPopoverMode.ts b/web/packages/agenta-entity-ui/src/selection/hooks/modes/useListPopoverMode.ts index 2855ec4a8f..c5011574c4 100644 --- a/web/packages/agenta-entity-ui/src/selection/hooks/modes/useListPopoverMode.ts +++ b/web/packages/agenta-entity-ui/src/selection/hooks/modes/useListPopoverMode.ts @@ -25,6 +25,8 @@ import { } from "../useEntitySelectionCore" import {useLevelData, filterItems, buildPathItem, type LevelQueryState} from "../utilities" +import {resolveAutoSelectLatestChild} from "./autoSelectLatestChild" + // ============================================================================ // TYPES // ============================================================================ @@ -161,6 +163,8 @@ export interface UseListPopoverModeResult { // Auto-select state /** Parent being auto-selected (for latest selection) */ autoSelectingParent: {id: string; label: string} | null + /** Clear the pending latest-child auto-selection */ + clearAutoSelectingParent: () => void // Core /** Resolved adapter */ @@ -299,6 +303,9 @@ export function useListPopoverMode( id: string label: string } | null>(null) + const clearAutoSelectingParent = useCallback(() => { + setAutoSelectingParent(null) + }, []) // ======================================================================== // PARENT DATA @@ -506,6 +513,7 @@ export function useListPopoverMode( // Auto-select autoSelectingParent, + clearAutoSelectingParent, // Core adapter, @@ -544,6 +552,7 @@ export interface UseAutoSelectLatestChildOptions childLevelConfig: HierarchyLevel + disabledChildIds?: Set createSelection: (path: SelectionPathItem[], entity: unknown) => TSelection onSelect?: (selection: TSelection) => void onComplete: () => void @@ -554,6 +563,7 @@ export function useAutoSelectLatestChild({ parentLabel, parentLevelConfig, childLevelConfig, + disabledChildIds, createSelection, onSelect, onComplete, @@ -563,30 +573,47 @@ export function useAutoSelectLatestChild({ // Fetch children const {items: children, query} = useChildrenData(childLevelConfig, parentId, true) - // Auto-select first child when loaded useEffect(() => { - if (hasSelectedRef.current || query.isPending || children.length === 0) { - return - } + hasSelectedRef.current = false + }, [parentId]) - hasSelectedRef.current = true - const firstChild = children[0] + // Auto-select first enabled child when loaded + useEffect(() => { + if (hasSelectedRef.current) return - const parentPathItem: SelectionPathItem = { - type: parentLevelConfig.type, - id: parentId, - label: parentLabel, - } + const decision = resolveAutoSelectLatestChild({ + children, + query, + getId: childLevelConfig.getId, + disabledChildIds, + }) + if (decision.status === "wait") return - const childPathItem = buildPathItem(firstChild, childLevelConfig) - const fullPath = [parentPathItem, childPathItem] - const selection = createSelection(fullPath, firstChild) + if (decision.status === "select") { + hasSelectedRef.current = true + const parentPathItem: SelectionPathItem = { + type: parentLevelConfig.type, + id: parentId, + label: parentLabel, + } - onSelect?.(selection) + const childPathItem = buildPathItem(decision.child, childLevelConfig) + const fullPath = [parentPathItem, childPathItem] + const selection = createSelection(fullPath, decision.child) + + onSelect?.(selection) + onComplete() + return + } + + hasSelectedRef.current = true onComplete() }, [ query.isPending, + query.isError, + query.isFetched, children, + disabledChildIds, parentId, parentLabel, parentLevelConfig, diff --git a/web/packages/agenta-entity-ui/src/selection/hooks/utilities/useLevelData.ts b/web/packages/agenta-entity-ui/src/selection/hooks/utilities/useLevelData.ts index 6429850852..582d80390f 100644 --- a/web/packages/agenta-entity-ui/src/selection/hooks/utilities/useLevelData.ts +++ b/web/packages/agenta-entity-ui/src/selection/hooks/utilities/useLevelData.ts @@ -33,6 +33,8 @@ export interface LevelQueryState { isError: boolean /** Error object if any */ error: Error | null + /** Whether the backing query has completed at least once */ + isFetched?: boolean } /** @@ -185,6 +187,7 @@ export function useLevelData(options: UseLevelDataOptions): UseL isPending: queryState.isPending, isError: queryState.isError, error: queryState.error ?? null, + isFetched: queryState.isFetched, }, } } diff --git a/web/packages/agenta-entity-ui/src/selection/types.ts b/web/packages/agenta-entity-ui/src/selection/types.ts index 31bc53c0f0..612c48cf03 100644 --- a/web/packages/agenta-entity-ui/src/selection/types.ts +++ b/web/packages/agenta-entity-ui/src/selection/types.ts @@ -73,6 +73,8 @@ export interface ListQueryState { isPending: boolean isError: boolean error?: Error | null + /** Whether the backing query has completed at least once. */ + isFetched?: boolean } // ============================================================================ @@ -254,6 +256,12 @@ export interface HierarchyLevel { */ getDescription?: (entity: T) => string | undefined + /** + * Get a suffix node rendered after the label block, before the chevron + * (e.g., an evaluator type tag). Vertically centered against the whole row. + */ + getSuffixNode?: (entity: T) => ReactNode + /** * Filter function to exclude items from the list. * Return true to include the item, false to exclude it. diff --git a/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/index.ts b/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/index.ts index 78cbf1903a..9915bab230 100644 --- a/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/index.ts +++ b/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/index.ts @@ -22,6 +22,10 @@ export { workflowRevisionDrawerEntityIdAtom, workflowRevisionDrawerContextAtom, workflowRevisionDrawerExpandedAtom, + workflowRevisionDrawerIsolatedPlaygroundAtom, + workflowRevisionDrawerInitialAppSelectionAtom, + workflowRevisionDrawerPostCreateNavigationAtom, + workflowRevisionDrawerScopedDirtyAtom, workflowRevisionDrawerNavigationIdsAtom, workflowRevisionDrawerCallbackAtom, workflowRevisionDrawerViewModeAtom, @@ -36,7 +40,9 @@ export { isCreateContext, // Types type DrawerContext, + type DrawerInitialAppSelection, type OpenDrawerParams, + type WorkflowCreatedResult, } from "./store" // Loading state (for external consumption, e.g., disabling nav buttons) diff --git a/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/store.ts b/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/store.ts index 610c78846d..49f0642a2b 100644 --- a/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/store.ts +++ b/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/store.ts @@ -12,6 +12,7 @@ * - "app-create": Ephemeral app from template (new app creation flow) */ +import type {Workflow} from "@agenta/entities/workflow" import {atom} from "jotai" import {atomWithReset, RESET} from "jotai/utils" @@ -36,6 +37,18 @@ export type DrawerContext = export const isCreateContext = (context: DrawerContext): boolean => context === "evaluator-create" || context === "app-create" +export interface WorkflowCreatedResult { + configId?: string + newAppId?: string + newRevisionId?: string + workflow?: Workflow +} + +export interface DrawerInitialAppSelection { + revisionId: string + label: string +} + export interface OpenDrawerParams { entityId: string context: DrawerContext @@ -49,11 +62,7 @@ export interface OpenDrawerParams { * 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 + onWorkflowCreated?: (result: WorkflowCreatedResult) => void /** * @deprecated Use `onWorkflowCreated` instead. Kept for backward compatibility * with existing evaluator-create call sites; will be removed in a follow-up. @@ -73,6 +82,21 @@ export interface OpenDrawerParams { * from the editor. */ stacked?: boolean + /** + * Run the drawer playground in a dedicated Jotai store. Intended for + * opening a creation playground on top of an already-mounted playground. + */ + isolatedPlayground?: boolean + /** + * App revision to connect when initializing an evaluator playground. + * Existing persisted selection remains the fallback when omitted. + */ + initialAppSelection?: DrawerInitialAppSelection + /** + * Controls evaluator-create navigation after commit. + * @default "default" + */ + postCreateNavigation?: "default" | "stay" } // ================================================================ @@ -93,6 +117,13 @@ export const workflowRevisionDrawerExpandedAtom = atomWithReset(false) /** Whether the drawer is stacked over another drawer (forces mask + focus lock) */ export const workflowRevisionDrawerStackedAtom = atomWithReset(false) +export const workflowRevisionDrawerIsolatedPlaygroundAtom = atomWithReset(false) +export const workflowRevisionDrawerInitialAppSelectionAtom = + atomWithReset(null) +export const workflowRevisionDrawerPostCreateNavigationAtom = atomWithReset<"default" | "stay">( + "default", +) +export const workflowRevisionDrawerScopedDirtyAtom = atomWithReset(false) /** Config view mode (form/json/yaml) — persists across expand/collapse */ export const workflowRevisionDrawerViewModeAtom = atomWithReset("form") @@ -106,7 +137,7 @@ export const workflowRevisionDrawerNavigationIdsAtom = atomWithReset([ * inside `openWorkflowRevisionDrawerAtom`. */ export const workflowRevisionDrawerCallbackAtom = atom< - ((result: {configId?: string; newAppId?: string; newRevisionId?: string}) => void) | undefined + ((result: WorkflowCreatedResult) => void) | undefined >(undefined) // ================================================================ @@ -139,6 +170,10 @@ export const openWorkflowRevisionDrawerAtom = atom(null, (get, set, params: Open set(workflowRevisionDrawerExpandedAtom, opensExpanded) set(workflowRevisionDrawerContextAtom, params.context) set(workflowRevisionDrawerStackedAtom, params.stacked ?? false) + set(workflowRevisionDrawerIsolatedPlaygroundAtom, params.isolatedPlayground ?? false) + set(workflowRevisionDrawerInitialAppSelectionAtom, params.initialAppSelection ?? null) + set(workflowRevisionDrawerPostCreateNavigationAtom, params.postCreateNavigation ?? "default") + set(workflowRevisionDrawerScopedDirtyAtom, false) if (params.navigationIds !== undefined) { set(workflowRevisionDrawerNavigationIdsAtom, params.navigationIds) } @@ -182,6 +217,10 @@ export const closeWorkflowRevisionDrawerAtom = atom( set(workflowRevisionDrawerExpandedAtom, RESET) set(workflowRevisionDrawerContextAtom, RESET) set(workflowRevisionDrawerStackedAtom, RESET) + set(workflowRevisionDrawerIsolatedPlaygroundAtom, RESET) + set(workflowRevisionDrawerInitialAppSelectionAtom, RESET) + set(workflowRevisionDrawerPostCreateNavigationAtom, RESET) + set(workflowRevisionDrawerScopedDirtyAtom, RESET) set(workflowRevisionDrawerNavigationIdsAtom, RESET) set(workflowRevisionDrawerCallbackAtom, undefined) set(workflowRevisionDrawerViewModeAtom, RESET) diff --git a/web/packages/agenta-playground/src/state/atoms/playground.ts b/web/packages/agenta-playground/src/state/atoms/playground.ts index 554abee71f..5219e6f868 100644 --- a/web/packages/agenta-playground/src/state/atoms/playground.ts +++ b/web/packages/agenta-playground/src/state/atoms/playground.ts @@ -5,6 +5,7 @@ */ import {atom, type PrimitiveAtom} from "jotai" +import {getDefaultStore} from "jotai/vanilla" import type {PlaygroundNode, PlaygroundAction, ConnectedTestset, ExtraColumn} from "../types" @@ -55,6 +56,12 @@ export const mappingModalOpenAtom = atom(false) as PrimitiveAtom(null) as PrimitiveAtom +/** + * Store used by the current playground session for imperative subscriptions + * that cannot be expressed through an atom's get/set pair. + */ +export const playgroundStoreAtom = atom>(getDefaultStore()) + // ============================================================================ // DERIVED PLAYGROUND ATOMS // ============================================================================ diff --git a/web/packages/agenta-playground/src/state/controllers/playgroundController.ts b/web/packages/agenta-playground/src/state/controllers/playgroundController.ts index 7ad4cd48a8..7f4148c554 100644 --- a/web/packages/agenta-playground/src/state/controllers/playgroundController.ts +++ b/web/packages/agenta-playground/src/state/controllers/playgroundController.ts @@ -40,7 +40,6 @@ import { import {projectIdAtom} from "@agenta/shared/state" import {atom} from "jotai" import type {Getter, Setter} from "jotai" -import {getDefaultStore} from "jotai/vanilla" import type {SnapshotLoadableConnection, SnapshotLocalTestset} from "../../snapshot" import {outputConnectionsAtom} from "../atoms/connections" @@ -51,6 +50,7 @@ import { extraColumnsAtom, hasMultipleNodesAtom, mappingModalOpenAtom, + playgroundStoreAtom, playgroundDispatchAtom, playgroundNodesAtom, selectedNodeIdAtom, @@ -362,7 +362,7 @@ const connectDownstreamNodeAtom = atom( // For downstream entities, eagerly subscribe to the molecule's query atom // so the per-ID fetch fires immediately. if (entity.type === "workflow") { - const store = getDefaultStore() + const store = get(playgroundStoreAtom) const unsub = store.sub(workflowMolecule.selectors.data(entity.id), () => {}) // Unsubscribe after a generous window — the query cache keeps the data alive. setTimeout(() => unsub(), 60_000) @@ -2278,7 +2278,7 @@ const reconcileRowsToPrimaryAtom = atom(null, (get, set) => { const entityLoaded = get(workflowMolecule.selectors.data(entityId)) != null if (entityLoaded) return - const store = getDefaultStore() + const store = get(playgroundStoreAtom) const unsub = store.sub(workflowMolecule.selectors.inputPorts(entityId), () => { const retryStatus = pruneTestcaseRowsForEntity(store.get, store.set, entityId) const nowLoaded = store.get(workflowMolecule.selectors.data(entityId)) != null diff --git a/web/packages/agenta-playground/src/state/execution/executionItems.ts b/web/packages/agenta-playground/src/state/execution/executionItems.ts index 5127d0fcdc..d7cc6fdb7f 100644 --- a/web/packages/agenta-playground/src/state/execution/executionItems.ts +++ b/web/packages/agenta-playground/src/state/execution/executionItems.ts @@ -12,7 +12,6 @@ import {workflowMolecule} from "@agenta/entities/workflow" import {getAgentaApiUrl} from "@agenta/shared/api/env" import {generateId} from "@agenta/shared/utils" import {atom, type Getter, type Setter} from "jotai" -import {getDefaultStore} from "jotai/vanilla" import {entityIdsAtom} from "../atoms/playground" import {messageIdsAtomFamily, messagesByIdAtomFamily} from "../chat/messageAtoms" @@ -363,8 +362,7 @@ function cancelExecutionItemRun( if (existing.runId) { abortRun(existing.runId) - const store = getDefaultStore() - const adapter = store.get(executionAdapterAtom) + const adapter = get(executionAdapterAtom) adapter.cancel?.(existing.runId) } diff --git a/web/packages/agenta-playground/src/state/execution/executionRunner.ts b/web/packages/agenta-playground/src/state/execution/executionRunner.ts index 3ce4e04e62..c9cd82de0a 100644 --- a/web/packages/agenta-playground/src/state/execution/executionRunner.ts +++ b/web/packages/agenta-playground/src/state/execution/executionRunner.ts @@ -13,7 +13,6 @@ import {isLocalDraftId} from "@agenta/entities/shared" import {workflowMolecule} from "@agenta/entities/workflow" import {generateId} from "@agenta/shared/utils" import type {Getter, Setter} from "jotai" -import {getDefaultStore} from "jotai/vanilla" import {messageIdsAtomFamily, messagesByIdAtomFamily} from "../chat/messageAtoms" import {SHARED_SESSION_ID, type ChatMessage} from "../chat/messageTypes" @@ -633,11 +632,10 @@ export async function executeStepForSessionWithExecutionItems( const upstreamOutput = upstreamResult?.output ?? upstreamResult?.structuredOutput - const evalStore = getDefaultStore() - const stageConfiguration = evalStore.get( + const stageConfiguration = get( workflowMolecule.selectors.configuration(targetEntityId), ) - const stageSchemas = evalStore.get( + const stageSchemas = get( workflowMolecule.selectors.ioSchemas(targetEntityId), ) const inputSchema = diff --git a/web/packages/agenta-playground/src/state/execution/selectors.ts b/web/packages/agenta-playground/src/state/execution/selectors.ts index a57b7467df..8742af20da 100644 --- a/web/packages/agenta-playground/src/state/execution/selectors.ts +++ b/web/packages/agenta-playground/src/state/execution/selectors.ts @@ -18,11 +18,10 @@ import {testcaseMolecule, isSystemField} from "@agenta/entities/testcase" import {workflowMolecule} from "@agenta/entities/workflow" import {atom, type Getter} from "jotai" import {selectAtom} from "jotai/utils" -import {getDefaultStore} from "jotai/vanilla" import {atomFamily} from "jotai-family" import {playgroundIsChatBehaviorAtom} from "../atoms/modeOverride" -import {entityIdsAtom, playgroundNodesAtom} from "../atoms/playground" +import {entityIdsAtom, playgroundNodesAtom, playgroundStoreAtom} from "../atoms/playground" import {addUserMessageAtom} from "../chat" import {sharedMessageIdsAtomFamily} from "../chat/messageSelectors" @@ -968,7 +967,7 @@ export const generationRowIdsAtom = atom((get) => { const rowIds = get(sharedMessageIdsAtomFamily(loadableId)) if (rowIds.length === 0) { // Bootstrap first blank user message for chat mode - getDefaultStore().set(addUserMessageAtom, {loadableId, userMessage: null}) + get(playgroundStoreAtom).set(addUserMessageAtom, {loadableId, userMessage: null}) return get(sharedMessageIdsAtomFamily(loadableId)) } return rowIds diff --git a/web/packages/agenta-playground/src/state/execution/webWorkerIntegration.ts b/web/packages/agenta-playground/src/state/execution/webWorkerIntegration.ts index c0fa363430..a2a0048ae4 100644 --- a/web/packages/agenta-playground/src/state/execution/webWorkerIntegration.ts +++ b/web/packages/agenta-playground/src/state/execution/webWorkerIntegration.ts @@ -13,7 +13,6 @@ import {loadableController, type RunnableType} from "@agenta/entities/runnable" import {projectIdAtom} from "@agenta/shared/state" import {isPlainObject} from "@agenta/shared/utils" import {atom} from "jotai" -import {getDefaultStore} from "jotai/vanilla" import {queryClientAtom} from "jotai-tanstack-query" import {outputConnectionsAtom} from "../atoms/connections" @@ -57,10 +56,7 @@ import {extractTraceIdFromPayload} from "./trace" let _sharedLimiter: ((fn: () => Promise) => Promise) | null = null let _sharedLimiterConcurrency = 0 -function getSharedConcurrencyLimiter(): (fn: () => Promise) => Promise { - const store = getDefaultStore() - const concurrency = store.get(executionConcurrencyAtom) - +function getSharedConcurrencyLimiter(concurrency: number): (fn: () => Promise) => Promise { // Re-create if concurrency changed or first call if (!_sharedLimiter || _sharedLimiterConcurrency !== concurrency) { _sharedLimiterConcurrency = concurrency @@ -380,7 +376,7 @@ export const triggerExecutionAtom = atom( ) : connections - const limiter = getSharedConcurrencyLimiter() + const limiter = getSharedConcurrencyLimiter(get(executionConcurrencyAtom)) await limiter(() => executeStepForSessionWithExecutionItems({ get, diff --git a/web/packages/agenta-playground/src/state/index.ts b/web/packages/agenta-playground/src/state/index.ts index 84c1dc2b66..072a7ef4ef 100644 --- a/web/packages/agenta-playground/src/state/index.ts +++ b/web/packages/agenta-playground/src/state/index.ts @@ -204,6 +204,8 @@ export { type PlaygroundStatus, } from "./execution" +export {playgroundStoreAtom} from "./atoms/playground" + // Web worker integration export { executionHeadersAtom, diff --git a/web/packages/agenta-ui/src/components/selection/ListItem.tsx b/web/packages/agenta-ui/src/components/selection/ListItem.tsx index bb07b7d6de..dc999b115d 100644 --- a/web/packages/agenta-ui/src/components/selection/ListItem.tsx +++ b/web/packages/agenta-ui/src/components/selection/ListItem.tsx @@ -52,6 +52,24 @@ export interface ListItemProps { */ icon?: React.ReactNode + /** + * Node rendered before the icon/label (e.g., a selection checkbox). + * Click handling inside this node must stopPropagation itself so the + * row click (navigation/selection) is not triggered. + */ + prefixNode?: React.ReactNode + + /** + * Node rendered below the label/description block (e.g., selected-revision chips). + */ + footerNode?: React.ReactNode + + /** + * Node rendered after the label block, before the chevron (e.g., a type tag). + * Vertically centered against the whole row, independent of the label lines. + */ + suffixNode?: React.ReactNode + /** * Whether the item can be navigated into */ @@ -105,6 +123,9 @@ export function ListItem({ labelNode, description, icon, + prefixNode, + footerNode, + suffixNode, hasChildren = false, isSelectable = false, isSelected = false, @@ -165,6 +186,9 @@ export function ListItem({ aria-selected={isSelected} >
+ {prefixNode && ( + {prefixNode} + )} {icon && {icon}}
@@ -173,9 +197,14 @@ export function ListItem({ {description && (
{description}
)} + {footerNode}
+ {suffixNode && ( + {suffixNode} + )} + {/* Show chevron for items with children (indicates popover/drill-down available) */} {hasChildren && (