From d1941efd98e2ffe79b46e63104d5b819b524d55a Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Wed, 10 Jun 2026 16:26:11 +0600 Subject: [PATCH 1/8] Enhance Cascade Entity Selector UI with workflow metadata and parent-child selection features --- .../Components/PlaygroundHeader/index.tsx | 120 +++++++++++- .../agenta-entities/src/workflow/index.ts | 3 + .../src/workflow/state/evaluatorUtils.ts | 42 +++++ .../src/workflow/state/index.ts | 3 + .../src/workflow/state/store.ts | 5 +- .../adapters/useEnrichedEvaluatorAdapter.ts | 51 ++++- .../workflowRevisionRelationAdapter.ts | 2 + .../components/UnifiedEntityPicker/types.ts | 77 ++++++++ .../variants/PopoverCascaderVariant.tsx | 178 +++++++++++++++++- .../src/components/selection/ListItem.tsx | 18 ++ 10 files changed, 484 insertions(+), 15 deletions(-) diff --git a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx index 681628ab8f..a0d5fc7c74 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx @@ -7,6 +7,8 @@ import { parseWorkflowKeyFromUri, workflowMolecule, createEvaluatorFromTemplate, + evaluatorWorkflowMetaMapAtom, + workflowLatestRevisionQueryAtomFamily, } from "@agenta/entities/workflow" import type {EvaluatorCatalogTemplate, WorkflowTypeColor} from "@agenta/entities/workflow" import {EntityPicker} from "@agenta/entity-ui" @@ -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, getDefaultStore, useAtomValue, useSetAtom} from "jotai" import dynamic from "next/dynamic" import EvaluatorTemplateDropdown from "@/oss/components/Evaluators/components/EvaluatorTemplateDropdown" @@ -185,6 +187,50 @@ const PlaygroundHeader: React.FC = ({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,8 +242,67 @@ 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], + ) + + // Parent checkbox toggle: check connects the workflow's latest revision, + // uncheck disconnects every connected revision of that workflow. + const handleParentToggle = useCallback( + (parentId: string, checked: boolean) => { + if (!checked) { + selectedChildrenByParent + .get(parentId) + ?.forEach((child) => handleDeselectChild(child.id)) + return + } + + const rootNode = nodes.find((n) => n.depth === 0) + if (!rootNode) return + + // Latest revision is already batch-fetched and cached for the picker's metadata + const revision = getDefaultStore().get( + workflowLatestRevisionQueryAtomFamily(parentId), + ).data + if (!revision?.id || connectedRevisionIds.has(revision.id)) return + + const workflowName = revision.name?.trim() || revision.slug?.trim() || "Evaluator" + connectDownstreamNode({ + sourceNodeId: rootNode.id, + entity: { + type: "workflow", + id: revision.id, + label: `${workflowName} / v${revision.version ?? 0}`, + metadata: { + workflowId: parentId, + workflowName, + variantId: "", + variantName: "", + revision: revision.version ?? 0, + }, + }, + }) + }, + [ + nodes, + selectedChildrenByParent, + connectedRevisionIds, + connectDownstreamNode, + handleDeselectChild, + ], + ) + + // Evaluator-only adapter with colored type tags, human filtering, custom revision + // labels, and workflow metadata ("N versions · date") for the picker rows + const evaluatorWorkflowAdapter = useEvaluatorOnlyAdapter(renderWorkflowRevisionLabel, { + showWorkflowMeta: true, + }) // Controlled state for EvaluatorTemplateDropdown const [templateDropdownOpen, setTemplateDropdownOpen] = useState(false) @@ -374,6 +479,15 @@ const PlaygroundHeader: React.FC = ({className, ...divPro selectionSummary childItemLabelMode="simple" panelWidth={280} + showParentCheckboxes + selectedChildrenByParent={selectedChildrenByParent} + totalChildrenByParent={totalChildrenByParent} + onParentToggle={handleParentToggle} + onDeselectChild={handleDeselectChild} + showParentDescription + showGroupHeaders + showChildSelectAll + onClearAll={handleDisconnectAll} // TODO: Implement evaluator template creation in checkpoint to with different playground context // We can scope using entity revision id // And have multiple playgrounds diff --git a/web/packages/agenta-entities/src/workflow/index.ts b/web/packages/agenta-entities/src/workflow/index.ts index 51aaa9d4f8..12e03aa500 100644 --- a/web/packages/agenta-entities/src/workflow/index.ts +++ b/web/packages/agenta-entities/src/workflow/index.ts @@ -304,6 +304,9 @@ export { evaluatorPresetsAtomFamily, // Key map evaluatorKeyMapAtom, + // Workflow display metadata (version count + last modified) + evaluatorWorkflowMetaMapAtom, + type EvaluatorWorkflowMeta, // Evaluator configs (non-human, non-custom) evaluatorConfigsListDataAtom, evaluatorConfigsQueryStateAtom, diff --git a/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts b/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts index 29d362d1c7..9adb8a246a 100644 --- a/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts +++ b/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts @@ -247,6 +247,48 @@ 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 `updated_at`, falling back to `created_at`. */ + lastModifiedAt: string | null +} + +/** + * Derived atom: workflowId → display metadata (version count + last modified date). + * + * 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, + lastModifiedAt: evaluator.updated_at ?? evaluator.created_at ?? null, + }) + } + + return map +}) + interface EvaluatorRevisionFlags { isFeedback: boolean isCustom: boolean diff --git a/web/packages/agenta-entities/src/workflow/state/index.ts b/web/packages/agenta-entities/src/workflow/state/index.ts index 9f0d424dc2..d2f94559c1 100644 --- a/web/packages/agenta-entities/src/workflow/state/index.ts +++ b/web/packages/agenta-entities/src/workflow/state/index.ts @@ -174,6 +174,9 @@ export { evaluatorPresetsAtomFamily, // Key map evaluatorKeyMapAtom, + // Workflow display metadata (version count + last modified) + evaluatorWorkflowMetaMapAtom, + type EvaluatorWorkflowMeta, // Evaluator configs (non-human, non-custom) evaluatorConfigsListDataAtom, evaluatorConfigsQueryStateAtom, diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index 8afe2bf3b9..cfd2f3d14b 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, } } @@ -2250,6 +2252,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/useEnrichedEvaluatorAdapter.ts b/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts index 8333582859..64e77eea37 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts @@ -20,9 +20,11 @@ import { evaluatorTemplatesMapAtom, evaluatorTemplatesDataAtom, evaluatorConfigsQueryStateAtom, + evaluatorWorkflowMetaMapAtom, humanEvaluatorsListQueryAtom, workflowAppTypeAtomFamily, workflowsListDataAtom, + type EvaluatorWorkflowMeta, } from "@agenta/entities/workflow" import {atom, getDefaultStore, useAtomValue} from "jotai" @@ -114,6 +116,37 @@ export function useEnrichedEvaluatorBrowseAdapter() { // EVALUATOR-ONLY ADAPTER (Evaluators only, colored tags, no human) // ============================================================================ +/** + * Format an evaluator workflow's metadata as a one-line subtitle, + * e.g. "12 versions · Jan 6, 2026". Returns undefined when nothing resolved. + */ +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"}`) + } + + if (meta.lastModifiedAt) { + const date = new Date(meta.lastModifiedAt) + if (!isNaN(date.getTime())) { + parts.push( + date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }), + ) + } + } + + return parts.length > 0 ? parts.join(" · ") : undefined +} + /** * Hook that returns an adapter for the evaluator-only picker. * Filters to evaluators only (excluding human), with colored type tags. @@ -121,21 +154,29 @@ 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. */ export function useEnrichedEvaluatorOnlyAdapter( revisionLabelOverride?: (entity: unknown) => React.ReactNode, + options?: {showWorkflowMeta?: 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) // Build a stable Map from template data const templateCategoryMap = useMemo(() => { @@ -192,12 +233,20 @@ 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, getGroupKey, getGroupLabel, buildTabs: (entities: unknown[]) => { @@ -232,7 +281,7 @@ export function useEnrichedEvaluatorOnlyAdapter( } return createWorkflowRevisionAdapter(options) - }, [hasRevisionLabelOverride, autoEvaluatorsListAtom]) + }, [hasRevisionLabelOverride, showWorkflowMeta, autoEvaluatorsListAtom]) } type AnnotationWorkflowRevisionSelectionResult = WorkflowRevisionSelectionResult & { 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..938c1a2661 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,7 @@ export interface CreateWorkflowRevisionAdapterOptions { */ grandparentOverrides?: { getLabelNode?: (entity: unknown) => React.ReactNode + getDescription?: (entity: unknown) => string | undefined getGroupKey?: (entity: unknown) => string | null | undefined getGroupLabel?: (key: string) => string buildTabs?: (items: unknown[]) => import("../types").TabDefinition[] @@ -502,6 +503,7 @@ export function createWorkflowRevisionAdapter( getId: (entity: unknown) => (entity as {id: string}).id, getLabel: getWorkflowDisplayName, getLabelNode: grandparentOverrides.getLabelNode ?? renderWorkflowLabelNode, + getDescription: grandparentOverrides.getDescription, hasChildren: true, isSelectable: false, getGroupKey: grandparentOverrides.getGroupKey, 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..df0aa57715 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 @@ -679,6 +679,83 @@ 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 parent checkbox is toggled. + * `checked: true` — the consumer should select the parent's latest child. + * `checked: false` — the consumer should deselect ALL children of that parent. + */ + onParentToggle?: (parentId: string, checked: boolean) => void + + /** + * Called when a selected-child chip's remove (×) button is clicked. + */ + 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/PopoverCascaderVariant.tsx b/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/variants/PopoverCascaderVariant.tsx index ad2cbdcce2..9893f359dd 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 @@ -18,7 +18,7 @@ import React, {useCallback, useEffect, useMemo, useState, type CSSProperties} fr 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" @@ -55,6 +55,7 @@ function ChildPanelContent({ multiSelect = false, selectedChildIds, childItemLabelMode = "full", + showSelectAll = false, }: { parentId: string parentLabel: string @@ -69,6 +70,7 @@ function ChildPanelContent({ multiSelect?: boolean selectedChildIds?: Set childItemLabelMode?: "full" | "simple" + showSelectAll?: boolean }) { const {items, query} = useLevelData({ levelConfig: childLevelConfig, @@ -101,6 +103,16 @@ 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 (
@@ -124,6 +136,18 @@ function ChildPanelContent({ )}
+ {showSelectAll && + enabledChildren.length > 0 && + selectedCount < enabledChildren.length && ( + + )} )} @@ -197,6 +221,41 @@ function ChildPanelContent({ ) } +// ============================================================================ +// SELECTED CHILD CHIPS (rendered under a root item's label) +// ============================================================================ + +/** + * Compact removable chips for a root item's selected children (e.g. "v2 ×"). + * Clicks inside the chips row stop propagation so they never trigger the + * row's navigation click. + */ +function SelectedChildChips({ + chips, + onDeselectChild, +}: { + chips: {id: string; label: string}[] + onDeselectChild?: (childId: string) => void +}) { + return ( +
e.stopPropagation()}> + {chips.map((chip) => ( + + {chip.label} + onDeselectChild?.(chip.id)} + /> + + ))} +
+ ) +} + // ============================================================================ // ROOT ITEM RENDERER (shared between grouped and flat rendering) // ============================================================================ @@ -209,6 +268,12 @@ function RootItemRenderer({ selectedRootId, openChildOnHover, onRootItemClick, + showParentCheckboxes = false, + selectedChildrenByParent, + totalChildrenByParent, + onParentToggle, + onDeselectChild, + showParentDescription = false, }: { item: unknown rootLevel: HierarchyLevel @@ -217,8 +282,42 @@ function RootItemRenderer({ selectedRootId: string | null openChildOnHover: boolean onRootItemClick: (item: unknown) => void + showParentCheckboxes?: boolean + selectedChildrenByParent?: Map + totalChildrenByParent?: Map + onParentToggle?: (parentId: string, checked: boolean) => void + onDeselectChild?: (childId: string) => void + 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"> + onParentToggle?.(id, !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 + return (
1} isSelectable={totalLevels <= 1} isSelected={id === selectedParentId} @@ -270,6 +372,19 @@ export function PopoverCascaderVariant({ selectedChildIds, selectionSummary, childItemLabelMode = "full", + // Parent multi-select props + showParentCheckboxes = false, + selectedChildrenByParent, + totalChildrenByParent, + onParentToggle, + onDeselectChild, + // Root row metadata + showParentDescription = false, + // Group headers + showGroupHeaders = false, + // Bulk actions + showChildSelectAll = false, + onClearAll, }: PopoverCascaderVariantProps) { const {hierarchyLevels, createSelection} = useEntitySelectionCore({ adapter: adapterProp, @@ -500,6 +615,12 @@ export function PopoverCascaderVariant({ selectedRootId, openChildOnHover, onRootItemClick: handleRootItemClick, + showParentCheckboxes, + selectedChildrenByParent, + totalChildrenByParent, + onParentToggle, + onDeselectChild, + showParentDescription, }), [ rootLevel, @@ -508,6 +629,12 @@ export function PopoverCascaderVariant({ selectedRootId, openChildOnHover, handleRootItemClick, + showParentCheckboxes, + selectedChildrenByParent, + totalChildrenByParent, + onParentToggle, + onDeselectChild, + showParentDescription, ], ) @@ -560,10 +687,20 @@ export function PopoverCascaderVariant({ > {/* Selection summary */} {selectionSummaryText ? ( -
+
{selectionSummaryText} + {onClearAll && (selectedChildIds?.size ?? 0) > 0 && ( + + )}
) : null} @@ -585,6 +722,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) @@ -638,6 +795,7 @@ export function PopoverCascaderVariant({ multiSelect={multiSelect} selectedChildIds={selectedChildIds} childItemLabelMode={childItemLabelMode} + showSelectAll={showChildSelectAll} />
)} diff --git a/web/packages/agenta-ui/src/components/selection/ListItem.tsx b/web/packages/agenta-ui/src/components/selection/ListItem.tsx index 4f4288613e..6f56954b5d 100644 --- a/web/packages/agenta-ui/src/components/selection/ListItem.tsx +++ b/web/packages/agenta-ui/src/components/selection/ListItem.tsx @@ -52,6 +52,18 @@ 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 + /** * Whether the item can be navigated into */ @@ -105,6 +117,8 @@ export function ListItem({ labelNode, description, icon, + prefixNode, + footerNode, hasChildren = false, isSelectable = false, isSelected = false, @@ -166,6 +180,9 @@ export function ListItem({ aria-selected={isSelected} >
+ {prefixNode && ( + {prefixNode} + )} {icon && {icon}}
@@ -174,6 +191,7 @@ export function ListItem({ {description && (
{description}
)} + {footerNode}
From e8b04f78a832162b99e0fa6785726250c050cbc8 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Wed, 10 Jun 2026 22:51:32 +0600 Subject: [PATCH 2/8] implemented create evaluator feature and added an isolated workflow drawer config --- .../components/ConfigureEvaluator/atoms.ts | 27 ++- .../components/EvaluatorTemplateDropdown.tsx | 6 +- .../Components/MainLayout/index.tsx | 8 +- .../Components/PlaygroundHeader/index.tsx | 180 +++++++++++------ .../WorkflowRevisionDrawerWrapper/index.tsx | 186 +++++++++++++++--- .../state/evaluator/evaluatorDrawerStore.ts | 16 +- .../variants/PopoverCascaderVariant.tsx | 17 +- .../WorkflowRevisionDrawer/index.ts | 6 + .../WorkflowRevisionDrawer/store.ts | 51 ++++- .../src/state/atoms/playground.ts | 7 + .../state/controllers/playgroundController.ts | 6 +- .../src/state/execution/executionItems.ts | 4 +- .../src/state/execution/executionRunner.ts | 6 +- .../src/state/execution/selectors.ts | 5 +- .../state/execution/webWorkerIntegration.ts | 8 +- .../agenta-playground/src/state/index.ts | 2 + 16 files changed, 402 insertions(+), 133 deletions(-) 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 a0d5fc7c74..faa670ca1f 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx @@ -10,7 +10,7 @@ import { evaluatorWorkflowMetaMapAtom, workflowLatestRevisionQueryAtomFamily, } 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" @@ -22,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 {atom, getDefaultStore, useAtomValue, useSetAtom} from "jotai" +import {atom, getDefaultStore, useAtomValue, useSetAtom, useStore} from "jotai" import dynamic from "next/dynamic" import EvaluatorTemplateDropdown from "@/oss/components/Evaluators/components/EvaluatorTemplateDropdown" @@ -308,14 +308,69 @@ const PlaygroundHeader: React.FC = ({className, ...divPro 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() + const currentAppSelection = useMemo(() => { + if (currentWorkflowCtx.workflowKind === "evaluator") return undefined + + const rootNode = nodes.find((node) => node.depth === 0) + if (!rootNode) return undefined + + return { + revisionId: rootNode.entityId, + label: rootNode.label?.trim() || currentWorkflow?.name?.trim() || "Application", + } + }, [currentWorkflow?.name, currentWorkflowCtx.workflowKind, nodes]) + + 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( @@ -335,9 +390,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 @@ -464,58 +523,59 @@ 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} - showParentCheckboxes - selectedChildrenByParent={selectedChildrenByParent} - totalChildrenByParent={totalChildrenByParent} - onParentToggle={handleParentToggle} - onDeselectChild={handleDeselectChild} - showParentDescription - showGroupHeaders - showChildSelectAll - onClearAll={handleDisconnectAll} - // 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={280} + showParentCheckboxes + selectedChildrenByParent={selectedChildrenByParent} + totalChildrenByParent={totalChildrenByParent} + onParentToggle={handleParentToggle} + 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-entity-ui/src/selection/components/UnifiedEntityPicker/variants/PopoverCascaderVariant.tsx b/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/variants/PopoverCascaderVariant.tsx index 9893f359dd..ded8cb25f5 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,7 +14,7 @@ * 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" @@ -401,6 +401,7 @@ export function PopoverCascaderVariant({ const [searchTerm, setSearchTerm] = useState("") const [selectedRootId, setSelectedRootId] = useState(null) const [selectedRootEntity, setSelectedRootEntity] = useState(null) + const pendingCreateRef = useRef(false) // Active tab state — always starts on "all", reset on close const [activeTabKey, setActiveTabKey] = useState("all") @@ -602,9 +603,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( @@ -809,6 +819,7 @@ export function PopoverCascaderVariant({ trigger="click" open={open} onOpenChange={handleOpenChange} + afterOpenChange={handleAfterOpenChange} placement={placement} styles={{container: {padding: 0}}} arrow={false} 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 5472bff44b..6155e08df3 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, @@ -361,7 +361,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) @@ -2271,7 +2271,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 40929d9c1e..4352173a34 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" @@ -525,11 +524,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 9ff4107033..b88bfbde28 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" @@ -942,7 +941,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 c0ffdcd055..be0bafa3d7 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" @@ -56,10 +55,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 @@ -370,7 +366,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, From a77db376e3613726795778a366764e94414e47e6 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Thu, 11 Jun 2026 11:59:00 +0600 Subject: [PATCH 3/8] Enhance evaluator selection and display features across components, including suffix node support and improved evaluator name resolution. --- .../Components/PlaygroundHeader/index.tsx | 52 ++++++++++-- .../EntityEvaluatorSelector.tsx | 3 + .../agenta-entities/src/workflow/index.ts | 2 + .../src/workflow/state/evaluatorUtils.ts | 20 +++++ .../src/workflow/state/index.ts | 2 + .../src/selection/adapters/createAdapter.ts | 1 + .../adapters/createAdapterFromRelations.ts | 4 + .../adapters/createLevelFromRelation.ts | 7 ++ .../selection/adapters/evaluatorLabelUtils.ts | 82 +++++++++++++------ .../src/selection/adapters/types.ts | 5 ++ .../adapters/useEnrichedEvaluatorAdapter.ts | 41 ++++++++-- .../workflowRevisionRelationAdapter.ts | 2 + .../components/UnifiedEntityPicker/types.ts | 7 ++ .../variants/PopoverCascaderVariant.tsx | 32 ++++++-- .../agenta-entity-ui/src/selection/types.ts | 6 ++ .../assets/ExecutionRow/shared.tsx | 11 ++- .../src/components/selection/ListItem.tsx | 11 +++ 17 files changed, 236 insertions(+), 52 deletions(-) diff --git a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx index faa670ca1f..215cfdce64 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx @@ -7,7 +7,9 @@ import { parseWorkflowKeyFromUri, workflowMolecule, createEvaluatorFromTemplate, + evaluatorNameByRevisionAtomFamily, evaluatorWorkflowMetaMapAtom, + evaluatorsListDataAtom, workflowLatestRevisionQueryAtomFamily, } from "@agenta/entities/workflow" import type {EvaluatorCatalogTemplate, Workflow, WorkflowTypeColor} from "@agenta/entities/workflow" @@ -116,12 +118,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 ).data if (!revision?.id || connectedRevisionIds.has(revision.id)) return - const workflowName = revision.name?.trim() || revision.slug?.trim() || "Evaluator" + // The revision's own name is usually the variant name ("default") — + // use the evaluator workflow's name from the list instead. + const workflowEntityName = getDefaultStore() + .get(evaluatorsListDataAtom) + .find((w) => w.id === parentId) + ?.name?.trim() + const workflowName = + workflowEntityName || revision.name?.trim() || revision.slug?.trim() || "Evaluator" connectDownstreamNode({ sourceNodeId: rootNode.id, entity: { @@ -299,9 +314,12 @@ const PlaygroundHeader: React.FC = ({className, ...divPro ) // Evaluator-only adapter with colored type tags, human filtering, custom revision - // labels, and workflow metadata ("N versions · date") for the picker rows + // 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 @@ -314,17 +332,36 @@ const PlaygroundHeader: React.FC = ({className, ...divPro 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: rootNode.label?.trim() || currentWorkflow?.name?.trim() || "Application", + label: rootRevisionVersion != null ? `${appName} / v${rootRevisionVersion}` : appName, } - }, [currentWorkflow?.name, currentWorkflowCtx.workflowKind, nodes]) + }, [currentWorkflow?.name, currentWorkflowCtx.workflowKind, nodes, rootRevisionVersion]) const handleCreatedEvaluator = useCallback( ({ @@ -538,7 +575,8 @@ const PlaygroundHeader: React.FC = ({className, ...divPro selectedChildIds={connectedRevisionIds} selectionSummary childItemLabelMode="simple" - panelWidth={280} + panelWidth={320} + childPanelWidth={180} showParentCheckboxes selectedChildrenByParent={selectedChildrenByParent} totalChildrenByParent={totalChildrenByParent} 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..f61ec4f3eb 100644 --- a/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/EntityEvaluatorSelector.tsx +++ b/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/EntityEvaluatorSelector.tsx @@ -31,6 +31,7 @@ export interface EntityEvaluatorSelectorProps { disabledRevisionTooltip?: string panelMinWidth?: number panelWidth?: number + childPanelWidth?: number disabled?: boolean selectedEvaluatorId?: string | null selectedRevisionId?: string | null @@ -133,6 +134,7 @@ export function EntityEvaluatorSelector({ disabledRevisionTooltip = "Already added", panelMinWidth = 280, panelWidth, + childPanelWidth, disabled = false, selectedEvaluatorId, selectedRevisionId, @@ -179,6 +181,7 @@ export function EntityEvaluatorSelector({ showDropdownIcon={false} panelMinWidth={panelMinWidth} panelWidth={panelWidth} + childPanelWidth={childPanelWidth} disabled={disabled} selectedParentId={selectedEvaluatorId} selectedChildId={selectedRevisionId} diff --git a/web/packages/agenta-entities/src/workflow/index.ts b/web/packages/agenta-entities/src/workflow/index.ts index 12e03aa500..5644fd923b 100644 --- a/web/packages/agenta-entities/src/workflow/index.ts +++ b/web/packages/agenta-entities/src/workflow/index.ts @@ -307,6 +307,8 @@ export { // Workflow display metadata (version count + last modified) evaluatorWorkflowMetaMapAtom, type EvaluatorWorkflowMeta, + // Parent evaluator name lookup per revision + evaluatorNameByRevisionAtomFamily, // Evaluator configs (non-human, non-custom) evaluatorConfigsListDataAtom, evaluatorConfigsQueryStateAtom, diff --git a/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts b/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts index 9adb8a246a..5bc27092ef 100644 --- a/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts +++ b/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts @@ -33,6 +33,7 @@ import { workflowProjectIdAtom, workflowLocalServerDataAtomFamily, workflowLatestRevisionQueryAtomFamily, + workflowEntityAtomFamily, invalidateWorkflowsListCache, type WorkflowListRef, toWorkflowListRef, @@ -289,6 +290,25 @@ export const evaluatorWorkflowMetaMapAtom = atom + 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 + }), +) + interface EvaluatorRevisionFlags { isFeedback: boolean isCustom: boolean diff --git a/web/packages/agenta-entities/src/workflow/state/index.ts b/web/packages/agenta-entities/src/workflow/state/index.ts index d2f94559c1..6894605856 100644 --- a/web/packages/agenta-entities/src/workflow/state/index.ts +++ b/web/packages/agenta-entities/src/workflow/state/index.ts @@ -177,6 +177,8 @@ export { // Workflow display metadata (version count + last modified) evaluatorWorkflowMetaMapAtom, type EvaluatorWorkflowMeta, + // Parent evaluator name lookup per revision + evaluatorNameByRevisionAtomFamily, // Evaluator configs (non-human, non-custom) evaluatorConfigsListDataAtom, evaluatorConfigsQueryStateAtom, 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/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 64e77eea37..4794c49dfe 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts @@ -28,7 +28,11 @@ import { } from "@agenta/entities/workflow" import {atom, getDefaultStore, useAtomValue} from "jotai" -import {renderEvaluatorPickerLabelNode} from "./evaluatorLabelUtils" +import { + renderEvaluatorPickerLabelNode, + renderEvaluatorPickerNameNode, + renderEvaluatorTypeTag, +} from "./evaluatorLabelUtils" import { createWorkflowRevisionAdapter, type WorkflowRevisionSelectionResult, @@ -157,10 +161,14 @@ function formatWorkflowMetaDescription( * @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}, + options?: {showWorkflowMeta?: boolean; splitTypeTag?: boolean}, ) { const {evaluatorKeyMap, evaluatorDefsByKey} = useEvaluatorEnrichedData() const templates = useAtomValue(evaluatorTemplatesDataAtom) @@ -177,6 +185,7 @@ export function useEnrichedEvaluatorOnlyAdapter( const hasRevisionLabelOverride = Boolean(revisionLabelOverride) const showWorkflowMeta = Boolean(options?.showWorkflowMeta) + const splitTypeTag = Boolean(options?.splitTypeTag) // Build a stable Map from template data const templateCategoryMap = useMemo(() => { @@ -208,12 +217,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 => { @@ -247,6 +269,7 @@ export function useEnrichedEvaluatorOnlyAdapter( grandparentOverrides: { getLabelNode, getDescription, + getSuffixNode, getGroupKey, getGroupLabel, buildTabs: (entities: unknown[]) => { @@ -281,7 +304,7 @@ export function useEnrichedEvaluatorOnlyAdapter( } return createWorkflowRevisionAdapter(options) - }, [hasRevisionLabelOverride, showWorkflowMeta, autoEvaluatorsListAtom]) + }, [hasRevisionLabelOverride, showWorkflowMeta, splitTypeTag, autoEvaluatorsListAtom]) } type AnnotationWorkflowRevisionSelectionResult = WorkflowRevisionSelectionResult & { 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 938c1a2661..9b7d216f90 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/workflowRevisionRelationAdapter.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/workflowRevisionRelationAdapter.ts @@ -294,6 +294,7 @@ 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[] @@ -504,6 +505,7 @@ export function createWorkflowRevisionAdapter( getLabel: getWorkflowDisplayName, getLabelNode: grandparentOverrides.getLabelNode ?? renderWorkflowLabelNode, getDescription: grandparentOverrides.getDescription, + getSuffixNode: grandparentOverrides.getSuffixNode, hasChildren: true, isSelectable: false, getGroupKey: grandparentOverrides.getGroupKey, 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 df0aa57715..3fac0f37b3 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,13 @@ 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 + /** * Maximum height of item lists (px) * @default 340 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 ded8cb25f5..400c02b930 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 @@ -227,8 +227,9 @@ function ChildPanelContent({ /** * Compact removable chips for a root item's selected children (e.g. "v2 ×"). - * Clicks inside the chips row stop propagation so they never trigger the - * row's navigation click. + * 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, @@ -238,7 +239,7 @@ function SelectedChildChips({ onDeselectChild?: (childId: string) => void }) { return ( -
e.stopPropagation()}> +
{chips.map((chip) => ( onDeselectChild?.(chip.id)} + onClick={(e) => { + e.stopPropagation() + onDeselectChild?.(chip.id) + }} /> ))} @@ -330,6 +334,7 @@ function RootItemRenderer({ description={description} prefixNode={prefixNode} footerNode={footerNode} + suffixNode={rootLevel.getSuffixNode?.(item)} hasChildren={totalLevels > 1} isSelectable={totalLevels <= 1} isSelected={id === selectedParentId} @@ -358,6 +363,7 @@ export function PopoverCascaderVariant({ placement = "bottomLeft", panelMinWidth = 220, panelWidth, + childPanelWidth, maxHeight = 340, popupFooter, onCreateNew, @@ -466,12 +472,22 @@ export function PopoverCascaderVariant({ [panelWidth, panelMinWidth], ) + // The child panel falls back to the shared panelWidth when no dedicated + // childPanelWidth is provided. + const resolvedChildWidth = childPanelWidth ?? panelWidth + const childPanelStyle = useMemo( () => - panelWidth != null - ? {width: panelWidth} + resolvedChildWidth != null + ? {width: resolvedChildWidth} : {minWidth: panelMinWidth, maxWidth: panelMinWidth}, - [panelWidth, panelMinWidth], + [resolvedChildWidth, panelMinWidth], + ) + + const childPanelOuterStyle = useMemo( + () => + resolvedChildWidth != null ? {width: resolvedChildWidth} : {minWidth: panelMinWidth}, + [resolvedChildWidth, panelMinWidth], ) // Maintain auto-selection to prevent pixel shifts when searching/filtering @@ -789,7 +805,7 @@ export function PopoverCascaderVariant({ {/* CHILD PANEL */} {selectedRootId && totalLevels > 1 && ( -
+
{ */ 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/ExecutionItems/assets/ExecutionRow/shared.tsx b/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/shared.tsx index ff948c142c..808a6f4cd8 100644 --- a/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/shared.tsx +++ b/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/shared.tsx @@ -1,7 +1,7 @@ import React, {useCallback, useMemo} from "react" import type {PlaygroundNode} from "@agenta/entities/runnable" -import {workflowMolecule} from "@agenta/entities/workflow" +import {evaluatorNameByRevisionAtomFamily, workflowMolecule} from "@agenta/entities/workflow" import {DropdownButton} from "@agenta/ui/components" import type {DropdownButtonOption} from "@agenta/ui/components" import {RunButton} from "@agenta/ui/components/presentational" @@ -16,9 +16,14 @@ export const usePlaygroundNodeLabels = (nodes: PlaygroundNode[] | null) => { if (!nodes) return {} as Record const names: Record = {} for (const node of nodes) { + // Evaluator revisions are often named after their variant + // ("default") — prefer the parent evaluator workflow's name. + // Resolves to null for non-evaluator (app) revisions. + const evaluatorName = get(evaluatorNameByRevisionAtomFamily(node.entityId)) const data = get(workflowMolecule.selectors.data(node.entityId)) - if (data?.name) { - names[node.id] = data.name + const name = evaluatorName ?? data?.name + if (name) { + names[node.id] = name } } return names diff --git a/web/packages/agenta-ui/src/components/selection/ListItem.tsx b/web/packages/agenta-ui/src/components/selection/ListItem.tsx index 6f56954b5d..4bb6e26b4b 100644 --- a/web/packages/agenta-ui/src/components/selection/ListItem.tsx +++ b/web/packages/agenta-ui/src/components/selection/ListItem.tsx @@ -64,6 +64,12 @@ export interface ListItemProps { */ 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 */ @@ -119,6 +125,7 @@ export function ListItem({ icon, prefixNode, footerNode, + suffixNode, hasChildren = false, isSelectable = false, isSelected = false, @@ -195,6 +202,10 @@ export function ListItem({
+ {suffixNode && ( + {suffixNode} + )} + {/* Show chevron for items with children (indicates popover/drill-down available) */} {hasChildren && (
From 1d1d10949ea41fdb7ad6d8cc33c16493938c2e66 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Thu, 11 Jun 2026 12:01:22 +0600 Subject: [PATCH 4/8] Refactor evaluator and workflow components for improved selection handling and metadata display --- .../Components/PlaygroundHeader/index.tsx | 58 +-------- .../EntityEvaluatorSelector.tsx | 36 ++++-- .../components/CreateQueueDrawer/index.tsx | 40 +++++-- .../src/shared/molecule/types.ts | 2 + .../agenta-entities/src/workflow/relations.ts | 1 + .../src/workflow/state/evaluatorUtils.ts | 8 +- .../adapters/useEnrichedEvaluatorAdapter.ts | 19 ++- .../shared/AutoSelectHandler.tsx | 7 ++ .../components/UnifiedEntityPicker/types.ts | 8 +- .../variants/ListPopoverVariant.tsx | 6 +- .../variants/PopoverCascaderVariant.tsx | 113 +++++++++++++----- .../hooks/modes/useListPopoverMode.ts | 54 ++++++--- .../selection/hooks/utilities/useLevelData.ts | 3 + .../agenta-entity-ui/src/selection/types.ts | 2 + 14 files changed, 218 insertions(+), 139 deletions(-) diff --git a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx index 215cfdce64..21aa49a5b0 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx @@ -9,8 +9,6 @@ import { createEvaluatorFromTemplate, evaluatorNameByRevisionAtomFamily, evaluatorWorkflowMetaMapAtom, - evaluatorsListDataAtom, - workflowLatestRevisionQueryAtomFamily, } from "@agenta/entities/workflow" import type {EvaluatorCatalogTemplate, Workflow, WorkflowTypeColor} from "@agenta/entities/workflow" import {EntityPicker} from "@agenta/entity-ui" @@ -24,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 {atom, getDefaultStore, useAtomValue, useSetAtom, useStore} from "jotai" +import {atom, useAtomValue, useSetAtom, useStore} from "jotai" import dynamic from "next/dynamic" import EvaluatorTemplateDropdown from "@/oss/components/Evaluators/components/EvaluatorTemplateDropdown" @@ -260,59 +258,6 @@ const PlaygroundHeader: React.FC = ({className, ...divPro [connectedEvaluatorNodes, disconnectSingleDownstreamNode], ) - // Parent checkbox toggle: check connects the workflow's latest revision, - // uncheck disconnects every connected revision of that workflow. - const handleParentToggle = useCallback( - (parentId: string, checked: boolean) => { - if (!checked) { - selectedChildrenByParent - .get(parentId) - ?.forEach((child) => handleDeselectChild(child.id)) - return - } - - const rootNode = nodes.find((n) => n.depth === 0) - if (!rootNode) return - - // Latest revision is already batch-fetched and cached for the picker's metadata - const revision = getDefaultStore().get( - workflowLatestRevisionQueryAtomFamily(parentId), - ).data - if (!revision?.id || connectedRevisionIds.has(revision.id)) return - - // The revision's own name is usually the variant name ("default") — - // use the evaluator workflow's name from the list instead. - const workflowEntityName = getDefaultStore() - .get(evaluatorsListDataAtom) - .find((w) => w.id === parentId) - ?.name?.trim() - const workflowName = - workflowEntityName || revision.name?.trim() || revision.slug?.trim() || "Evaluator" - connectDownstreamNode({ - sourceNodeId: rootNode.id, - entity: { - type: "workflow", - id: revision.id, - label: `${workflowName} / v${revision.version ?? 0}`, - metadata: { - workflowId: parentId, - workflowName, - variantId: "", - variantName: "", - revision: revision.version ?? 0, - }, - }, - }) - }, - [ - nodes, - selectedChildrenByParent, - connectedRevisionIds, - connectDownstreamNode, - handleDeselectChild, - ], - ) - // 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 @@ -580,7 +525,6 @@ const PlaygroundHeader: React.FC = ({className, ...divPro showParentCheckboxes selectedChildrenByParent={selectedChildrenByParent} totalChildrenByParent={totalChildrenByParent} - onParentToggle={handleParentToggle} onDeselectChild={handleDeselectChild} showParentDescription showGroupHeaders 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 f61ec4f3eb..0e9d239fe7 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,15 @@ 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 } @@ -130,14 +131,15 @@ export function EntityEvaluatorSelector({ buttonLabel = "Add evaluator", onCreate, createLabel = "Create evaluator", - disabledRevisionIds, - disabledRevisionTooltip = "Already added", panelMinWidth = 280, panelWidth, childPanelWidth, disabled = false, - selectedEvaluatorId, - selectedRevisionId, + selectedRevisionIds, + selectedRevisionsByEvaluator, + totalRevisionsByEvaluator, + onDeselectRevision, + onClearAll, openVersionOnHover = false, }: EntityEvaluatorSelectorProps) { const renderRevisionLabel = useCallback((entity: unknown) => { @@ -166,7 +168,9 @@ export function EntityEvaluatorSelector({ ) }, []) - const evaluatorAdapter = useEnrichedHumanEvaluatorAdapter(renderRevisionLabel) + const evaluatorAdapter = useEnrichedHumanEvaluatorAdapter(renderRevisionLabel, { + showWorkflowMeta: true, + }) return (
@@ -183,11 +187,17 @@ export function EntityEvaluatorSelector({ panelWidth={panelWidth} childPanelWidth={childPanelWidth} disabled={disabled} - selectedParentId={selectedEvaluatorId} - selectedChildId={selectedRevisionId} - disabledChildIds={disabledRevisionIds} - disabledChildTooltip={disabledRevisionTooltip} openChildOnHover={openVersionOnHover} + 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/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 5bc27092ef..c015ad0656 100644 --- a/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts +++ b/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts @@ -262,12 +262,12 @@ export interface EvaluatorWorkflowMeta { * version number equals the revision count — no full-list fetch needed. */ versionCount: number | null - /** Workflow-level `updated_at`, falling back to `created_at`. */ - lastModifiedAt: string | null + /** Workflow-level creation timestamp. */ + createdAt: string | null } /** - * Derived atom: workflowId → display metadata (version count + last modified date). + * Derived atom: workflowId → display metadata (version count + creation date). * * Reads the same batched + cached latest-revision queries as `evaluatorKeyMapAtom`, * so subscribing to this atom adds no extra requests. @@ -283,7 +283,7 @@ export const evaluatorWorkflowMetaMapAtom = atom 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. @@ -361,6 +366,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} @@ -396,7 +409,7 @@ export function useEnrichedHumanEvaluatorAdapter( } return createWorkflowRevisionAdapter(options) - }, [hasRevisionLabelOverride, humanEvaluatorsListAtom]) + }, [hasRevisionLabelOverride, humanEvaluatorsListAtom, showWorkflowMeta]) } /** 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 3fac0f37b3..017f276f05 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 @@ -712,15 +712,9 @@ export interface PopoverCascaderVariantProps< */ totalChildrenByParent?: Map - /** - * Called when a parent checkbox is toggled. - * `checked: true` — the consumer should select the parent's latest child. - * `checked: false` — the consumer should deselect ALL children of that parent. - */ - onParentToggle?: (parentId: string, checked: boolean) => void - /** * 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 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 400c02b930..8617d30d88 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 @@ -24,6 +24,7 @@ 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 = { @@ -275,8 +276,9 @@ function RootItemRenderer({ showParentCheckboxes = false, selectedChildrenByParent, totalChildrenByParent, - onParentToggle, + onParentCheckboxChange, onDeselectChild, + isParentSelectionPending = false, showParentDescription = false, }: { item: unknown @@ -289,8 +291,9 @@ function RootItemRenderer({ showParentCheckboxes?: boolean selectedChildrenByParent?: Map totalChildrenByParent?: Map - onParentToggle?: (parentId: string, checked: boolean) => void + onParentCheckboxChange: (item: unknown, checked: boolean) => void onDeselectChild?: (childId: string) => void + isParentSelectionPending?: boolean showParentDescription?: boolean }) { const id = rootLevel.getId(item) @@ -308,7 +311,8 @@ function RootItemRenderer({ onParentToggle?.(id, !isChecked)} + disabled={isParentSelectionPending} + onChange={() => onParentCheckboxChange(item, !isChecked)} /> ) : undefined @@ -382,7 +386,6 @@ export function PopoverCascaderVariant({ showParentCheckboxes = false, selectedChildrenByParent, totalChildrenByParent, - onParentToggle, onDeselectChild, // Root row metadata showParentDescription = false, @@ -407,6 +410,10 @@ 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 @@ -607,6 +614,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) @@ -644,8 +681,9 @@ export function PopoverCascaderVariant({ showParentCheckboxes, selectedChildrenByParent, totalChildrenByParent, - onParentToggle, + onParentCheckboxChange: handleParentCheckboxChange, onDeselectChild, + isParentSelectionPending: pendingParentSelection !== null, showParentDescription, }), [ @@ -658,8 +696,9 @@ export function PopoverCascaderVariant({ showParentCheckboxes, selectedChildrenByParent, totalChildrenByParent, - onParentToggle, + handleParentCheckboxChange, onDeselectChild, + pendingParentSelection, showParentDescription, ], ) @@ -830,29 +869,45 @@ export function PopoverCascaderVariant({ ) return ( - - - + + + ) } 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 c6fec553ff..3a31554e76 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 @@ -161,6 +161,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 +301,9 @@ export function useListPopoverMode( id: string label: string } | null>(null) + const clearAutoSelectingParent = useCallback(() => { + setAutoSelectingParent(null) + }, []) // ======================================================================== // PARENT DATA @@ -499,6 +504,7 @@ export function useListPopoverMode( // Auto-select autoSelectingParent, + clearAutoSelectingParent, // Core adapter, @@ -537,6 +543,7 @@ export interface UseAutoSelectLatestChildOptions childLevelConfig: HierarchyLevel + disabledChildIds?: Set createSelection: (path: SelectionPathItem[], entity: unknown) => TSelection onSelect?: (selection: TSelection) => void onComplete: () => void @@ -547,6 +554,7 @@ export function useAutoSelectLatestChild({ parentLabel, parentLevelConfig, childLevelConfig, + disabledChildIds, createSelection, onSelect, onComplete, @@ -556,30 +564,48 @@ 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 || query.isPending) return + + const firstChild = children.find( + (child) => !disabledChildIds?.has(childLevelConfig.getId(child)), + ) + + if (firstChild) { + hasSelectedRef.current = true + const parentPathItem: SelectionPathItem = { + type: parentLevelConfig.type, + id: parentId, + label: parentLabel, + } + + const childPathItem = buildPathItem(firstChild, childLevelConfig) + const fullPath = [parentPathItem, childPathItem] + const selection = createSelection(fullPath, firstChild) - const parentPathItem: SelectionPathItem = { - type: parentLevelConfig.type, - id: parentId, - label: parentLabel, + onSelect?.(selection) + onComplete() + return } - const childPathItem = buildPathItem(firstChild, childLevelConfig) - const fullPath = [parentPathItem, childPathItem] - const selection = createSelection(fullPath, firstChild) + // An empty snapshot can be emitted before a lazy query starts. Only + // finish without a selection once the adapter confirms the query + // settled, or when loaded children are all disabled. + if (!query.isError && !query.isFetched && children.length === 0) return - onSelect?.(selection) + 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 510b9346c6..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 } // ============================================================================ From ca5c677212fd0e01fc39e1eb5b409d97f5c6a5a9 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Fri, 12 Jun 2026 13:55:06 +0600 Subject: [PATCH 5/8] fix: evaluator workflow metadata handling and auto-selection logic in UI components --- .../src/workflow/state/evaluatorUtils.ts | 5 ++- .../evaluatorWorkflowMetaDescription.ts | 34 +++++++++++++++++ .../adapters/useEnrichedEvaluatorAdapter.ts | 33 +--------------- .../workflowRevisionRelationAdapter.ts | 2 + .../hooks/modes/autoSelectLatestChild.ts | 38 +++++++++++++++++++ .../hooks/modes/useListPopoverMode.ts | 25 ++++++------ 6 files changed, 92 insertions(+), 45 deletions(-) create mode 100644 web/packages/agenta-entity-ui/src/selection/adapters/evaluatorWorkflowMetaDescription.ts create mode 100644 web/packages/agenta-entity-ui/src/selection/hooks/modes/autoSelectLatestChild.ts diff --git a/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts b/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts index c015ad0656..3445de80a3 100644 --- a/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts +++ b/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts @@ -264,10 +264,12 @@ export interface EvaluatorWorkflowMeta { 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 + creation date). + * 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. @@ -284,6 +286,7 @@ export const evaluatorWorkflowMetaMapAtom = atom 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/useEnrichedEvaluatorAdapter.ts b/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts index 56d66382c5..f84893cf29 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts @@ -24,7 +24,6 @@ import { humanEvaluatorsListQueryAtom, workflowAppTypeAtomFamily, workflowsListDataAtom, - type EvaluatorWorkflowMeta, } from "@agenta/entities/workflow" import {atom, getDefaultStore, useAtomValue} from "jotai" @@ -33,6 +32,7 @@ import { renderEvaluatorPickerNameNode, renderEvaluatorTypeTag, } from "./evaluatorLabelUtils" +import {formatWorkflowMetaDescription} from "./evaluatorWorkflowMetaDescription" import { createWorkflowRevisionAdapter, type WorkflowRevisionSelectionResult, @@ -120,37 +120,6 @@ export function useEnrichedEvaluatorBrowseAdapter() { // EVALUATOR-ONLY ADAPTER (Evaluators only, colored tags, no human) // ============================================================================ -/** - * Format an evaluator workflow's metadata as a one-line subtitle, - * e.g. "12 versions · Jan 6, 2026". Returns undefined when nothing resolved. - */ -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"}`) - } - - if (meta.createdAt) { - const date = new Date(meta.createdAt) - if (!isNaN(date.getTime())) { - parts.push( - date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: "numeric", - }), - ) - } - } - - return parts.length > 0 ? parts.join(" · ") : undefined -} - /** * Hook that returns an adapter for the evaluator-only picker. * Filters to evaluators only (excluding human), with colored type tags. 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 9b7d216f90..ceb4655b5b 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/workflowRevisionRelationAdapter.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/workflowRevisionRelationAdapter.ts @@ -755,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/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 3a31554e76..5ba9e6e0ca 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 // ============================================================================ @@ -570,13 +572,17 @@ export function useAutoSelectLatestChild({ // Auto-select first enabled child when loaded useEffect(() => { - if (hasSelectedRef.current || query.isPending) return + if (hasSelectedRef.current) return - const firstChild = children.find( - (child) => !disabledChildIds?.has(childLevelConfig.getId(child)), - ) + const decision = resolveAutoSelectLatestChild({ + children, + query, + getId: childLevelConfig.getId, + disabledChildIds, + }) + if (decision.status === "wait") return - if (firstChild) { + if (decision.status === "select") { hasSelectedRef.current = true const parentPathItem: SelectionPathItem = { type: parentLevelConfig.type, @@ -584,20 +590,15 @@ export function useAutoSelectLatestChild({ label: parentLabel, } - const childPathItem = buildPathItem(firstChild, childLevelConfig) + const childPathItem = buildPathItem(decision.child, childLevelConfig) const fullPath = [parentPathItem, childPathItem] - const selection = createSelection(fullPath, firstChild) + const selection = createSelection(fullPath, decision.child) onSelect?.(selection) onComplete() return } - // An empty snapshot can be emitted before a lazy query starts. Only - // finish without a selection once the adapter confirms the query - // settled, or when loaded children are all disabled. - if (!query.isError && !query.isFetched && children.length === 0) return - hasSelectedRef.current = true onComplete() }, [ From 056d3ce9e87c96b154e701cfaef84177c89c8dc7 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Fri, 12 Jun 2026 14:17:29 +0600 Subject: [PATCH 6/8] feat: enhance PopoverCascaderVariant with default child panel opening and improved UI interactions --- .../Components/PlaygroundHeader/index.tsx | 1 + .../EntityEvaluatorSelector.tsx | 5 +- .../components/UnifiedEntityPicker/types.ts | 10 +++ .../variants/PopoverCascaderVariant.tsx | 75 +++++++++++-------- 4 files changed, 59 insertions(+), 32 deletions(-) diff --git a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx index 21aa49a5b0..dd141c8830 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx @@ -522,6 +522,7 @@ const PlaygroundHeader: React.FC = ({className, ...divPro childItemLabelMode="simple" panelWidth={320} childPanelWidth={180} + openChildOnHover showParentCheckboxes selectedChildrenByParent={selectedChildrenByParent} totalChildrenByParent={totalChildrenByParent} 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 0e9d239fe7..b720c1ab24 100644 --- a/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/EntityEvaluatorSelector.tsx +++ b/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/EntityEvaluatorSelector.tsx @@ -37,6 +37,7 @@ export interface EntityEvaluatorSelectorProps { onDeselectRevision: (revisionId: string) => void onClearAll: () => void openVersionOnHover?: boolean + defaultOpenVersionPanel?: boolean } function getNestedValue(obj: unknown, ...keys: string[]): unknown { @@ -140,7 +141,8 @@ export function EntityEvaluatorSelector({ totalRevisionsByEvaluator, onDeselectRevision, onClearAll, - openVersionOnHover = false, + openVersionOnHover = true, + defaultOpenVersionPanel = false, }: EntityEvaluatorSelectorProps) { const renderRevisionLabel = useCallback((entity: unknown) => { const revision = entity as WorkflowRevisionLike @@ -188,6 +190,7 @@ export function EntityEvaluatorSelector({ childPanelWidth={childPanelWidth} disabled={disabled} openChildOnHover={openVersionOnHover} + defaultOpenChildPanel={defaultOpenVersionPanel} multiSelect selectedChildIds={selectedRevisionIds} selectionSummary 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 017f276f05..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 @@ -617,6 +617,16 @@ export interface PopoverCascaderVariantProps< */ 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 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 8617d30d88..dfe19355c4 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 @@ -126,9 +126,12 @@ function ChildPanelContent({
{/* Child panel header */} {multiSelect && ( -
-
- +
+
+ {parentLabel} {multiSelect && ( @@ -143,7 +146,7 @@ function ChildPanelContent({