From bcbd4a44dd0c23d18edd44046a74ad1f02734c53 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 15 Jun 2026 16:33:18 +0200 Subject: [PATCH 01/21] feat(frontend): add project-scoped Query Registry Add a /queries page to view and manage saved trace-filter queries used by live evaluations: - New scoped @agenta/entities/query entity (create/edit/archive/unarchive, list, live match-count, matching-traces) over the Fern queries client - Query Registry dashboard with grouped table, search, duplicate, and a manage drawer that reuses the shared Filters editor inline - Toggle-able matching-traces preview reusing the observability columns + InfiniteVirtualTable shell - Active list plus a dedicated /queries/archived route with restore, mirroring the Evaluators archived-route pattern - Sidebar link, EE page stubs, and full-height layout wiring - Repoint the live-eval Online Evaluation drawer at the shared create path --- TODOS.md | 16 + .../p/[project_id]/queries/archived/index.tsx | 3 + .../p/[project_id]/queries/index.tsx | 3 + web/oss/src/components/Filters/Filters.tsx | 1982 ++++++++--------- web/oss/src/components/Filters/types.d.ts | 5 + web/oss/src/components/Layout/Layout.tsx | 3 + .../QueryRegistry/ArchivedQueriesPage.tsx | 5 + .../Drawer/QueryRegistryDrawer.tsx | 287 +++ .../Drawer/QueryTracePreview.tsx | 134 ++ .../Table/QueryRegistryTable.tsx | 75 + .../Table/assets/queryRegistryColumns.tsx | 281 +++ .../src/components/QueryRegistry/index.tsx | 257 +++ .../store/queryRegistryFilterAtoms.ts | 21 + .../QueryRegistry/store/queryRegistryStore.ts | 182 ++ .../Sidebar/hooks/useSidebarConfig/index.tsx | 8 + .../OnlineEvaluationDrawer.tsx | 102 +- .../components/QueryEditor.tsx | 94 + .../p/[project_id]/queries/archived/index.tsx | 1 + .../p/[project_id]/queries/index.tsx | 7 + web/packages/agenta-entities/package.json | 1 + .../agenta-entities/src/query/api/api.ts | 170 ++ .../agenta-entities/src/query/api/index.ts | 22 + .../src/query/api/mutations.ts | 116 + .../agenta-entities/src/query/core/index.ts | 9 + .../agenta-entities/src/query/core/types.ts | 31 + .../agenta-entities/src/query/index.ts | 40 + .../agenta-entities/src/query/state/index.ts | 1 + .../agenta-entities/src/query/state/store.ts | 23 + .../tests/unit/query/archiveTabs.test.ts | 65 + .../unit/query/createSimpleQuery.test.ts | 75 + .../table-states/TableEmptyState.tsx | 29 +- 31 files changed, 2957 insertions(+), 1091 deletions(-) create mode 100644 web/ee/src/pages/w/[workspace_id]/p/[project_id]/queries/archived/index.tsx create mode 100644 web/ee/src/pages/w/[workspace_id]/p/[project_id]/queries/index.tsx create mode 100644 web/oss/src/components/QueryRegistry/ArchivedQueriesPage.tsx create mode 100644 web/oss/src/components/QueryRegistry/Drawer/QueryRegistryDrawer.tsx create mode 100644 web/oss/src/components/QueryRegistry/Drawer/QueryTracePreview.tsx create mode 100644 web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx create mode 100644 web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx create mode 100644 web/oss/src/components/QueryRegistry/index.tsx create mode 100644 web/oss/src/components/QueryRegistry/store/queryRegistryFilterAtoms.ts create mode 100644 web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts create mode 100644 web/oss/src/components/pages/evaluations/onlineEvaluation/components/QueryEditor.tsx create mode 100644 web/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/archived/index.tsx create mode 100644 web/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/index.tsx create mode 100644 web/packages/agenta-entities/src/query/api/api.ts create mode 100644 web/packages/agenta-entities/src/query/api/index.ts create mode 100644 web/packages/agenta-entities/src/query/api/mutations.ts create mode 100644 web/packages/agenta-entities/src/query/core/index.ts create mode 100644 web/packages/agenta-entities/src/query/core/types.ts create mode 100644 web/packages/agenta-entities/src/query/index.ts create mode 100644 web/packages/agenta-entities/src/query/state/index.ts create mode 100644 web/packages/agenta-entities/src/query/state/store.ts create mode 100644 web/packages/agenta-entities/tests/unit/query/archiveTabs.test.ts create mode 100644 web/packages/agenta-entities/tests/unit/query/createSimpleQuery.test.ts diff --git a/TODOS.md b/TODOS.md index c85efbe52b..e6bdcac86d 100644 --- a/TODOS.md +++ b/TODOS.md @@ -22,3 +22,19 @@ (Eng Review Decisions → run-creation orchestration). - **Depends on / blocked by:** Backend team; relates to the FE evaluations migration landing first (FE rollback is the interim state). + +## Query Registry — fast-follows + +### Backend query-usage endpoint (enumerate referencing live evals) +- **What:** Add `POST /queries/revisions/{id}/usage` (or `/queries/{id}/usage`) returning the evaluation-run ids that reference a given query revision. +- **Why:** v1 ships a generic "this query may be in use by a live evaluation" confirm before archive, because there is no reverse-lookup today. This endpoint lets the manage drawer name the specific live evals before archiving — real safety instead of a generic warning. +- **Pros:** Turns the safe-archive UX from 7/10 (generic) to 10/10 (enumerated); reuses data that already exists. +- **Cons:** Backend work (new router/service/DAO); a reverse scan of eval-run references. +- **Context:** The reference data exists, flattened, in the evaluations domain under `QUERY_REFERENCE_KEY = "query_revision"` (`api/oss/src/dbs/postgres/evaluations/utils.py`). Eval runs store `data.steps[].references["query_revision"]`. There is currently NO reverse-lookup endpoint and archive does not block in-use queries (`api/oss/src/core/queries/service.py:844` `archive_query_revision` has no reference check). Verified during the eng review of branch `claude/intelligent-bassi-ca4cc0`. +- **Depends on / blocked by:** None. Independent of the FE registry; the FE swaps the generic confirm for enumeration when this lands. + +### Backend: simple-queries list should return `variant_id` and `revision_id` +- **What:** `query_simple_queries` (`api/oss/src/core/queries/service.py:~1505`) builds each `SimpleQuery` without `variant_id` / `revision_id` (only id/slug/timestamps/name/data). Populate both, like the create/retrieve responses do. +- **Why:** The Query Registry's revision-history expand needs `variant_id` to lazy-load a query's revisions (`queries/revisions/query` with `query_variant_refs`). Without it, every row is non-expandable and the expand column renders empty, so the feature was removed from v1. +- **Context:** The FE already has the pieces (`queryQueryRevisions` in `@agenta/entities/query`, and a deleted `QueryRevisionList` component — easy to restore). Once the list returns `variant_id`, re-add the `expandable` config to `QueryRegistryTable` and the row's `variantId` will drive it. Verified on branch `claude/intelligent-bassi-ca4cc0`. +- **Depends on / blocked by:** None. Backend-only change unblocks the FE feature. diff --git a/web/ee/src/pages/w/[workspace_id]/p/[project_id]/queries/archived/index.tsx b/web/ee/src/pages/w/[workspace_id]/p/[project_id]/queries/archived/index.tsx new file mode 100644 index 0000000000..4f66bd6b47 --- /dev/null +++ b/web/ee/src/pages/w/[workspace_id]/p/[project_id]/queries/archived/index.tsx @@ -0,0 +1,3 @@ +import ArchivedQueriesPage from "@agenta/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/archived" + +export default ArchivedQueriesPage diff --git a/web/ee/src/pages/w/[workspace_id]/p/[project_id]/queries/index.tsx b/web/ee/src/pages/w/[workspace_id]/p/[project_id]/queries/index.tsx new file mode 100644 index 0000000000..0aba5ae7dd --- /dev/null +++ b/web/ee/src/pages/w/[workspace_id]/p/[project_id]/queries/index.tsx @@ -0,0 +1,3 @@ +import QueriesPage from "@agenta/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/index" + +export default QueriesPage diff --git a/web/oss/src/components/Filters/Filters.tsx b/web/oss/src/components/Filters/Filters.tsx index d61f056c59..fc94e20292 100644 --- a/web/oss/src/components/Filters/Filters.tsx +++ b/web/oss/src/components/Filters/Filters.tsx @@ -284,6 +284,7 @@ const Filters: React.FC = ({ onClearFilter, buttonProps, reconcileFilterRows, + inline = false, }) => { const evaluatorPreviews = useAtomValue(evaluatorsListDataAtom) const evaluatorFeedbackSchemas = useAtomValue(evaluatorFeedbackSchemasAtom) @@ -841,832 +842,935 @@ const Filters: React.FC = ({ overflow: "auto", } as const - return ( - { - setIsFilterOpen(open) - if (!open) setActiveFieldDropdown(null) - }} - open={isFilterOpen} - placement="bottomLeft" - autoAdjustOverflow - styles={{body: {maxHeight: "70vh"}, root: {maxWidth: "100vw"}}} - destroyOnHidden - content={ -
+ const filterBody = ( +
+ {!inline && ( + <>
Filter
+ + )} + +
+ {displayedFilter.map((item, idx) => { + const uiKey = item.selectedField || item.field || "" + const baseFieldCfg = getField(uiKey) + const field = effectiveFieldForRow(baseFieldCfg, item) + + const isAnnotationFieldSelected = + field?.baseField?.includes("annotation") ?? false + + const operatorOptions = field + ? (field.operatorOptions ?? operatorOptionsFromIds(field.operatorIds)) + : [] + + const singleOperator = operatorOptions.length === 1 + const operatorValue = + item.operator || (singleOperator ? operatorOptions[0]?.value : undefined) + + const plan = + field && operatorValue ? planInputs(field, operatorValue as any) : undefined + const showKey = Boolean(plan?.needsKey) + const showValue = Boolean(plan?.showValue) + const valueAs = plan?.valueAs + const valueOptions = plan?.valueOptions + const keyPlaceholder = plan?.placeholders?.key ?? "Key" + const valuePlaceholder = plan?.placeholders?.value ?? "Value" + + const rawValue = Array.isArray(item.value) ? "" : (item.value as any) + const displayValue = (field as any)?.valueDisplayText || rawValue + const validation = rowValidations[idx] ?? {isValid: true} + const valueHasError = Boolean(validation.valueInvalid) + + const annotationValue = extractAnnotationValue(item.value) + + const disableHasAnnotationForRow = hasAnnotationIndices.some( + (annotationIdx) => annotationIdx !== idx, + ) + const disabledFieldOptionsForMenu = disableHasAnnotationForRow + ? annotationDisabledOptions + : undefined + + const setAnnotationValue = ( + updater: ( + prev: AnnotationFilterValue | undefined, + ) => AnnotationFilterValue | undefined, + ) => { + const next = updater(annotationValue ? {...annotationValue} : undefined) + if (!next || Object.keys(next).length === 0) { + onFilterChange({columnName: "value", value: [], idx}) + return + } -
- {displayedFilter.map((item, idx) => { - const uiKey = item.selectedField || item.field || "" - const baseFieldCfg = getField(uiKey) - const field = effectiveFieldForRow(baseFieldCfg, item) - - const isAnnotationFieldSelected = - field?.baseField?.includes("annotation") ?? false - - const operatorOptions = field - ? (field.operatorOptions ?? - operatorOptionsFromIds(field.operatorIds)) - : [] - - const singleOperator = operatorOptions.length === 1 - const operatorValue = - item.operator || - (singleOperator ? operatorOptions[0]?.value : undefined) - - const plan = - field && operatorValue - ? planInputs(field, operatorValue as any) - : undefined - const showKey = Boolean(plan?.needsKey) - const showValue = Boolean(plan?.showValue) - const valueAs = plan?.valueAs - const valueOptions = plan?.valueOptions - const keyPlaceholder = plan?.placeholders?.key ?? "Key" - const valuePlaceholder = plan?.placeholders?.value ?? "Value" - - const rawValue = Array.isArray(item.value) ? "" : (item.value as any) - const displayValue = (field as any)?.valueDisplayText || rawValue - const validation = rowValidations[idx] ?? {isValid: true} - const valueHasError = Boolean(validation.valueInvalid) - - const annotationValue = extractAnnotationValue(item.value) - - const disableHasAnnotationForRow = hasAnnotationIndices.some( - (annotationIdx) => annotationIdx !== idx, - ) - const disabledFieldOptionsForMenu = disableHasAnnotationForRow - ? annotationDisabledOptions - : undefined - - const setAnnotationValue = ( - updater: ( - prev: AnnotationFilterValue | undefined, - ) => AnnotationFilterValue | undefined, - ) => { - const next = updater( - annotationValue ? {...annotationValue} : undefined, - ) - if (!next || Object.keys(next).length === 0) { - onFilterChange({columnName: "value", value: [], idx}) - return - } - - const valueToStore: AnnotationFilterValue = {...next} - if (valueToStore.feedback) { - const cleanedFeedback = {...valueToStore.feedback} - if (cleanedFeedback.valueType === undefined) - cleanedFeedback.valueType = "string" - valueToStore.feedback = cleanedFeedback - } + const valueToStore: AnnotationFilterValue = {...next} + if (valueToStore.feedback) { + const cleanedFeedback = {...valueToStore.feedback} + if (cleanedFeedback.valueType === undefined) + cleanedFeedback.valueType = "string" + valueToStore.feedback = cleanedFeedback + } - onFilterChange({columnName: "value", value: [valueToStore], idx}) - } + onFilterChange({columnName: "value", value: [valueToStore], idx}) + } - const currentFeedback = annotationValue?.feedback - - // Build available feedback options - const availableFeedbackOptions = (() => { - if (annotationValue?.evaluator) { - const filtered = annotationFeedbackOptions.filter( - (option) => - option.evaluatorSlug === annotationValue.evaluator, - ) - // Keep currently selected key if it exists and is not part of filtered - const selectedKey = Array.isArray(currentFeedback?.field) - ? currentFeedback?.field[0] - : currentFeedback?.field - const selected = selectedKey - ? annotationFeedbackOptions.find( - (option) => option.value === selectedKey, - ) - : undefined - if ( - selected && - !filtered.some((o) => o.value === selected.value) - ) - return [selected, ...filtered] - return filtered - } - // No evaluator. Show deduped feedback names across all evaluators - return dedupeFeedbackOptions(annotationFeedbackOptions) - })() + const currentFeedback = annotationValue?.feedback - // Pick a type from the first selected key if present - const selectedFeedbackKey = Array.isArray(currentFeedback?.field) + // Build available feedback options + const availableFeedbackOptions = (() => { + if (annotationValue?.evaluator) { + const filtered = annotationFeedbackOptions.filter( + (option) => option.evaluatorSlug === annotationValue.evaluator, + ) + // Keep currently selected key if it exists and is not part of filtered + const selectedKey = Array.isArray(currentFeedback?.field) ? currentFeedback?.field[0] : currentFeedback?.field - const selectedFeedbackOption = selectedFeedbackKey - ? availableFeedbackOptions.find( - (option) => option.value === selectedFeedbackKey, + const selected = selectedKey + ? annotationFeedbackOptions.find( + (option) => option.value === selectedKey, ) : undefined + if (selected && !filtered.some((o) => o.value === selected.value)) + return [selected, ...filtered] + return filtered + } + // No evaluator. Show deduped feedback names across all evaluators + return dedupeFeedbackOptions(annotationFeedbackOptions) + })() + + // Pick a type from the first selected key if present + const selectedFeedbackKey = Array.isArray(currentFeedback?.field) + ? currentFeedback?.field[0] + : currentFeedback?.field + const selectedFeedbackOption = selectedFeedbackKey + ? availableFeedbackOptions.find( + (option) => option.value === selectedFeedbackKey, + ) + : undefined + + const feedbackValueType = + currentFeedback?.valueType ?? selectedFeedbackOption?.type ?? "string" + + const isEvaluatorActive = annotationValue + ? "evaluator" in annotationValue + : false + const isFeedbackActive = annotationValue ? "feedback" in annotationValue : false + + const feedbackOperatorOptions = ALL_FEEDBACK_OPERATOR_OPTIONS + + const coerceNumericFeedbackValue = ( + input: unknown, + ): string | number | undefined => { + if (typeof input === "number") + return Number.isFinite(input) ? input : undefined + if (typeof input === "string") { + const trimmed = input.trim() + if (!trimmed) return "" + const numericPattern = /^-?(?:\d+|\d*\.\d+)$/ + return numericPattern.test(trimmed) ? Number(trimmed) : input + } + return undefined + } - const feedbackValueType = - currentFeedback?.valueType ?? - selectedFeedbackOption?.type ?? - "string" - - const isEvaluatorActive = annotationValue - ? "evaluator" in annotationValue - : false - const isFeedbackActive = annotationValue - ? "feedback" in annotationValue - : false - - const feedbackOperatorOptions = ALL_FEEDBACK_OPERATOR_OPTIONS - - const coerceNumericFeedbackValue = ( - input: unknown, - ): string | number | undefined => { - if (typeof input === "number") - return Number.isFinite(input) ? input : undefined - if (typeof input === "string") { - const trimmed = input.trim() - if (!trimmed) return "" - const numericPattern = /^-?(?:\d+|\d*\.\d+)$/ - return numericPattern.test(trimmed) ? Number(trimmed) : input - } - return undefined - } + const parseFeedbackArrayInput = (input: string): any[] | undefined => { + const trimmed = input.trim() + if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return undefined + try { + const parsed = JSON.parse(trimmed) + return Array.isArray(parsed) ? parsed : undefined + } catch { + return undefined + } + } - const parseFeedbackArrayInput = (input: string): any[] | undefined => { - const trimmed = input.trim() - if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) - return undefined - try { - const parsed = JSON.parse(trimmed) - return Array.isArray(parsed) ? parsed : undefined - } catch { - return undefined - } - } + const ensureFeedbackOperator = ( + type: AnnotationFeedbackValueType, + current?: FilterConditions, + ): FilterConditions => { + if (current && ALL_FEEDBACK_OPERATOR_VALUES.has(current)) return current + if (type === "number") { + return NUM_OPS[0]?.value ?? "" + } + return STRING_EQU_OPS[0]?.value ?? "" + } - const ensureFeedbackOperator = ( - type: AnnotationFeedbackValueType, - current?: FilterConditions, - ): FilterConditions => { - if (current && ALL_FEEDBACK_OPERATOR_VALUES.has(current)) - return current - if (type === "number") { - return NUM_OPS[0]?.value ?? "" - } - return STRING_EQU_OPS[0]?.value ?? "" + const handleEvaluatorChange = (value?: string) => { + setAnnotationValue((prev) => { + const base: AnnotationFilterValue = {...(prev ?? {})} + + if (!value) { + // Removing evaluator. Keep feedback as is. Now it means across any evaluator + delete base.evaluator + return Object.keys(base).length ? base : undefined } - const handleEvaluatorChange = (value?: string) => { - setAnnotationValue((prev) => { - const base: AnnotationFilterValue = {...(prev ?? {})} + base.evaluator = value - if (!value) { - // Removing evaluator. Keep feedback as is. Now it means across any evaluator - delete base.evaluator - return Object.keys(base).length ? base : undefined - } + if (base.feedback?.field) { + const allowed = new Set( + annotationFeedbackOptions + .filter((o) => o.evaluatorSlug === value) + .map((o) => o.value), + ) + if (Array.isArray(base.feedback.field)) { + const kept = base.feedback.field.filter((k) => allowed.has(k)) + base.feedback.field = kept[0] ?? undefined + } else if ( + base.feedback.field && + !allowed.has(base.feedback.field) + ) { + base.feedback.field = undefined + } + } - base.evaluator = value + return base + }) + } - if (base.feedback?.field) { - const allowed = new Set( - annotationFeedbackOptions - .filter((o) => o.evaluatorSlug === value) - .map((o) => o.value), - ) - if (Array.isArray(base.feedback.field)) { - const kept = base.feedback.field.filter((k) => - allowed.has(k), - ) - base.feedback.field = kept[0] ?? undefined - } else if ( - base.feedback.field && - !allowed.has(base.feedback.field) - ) { - base.feedback.field = undefined - } - } + const handleFeedbackFieldChange = (value: string | string[]) => { + setAnnotationValue((prev) => { + const base: AnnotationFilterValue = {...(prev ?? {})} + const feedback = {...(base.feedback ?? {})} - return base - }) - } + const nextField: string | string[] = annotationValue?.evaluator + ? Array.isArray(value) + ? value[0] + : value + : value - const handleFeedbackFieldChange = (value: string | string[]) => { - setAnnotationValue((prev) => { - const base: AnnotationFilterValue = {...(prev ?? {})} - const feedback = {...(base.feedback ?? {})} - - const nextField: string | string[] = annotationValue?.evaluator - ? Array.isArray(value) - ? value[0] - : value - : value - - const sampleKey = Array.isArray(nextField) - ? nextField[0] - : nextField - const option = availableFeedbackOptions.find( - (opt) => opt.value === sampleKey, - ) - const nextType = option - ? option.type - : (feedback.valueType ?? "string") - - feedback.field = nextField - feedback.valueType = nextType - feedback.operator = ensureFeedbackOperator( - nextType, - feedback.operator, - ) - feedback.value = nextType === "boolean" ? true : "" - - base.feedback = feedback - return base - }) - } + const sampleKey = Array.isArray(nextField) ? nextField[0] : nextField + const option = availableFeedbackOptions.find( + (opt) => opt.value === sampleKey, + ) + const nextType = option ? option.type : (feedback.valueType ?? "string") - const handleFeedbackOperatorChange = (operator: FilterConditions) => { - setAnnotationValue((prev) => { - const base: AnnotationFilterValue = {...(prev ?? {})} - const feedback = {...(base.feedback ?? {}), operator} + feedback.field = nextField + feedback.valueType = nextType + feedback.operator = ensureFeedbackOperator(nextType, feedback.operator) + feedback.value = nextType === "boolean" ? true : "" - if (NUMERIC_FEEDBACK_OPERATOR_VALUES.has(operator)) { - feedback.valueType = "number" - const currentValue = feedback.value - const coerced = coerceNumericFeedbackValue(currentValue) - feedback.value = coerced === undefined ? "" : coerced - } + base.feedback = feedback + return base + }) + } - base.feedback = feedback - return base - }) - } + const handleFeedbackOperatorChange = (operator: FilterConditions) => { + setAnnotationValue((prev) => { + const base: AnnotationFilterValue = {...(prev ?? {})} + const feedback = {...(base.feedback ?? {}), operator} - const handleFeedbackTypeChange = ( - type: AnnotationFeedbackValueType, - ) => { - setAnnotationValue((prev) => { - const base: AnnotationFilterValue = {...(prev ?? {})} - const feedback = {...(base.feedback ?? {})} - feedback.valueType = type - feedback.operator = ensureFeedbackOperator( - type, - feedback.operator, - ) - feedback.value = type === "boolean" ? true : "" - base.feedback = feedback - return base - }) + if (NUMERIC_FEEDBACK_OPERATOR_VALUES.has(operator)) { + feedback.valueType = "number" + const currentValue = feedback.value + const coerced = coerceNumericFeedbackValue(currentValue) + feedback.value = coerced === undefined ? "" : coerced } - const handleFeedbackValueChange = (raw: string | number | boolean) => { - setAnnotationValue((prev) => { - const base: AnnotationFilterValue = {...(prev ?? {})} - const fb = {...(base.feedback ?? {})} + base.feedback = feedback + return base + }) + } - const type = fb.valueType ?? "string" - let value: any = raw as any + const handleFeedbackTypeChange = (type: AnnotationFeedbackValueType) => { + setAnnotationValue((prev) => { + const base: AnnotationFilterValue = {...(prev ?? {})} + const feedback = {...(base.feedback ?? {})} + feedback.valueType = type + feedback.operator = ensureFeedbackOperator(type, feedback.operator) + feedback.value = type === "boolean" ? true : "" + base.feedback = feedback + return base + }) + } - if (typeof raw === "string") { - const parsedArray = parseFeedbackArrayInput(raw) - if (parsedArray !== undefined) { - value = parsedArray - } - } + const handleFeedbackValueChange = (raw: string | number | boolean) => { + setAnnotationValue((prev) => { + const base: AnnotationFilterValue = {...(prev ?? {})} + const fb = {...(base.feedback ?? {})} - if (!Array.isArray(value)) { - if (type === "number") { - if (typeof raw === "number") { - value = Number.isFinite(raw) ? raw : fb.value - } else { - const coerced = coerceNumericFeedbackValue(raw) - value = coerced === undefined ? "" : coerced - } - } else if (type === "boolean") { - if (typeof raw === "boolean") { - value = raw - } else { - const s = String(raw).trim().toLowerCase() - value = - s === "true" - ? true - : s === "false" - ? false - : undefined - } - } else if (typeof raw === "string") { - value = raw - } else { - value = String(raw) - } - } + const type = fb.valueType ?? "string" + let value: any = raw as any - base.feedback = {...fb, value} - return base - }) + if (typeof raw === "string") { + const parsedArray = parseFeedbackArrayInput(raw) + if (parsedArray !== undefined) { + value = parsedArray + } } - const removeEvaluator = () => { - setAnnotationValue((prev) => { - if (!prev?.feedback) { - onDeleteFilter(idx) - return undefined + if (!Array.isArray(value)) { + if (type === "number") { + if (typeof raw === "number") { + value = Number.isFinite(raw) ? raw : fb.value + } else { + const coerced = coerceNumericFeedbackValue(raw) + value = coerced === undefined ? "" : coerced + } + } else if (type === "boolean") { + if (typeof raw === "boolean") { + value = raw + } else { + const s = String(raw).trim().toLowerCase() + value = + s === "true" ? true : s === "false" ? false : undefined } - const next = {...(prev ?? {})} - delete next.evaluator - return Object.keys(next).length ? next : undefined - }) + } else if (typeof raw === "string") { + value = raw + } else { + value = String(raw) + } } - const removeFeedback = () => { - setAnnotationValue((prev) => { - if (!prev?.feedback) return prev + base.feedback = {...fb, value} + return base + }) + } - if (!prev.evaluator) { - onDeleteFilter(idx) - return undefined - } - const next = {...(prev ?? {})} - delete next.feedback - return Object.keys(next).length ? next : undefined - }) + const removeEvaluator = () => { + setAnnotationValue((prev) => { + if (!prev?.feedback) { + onDeleteFilter(idx) + return undefined } + const next = {...(prev ?? {})} + delete next.evaluator + return Object.keys(next).length ? next : undefined + }) + } - const feedbackValueRaw = (() => { - const raw = currentFeedback?.value - if (Array.isArray(raw)) { - try { - return JSON.stringify(raw) - } catch { - return "" - } - } - if (raw && typeof raw === "object") { - try { - return JSON.stringify(raw) - } catch { - return "" - } - } - if (raw === undefined || raw === null) return "" - if (typeof raw === "string") return raw - if (typeof raw === "number") return String(raw) - if (typeof raw === "boolean") return raw ? "true" : "false" - return "" - })() - - const renderAddFeedbackButton = () => ( - - ) + const removeFeedback = () => { + setAnnotationValue((prev) => { + if (!prev?.feedback) return prev + + if (!prev.evaluator) { + onDeleteFilter(idx) + return undefined + } + const next = {...(prev ?? {})} + delete next.feedback + return Object.keys(next).length ? next : undefined + }) + } - const feedbackFieldValueForSelect: string | string[] | undefined = - (() => { - const f = currentFeedback?.field - if (Array.isArray(f)) return f - return f ?? undefined - })() - - const feedbackOptionsForSelect = (() => { - const options = availableFeedbackOptions.map((option) => ({ - label: option.label, - value: option.value, + const feedbackValueRaw = (() => { + const raw = currentFeedback?.value + if (Array.isArray(raw)) { + try { + return JSON.stringify(raw) + } catch { + return "" + } + } + if (raw && typeof raw === "object") { + try { + return JSON.stringify(raw) + } catch { + return "" + } + } + if (raw === undefined || raw === null) return "" + if (typeof raw === "string") return raw + if (typeof raw === "number") return String(raw) + if (typeof raw === "boolean") return raw ? "true" : "false" + return "" + })() + + const renderAddFeedbackButton = () => ( + + ) + + const feedbackFieldValueForSelect: string | string[] | undefined = (() => { + const f = currentFeedback?.field + if (Array.isArray(f)) return f + return f ?? undefined + })() + + const feedbackOptionsForSelect = (() => { + const options = availableFeedbackOptions.map((option) => ({ + label: option.label, + value: option.value, + })) + const known = new Set(options.map((o) => o.value)) + + // Keep already-selected custom keys visible with a label. + const selectedFields = Array.isArray(feedbackFieldValueForSelect) + ? feedbackFieldValueForSelect + : feedbackFieldValueForSelect + ? [feedbackFieldValueForSelect] + : [] + for (const selected of selectedFields) { + if (selected && !known.has(selected)) { + options.push({label: selected, value: selected}) + known.add(selected) + } + } - // Surface the text the user is typing as a selectable option, so - // evaluators without an output schema can still be given a - // feedback name. Enter or click commits it. - const typed = (feedbackFieldSearch[idx] ?? "").trim() - if (typed && !known.has(typed)) { - options.unshift({label: `${typed} (custom)`, value: typed}) - } + // Surface the text the user is typing as a selectable option, so + // evaluators without an output schema can still be given a + // feedback name. Enter or click commits it. + const typed = (feedbackFieldSearch[idx] ?? "").trim() + if (typed && !known.has(typed)) { + options.unshift({label: `${typed} (custom)`, value: typed}) + } - return options - })() - - return ( - - - {idx === 0 ? "Where" : "And"} - - - -
- - setActiveFieldDropdown(open ? idx : null) - } - menu={{ - items: buildFieldMenuItems( - columns, - (value, labelFromGroup) => - handleFieldSelection( - value, - idx, - labelFromGroup, - ), - "root", - [], - fieldDropdownSubmenuClass, - disabledFieldOptionsForMenu ?? - EMPTY_DISABLED_OPTIONS, + return options + })() + + return ( + + + {idx === 0 ? "Where" : "And"} + + + +
+ + setActiveFieldDropdown(open ? idx : null) + } + menu={{ + items: buildFieldMenuItems( + columns, + (value, labelFromGroup) => + handleFieldSelection( + value, + idx, + labelFromGroup, ), - onClick: ({key}) => - handleFieldSelection(String(key), idx), - }} - getPopupContainer={(t) => getWithinPopover(t)} - > - - - - {showKey && - (field!.keyInput!.kind === "select" ? ( - (() => { - const options = field!.keyInput! - .options as SelectOption[] - const optionValues = - collectOptionValues(options) - const currentSearch = - keySearchTerms[idx] ?? "" - const normalizedSearch = - normalizeAttributeSearch(currentSearch) - const additionalNodes: NonNullable< - TreeSelectProps["treeData"] - > = [] - const keyValue = - item.key === undefined || - item.key === null - ? undefined - : String(item.key) - if ( - normalizedSearch && - !optionValues.has( - normalizedSearch.value, - ) - ) { - additionalNodes.push( - buildCustomTreeNode( - normalizedSearch.value, - normalizedSearch.pathLabel, - ), - ) - } - if ( - keyValue && - !optionValues.has(keyValue) && - !additionalNodes.some( - (node) => node.value === keyValue, - ) - ) { - additionalNodes.push( - buildCustomTreeNode( - keyValue, - valueToPathLabel(keyValue), - ), - ) + "root", + [], + fieldDropdownSubmenuClass, + disabledFieldOptionsForMenu ?? + EMPTY_DISABLED_OPTIONS, + ), + onClick: ({key}) => + handleFieldSelection(String(key), idx), + }} + getPopupContainer={(t) => getWithinPopover(t)} + > + + + + {showKey && + (field!.keyInput!.kind === "select" ? ( + (() => { + const options = field!.keyInput! + .options as SelectOption[] + const optionValues = collectOptionValues(options) + const currentSearch = keySearchTerms[idx] ?? "" + const normalizedSearch = + normalizeAttributeSearch(currentSearch) + const additionalNodes: NonNullable< + TreeSelectProps["treeData"] + > = [] + const keyValue = + item.key === undefined || item.key === null + ? undefined + : String(item.key) + if ( + normalizedSearch && + !optionValues.has(normalizedSearch.value) + ) { + additionalNodes.push( + buildCustomTreeNode( + normalizedSearch.value, + normalizedSearch.pathLabel, + ), + ) + } + if ( + keyValue && + !optionValues.has(keyValue) && + !additionalNodes.some( + (node) => node.value === keyValue, + ) + ) { + additionalNodes.push( + buildCustomTreeNode( + keyValue, + valueToPathLabel(keyValue), + ), + ) + } + const baseTreeData = mapToTreeData(options) + const treeData = + additionalNodes.length > 0 + ? [...additionalNodes, ...baseTreeData] + : baseTreeData + const expandedKeys = collectTreeKeys(treeData) + return ( + + getWithinPopover(t) } - const baseTreeData = mapToTreeData(options) - const treeData = - additionalNodes.length > 0 - ? [ - ...additionalNodes, - ...baseTreeData, - ] - : baseTreeData - const expandedKeys = - collectTreeKeys(treeData) - return ( - - getWithinPopover(t) - } - value={ - item.key && item.key !== "" - ? (item.key as - | string - | number) - : undefined - } - onChange={(v) => - onFilterChange({ - columnName: "key", - value: v == null ? "" : v, - idx, - }) - } - onSearch={(searchValue) => - setKeySearchTerms((prev) => { - const trimmed = - searchValue.trim() - if (!trimmed) { - if (!(idx in prev)) - return prev - const next = {...prev} - delete next[idx] - return next - } - return { - ...prev, - [idx]: trimmed, - } - }) - } - onDropdownVisibleChange={(open) => { - if (!open) { - setKeySearchTerms( - (prev) => { - if (!(idx in prev)) - return prev - const next = { - ...prev, - } - delete next[idx] - return next - }, - ) - } - }} - placeholder={keyPlaceholder} - showSearch - treeNodeFilterProp="title" - treeDefaultExpandAll - treeExpandedKeys={expandedKeys} - onTreeExpand={noopTreeExpand} - treeLine={{showLeafIcon: false}} - disabled={item.isPermanent} - filterTreeNode={(input, node) => { - const title = - typeof node?.title === - "string" - ? node.title - : String( - node?.title ?? "", - ) - const value = String( - node?.value ?? "", - ) - const pathLabel = - typeof (node as any) - ?.pathLabel === "string" - ? ((node as any) - .pathLabel as string) - : "" - const search = input - .trim() - .toLowerCase() - return ( - title - .toLowerCase() - .includes(search) || - value - .toLowerCase() - .includes(search) || - pathLabel - .toLowerCase() - .includes(search) - ) - }} - /> - ) - })() - ) : ( - + onChange={(v) => onFilterChange({ columnName: "key", - value: e.target.value, + value: v == null ? "" : v, idx, }) } + onSearch={(searchValue) => + setKeySearchTerms((prev) => { + const trimmed = searchValue.trim() + if (!trimmed) { + if (!(idx in prev)) return prev + const next = {...prev} + delete next[idx] + return next + } + return { + ...prev, + [idx]: trimmed, + } + }) + } + onDropdownVisibleChange={(open) => { + if (!open) { + setKeySearchTerms((prev) => { + if (!(idx in prev)) return prev + const next = { + ...prev, + } + delete next[idx] + return next + }) + } + }} + placeholder={keyPlaceholder} + showSearch + treeNodeFilterProp="title" + treeDefaultExpandAll + treeExpandedKeys={expandedKeys} + onTreeExpand={noopTreeExpand} + treeLine={{showLeafIcon: false}} disabled={item.isPermanent} + filterTreeNode={(input, node) => { + const title = + typeof node?.title === "string" + ? node.title + : String(node?.title ?? "") + const value = String(node?.value ?? "") + const pathLabel = + typeof (node as any)?.pathLabel === + "string" + ? ((node as any) + .pathLabel as string) + : "" + const search = input + .trim() + .toLowerCase() + return ( + title + .toLowerCase() + .includes(search) || + value + .toLowerCase() + .includes(search) || + pathLabel + .toLowerCase() + .includes(search) + ) + }} /> - ))} - - {isAnnotationFieldSelected && ( - - That - - )} - - {!singleOperator && ( + ) + })() + ) : ( + + onFilterChange({ + columnName: "key", + value: e.target.value, + idx, + }) + } + disabled={item.isPermanent} + /> + ))} + + {isAnnotationFieldSelected && ( + + That + + )} + + {!singleOperator && ( + - !label.value ? "Condition" : label.label - } - suffixIcon={} + className="w-[220px] flex-1" + showSearch + placeholder="Evaluator" + value={annotationValue?.evaluator} + options={annotationEvaluatorOptions} onChange={(value) => - onFilterChange({ - columnName: "operator", - value, - idx, - }) + handleEvaluatorChange(value) } - className="w-[140px]" - popupMatchSelectWidth={140} - value={operatorValue} - options={operatorOptions} - disabled={item.isPermanent} + allowClear + suffixIcon={} + optionFilterProp="label" getPopupContainer={(t) => getWithinPopover(t)} styles={{ popup: { root: { - ...dropdownPanelStyle, + ...(dropdownPanelStyle || {}), }, }, }} /> - )} - {isAnnotationFieldSelected ? ( - isEvaluatorActive ? ( -
- } + onClick={removeEvaluator} /> - ) : valueAs === "tags" ? ( - + ) : valueAs === "tags" ? ( + + onFilterChange({ + columnName: "value", + value: v, + idx, + }) + } + placeholder={valuePlaceholder} + suffixIcon={} + popupMatchSelectWidth + disabled={item.isPermanent} + status={valueHasError ? "error" : undefined} + getPopupContainer={(t) => getWithinPopover(t)} + styles={{ + popup: { + root: { + ...(dropdownPanelStyle || {}), + }, + }, + }} + /> + ) : valueAs === "range" ? ( + + onFilterChange({ + columnName: "value", + value: e.target.value, + idx, + }) + } + disabled={item.isPermanent} + className="flex-1 min-w-[160px] w-full" + status={valueHasError ? "error" : undefined} + /> + ) : ( + + onFilterChange({ + columnName: "value", + value: e.target.value, + idx, + }) + } + disabled={item.isPermanent} + className="flex-1 min-w-[160px] w-full" + status={valueHasError ? "error" : undefined} + /> + )} + + {field?.optionKey === "custom" && ( + + setFeedbackFieldSearch((prev) => ({ + ...prev, + [idx]: searchValue, + })) + } + onChange={(val) => { + handleFeedbackFieldChange( + val as string | string[], + ) + setFeedbackFieldSearch((prev) => ({ + ...prev, + [idx]: "", + })) + }} + onOpenChange={(open) => { + if (!open) + setFeedbackFieldSearch((prev) => ({ + ...prev, + [idx]: "", + })) + }} + suffixIcon={} + optionFilterProp="label" + getPopupContainer={(t) => getWithinPopover(t)} + styles={{ + popup: { + root: { + ...(dropdownPanelStyle || {}), }, - }} - /> - ) : valueAs === "select" ? ( + }, + }} + /> + - onFilterChange({ - columnName: "value", - value: v, - idx, - }) - } - placeholder={valuePlaceholder} + className="flex-1" + value={currentFeedback?.value ?? true} + options={[ + {label: "true", value: true}, + {label: "false", value: false}, + ]} + onChange={handleFeedbackValueChange} suffixIcon={} - popupMatchSelectWidth - disabled={item.isPermanent} - status={valueHasError ? "error" : undefined} getPopupContainer={(t) => getWithinPopover(t)} styles={{ popup: { @@ -1676,285 +1780,131 @@ const Filters: React.FC = ({ }, }} /> - ) : valueAs === "range" ? ( - - onFilterChange({ - columnName: "value", - value: e.target.value, - idx, - }) - } - disabled={item.isPermanent} - className="flex-1 min-w-[160px] w-full" - status={valueHasError ? "error" : undefined} - /> ) : ( - onFilterChange({ - columnName: "value", - value: e.target.value, - idx, - }) + handleFeedbackValueChange(e.target.value) } - disabled={item.isPermanent} - className="flex-1 min-w-[160px] w-full" - status={valueHasError ? "error" : undefined} /> )} - - {field?.optionKey === "custom" && ( - + handleFeedbackTypeChange( + value as AnnotationFeedbackValueType, + ) + } + suffixIcon={} + getPopupContainer={(t) => getWithinPopover(t)} + styles={{ + popup: { + root: { + ...(dropdownPanelStyle || {}), }, - }} - /> - )} + }, + }} + /> - {!item.isPermanent && - nonPermanentFilterCount > 1 && - !( - isAnnotationFieldSelected && - (isEvaluatorActive || isFeedbackActive) - ) && ( -
- {(isEvaluatorActive || isFeedbackActive) && - (isFeedbackActive ? ( -
- - Feedback - - } - getPopupContainer={(t) => - getWithinPopover(t) - } - styles={{ - popup: { - root: { - ...(dropdownPanelStyle || {}), - }, - }, - }} - /> - {feedbackValueType === "boolean" ? ( - - handleFeedbackValueChange( - e.target.value, - ) - } - /> - )} - + + +
+ {matchLabel ? ( +
+ + {matchLabel} + +
+ ) : ( + + )} + +
+ + {showPreview ? ( +
+ +
+ ) : null} + + ) +} + +export default QueryRegistryDrawer diff --git a/web/oss/src/components/QueryRegistry/Drawer/QueryTracePreview.tsx b/web/oss/src/components/QueryRegistry/Drawer/QueryTracePreview.tsx new file mode 100644 index 0000000000..31ef188c48 --- /dev/null +++ b/web/oss/src/components/QueryRegistry/Drawer/QueryTracePreview.tsx @@ -0,0 +1,134 @@ +import {type Key, useEffect, useMemo, useState} from "react" + +import {queryMatchingTraces} from "@agenta/entities/query" +import { + InfiniteVirtualTableFeatureShell, + type TableFeaturePagination, + type TableScopeConfig, +} from "@agenta/ui/table" +import {Alert} from "antd" +import type {ColumnsType} from "antd/es/table" +import {useStore} from "jotai" + +import {getObservabilityColumns} from "@/oss/components/pages/observability/assets/getObservabilityColumns" +import {TraceSpanNode} from "@/oss/services/tracing/types" + +export interface QueryTracePreviewProps { + projectId?: string | null + /** + * Structured tracing filter payload (`toFilteringPayload(filters)`). + * `undefined` matches every trace; the parent decides whether to render the + * preview at all in that case. + */ + filtering: unknown + /** Max traces to fetch for the preview (no pagination — this is a peek). */ + limit?: number +} + +/** + * The InfiniteVirtualTable row constraint (`InfiniteTableRowBase`) requires a + * required `key` and an index signature, which the observability `TraceSpanNode` + * doesn't declare. ObservabilityTable lives with the resulting tsc mismatch; + * here we widen to a constraint-satisfying row so this new file stays clean. + */ +type PreviewRow = TraceSpanNode & {key: Key; [extra: string]: unknown} + +// Stable empty array so the observability columns memo never re-creates and the +// drawer preview never shows the evaluator-metrics column group. +const NO_EVALUATORS: string[] = [] + +/** + * Read-only preview of the traces matching a query's filter, rendered with the + * exact observability table (columns + InfiniteVirtualTable shell) so it stays + * visually identical to the Observability page. Fetches a single page via the + * query entity's `queryMatchingTraces`; this is a peek, not a paginated browser. + */ +const QueryTracePreview = ({projectId, filtering, limit = 50}: QueryTracePreviewProps) => { + const store = useStore() + const columns = useMemo( + () => + getObservabilityColumns({ + evaluatorSlugs: NO_EVALUATORS, + }) as unknown as ColumnsType, + [], + ) + + const [traces, setTraces] = useState([]) + const [status, setStatus] = useState<"loading" | "done" | "error">("loading") + + useEffect(() => { + if (!projectId) return + let cancelled = false + setStatus("loading") + queryMatchingTraces({projectId, filtering, limit}) + .then((result) => { + if (cancelled) return + // Entity TraceSpanNode is structurally the OSS node the observability + // columns render against; the cast bridges the two package types. + setTraces(result as unknown as PreviewRow[]) + setStatus("done") + }) + .catch(() => { + if (cancelled) return + setTraces([]) + setStatus("error") + }) + return () => { + cancelled = true + } + }, [projectId, filtering, limit]) + + const tableScope: TableScopeConfig = useMemo( + () => ({scopeId: "query-trace-preview", pageSize: limit, enableInfiniteScroll: false}), + [limit], + ) + + const pagination: TableFeaturePagination = useMemo( + () => ({ + rows: traces, + loadNextPage: () => undefined, + resetPages: () => undefined, + paginationInfo: { + hasMore: false, + nextCursor: null, + nextOffset: null, + isFetching: status === "loading", + totalCount: traces.length, + }, + }), + [traces, status], + ) + + if (status === "error") { + return ( + + ) + } + + return ( + + tableScope={tableScope} + columns={columns} + rowKey={(record) => record.span_id || record.key} + pagination={pagination} + resizableColumns + enableExport={false} + useSettingsDropdown={false} + store={store} + className="flex-1 min-h-0 [&_.ant-table-thead_tr:nth-child(2)]:hidden" + tableProps={{ + bordered: true, + loading: status === "loading", + sticky: true, + size: "small", + }} + /> + ) +} + +export default QueryTracePreview diff --git a/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx new file mode 100644 index 0000000000..3c83c5dc5c --- /dev/null +++ b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx @@ -0,0 +1,75 @@ +import type {ReactNode} from "react" +import {useMemo} from "react" + +import {InfiniteVirtualTableFeatureShell, useTableManager} from "@agenta/ui/table" + +import getFilterColumns from "@/oss/components/pages/observability/assets/getFilterColumns" + +import type {QueryRegistryStatus} from "../store/queryRegistryFilterAtoms" +import type {QueryRegistryRow} from "../store/queryRegistryStore" +import {getQueryRegistryTableState} from "../store/queryRegistryStore" + +import { + buildFieldLabelMap, + createQueryRegistryColumns, + type QueryColumnActions, +} from "./assets/queryRegistryColumns" + +interface QueryRegistryTableProps { + actions: QueryColumnActions + onRowClick?: (record: QueryRegistryRow) => void + filters?: ReactNode + primaryActions?: ReactNode + /** Rendered by the antd Table when there are no rows (post-load). */ + emptyState?: ReactNode + searchDeps?: unknown[] + /** Active vs archived view — selects the store and the restore-only actions. */ + mode?: QueryRegistryStatus +} + +const QueryRegistryTable = ({ + actions, + onRowClick, + filters, + primaryActions, + emptyState, + searchDeps = [], + mode = "active", +}: QueryRegistryTableProps) => { + const isArchived = mode === "archived" + const datasetStore = getQueryRegistryTableState(mode).store + const table = useTableManager({ + datasetStore: datasetStore as never, + scopeId: isArchived ? "query-registry-archived" : "query-registry", + pageSize: 50, + onRowClick, + searchDeps, + columnVisibilityStorageKey: isArchived + ? "agenta:query-registry-archived:column-visibility" + : "agenta:query-registry:column-visibility", + }) + + const fieldLabels = useMemo(() => buildFieldLabelMap(getFilterColumns()), []) + const columns = useMemo( + () => createQueryRegistryColumns(actions, fieldLabels, isArchived), + [actions, fieldLabels, isArchived], + ) + + return ( + + {...table.shellProps} + useSettingsDropdown + columns={columns} + filters={filters} + primaryActions={primaryActions} + className="flex-1 min-h-0" + autoHeight + tableProps={{ + ...table.shellProps.tableProps, + ...(emptyState ? {locale: {emptyText: emptyState}} : {}), + }} + /> + ) +} + +export default QueryRegistryTable diff --git a/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx new file mode 100644 index 0000000000..f004802360 --- /dev/null +++ b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx @@ -0,0 +1,281 @@ +import {UserAuthorLabel} from "@agenta/entities/shared/user" +import {SkeletonLine, createStandardColumns, formatDateCell} from "@agenta/ui/table" +import { + ArchiveIcon, + ArrowCounterClockwise, + CopySimple, + Eye, + PencilSimple, +} from "@phosphor-icons/react" +import {Popover, Tag, Typography} from "antd" + +import type {QueryRegistryRow} from "../../store/queryRegistryStore" + +const {Text} = Typography + +interface FilterLeaf { + field: string + key?: string + operator?: string + value?: unknown +} + +/** + * Flatten a (possibly nested AND/OR) filtering tree into its leaf conditions. + * Drives the filter-summary cell (design D1): the first chips render inline, the + * full set in a popover. + */ +export function flattenConditions(filtering: unknown): FilterLeaf[] { + if (!filtering || typeof filtering !== "object") return [] + const node = filtering as { + conditions?: unknown[] + field?: unknown + key?: unknown + operator?: unknown + value?: unknown + } + if (!Array.isArray(node.conditions)) { + if (typeof node.field !== "string") return [] + return [ + { + field: node.field, + key: typeof node.key === "string" ? node.key : undefined, + operator: typeof node.operator === "string" ? node.operator : undefined, + value: node.value, + }, + ] + } + return node.conditions.flatMap(flattenConditions) +} + +/** field → friendly label + value-option labels, derived from the Filters config. */ +export type FieldLabelMap = Map}> + +interface FilterColumnNode { + kind?: string + label?: string + displayLabel?: string + field?: string + value?: string + children?: FilterColumnNode[] + valueInput?: {options?: {label: string; value: unknown}[]} +} + +/** + * Walk the Filters menu tree and build a field → label map so the chips read + * "Trace Type is Invocation" instead of the raw "trace_type is invocation" + * (design D1: reuse the Filters component's labels). + */ +export function buildFieldLabelMap(nodes: readonly unknown[]): FieldLabelMap { + const map: FieldLabelMap = new Map() + const walk = (items?: readonly unknown[]) => { + for (const raw of items ?? []) { + const node = raw as FilterColumnNode + if (node.children?.length) { + walk(node.children) + continue + } + const field = node.field ?? node.value + if (!field) continue + const values = new Map() + for (const option of node.valueInput?.options ?? []) { + values.set(String(option.value), option.label) + } + map.set(field, {label: node.displayLabel ?? node.label ?? field, values}) + } + } + walk(nodes) + return map +} + +/** Compact, human label for one condition (e.g. `Trace Type is Invocation`). */ +function conditionLabel({field, key, operator, value}: FilterLeaf, labels?: FieldLabelMap): string { + const info = labels?.get(field) + const fieldLabel = info?.label ?? field + const lhs = key ? `${fieldLabel}.${key}` : fieldLabel + const op = operator ? ` ${operator}` : "" + let rhs = "" + if (value !== undefined && value !== null) { + const toLabel = (v: unknown) => info?.values.get(String(v)) ?? String(v) + rhs = Array.isArray(value) ? ` ${value.map(toLabel).join(", ")}` : ` ${toLabel(value)}` + } + return `${lhs}${op}${rhs}`.trim() +} + +export interface QueryColumnActions { + handleOpen?: (record: QueryRegistryRow) => void + handleEdit?: (record: QueryRegistryRow) => void + handleDuplicate?: (record: QueryRegistryRow) => void + handleArchive?: (record: QueryRegistryRow) => void + handleRestore?: (record: QueryRegistryRow) => void +} + +/** + * Row actions differ by tab: the Active tab edits/duplicates/archives; the + * Archived tab only restores (editing an archived query is meaningless — it would + * commit a revision on a soft-deleted artifact). + */ +function buildActionItems(actions: QueryColumnActions, isArchived: boolean) { + if (isArchived) { + return [ + { + key: "restore", + label: "Restore", + icon: , + onClick: (record: QueryRegistryRow) => actions.handleRestore?.(record), + }, + ] + } + return [ + { + key: "open", + label: "Open details", + icon: , + onClick: (record: QueryRegistryRow) => actions.handleOpen?.(record), + }, + { + key: "edit", + label: "Edit", + icon: , + onClick: (record: QueryRegistryRow) => actions.handleEdit?.(record), + }, + { + key: "duplicate", + label: "Duplicate", + icon: , + onClick: (record: QueryRegistryRow) => actions.handleDuplicate?.(record), + }, + {type: "divider" as const}, + { + key: "archive", + label: "Archive", + icon: , + danger: true, + onClick: (record: QueryRegistryRow) => actions.handleArchive?.(record), + }, + ] +} + +export function createQueryRegistryColumns( + actions: QueryColumnActions, + labels?: FieldLabelMap, + isArchived = false, +) { + return createStandardColumns([ + { + type: "text", + key: "name", + title: "Name", + width: 280, + fixed: "left", + columnVisibilityLocked: true, + render: (_value, record) => { + if (record.__isSkeleton) return + return ( +
+ {record.name} +
+ ) + }, + }, + { + type: "text", + key: "filter", + title: "Filter", + width: 260, + render: (_value, record) => { + if (record.__isSkeleton) return + const conditions = flattenConditions(record.filtering) + if (conditions.length === 0) { + return ( +
+ + No filter + +
+ ) + } + const shown = conditions.slice(0, 2) + const rest = conditions.length - shown.length + return ( + // Hover + focus + click so keyboard and touch users reach the + // full filter, not just mouse hover (design Pass 6 a11y). + + {conditions.map((condition, index) => ( + + {conditionLabel(condition, labels)} + + ))} +
+ } + > + + {shown.map((condition, index) => ( + + {conditionLabel(condition, labels)} + + ))} + {rest > 0 ? ( + + +{rest} more + + ) : null} + + + ) + }, + }, + { + type: "text", + key: "createdAt", + title: "Created on", + width: 160, + render: (_value, record) => { + if (record.__isSkeleton) return + return ( +
+ {formatDateCell(record.createdAt)} +
+ ) + }, + }, + { + type: "text", + key: "createdBy", + title: "Created by", + width: 180, + render: (_value, record) => { + if (record.__isSkeleton) return + if (!record.createdById) { + return ( +
+ + — + +
+ ) + } + return ( +
+ +
+ ) + }, + }, + { + type: "actions", + width: 48, + maxWidth: 48, + items: buildActionItems(actions, isArchived), + getRecordId: (record: QueryRegistryRow) => record.queryId, + }, + ]) +} diff --git a/web/oss/src/components/QueryRegistry/index.tsx b/web/oss/src/components/QueryRegistry/index.tsx new file mode 100644 index 0000000000..0c87cd2f8f --- /dev/null +++ b/web/oss/src/components/QueryRegistry/index.tsx @@ -0,0 +1,257 @@ +import {useCallback, useMemo, useState} from "react" + +import { + archiveSimpleQuery, + createSimpleQuery, + invalidateQueryCache, + unarchiveSimpleQuery, + type SimpleQueryCreate, +} from "@agenta/entities/query" +import {projectIdAtom} from "@agenta/shared/state" +import {PageLayout} from "@agenta/ui" +import {message} from "@agenta/ui/app-message" +import {TableEmptyState} from "@agenta/ui/components/presentational" +import {PlusOutlined} from "@ant-design/icons" +import {ArrowLeft, Tray} from "@phosphor-icons/react" +import {Button, Input, Space, Typography} from "antd" +import {useAtomValue, useSetAtom} from "jotai" +import {useRouter} from "next/router" + +import EnhancedModal from "@/oss/components/EnhancedUIs/Modal" +import useURL from "@/oss/hooks/useURL" + +import QueryRegistryDrawer from "./Drawer/QueryRegistryDrawer" +import {querySearchTermAtom, queryRegistryActiveRowAtom} from "./store/queryRegistryFilterAtoms" +import type {QueryRegistryStatus} from "./store/queryRegistryFilterAtoms" +import type {QueryRegistryRow} from "./store/queryRegistryStore" +import {invalidateQueryRegistryStore} from "./store/queryRegistryStore" +import type {QueryColumnActions} from "./Table/assets/queryRegistryColumns" +import QueryRegistryTable from "./Table/QueryRegistryTable" + +const {Text} = Typography + +const EMPTY_CREATE_ROW: QueryRegistryRow = { + key: "new", + queryId: "", + variantId: null, + revisionId: null, + name: "", + slug: null, + filtering: null, + windowing: null, + createdAt: null, + createdById: null, +} + +interface QueryRegistryProps { + /** Active vs archived view — driven by the route (`/queries` vs `/queries/archived`). */ + mode?: QueryRegistryStatus +} + +/** + * Project-scoped Query Registry — lists saved trace-filter queries (SimpleQuery + * rows with head-revision data inlined). Row click / "New query" open the manage + * drawer via the active-row atom; duplicate and archive are wired here. Archive + * uses a generic confirm because the backend exposes no reverse-reference lookup + * (design decision D6). The archive lives at its own route (`/queries/archived`), + * reached via the "Archived" header button — mirroring the Evaluators page. + */ +const QueryRegistry = ({mode = "active"}: QueryRegistryProps) => { + const projectId = useAtomValue(projectIdAtom) + const setSearchTerm = useSetAtom(querySearchTermAtom) + const setActiveRow = useSetAtom(queryRegistryActiveRowAtom) + const router = useRouter() + const {projectURL} = useURL() + const [search, setSearch] = useState("") + const [archiveTarget, setArchiveTarget] = useState(null) + const [archiving, setArchiving] = useState(false) + + const isArchived = mode === "archived" + + const handleSearch = useCallback( + (value: string) => { + setSearch(value) + setSearchTerm(value) + }, + [setSearchTerm], + ) + + const refresh = useCallback(() => { + invalidateQueryRegistryStore() + invalidateQueryCache() + }, []) + + const openDrawer = useCallback( + (record: QueryRegistryRow) => { + setActiveRow(record) + }, + [setActiveRow], + ) + + const handleNewQuery = useCallback(() => { + setActiveRow(EMPTY_CREATE_ROW) + }, [setActiveRow]) + + const handleDuplicate = useCallback( + async (record: QueryRegistryRow) => { + if (!projectId) return + try { + await createSimpleQuery({ + projectId, + query: { + name: `Copy of ${record.name}`, + ...(record.filtering + ? {data: {filtering: record.filtering} as SimpleQueryCreate["data"]} + : {}), + }, + }) + message.success("Query duplicated") + refresh() + } catch { + message.error("Could not duplicate query") + } + }, + [projectId, refresh], + ) + + const handleArchive = useCallback((record: QueryRegistryRow) => { + setArchiveTarget(record) + }, []) + + const confirmArchive = useCallback(async () => { + if (!projectId || !archiveTarget) return + setArchiving(true) + try { + await archiveSimpleQuery({projectId, queryId: archiveTarget.queryId}) + message.success("Query archived") + setArchiveTarget(null) + refresh() + } catch { + message.error("Could not archive query") + } finally { + setArchiving(false) + } + }, [projectId, archiveTarget, refresh]) + + const handleRestore = useCallback( + async (record: QueryRegistryRow) => { + if (!projectId) return + try { + await unarchiveSimpleQuery({projectId, queryId: record.queryId}) + message.success("Query restored") + refresh() + } catch { + message.error("Could not restore query") + } + }, + [projectId, refresh], + ) + + const actions: QueryColumnActions = useMemo( + () => ({ + handleOpen: openDrawer, + handleEdit: openDrawer, + handleDuplicate, + handleArchive, + handleRestore, + }), + [openDrawer, handleDuplicate, handleArchive, handleRestore], + ) + + // Archived view: swap the title for a back-arrow + "Archived Queries", exactly + // like the Evaluators archived route. + const title = isArchived ? ( + + + + + ) + + const emptyState = isArchived ? ( + + ) : ( + } onClick={handleNewQuery}> + New query + + } + /> + ) + + return ( + + + + setArchiveTarget(null)} + okButtonProps={{danger: true, loading: archiving}} + > + + This query may be in use by a live evaluation. Archived queries can be restored + later. + + + + ) +} + +export default QueryRegistry diff --git a/web/oss/src/components/QueryRegistry/store/queryRegistryFilterAtoms.ts b/web/oss/src/components/QueryRegistry/store/queryRegistryFilterAtoms.ts new file mode 100644 index 0000000000..7812be9008 --- /dev/null +++ b/web/oss/src/components/QueryRegistry/store/queryRegistryFilterAtoms.ts @@ -0,0 +1,21 @@ +import {atom} from "jotai" + +import type {QueryRegistryRow} from "./queryRegistryStore" + +/** Search term for the Query Registry list (client-side filter on the loaded page). */ +export const querySearchTermAtom = atom("") + +/** + * Active vs archived view. Driven by the route (`/queries` vs `/queries/archived`) + * and passed as a `mode` prop — mirrors the Evaluators archived-route pattern — + * so it lives as a type, not a shared atom. + */ +export type QueryRegistryStatus = "active" | "archived" + +/** + * The query the manage drawer is editing, or `null` when closed. A row with an + * empty `queryId` means "create a new query". Set by the registry dashboard, read + * by the drawer — avoids re-fetching, since the SimpleQuery row already carries + * the head filtering. + */ +export const queryRegistryActiveRowAtom = atom(null) diff --git a/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts b/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts new file mode 100644 index 0000000000..4037685d35 --- /dev/null +++ b/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts @@ -0,0 +1,182 @@ +/** + * Query Registry Paginated Store + * + * Project-scoped list of SimpleQueries (flattened query + head-revision data). + * Each row carries its filtering, variant_id, and revision_id inline, so the + * table needs no per-row revision fetch — the head data comes from the list. + * + * Pagination: SimpleQueriesResponse does not echo a windowing cursor, so we use + * keyset-by-id (descending) — the next cursor is the last row's id when the page + * is full. TODO(verify): confirm the backend honors `windowing.next` for the + * simple-queries list against a project with > limit queries. + */ + +import {querySimpleQueries, type SimpleQuery} from "@agenta/entities/query" +import {createPaginatedEntityStore} from "@agenta/entities/shared" +import type {InfiniteTableFetchResult} from "@agenta/entities/shared" +import {projectIdAtom} from "@agenta/shared/state" +import {atom} from "jotai" + +import {emptyFetchResult} from "@/oss/state/entities/shared" + +import {querySearchTermAtom, type QueryRegistryStatus} from "./queryRegistryFilterAtoms" + +// ============================================================================ +// TABLE ROW TYPE +// ============================================================================ + +export interface QueryRegistryRow { + key: string + __isSkeleton?: boolean + queryId: string + variantId: string | null + revisionId: string | null + name: string + slug: string | null + /** Head revision's filtering tree — source for the filter-summary cell. */ + filtering: unknown + /** Head revision's windowing (sampling rate / time bounds) — preserved on edit. */ + windowing: unknown + createdAt: string | null + createdById: string | null + [k: string]: unknown +} + +// ============================================================================ +// QUERY META +// ============================================================================ + +interface QueryRegistryMeta { + projectId: string | null + searchTerm?: string + status: QueryRegistryStatus +} + +// One meta atom per mode — `status` is baked in rather than read from a shared +// atom, so the active and archived routes drive independent stores (mirrors the +// Evaluators `getEvaluatorsTableState(mode)` factory and avoids a stale first +// fetch when landing directly on the archived route). +const queryRegistryMetaAtomByStatus = (status: QueryRegistryStatus) => + atom((get) => ({ + projectId: get(projectIdAtom), + searchTerm: get(querySearchTermAtom) || undefined, + status, + })) + +const skeletonDefaults: Partial = { + queryId: "", + variantId: null, + revisionId: null, + name: "", + slug: null, + filtering: null, + windowing: null, + createdAt: null, + createdById: null, + key: "", +} + +const toRow = (query: SimpleQuery): QueryRegistryRow => ({ + key: query.id ?? query.revision_id ?? "", + queryId: query.id ?? "", + variantId: query.variant_id ?? null, + revisionId: query.revision_id ?? null, + name: query.name ?? query.slug ?? query.id ?? "Untitled query", + slug: query.slug ?? null, + filtering: query.data?.filtering ?? null, + windowing: query.data?.windowing ?? null, + createdAt: query.created_at ?? null, + createdById: query.created_by_id ?? null, +}) + +// ============================================================================ +// PAGINATED STORE (per-mode factory) +// ============================================================================ + +const createQueryRegistryStore = (status: QueryRegistryStatus) => + createPaginatedEntityStore({ + entityName: status === "archived" ? "query-registry-archived" : "query-registry", + metaAtom: queryRegistryMetaAtomByStatus(status), + fetchPage: async ({ + meta, + limit, + cursor, + }: { + meta: QueryRegistryMeta + limit?: number + cursor?: string | null + }): Promise> => { + if (!meta.projectId) { + return emptyFetchResult() + } + + const isArchived = meta.status === "archived" + + const response = await querySimpleQueries({ + projectId: meta.projectId, + includeArchived: isArchived, + windowing: { + next: cursor ?? undefined, + limit: limit ?? undefined, + order: "descending", + }, + }) + + const raw = response.queries ?? [] + let queries = raw + + // `include_archived` returns active + archived; split by the soft-delete + // marker so each tab shows only its own rows. + queries = queries.filter((query) => + isArchived ? Boolean(query.deleted_at) : !query.deleted_at, + ) + + // SimpleQueryQuery has no name filter — search is client-side on the page. + if (meta.searchTerm) { + const term = meta.searchTerm.toLowerCase() + queries = queries.filter((query) => + (query.name ?? query.slug ?? "").toLowerCase().includes(term), + ) + } + + // Keyset cursor walks the full backend list, so derive it from the raw + // (pre-filter) page — otherwise client-side filtering would skip rows. + const full = typeof limit === "number" && raw.length >= limit + const lastId = raw.length > 0 ? (raw[raw.length - 1]?.id ?? null) : null + + return { + rows: queries, + totalCount: response.count ?? null, + hasMore: full && Boolean(lastId), + nextCursor: full ? lastId : null, + nextOffset: null, + nextWindowing: null, + } + }, + rowConfig: { + getRowId: (row) => row.id ?? row.revision_id ?? "", + skeletonDefaults, + }, + transformRow: toRow, + isEnabled: (meta) => Boolean(meta?.projectId), + listCountsConfig: { + totalCountMode: "unknown", + }, + }) + +// Lazily-built, cached store per mode. The table reads the store for its current +// mode; both are invalidated together after a create/edit/archive/restore. +const _stores = new Map>() + +export function getQueryRegistryTableState(status: QueryRegistryStatus) { + let store = _stores.get(status) + if (!store) { + store = createQueryRegistryStore(status) + _stores.set(status, store) + } + return store +} + +export function invalidateQueryRegistryStore() { + _stores.forEach((store) => store.invalidate()) +} diff --git a/web/oss/src/components/Sidebar/hooks/useSidebarConfig/index.tsx b/web/oss/src/components/Sidebar/hooks/useSidebarConfig/index.tsx index eb467d00f8..ccb7326095 100644 --- a/web/oss/src/components/Sidebar/hooks/useSidebarConfig/index.tsx +++ b/web/oss/src/components/Sidebar/hooks/useSidebarConfig/index.tsx @@ -4,6 +4,7 @@ import { DatabaseIcon, DesktopIcon, FlaskIcon, + FunnelIcon, PaperPlaneIcon, PhoneIcon, QuestionIcon, @@ -106,6 +107,13 @@ export const useSidebarConfig = () => { icon: , disabled: !hasProjectURL, }, + { + key: "project-queries-link", + title: "Queries", + link: `${projectURL}/queries`, + icon: , + disabled: !hasProjectURL, + }, { key: "overview-link", title: "Overview", diff --git a/web/oss/src/components/pages/evaluations/onlineEvaluation/OnlineEvaluationDrawer.tsx b/web/oss/src/components/pages/evaluations/onlineEvaluation/OnlineEvaluationDrawer.tsx index 10905313fc..fcb359c964 100644 --- a/web/oss/src/components/pages/evaluations/onlineEvaluation/OnlineEvaluationDrawer.tsx +++ b/web/oss/src/components/pages/evaluations/onlineEvaluation/OnlineEvaluationDrawer.tsx @@ -1,6 +1,11 @@ import {useCallback, useEffect, useMemo, useState} from "react" import type {ReactNode} from "react" +import { + createSimpleQuery as createQueryEntity, + invalidateQueryCache, + type SimpleQueryCreate, +} from "@agenta/entities/query" import { evaluatorConfigRevisionsListDataAtom, evaluatorConfigRevisionsQueryStateAtom, @@ -9,23 +14,19 @@ import { isOnlineCapableEvaluator, } from "@agenta/entities/workflow" import {message} from "@agenta/ui/app-message" -import {Button, Collapse, DatePicker, Form, Input, Select, Switch, Tooltip, Typography} from "antd" +import {Button, Collapse, Form, Input, Select, Tooltip, Typography} from "antd" import dayjs from "dayjs" import type {Dayjs} from "dayjs" import {useAtom, useAtomValue, useSetAtom} from "jotai" import {queryClientAtom} from "jotai-tanstack-query" -import dynamic from "next/dynamic" import {useRouter} from "next/router" import {v4 as uuidv4} from "uuid" import EnhancedDrawer from "@/oss/components/EnhancedUIs/Drawer" import getFilterColumns from "@/oss/components/pages/observability/assets/getFilterColumns" -import type {Filter} from "@/oss/lib/Types" import { createSimpleEvaluation, - createSimpleQuery, - retrieveQueryRevision, type QueryRevisionDataPayload, type SimpleEvaluationCreatePayload, type SimpleQueryCreatePayload, @@ -40,7 +41,7 @@ import { import {onlineEvalFiltersAtom, resetOnlineEvalFiltersAtom} from "./assets/state" import EvaluatorDetailsPreview from "./components/EvaluatorDetailsPreview" import EvaluatorTypeTag from "./components/EvaluatorTypeTag" -import SamplingRateControl from "./components/SamplingRateControl" +import QueryEditor from "./components/QueryEditor" import {useEvaluatorDetails} from "./hooks/useEvaluatorDetails" import {useEvaluatorSelection} from "./hooks/useEvaluatorSelection" import {useEvaluatorTypeFromConfigs} from "./hooks/useEvaluatorTypeFromConfigs" @@ -53,8 +54,6 @@ interface OnlineEvaluationDrawerProps { } const {Text, Link: TypographyLink} = Typography -const {RangePicker} = DatePicker -const Filters = dynamic(() => import("@/oss/components/Filters/Filters"), {ssr: false}) const collapseClass = "[&_.ant-collapse-item]:!border-none [&_.ant-collapse-item]:!rounded-[10px] [&_.ant-collapse-item]:overflow-hidden [&_.ant-collapse-item]:bg-colorBgContainer [&_.ant-collapse-item]:shadow-[0_1px_2px_rgba(15,23,42,0.06)] [&_.ant-collapse-item+.ant-collapse-item]:mt-2 [&_.ant-collapse-header]:!bg-[var(--ag-c-FAFAFB)] [&_.ant-collapse-header]:!border-b [&_.ant-collapse-header]:!border-solid [&_.ant-collapse-header]:!border-[var(--ag-colorSplit)] [&_.ant-collapse-header]:!p-[12px_16px] [&_.ant-collapse-content]:!border-t-0 [&_.ant-collapse-content]:!rounded-[0_0_10px_10px] [&_.ant-collapse-content>.ant-collapse-content-box]:!p-4" @@ -309,19 +308,27 @@ const OnlineEvaluationDrawer = ({open, onClose, onCreate}: OnlineEvaluationDrawe queryPayload.data = queryData } - const queryResponse = await createSimpleQuery({query: queryPayload}) - const queryId = queryResponse.query?.id - if (!queryId) { - throw new Error("Unable to create query for online evaluation.") + if (!projectId) { + throw new Error("Missing project for online evaluation.") } - const revisionResponse = await retrieveQueryRevision({ - query_ref: {id: queryId}, + // Single create path: the entity mutation creates the query and + // resolves its head revision, then fires invalidateQueryCache so the + // new query surfaces in the Query Registry. `data` is cast because the + // live-eval helpers still emit the pre-Fern payload shape (Phase 2 + // migrates toFilteringPayload/toWindowingPayload to Fern types). + const {revisionId: queryRevisionId} = await createQueryEntity({ + projectId, + query: { + slug: querySlug, + name: values.name, + description: values.description, + ...(queryPayload.data + ? {data: queryPayload.data as SimpleQueryCreate["data"]} + : {}), + }, }) - const queryRevisionId = revisionResponse.query_revision?.id - if (!queryRevisionId) { - throw new Error("Unable to resolve query revision for online evaluation.") - } + invalidateQueryCache() const evaluatorRevisionStepId = selectedEvaluatorRevisionId ?? @@ -524,60 +531,11 @@ const OnlineEvaluationDrawer = ({open, onClose, onCreate}: OnlineEvaluationDrawe label: buildPanelHeader("Query", querySummary), style: {marginBottom: 4}, children: ( - <> -
- - - setFilters(newFilters) - } - onClearFilter={(newFilters: Filter[]) => - setFilters(newFilters) - } - buttonProps={{ - size: "middle", - className: "!flex !items-center !gap-2", - }} - /> - - - - -
- -
-
- - - - - Run on historical data - -
- - - -
- + ), }, { diff --git a/web/oss/src/components/pages/evaluations/onlineEvaluation/components/QueryEditor.tsx b/web/oss/src/components/pages/evaluations/onlineEvaluation/components/QueryEditor.tsx new file mode 100644 index 0000000000..38eb81fa68 --- /dev/null +++ b/web/oss/src/components/pages/evaluations/onlineEvaluation/components/QueryEditor.tsx @@ -0,0 +1,94 @@ +import {DatePicker, Form, Switch, Tooltip, Typography} from "antd" +import dynamic from "next/dynamic" + +import getFilterColumns from "@/oss/components/pages/observability/assets/getFilterColumns" +import type {Filter} from "@/oss/lib/Types" + +import SamplingRateControl from "./SamplingRateControl" + +const Filters = dynamic(() => import("@/oss/components/Filters/Filters"), {ssr: false}) +const {Text} = Typography +const {RangePicker} = DatePicker + +export interface QueryEditorProps { + /** Current trace filter conditions. */ + filters: Filter[] + /** Called when the user applies or clears filters. */ + onFiltersChange: (filters: Filter[]) => void + /** Field menu for the filter builder (from `getFilterColumns`). */ + filterColumns: ReturnType + /** + * Render the filter editor inline (always-visible rows) instead of behind the + * funnel button. Used by the Query Registry drawer where editing the filter is + * the primary task; the live-eval drawer keeps the compact button. + */ + inlineFilters?: boolean +} + +/** + * Reusable query editor: trace filter + sampling rate + (coming-soon) historical + * window. Renders inside an antd Form context — `sampling_rate`, `historical`, + * and `historical_range` are form fields, so the parent form collects them + * unchanged. Shared by the live-eval Online Evaluation drawer and the Query + * Registry manage drawer. + */ +const QueryEditor = ({ + filters, + onFiltersChange, + filterColumns, + inlineFilters = false, +}: QueryEditorProps) => { + return ( + <> +
+ + onFiltersChange(newFilters)} + onClearFilter={(newFilters: Filter[]) => onFiltersChange(newFilters)} + buttonProps={{ + size: "middle", + className: "!flex !items-center !gap-2", + }} + /> + + + + +
+ +
+
+ + + + + Run on historical data + +
+ + + +
+ + ) +} + +export default QueryEditor diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/archived/index.tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/archived/index.tsx new file mode 100644 index 0000000000..3a88822b51 --- /dev/null +++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/archived/index.tsx @@ -0,0 +1 @@ +export {default} from "@/oss/components/QueryRegistry/ArchivedQueriesPage" diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/index.tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/index.tsx new file mode 100644 index 0000000000..7da68f2c57 --- /dev/null +++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/index.tsx @@ -0,0 +1,7 @@ +import QueryRegistry from "@/oss/components/QueryRegistry" + +const QueriesPage = () => { + return +} + +export default QueriesPage diff --git a/web/packages/agenta-entities/package.json b/web/packages/agenta-entities/package.json index 44f4814aa5..60d5e34a1f 100644 --- a/web/packages/agenta-entities/package.json +++ b/web/packages/agenta-entities/package.json @@ -46,6 +46,7 @@ "./trace/etl": "./src/trace/etl/index.ts", "./testset": "./src/testset/index.ts", "./testcase": "./src/testcase/index.ts", + "./query": "./src/query/index.ts", "./event": "./src/event/index.ts", "./event/state": "./src/event/state/index.ts", "./secret": "./src/secret/index.ts", diff --git a/web/packages/agenta-entities/src/query/api/api.ts b/web/packages/agenta-entities/src/query/api/api.ts new file mode 100644 index 0000000000..12d9ec67ef --- /dev/null +++ b/web/packages/agenta-entities/src/query/api/api.ts @@ -0,0 +1,170 @@ +/** + * Query API functions (Fern-backed, pure — no Jotai). + * + * Queries are project-scoped; every call passes project_id via queryParams. + */ + +import {getAgentaSdkClient} from "@agenta/sdk" +import {getAgentaApiUrl} from "@agenta/shared/api" +import type {AgentaApi} from "@agentaai/api-client" + +import {transformTracesResponseToTree, type TraceSpanNode} from "../../trace" + +export interface RetrieveQueryRevisionParams { + projectId: string + queryRef?: AgentaApi.Reference + queryVariantRef?: AgentaApi.Reference + queryRevisionRef?: AgentaApi.Reference + /** Execute the filter and return matching trace ids (used by the drawer match-count). */ + includeTraceIds?: boolean +} + +export interface CountMatchingTracesParams { + projectId: string + /** The in-progress query filtering (structurally the tracing FilteringInput). */ + filtering?: unknown + abortSignal?: AbortSignal +} + +/** + * Count traces matching a filter, for the drawer's live match-count (design D3). + * Executes the filter against the trace store with `limit: 1` and reads the + * window count — no trace payloads are materialized. + */ +export async function countMatchingTraces({ + projectId, + filtering, + abortSignal, +}: CountMatchingTracesParams): Promise { + // Match the trace entity's accessor (getTracesClient = getAgentaSdkClient() + // with no host override). Passing `{host: getAgentaApiUrl()}` here points the + // traces resource at a cross-origin host that rejects the session cookie (401). + const client = getAgentaSdkClient() + const response = await client.traces.queryTraces( + { + ...(filtering ? {filtering: filtering as AgentaApi.FilteringInput} : {}), + windowing: {limit: 1}, + }, + {queryParams: {project_id: projectId}, abortSignal}, + ) + return response.count ?? null +} + +export interface QueryMatchingTracesParams { + projectId: string + filtering?: unknown + limit?: number + abortSignal?: AbortSignal +} + +/** + * Fetch the traces matching a filter, as a tree, for the drawer's "matching + * traces" preview. Reuses the trace resource (`queryTraces`) + the shared + * `transformTracesResponseToTree` adapter so the observability columns render it. + */ +export async function queryMatchingTraces({ + projectId, + filtering, + limit = 50, +}: QueryMatchingTracesParams): Promise { + const client = getAgentaSdkClient() + const response = await client.traces.queryTraces( + { + ...(filtering ? {filtering: filtering as AgentaApi.FilteringInput} : {}), + windowing: {limit, order: "descending"}, + }, + {queryParams: {project_id: projectId}}, + ) + return transformTracesResponseToTree(response as never) +} + +export interface QuerySimpleQueriesParams { + projectId: string + windowing?: AgentaApi.Windowing + /** + * Include soft-deleted (archived) queries. The backend returns both active and + * archived rows when set; the Archived tab filters to `deleted_at != null`. + */ + includeArchived?: boolean +} + +/** + * List project-scoped SimpleQueries (flattened query + head-revision data), + * cursor-paginated via windowing. Each row carries `data` (filtering/windowing), + * `variant_id`, and `revision_id` inline — no per-row revision fetch needed. + */ +export async function querySimpleQueries({ + projectId, + windowing, + includeArchived, +}: QuerySimpleQueriesParams): Promise { + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + return await client.queries.querySimpleQueries( + { + ...(windowing ? {windowing} : {}), + ...(includeArchived ? {include_archived: true} : {}), + }, + {queryParams: {project_id: projectId}}, + ) +} + +export interface QueryRevisionSummary { + revisionId: string + version: string | null + filtering: unknown + createdAt: string | null + createdById: string | null + message: string | null +} + +export interface QueryRevisionsByVariantParams { + projectId: string + variantId: string +} + +/** + * List the revision history of one query variant (newest first), for the + * registry's lazy expand-on-click. Returns a flat summary per revision. + */ +export async function queryQueryRevisions({ + projectId, + variantId, +}: QueryRevisionsByVariantParams): Promise { + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + const response = await client.queries.queryQueryRevisions( + { + query_variant_refs: [{id: variantId}], + windowing: {limit: 100, order: "descending"}, + }, + {queryParams: {project_id: projectId}}, + ) + return (response.query_revisions ?? []).map((revision) => ({ + revisionId: revision.id ?? "", + version: revision.version ?? null, + filtering: revision.data?.filtering ?? null, + createdAt: revision.created_at ?? null, + createdById: revision.created_by_id ?? null, + message: revision.message ?? null, + })) +} + +/** + * Retrieve a query revision (latest by default). When no variant/revision ref is + * given, returns the head revision of the query artifact. + */ +export async function retrieveQueryRevision( + params: RetrieveQueryRevisionParams, +): Promise { + const {projectId, queryRef, queryVariantRef, queryRevisionRef, includeTraceIds} = params + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + const response = await client.queries.retrieveQueryRevision( + { + ...(queryRef ? {query_ref: queryRef} : {}), + ...(queryVariantRef ? {query_variant_ref: queryVariantRef} : {}), + ...(queryRevisionRef ? {query_revision_ref: queryRevisionRef} : {}), + ...(includeTraceIds ? {include_trace_ids: true} : {}), + }, + {queryParams: {project_id: projectId}}, + ) + return response.query_revision ?? null +} diff --git a/web/packages/agenta-entities/src/query/api/index.ts b/web/packages/agenta-entities/src/query/api/index.ts new file mode 100644 index 0000000000..aa10d25d59 --- /dev/null +++ b/web/packages/agenta-entities/src/query/api/index.ts @@ -0,0 +1,22 @@ +export { + retrieveQueryRevision, + type RetrieveQueryRevisionParams, + querySimpleQueries, + type QuerySimpleQueriesParams, + countMatchingTraces, + type CountMatchingTracesParams, + queryMatchingTraces, + type QueryMatchingTracesParams, + queryQueryRevisions, + type QueryRevisionSummary, + type QueryRevisionsByVariantParams, +} from "./api" +export { + createSimpleQuery, + editSimpleQuery, + type EditSimpleQueryParams, + archiveSimpleQuery, + type ArchiveSimpleQueryParams, + unarchiveSimpleQuery, + type UnarchiveSimpleQueryParams, +} from "./mutations" diff --git a/web/packages/agenta-entities/src/query/api/mutations.ts b/web/packages/agenta-entities/src/query/api/mutations.ts new file mode 100644 index 0000000000..af858f46b7 --- /dev/null +++ b/web/packages/agenta-entities/src/query/api/mutations.ts @@ -0,0 +1,116 @@ +/** + * Query mutations (Fern-backed, pure — no Jotai). + * + * `createSimpleQuery` is the single create path. The live-eval drawer is + * repointed at this (T1) so a query created during live-eval setup shares the + * entity cache and shows up in the Query Registry (callers fire + * `invalidateQueryCache` after a successful create). + */ + +import {getAgentaSdkClient} from "@agenta/sdk" +import {getAgentaApiUrl} from "@agenta/shared/api" +import {v4 as uuidv4} from "uuid" + +import type {CreateSimpleQueryParams, CreateSimpleQueryResult, SimpleQueryEdit} from "../core/types" + +import {retrieveQueryRevision} from "./api" + +/** + * The backend requires a non-null, project-unique artifact slug — without it + * `create_query` returns None and the create silently no-ops ({count: 0}). Derive + * a stable slug from the name plus a short random suffix. + */ +function makeQuerySlug(name?: string | null): string { + const base = (name ?? "query") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40) + return `${base || "query"}-${uuidv4().slice(0, 8)}` +} + +export async function createSimpleQuery({ + projectId, + query, +}: CreateSimpleQueryParams): Promise { + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + const response = await client.queries.createSimpleQuery( + {query: {...query, slug: query.slug || makeQuerySlug(query.name)}}, + {queryParams: {project_id: projectId}}, + ) + + const created = response.query + if (!created?.id) { + throw new Error("Unable to create query.") + } + + const variantId = created.variant_id ?? null + let revisionId = created.revision_id ?? null + + // Fallback preserves the live-eval drawer's original two-call behavior when + // the create response omits the head revision id. + if (!revisionId) { + const revision = await retrieveQueryRevision({projectId, queryRef: {id: created.id}}) + revisionId = revision?.id ?? null + } + + if (!revisionId) { + throw new Error("Unable to resolve query revision after create.") + } + + return {queryId: created.id, variantId, revisionId} +} + +export interface EditSimpleQueryParams { + projectId: string + queryId: string + query: SimpleQueryEdit +} + +/** Edit a query — commits a new head revision with the updated name/filter/window. */ +export async function editSimpleQuery({ + projectId, + queryId, + query, +}: EditSimpleQueryParams): Promise { + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + await client.queries.editSimpleQuery( + {query_id: queryId, query}, + {queryParams: {project_id: projectId}}, + ) +} + +export interface ArchiveSimpleQueryParams { + projectId: string + queryId: string +} + +/** Archive (soft-delete) a query. Reversible via unarchive; safe-archive confirm + * lives in the UI since the backend exposes no reverse-reference lookup. */ +export async function archiveSimpleQuery({ + projectId, + queryId, +}: ArchiveSimpleQueryParams): Promise { + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + await client.queries.archiveSimpleQuery( + {query_id: queryId}, + {queryParams: {project_id: projectId}}, + ) +} + +export interface UnarchiveSimpleQueryParams { + projectId: string + queryId: string +} + +/** Restore a previously archived query (clears the soft-delete marker). */ +export async function unarchiveSimpleQuery({ + projectId, + queryId, +}: UnarchiveSimpleQueryParams): Promise { + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + await client.queries.unarchiveSimpleQuery( + {query_id: queryId}, + {queryParams: {project_id: projectId}}, + ) +} diff --git a/web/packages/agenta-entities/src/query/core/index.ts b/web/packages/agenta-entities/src/query/core/index.ts new file mode 100644 index 0000000000..682b40b1e1 --- /dev/null +++ b/web/packages/agenta-entities/src/query/core/index.ts @@ -0,0 +1,9 @@ +export type { + SimpleQueryCreate, + SimpleQueryEdit, + QueryRevisionDataInput, + SimpleQuery, + QueryRevision, + CreateSimpleQueryParams, + CreateSimpleQueryResult, +} from "./types" diff --git a/web/packages/agenta-entities/src/query/core/types.ts b/web/packages/agenta-entities/src/query/core/types.ts new file mode 100644 index 0000000000..c25cb0f0ec --- /dev/null +++ b/web/packages/agenta-entities/src/query/core/types.ts @@ -0,0 +1,31 @@ +/** + * Query entity — core types. + * + * Queries are project-scoped, git-style saved trace filters + * (Query artifact → QueryVariant → QueryRevision). QueryRevisionData holds the + * filtering + windowing that a live evaluation uses to match traces. + * + * T1 (create-slice) only needs the create/retrieve shapes. The full read-path + * schemas (SimpleQuery list rows, filtering round-trip) land in Phase 2. + */ + +import type {AgentaApi} from "@agentaai/api-client" + +export type SimpleQueryCreate = AgentaApi.SimpleQueryCreate +export type SimpleQueryEdit = AgentaApi.SimpleQueryEdit +export type QueryRevisionDataInput = AgentaApi.QueryRevisionDataInput +export type SimpleQuery = AgentaApi.SimpleQuery +export type QueryRevision = AgentaApi.QueryRevision + +/** Payload for creating a SimpleQuery (name + slug + filtering/windowing data). */ +export interface CreateSimpleQueryParams { + projectId: string + query: SimpleQueryCreate +} + +/** Result of creating a SimpleQuery: the artifact id plus its head variant/revision. */ +export interface CreateSimpleQueryResult { + queryId: string + variantId: string | null + revisionId: string +} diff --git a/web/packages/agenta-entities/src/query/index.ts b/web/packages/agenta-entities/src/query/index.ts new file mode 100644 index 0000000000..3e4fd9dc05 --- /dev/null +++ b/web/packages/agenta-entities/src/query/index.ts @@ -0,0 +1,40 @@ +/** + * @agenta/entities/query — project-scoped saved trace filters. + * + * T1 ships the create-slice (used to repoint the live-eval drawer at a single + * create path). Phase 2 adds the list/detail atoms, paginated store, molecule, + * and filtering round-trip schemas for the Query Registry page. + */ + +export { + createSimpleQuery, + editSimpleQuery, + type EditSimpleQueryParams, + archiveSimpleQuery, + type ArchiveSimpleQueryParams, + unarchiveSimpleQuery, + type UnarchiveSimpleQueryParams, + retrieveQueryRevision, + type RetrieveQueryRevisionParams, + querySimpleQueries, + type QuerySimpleQueriesParams, + countMatchingTraces, + type CountMatchingTracesParams, + queryMatchingTraces, + type QueryMatchingTracesParams, + queryQueryRevisions, + type QueryRevisionSummary, + type QueryRevisionsByVariantParams, +} from "./api" + +export {invalidateQueryCache, QUERY_LIST_KEY, QUERY_DETAIL_KEY} from "./state" + +export type { + SimpleQueryCreate, + SimpleQueryEdit, + QueryRevisionDataInput, + SimpleQuery, + QueryRevision, + CreateSimpleQueryParams, + CreateSimpleQueryResult, +} from "./core" diff --git a/web/packages/agenta-entities/src/query/state/index.ts b/web/packages/agenta-entities/src/query/state/index.ts new file mode 100644 index 0000000000..6de4b9a9e5 --- /dev/null +++ b/web/packages/agenta-entities/src/query/state/index.ts @@ -0,0 +1 @@ +export {invalidateQueryCache, QUERY_LIST_KEY, QUERY_DETAIL_KEY} from "./store" diff --git a/web/packages/agenta-entities/src/query/state/store.ts b/web/packages/agenta-entities/src/query/state/store.ts new file mode 100644 index 0000000000..94ec59ce84 --- /dev/null +++ b/web/packages/agenta-entities/src/query/state/store.ts @@ -0,0 +1,23 @@ +/** + * Query entity — cache invalidation. + * + * T1 only needs invalidation (so a live-eval-created query refreshes the + * registry). The list/detail query atoms land in Phase 2 and will reuse these + * keys. + */ + +import {getDefaultStore} from "jotai" +import {queryClientAtom} from "jotai-tanstack-query" + +/** TanStack Query key prefix for the project-scoped SimpleQuery list. */ +export const QUERY_LIST_KEY = "queries-list" +/** TanStack Query key prefix for a single query / its revisions. */ +export const QUERY_DETAIL_KEY = "query" + +/** Invalidate the query list + detail caches after a create/commit/archive. */ +export function invalidateQueryCache(): void { + const store = getDefaultStore() + const queryClient = store.get(queryClientAtom) + queryClient.invalidateQueries({queryKey: [QUERY_LIST_KEY]}) + queryClient.invalidateQueries({queryKey: [QUERY_DETAIL_KEY]}) +} diff --git a/web/packages/agenta-entities/tests/unit/query/archiveTabs.test.ts b/web/packages/agenta-entities/tests/unit/query/archiveTabs.test.ts new file mode 100644 index 0000000000..e596d9cfa0 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/query/archiveTabs.test.ts @@ -0,0 +1,65 @@ +import {describe, it, expect, vi, beforeEach} from "vitest" + +// Regression guard for the Active/Archived tabs: the Archived tab depends on the +// list passing `include_archived` and on the unarchive (restore) mutation sending +// the right request shape. + +const querySimpleQueriesMock = vi.fn() +const unarchiveSimpleQueryMock = vi.fn() + +vi.mock("@agenta/sdk", () => ({ + getAgentaSdkClient: () => ({ + queries: { + querySimpleQueries: querySimpleQueriesMock, + unarchiveSimpleQuery: unarchiveSimpleQueryMock, + }, + }), +})) + +vi.mock("@agenta/shared/api", () => ({ + getAgentaApiUrl: () => "http://test", +})) + +import {querySimpleQueries} from "../../../src/query/api/api" +import {unarchiveSimpleQuery} from "../../../src/query/api/mutations" + +describe("query archived-tab contracts", () => { + beforeEach(() => { + querySimpleQueriesMock.mockReset() + unarchiveSimpleQueryMock.mockReset() + }) + + it("omits include_archived for the active list", async () => { + querySimpleQueriesMock.mockResolvedValue({queries: [], count: 0}) + + await querySimpleQueries({projectId: "p1"}) + + expect(querySimpleQueriesMock).toHaveBeenCalledWith({}, {queryParams: {project_id: "p1"}}) + }) + + it("sends include_archived when archived rows are requested", async () => { + querySimpleQueriesMock.mockResolvedValue({queries: [], count: 0}) + + await querySimpleQueries({ + projectId: "p1", + includeArchived: true, + windowing: {limit: 50, order: "descending"}, + }) + + expect(querySimpleQueriesMock).toHaveBeenCalledWith( + {include_archived: true, windowing: {limit: 50, order: "descending"}}, + {queryParams: {project_id: "p1"}}, + ) + }) + + it("restores a query via unarchive with the project_id queryParam", async () => { + unarchiveSimpleQueryMock.mockResolvedValue(undefined) + + await unarchiveSimpleQuery({projectId: "p1", queryId: "q9"}) + + expect(unarchiveSimpleQueryMock).toHaveBeenCalledWith( + {query_id: "q9"}, + {queryParams: {project_id: "p1"}}, + ) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/query/createSimpleQuery.test.ts b/web/packages/agenta-entities/tests/unit/query/createSimpleQuery.test.ts new file mode 100644 index 0000000000..efefa795a9 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/query/createSimpleQuery.test.ts @@ -0,0 +1,75 @@ +import {describe, it, expect, vi, beforeEach} from "vitest" + +// Regression guard for the live-eval create-path repoint (T1): the Online +// Evaluation drawer now creates queries through this mutation, so its request +// shape and revision resolution must stay stable. + +const createSimpleQueryMock = vi.fn() +const retrieveQueryRevisionMock = vi.fn() + +vi.mock("@agenta/sdk", () => ({ + getAgentaSdkClient: () => ({ + queries: { + createSimpleQuery: createSimpleQueryMock, + retrieveQueryRevision: retrieveQueryRevisionMock, + }, + }), +})) + +vi.mock("@agenta/shared/api", () => ({ + getAgentaApiUrl: () => "http://test", +})) + +import {createSimpleQuery} from "../../../src/query/api/mutations" + +describe("createSimpleQuery (live-eval repoint regression)", () => { + beforeEach(() => { + createSimpleQueryMock.mockReset() + retrieveQueryRevisionMock.mockReset() + }) + + it("sends {query} with the project_id queryParam and returns ids from the create response", async () => { + createSimpleQueryMock.mockResolvedValue({ + query: {id: "q1", variant_id: "v1", revision_id: "r1"}, + }) + + const query = {name: "n", slug: "s", data: {filtering: {conditions: []}}} + const result = await createSimpleQuery({projectId: "p1", query}) + + expect(createSimpleQueryMock).toHaveBeenCalledWith( + {query}, + {queryParams: {project_id: "p1"}}, + ) + // Head revision came inlined on the create response — no extra round-trip. + expect(retrieveQueryRevisionMock).not.toHaveBeenCalled() + expect(result).toEqual({queryId: "q1", variantId: "v1", revisionId: "r1"}) + }) + + it("falls back to retrieveQueryRevision when the create response omits revision_id", async () => { + createSimpleQueryMock.mockResolvedValue({query: {id: "q2", variant_id: "v2"}}) + retrieveQueryRevisionMock.mockResolvedValue({query_revision: {id: "r2"}}) + + const result = await createSimpleQuery({projectId: "p1", query: {name: "n"}}) + + expect(retrieveQueryRevisionMock).toHaveBeenCalledWith( + {query_ref: {id: "q2"}}, + {queryParams: {project_id: "p1"}}, + ) + expect(result).toEqual({queryId: "q2", variantId: "v2", revisionId: "r2"}) + }) + + it("throws when no query id is returned", async () => { + createSimpleQueryMock.mockResolvedValue({query: null}) + + await expect(createSimpleQuery({projectId: "p1", query: {}})).rejects.toThrow( + /create query/i, + ) + }) + + it("throws when no revision can be resolved", async () => { + createSimpleQueryMock.mockResolvedValue({query: {id: "q3"}}) + retrieveQueryRevisionMock.mockResolvedValue({query_revision: null}) + + await expect(createSimpleQuery({projectId: "p1", query: {}})).rejects.toThrow(/revision/i) + }) +}) diff --git a/web/packages/agenta-ui/src/components/presentational/table-states/TableEmptyState.tsx b/web/packages/agenta-ui/src/components/presentational/table-states/TableEmptyState.tsx index 3062b8a542..fc4dbe8d8d 100644 --- a/web/packages/agenta-ui/src/components/presentational/table-states/TableEmptyState.tsx +++ b/web/packages/agenta-ui/src/components/presentational/table-states/TableEmptyState.tsx @@ -14,13 +14,21 @@ * ``` */ -import {Empty} from "antd" +import type {ReactNode} from "react" + +import {Empty, Typography} from "antd" import {cn, flexLayouts, justifyClasses} from "../../../utils/styles" +const {Text} = Typography + export interface TableEmptyStateProps { /** Message to display (default: "No data found") */ message?: string + /** Secondary explanatory line shown under the message (e.g. what this list is). */ + description?: ReactNode + /** Primary action shown below the text (e.g. a "New …" CTA button). */ + action?: ReactNode /** Additional CSS class names */ className?: string /** Use simple image variant (default: true) */ @@ -29,6 +37,8 @@ export interface TableEmptyStateProps { export function TableEmptyState({ message = "No data found", + description, + action, className, simple = true, }: TableEmptyStateProps) { @@ -36,8 +46,21 @@ export function TableEmptyState({
+ description={ + description ? ( +
+ {message} + + {description} + +
+ ) : ( + message + ) + } + > + {action} +
) } From 1500e8c24ce98a5158336e19208f90901af6680d Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 15 Jun 2026 17:53:59 +0200 Subject: [PATCH 02/21] fix(frontend): show annotations in query matching-traces preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse the observability annotation pipeline (collect invocation links → queryAllAnnotations → attachAnnotationsToTraces) and derive evaluatorSlugs from the enriched traces, so the preview renders the evaluator-metric columns like the Observability table. --- .../Drawer/QueryTracePreview.tsx | 109 ++++++++++++++---- 1 file changed, 89 insertions(+), 20 deletions(-) diff --git a/web/oss/src/components/QueryRegistry/Drawer/QueryTracePreview.tsx b/web/oss/src/components/QueryRegistry/Drawer/QueryTracePreview.tsx index 31ef188c48..e33e4d8536 100644 --- a/web/oss/src/components/QueryRegistry/Drawer/QueryTracePreview.tsx +++ b/web/oss/src/components/QueryRegistry/Drawer/QueryTracePreview.tsx @@ -11,7 +11,12 @@ import type {ColumnsType} from "antd/es/table" import {useStore} from "jotai" import {getObservabilityColumns} from "@/oss/components/pages/observability/assets/getObservabilityColumns" +import {attachAnnotationsToTraces} from "@/oss/lib/hooks/useAnnotations/assets/helpers" +import {transformApiData} from "@/oss/lib/hooks/useAnnotations/assets/transformer" +import type {AnnotationDto} from "@/oss/lib/hooks/useAnnotations/types" +import {queryAllAnnotations} from "@/oss/services/annotations/api" import {TraceSpanNode} from "@/oss/services/tracing/types" +import {getOrgValues} from "@/oss/state/org" export interface QueryTracePreviewProps { projectId?: string | null @@ -33,46 +38,110 @@ export interface QueryTracePreviewProps { */ type PreviewRow = TraceSpanNode & {key: Key; [extra: string]: unknown} -// Stable empty array so the observability columns memo never re-creates and the -// drawer preview never shows the evaluator-metrics column group. -const NO_EVALUATORS: string[] = [] +interface TreeNode { + invocationIds?: {trace_id?: string; span_id?: string} | null + aggregatedEvaluatorMetrics?: Record | null + children?: TreeNode[] | null +} + +/** + * Walk the trace tree for the `{trace_id, span_id}` pairs that key annotation + * lookups. Mirrors `collectInvocationLinks` in + * `state/newObservability/atoms/queries.ts`. + */ +const collectInvocationLinks = (nodes: TreeNode[]) => { + const links: {trace_id: string; span_id: string}[] = [] + const seen = new Set() + const visit = (node?: TreeNode) => { + if (!node) return + const ids = node.invocationIds + if (ids?.trace_id && ids?.span_id) { + const key = `${ids.trace_id}:${ids.span_id}` + if (!seen.has(key)) { + seen.add(key) + links.push({trace_id: ids.trace_id, span_id: ids.span_id}) + } + } + node.children?.forEach(visit) + } + nodes.forEach(visit) + return links +} + +/** + * Gather evaluator slugs from each node's `aggregatedEvaluatorMetrics` so + * `getObservabilityColumns` renders the matching annotation columns. Mirrors + * `collectEvaluatorSlugsFromTraces` in `ObservabilityTable`. + */ +const collectEvaluatorSlugs = (nodes: TreeNode[]) => { + const slugs = new Set() + const visit = (node?: TreeNode) => { + if (!node) return + const metrics = node.aggregatedEvaluatorMetrics + if (metrics && typeof metrics === "object") { + Object.keys(metrics).forEach((slug) => slug && slugs.add(slug)) + } + node.children?.forEach(visit) + } + nodes.forEach(visit) + return Array.from(slugs) +} /** * Read-only preview of the traces matching a query's filter, rendered with the * exact observability table (columns + InfiniteVirtualTable shell) so it stays - * visually identical to the Observability page. Fetches a single page via the - * query entity's `queryMatchingTraces`; this is a peek, not a paginated browser. + * visually identical to the Observability page — including the annotation + * (evaluator-metrics) columns, which are loaded by fetching annotations for the + * matching traces and merging them with the same `attachAnnotationsToTraces` + * helper observability uses. Fetches a single page; this is a peek, not a browser. */ const QueryTracePreview = ({projectId, filtering, limit = 50}: QueryTracePreviewProps) => { const store = useStore() - const columns = useMemo( - () => - getObservabilityColumns({ - evaluatorSlugs: NO_EVALUATORS, - }) as unknown as ColumnsType, - [], - ) const [traces, setTraces] = useState([]) + const [evaluatorSlugs, setEvaluatorSlugs] = useState([]) const [status, setStatus] = useState<"loading" | "done" | "error">("loading") + const columns = useMemo( + () => getObservabilityColumns({evaluatorSlugs}) as unknown as ColumnsType, + [evaluatorSlugs], + ) + useEffect(() => { if (!projectId) return let cancelled = false setStatus("loading") - queryMatchingTraces({projectId, filtering, limit}) - .then((result) => { + ;(async () => { + try { + const rawTraces = await queryMatchingTraces({projectId, filtering, limit}) + const links = collectInvocationLinks(rawTraces as unknown as TreeNode[]) + let annotations: AnnotationDto[] = [] + if (links.length) { + const {selectedOrg} = getOrgValues() + const members = selectedOrg?.default_workspace?.members || [] + const res = await queryAllAnnotations({annotation: {links}}) + annotations = + res.annotations?.map((a) => + transformApiData({data: a, members}), + ) ?? [] + } + // Same merge observability uses — attaches `annotations` and + // `aggregatedEvaluatorMetrics` onto each matching node. + const enriched = attachAnnotationsToTraces( + rawTraces as never[], + annotations, + ) as unknown as PreviewRow[] if (cancelled) return - // Entity TraceSpanNode is structurally the OSS node the observability - // columns render against; the cast bridges the two package types. - setTraces(result as unknown as PreviewRow[]) + setTraces(enriched) + setEvaluatorSlugs(collectEvaluatorSlugs(enriched as unknown as TreeNode[])) setStatus("done") - }) - .catch(() => { + } catch { if (cancelled) return setTraces([]) + setEvaluatorSlugs([]) setStatus("error") - }) + } + })() return () => { cancelled = true } From ad43f9d04e54130621f2e4ba44f91afd4b293e9e Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 15 Jun 2026 21:07:20 +0200 Subject: [PATCH 03/21] fix(frontend): disable inline filter Apply button when draft is unchanged applyFilter already no-ops when the draft equals the applied filter; reflect that in the button's disabled state for the always-visible inline editor (Query Registry drawer). The popover keeps Apply enabled as its primary close affordance. --- web/oss/src/components/Filters/Filters.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/oss/src/components/Filters/Filters.tsx b/web/oss/src/components/Filters/Filters.tsx index fc94e20292..e25d6c9ddd 100644 --- a/web/oss/src/components/Filters/Filters.tsx +++ b/web/oss/src/components/Filters/Filters.tsx @@ -770,7 +770,7 @@ const Filters: React.FC = ({ return {isValid: true} }) - const isApplyDisabled = rowValidations.some(({isValid}) => !isValid) + const hasInvalidRows = rowValidations.some(({isValid}) => !isValid) const nonPermanentFilterCount = useMemo( () => filter.filter((f) => !f.isPermanent).length, @@ -833,6 +833,16 @@ const Filters: React.FC = ({ setIsFilterOpen(false) } + // Apply is a no-op when the draft already matches the applied filter (see the + // isEqual guard above). In the always-visible inline editor we also disable the + // button so it reads as "nothing to apply"; the popover keeps Apply enabled as + // its primary close affordance. + const isDraftUnchanged = isEqual( + sanitizeFilterItems(explodeAnnotationAnyEvaluatorRows(filter)), + filterData, + ) + const isApplyDisabled = hasInvalidRows || (inline && isDraftUnchanged) + const getWithinPopover = (trigger: HTMLElement | null) => (trigger && (trigger.closest(".ant-popover") as HTMLElement)) || document.body From b045f8eb4a3a1e3a2496cfa2419094f2d4aa449b Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 15 Jun 2026 21:22:55 +0200 Subject: [PATCH 04/21] fix(frontend): make inline filter Apply gating compare through a stable baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mapFilterData (props → internal) is not a clean inverse of sanitizeFilterItems, so comparing the sanitized draft against the raw filterData always read 'changed' — leaving Apply enabled on open and letting a clean draft fire onApplyFilter, which dirtied the drawer's Save too. Run both sides of the comparison through the same map → explode → sanitize pipeline so an untouched draft compares equal. Scoped to inline mode; the popover keeps Apply as its close affordance. --- web/oss/src/components/Filters/Filters.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/web/oss/src/components/Filters/Filters.tsx b/web/oss/src/components/Filters/Filters.tsx index e25d6c9ddd..729ab59922 100644 --- a/web/oss/src/components/Filters/Filters.tsx +++ b/web/oss/src/components/Filters/Filters.tsx @@ -833,14 +833,24 @@ const Filters: React.FC = ({ setIsFilterOpen(false) } - // Apply is a no-op when the draft already matches the applied filter (see the - // isEqual guard above). In the always-visible inline editor we also disable the - // button so it reads as "nothing to apply"; the popover keeps Apply enabled as - // its primary close affordance. + // Whether the draft differs from the applied filter. `mapFilterData` (props → + // internal) is NOT a clean inverse of `sanitizeFilterItems` (it injects + // `key: ""`, labels, toUI-transformed values…), so comparing the sanitized + // draft against the raw `filterData` always reads "changed". Instead, run both + // sides through the same `map → explode → sanitize` pipeline so the + // normalization cancels out and an untouched draft compares equal. + const appliedBaseline = useMemo( + () => + sanitizeFilterItems(explodeAnnotationAnyEvaluatorRows(mapFilterData(filterData ?? []))), + [filterData, columns], + ) const isDraftUnchanged = isEqual( sanitizeFilterItems(explodeAnnotationAnyEvaluatorRows(filter)), - filterData, + appliedBaseline, ) + // Inline editor (Query Registry drawer): disable Apply when there is nothing to + // apply, so a clean draft can't spuriously fire onApplyFilter and dirty the + // parent. The popover keeps Apply enabled as its primary close affordance. const isApplyDisabled = hasInvalidRows || (inline && isDraftUnchanged) const getWithinPopover = (trigger: HTMLElement | null) => From 1cd534f6a4b0dcd5c04f7b2e4f43170e3ffb8a3d Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 15 Jun 2026 22:16:30 +0200 Subject: [PATCH 05/21] feat(frontend): add query molecule with semantic draft diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queries are git-style entities (Query → Variant → Revision) with full commit/fork/revision-log on the backend, so model the edit drawer's draft state like the workflow/testset molecules instead of an ad-hoc form snapshot. - @agenta/entities/query: queryMolecule (createMolecule over the head-revision query + draft atoms), with a SEMANTIC order-insensitive isDirty (name + filtering + windowing deep-diff) so change-then-revert reads as clean, plus saveQueryHeadAtom committing a new head revision via editSimpleQuery. - Drawer: edit mode now drives the molecule (useController + reducers.update sync + saveQueryHeadAtom), replacing the snapshot-based dirty check; create stays on the one-shot path. - Unit tests for the semantic dirty diff (order-insensitivity + revert-to-clean). Revision history surfacing still depends on the backend simple-queries list returning variant_id (tracked in TODOS.md). --- .../Drawer/QueryRegistryDrawer.tsx | 100 +++++++++++------- .../agenta-entities/src/query/index.ts | 12 ++- .../agenta-entities/src/query/state/index.ts | 10 +- .../src/query/state/molecule.ts | 84 +++++++++++++++ .../agenta-entities/src/query/state/store.ts | 48 +++++++-- .../tests/unit/query/isQueryHeadDirty.test.ts | 56 ++++++++++ 6 files changed, 264 insertions(+), 46 deletions(-) create mode 100644 web/packages/agenta-entities/src/query/state/molecule.ts create mode 100644 web/packages/agenta-entities/tests/unit/query/isQueryHeadDirty.test.ts diff --git a/web/oss/src/components/QueryRegistry/Drawer/QueryRegistryDrawer.tsx b/web/oss/src/components/QueryRegistry/Drawer/QueryRegistryDrawer.tsx index 741b099765..81b435e162 100644 --- a/web/oss/src/components/QueryRegistry/Drawer/QueryRegistryDrawer.tsx +++ b/web/oss/src/components/QueryRegistry/Drawer/QueryRegistryDrawer.tsx @@ -1,17 +1,18 @@ -import {useCallback, useEffect, useMemo, useState} from "react" +import {useCallback, useEffect, useMemo, useRef, useState} from "react" import { countMatchingTraces, createSimpleQuery, - editSimpleQuery, invalidateQueryCache, + queryMolecule, + saveQueryHeadAtom, + type QueryRevision, type SimpleQueryCreate, - type SimpleQueryEdit, } from "@agenta/entities/query" import {projectIdAtom} from "@agenta/shared/state" import {message} from "@agenta/ui/app-message" import {Button, Form, Input, Typography} from "antd" -import {useAtom, useAtomValue} from "jotai" +import {useAtom, useAtomValue, useSetAtom} from "jotai" import EnhancedDrawer from "@/oss/components/EnhancedUIs/Drawer" import { @@ -60,33 +61,28 @@ const QueryRegistryDrawer = () => { const [saving, setSaving] = useState(false) const [matchState, setMatchState] = useState({status: "idle"}) const [showPreview, setShowPreview] = useState(false) - // Snapshot of the editable state when the drawer opened — used to disable Save - // until the user actually changes something. - const [initialSnapshot, setInitialSnapshot] = useState<{ - name: string - filteringKey: string - rate: number - } | null>(null) const watchedName = Form.useWatch("name", form) const watchedRate = Form.useWatch("sampling_rate", form) const open = activeRow !== null + const queryId = activeRow?.queryId ?? "" const isCreate = !activeRow?.queryId + // Edit mode is backed by the query molecule (committed head revision + draft). + // It owns the real, semantic dirty diff and the commit; create stays on the + // one-shot createSimpleQuery path. + const [editState] = queryMolecule.useController(queryId) + const syncDraft = useSetAtom(queryMolecule.reducers.update) + const discardDraft = useSetAtom(queryMolecule.reducers.discard) + const saveQueryHead = useSetAtom(saveQueryHeadAtom) + // Stable filtering payload for the preview — recomputed only when the filter // conditions change, so the preview table doesn't refetch on every render. const previewFiltering = useMemo(() => toFilteringPayload(filters), [filters]) - const filteringKey = useMemo(() => JSON.stringify(previewFiltering ?? null), [previewFiltering]) - // Create: enabled once a name is typed. Edit: enabled only when name, filter, - // or sampling rate differ from what was loaded. - const isDirty = isCreate - ? Boolean((watchedName ?? "").trim()) - : initialSnapshot !== null && - ((watchedName ?? "") !== initialSnapshot.name || - filteringKey !== initialSnapshot.filteringKey || - Number(watchedRate) !== initialSnapshot.rate) + // Create: enabled once a name is typed. Edit: the molecule's semantic isDirty. + const isDirty = isCreate ? Boolean((watchedName ?? "").trim()) : editState.isDirty useEffect(() => { if (!open || !activeRow) return @@ -97,15 +93,43 @@ const QueryRegistryDrawer = () => { const rawRate = (activeRow.windowing as {rate?: number} | null)?.rate const rate = typeof rawRate === "number" ? Math.round(rawRate * 100) : 100 form.setFieldsValue({name: activeRow.name, sampling_rate: rate, historical: false}) - // Capture the baseline through the same toFilteringPayload path the dirty - // check uses, so a clean round-trip never reads as dirty. - setInitialSnapshot({ - name: activeRow.name ?? "", - filteringKey: JSON.stringify(toFilteringPayload(hydratedFilters) ?? null), - rate, - }) }, [open, activeRow, form]) + // Mirror the live form state into the molecule draft (edit only). The molecule + // derives the semantic dirty diff against the committed head revision, so we + // only sync once the server data is loaded, and skip no-op writes via a key + // guard to avoid a render loop. + const lastSyncedRef = useRef("") + useEffect(() => { + if (isCreate || !open || !editState.serverData) return + const draft = { + name: watchedName ?? "", + data: { + filtering: toFilteringPayload(filters) ?? undefined, + windowing: + toWindowingPayload({ + samplingRate: parseSamplingRate(watchedRate), + historicalRange: undefined, + }) ?? undefined, + }, + } + const key = JSON.stringify(draft) + if (key === lastSyncedRef.current) return + lastSyncedRef.current = key + // OSS filtering/windowing payloads are structurally the entity's revision + // data; the cast bridges the two nominal package types. + syncDraft(queryId, draft as unknown as Partial) + }, [ + isCreate, + open, + editState.serverData, + watchedName, + watchedRate, + filters, + queryId, + syncDraft, + ]) + // D3: live debounced match-count. Executes the in-progress filter against the // trace store so a filter that matches nothing is caught at edit time. Cancels // the in-flight request on every keystroke via AbortController. @@ -138,13 +162,14 @@ const QueryRegistryDrawer = () => { }, [open, projectId, filters]) const close = useCallback(() => { + if (queryId) discardDraft(queryId) + lastSyncedRef.current = "" setActiveRow(null) setFilters([]) setMatchState({status: "idle"}) setShowPreview(false) - setInitialSnapshot(null) form.resetFields() - }, [setActiveRow, form]) + }, [setActiveRow, form, queryId, discardDraft]) let matchLabel: string | null = null let matchIsEmpty = false @@ -191,14 +216,13 @@ const QueryRegistryDrawer = () => { }) message.success("Query created") } else { - await editSimpleQuery({ - projectId, - queryId: activeRow.queryId, - query: { - name: values.name, - ...(dataField ? {data: dataField as SimpleQueryEdit["data"]} : {}), - }, - }) + // Commit through the molecule: flush the validated values into the + // draft, then save (commits a new head revision + clears the draft). + syncDraft(queryId, { + name: values.name, + data: {filtering: filtering ?? undefined, windowing: windowing ?? undefined}, + } as unknown as Partial) + await saveQueryHead({projectId, queryId}) message.success("Query updated") } @@ -210,7 +234,7 @@ const QueryRegistryDrawer = () => { } finally { setSaving(false) } - }, [projectId, activeRow, form, filters, isCreate, close]) + }, [projectId, activeRow, form, filters, isCreate, close, queryId, syncDraft, saveQueryHead]) return ( | null | undefined) => ({ + name: rev?.name ?? null, + filtering: rev?.data?.filtering ?? null, + windowing: rev?.data?.windowing ?? null, +}) + +/** + * Semantic, order-insensitive dirty check for a query head revision: true only + * when the committed fields (name + filtering + windowing) of the draft-merged + * view actually differ from the server. A change-then-revert reads as clean. + */ +export function isQueryHeadDirty( + serverData: QueryRevision | null, + draft: Partial | null, +): boolean { + if (!draft) return false + const merged = {...serverData, ...draft} as Partial + return !deepEqual(dirtySignature(merged), dirtySignature(serverData)) +} + +export const queryMolecule = createMolecule>({ + name: "query", + // jotai-family's atomFamily is structurally compatible but needs the cast. + queryAtomFamily: queryHeadQueryAtomFamily as unknown as AtomFamily>, + draftAtomFamily: queryHeadDraftAtomFamily, + isDirty: isQueryHeadDirty, +}) + +export interface SaveQueryHeadParams { + projectId: string + queryId: string +} + +/** + * Commit the draft as a new head revision (`editSimpleQuery`), clear the draft, + * and refresh caches. No-op when there are no unsaved changes. The draft carries + * the full {name, data}, so it wins over (possibly stale) server data on merge. + */ +export const saveQueryHeadAtom = atom( + null, + async (get, set, {projectId, queryId}: SaveQueryHeadParams): Promise => { + const draft = get(queryHeadDraftAtomFamily(queryId)) + if (!draft) return + const serverData = get(queryMolecule.atoms.serverData(queryId)) + const merged = {...serverData, ...draft} as Partial + const data = { + filtering: merged.data?.filtering ?? null, + windowing: merged.data?.windowing ?? null, + } as NonNullable + await editSimpleQuery({ + projectId, + queryId, + query: { + ...(merged.name != null ? {name: merged.name} : {}), + data, + }, + }) + set(queryHeadDraftAtomFamily(queryId), null) + invalidateQueryCache() + }, +) diff --git a/web/packages/agenta-entities/src/query/state/store.ts b/web/packages/agenta-entities/src/query/state/store.ts index 94ec59ce84..d0b696f99e 100644 --- a/web/packages/agenta-entities/src/query/state/store.ts +++ b/web/packages/agenta-entities/src/query/state/store.ts @@ -1,18 +1,53 @@ /** - * Query entity — cache invalidation. + * Query entity — server-state atoms + cache invalidation. * - * T1 only needs invalidation (so a live-eval-created query refreshes the - * registry). The list/detail query atoms land in Phase 2 and will reuse these - * keys. + * Queries are git-style entities (Query artifact → QueryVariant → QueryRevision). + * The molecule (see ./molecule) tracks committed-vs-draft state for a single + * query's head revision; these atoms are its server-data source and its draft + * storage. */ -import {getDefaultStore} from "jotai" -import {queryClientAtom} from "jotai-tanstack-query" +import {projectIdAtom} from "@agenta/shared/state" +import {isValidUUID} from "@agenta/shared/utils" +import {atom, getDefaultStore} from "jotai" +import {atomFamily} from "jotai-family" +import {atomWithQuery, queryClientAtom} from "jotai-tanstack-query" + +import {retrieveQueryRevision} from "../api" +import type {QueryRevision} from "../core" /** TanStack Query key prefix for the project-scoped SimpleQuery list. */ export const QUERY_LIST_KEY = "queries-list" /** TanStack Query key prefix for a single query / its revisions. */ export const QUERY_DETAIL_KEY = "query" +/** TanStack Query key prefix for a query's head revision (molecule server data). */ +export const QUERY_HEAD_KEY = "query-head" + +/** + * Server data for the molecule: a single query's head revision (carries `name` + * plus `data.filtering` / `data.windowing`). Keyed by the query artifact id. + */ +export const queryHeadQueryAtomFamily = atomFamily((queryId: string) => + atomWithQuery((get) => { + const projectId = get(projectIdAtom) + const enabled = Boolean(projectId) && isValidUUID(queryId) + return { + queryKey: [QUERY_HEAD_KEY, projectId, queryId], + queryFn: async () => { + if (!projectId || !queryId) return null + return retrieveQueryRevision({projectId, queryRef: {id: queryId}}) + }, + enabled, + staleTime: 60_000, + refetchOnWindowFocus: false, + } + }), +) + +/** Local edit draft for a query's head revision (null = no unsaved changes). */ +export const queryHeadDraftAtomFamily = atomFamily((_queryId: string) => + atom | null>(null), +) /** Invalidate the query list + detail caches after a create/commit/archive. */ export function invalidateQueryCache(): void { @@ -20,4 +55,5 @@ export function invalidateQueryCache(): void { const queryClient = store.get(queryClientAtom) queryClient.invalidateQueries({queryKey: [QUERY_LIST_KEY]}) queryClient.invalidateQueries({queryKey: [QUERY_DETAIL_KEY]}) + queryClient.invalidateQueries({queryKey: [QUERY_HEAD_KEY]}) } diff --git a/web/packages/agenta-entities/tests/unit/query/isQueryHeadDirty.test.ts b/web/packages/agenta-entities/tests/unit/query/isQueryHeadDirty.test.ts new file mode 100644 index 0000000000..cf8780ae2c --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/query/isQueryHeadDirty.test.ts @@ -0,0 +1,56 @@ +import {describe, it, expect} from "vitest" + +import {isQueryHeadDirty} from "../../../src/query/state/molecule" + +const server = { + id: "rev1", + name: "test-3", + data: {filtering: {conditions: [{field: "trace_type", operator: "is", value: "invocation"}]}}, +} as never + +describe("isQueryHeadDirty (semantic draft diff)", () => { + it("is not dirty when there is no draft", () => { + expect(isQueryHeadDirty(server, null)).toBe(false) + }) + + it("is not dirty when the draft equals the server values", () => { + const draft = { + name: "test-3", + data: {filtering: {conditions: server.data.filtering.conditions}}, + } + expect(isQueryHeadDirty(server, draft as never)).toBe(false) + }) + + it("is not dirty when filtering matches but key order differs (order-insensitive)", () => { + const draft = { + data: { + filtering: { + conditions: [{value: "invocation", operator: "is", field: "trace_type"}], + }, + }, + } + expect(isQueryHeadDirty(server, draft as never)).toBe(false) + }) + + it("is dirty when the name changes", () => { + expect(isQueryHeadDirty(server, {name: "renamed"} as never)).toBe(true) + }) + + it("is dirty when the filter changes", () => { + const draft = { + data: { + filtering: { + conditions: [{field: "trace_type", operator: "is", value: "completion"}], + }, + }, + } + expect(isQueryHeadDirty(server, draft as never)).toBe(true) + }) + + it("is clean again when a changed value is reverted", () => { + // Draft round-tripped back to the server values must read as not dirty, + // unlike a `draft !== null` check. + const reverted = {name: server.name, data: {filtering: server.data.filtering}} + expect(isQueryHeadDirty(server, reverted as never)).toBe(false) + }) +}) From 10611f1f967429fd19cdea154cc7d4651e71cb23 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 15 Jun 2026 23:17:23 +0200 Subject: [PATCH 06/21] feat(frontend): add query version-history expand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each query (artifact) row in the registry expands to its earlier revisions, mirroring the workflow registry variants table (custom Name-cell toggle + tree-child rows, virtual-table-friendly), lazy-loaded on first expand. - Repoint queryQueryRevisions to query by the artifact ref (query_refs), not variant_id — simple queries are single-variant, so this is the full history and needs no variant id (which the list doesn't return). No backend change. - Revision child rows show a version badge + filter + created on/by; a loader placeholder shows while fetching; 'No earlier versions' when a query has only its head. - Per-row action hiding (ActionItem.hidden) suppresses the menu on revision rows; row-click is a no-op on them. - Corrected the stale TODOS.md note (revision history was never actually blocked on variant_id). Mechanically the expand reuses useGroupedTreeData's pattern (controlled expandedRowKeys + expandIcon: null + custom cell toggle) without the flat-revisions store rework, preserving the active/archived tabs + search + archive that operate on the artifact. --- TODOS.md | 17 ++- .../Table/QueryRegistryTable.tsx | 128 +++++++++++++++++- .../Table/assets/queryRegistryColumns.tsx | 65 ++++++++- .../QueryRegistry/store/queryRegistryStore.ts | 8 ++ .../agenta-entities/src/query/api/api.ts | 17 ++- .../agenta-entities/src/query/api/index.ts | 2 +- .../agenta-entities/src/query/index.ts | 2 +- 7 files changed, 218 insertions(+), 21 deletions(-) diff --git a/TODOS.md b/TODOS.md index e6bdcac86d..373ef8f311 100644 --- a/TODOS.md +++ b/TODOS.md @@ -33,8 +33,15 @@ - **Context:** The reference data exists, flattened, in the evaluations domain under `QUERY_REFERENCE_KEY = "query_revision"` (`api/oss/src/dbs/postgres/evaluations/utils.py`). Eval runs store `data.steps[].references["query_revision"]`. There is currently NO reverse-lookup endpoint and archive does not block in-use queries (`api/oss/src/core/queries/service.py:844` `archive_query_revision` has no reference check). Verified during the eng review of branch `claude/intelligent-bassi-ca4cc0`. - **Depends on / blocked by:** None. Independent of the FE registry; the FE swaps the generic confirm for enumeration when this lands. -### Backend: simple-queries list should return `variant_id` and `revision_id` -- **What:** `query_simple_queries` (`api/oss/src/core/queries/service.py:~1505`) builds each `SimpleQuery` without `variant_id` / `revision_id` (only id/slug/timestamps/name/data). Populate both, like the create/retrieve responses do. -- **Why:** The Query Registry's revision-history expand needs `variant_id` to lazy-load a query's revisions (`queries/revisions/query` with `query_variant_refs`). Without it, every row is non-expandable and the expand column renders empty, so the feature was removed from v1. -- **Context:** The FE already has the pieces (`queryQueryRevisions` in `@agenta/entities/query`, and a deleted `QueryRevisionList` component — easy to restore). Once the list returns `variant_id`, re-add the `expandable` config to `QueryRegistryTable` and the row's `variantId` will drive it. Verified on branch `claude/intelligent-bassi-ca4cc0`. -- **Depends on / blocked by:** None. Backend-only change unblocks the FE feature. +### (RESOLVED) Revision-history expand — no backend change needed +- **What:** The Query Registry's version-history expand is implemented. Each query + (artifact) row expands to its earlier revisions, lazy-loaded on first expand. +- **Resolution:** Revisions are queried by the **artifact ref** (`query_refs: [{id: queryId}]`), + not the variant ref — `QueryRevisionQueryRequest` accepts `query_refs`, and the + service maps `artifact_refs=query_refs`. Simple queries are single-variant, so this + returns the full version history. The earlier assumption that the list must return + `variant_id` was wrong; the list already returns the artifact `id` (= queryId), which + is all the expand needs. +- **Nice-to-have (not blocking):** the simple-queries list could still surface + `revision_id` so the head row shows its version badge without a fetch, but the expand + works without it. diff --git a/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx index 3c83c5dc5c..9bb8ffe814 100644 --- a/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx +++ b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx @@ -1,7 +1,10 @@ import type {ReactNode} from "react" -import {useMemo} from "react" +import {useCallback, useMemo, useState} from "react" +import {queryQueryRevisions} from "@agenta/entities/query" +import {projectIdAtom} from "@agenta/shared/state" import {InfiniteVirtualTableFeatureShell, useTableManager} from "@agenta/ui/table" +import {useAtomValue} from "jotai" import getFilterColumns from "@/oss/components/pages/observability/assets/getFilterColumns" @@ -27,6 +30,31 @@ interface QueryRegistryTableProps { mode?: QueryRegistryStatus } +const isRevisionRow = (row: QueryRegistryRow) => + Boolean(row.__isRevisionChild || row.__isRevisionLoader) + +const loaderRow = (row: QueryRegistryRow): QueryRegistryRow => ({ + key: `${row.queryId}__rev-loader`, + queryId: row.queryId, + variantId: row.variantId, + revisionId: null, + name: "", + slug: null, + filtering: null, + windowing: null, + createdAt: null, + createdById: null, + __isRevisionLoader: true, +}) + +const emptyHistoryRow = (row: QueryRegistryRow): QueryRegistryRow => ({ + ...loaderRow(row), + key: `${row.queryId}__rev-empty`, + name: "No earlier versions", + __isRevisionLoader: false, + __isRevisionChild: true, +}) + const QueryRegistryTable = ({ actions, onRowClick, @@ -37,22 +65,112 @@ const QueryRegistryTable = ({ mode = "active", }: QueryRegistryTableProps) => { const isArchived = mode === "archived" + const projectId = useAtomValue(projectIdAtom) const datasetStore = getQueryRegistryTableState(mode).store + + // Lazily-loaded revision history per query (the version-history expand). + const [childrenByQueryId, setChildrenByQueryId] = useState>( + {}, + ) + const [expandedKeys, setExpandedKeys] = useState([]) + + // Row click opens the manage drawer, but not for revision/loader rows. + const handleRowClick = useCallback( + (record: QueryRegistryRow) => { + if (isRevisionRow(record)) return + onRowClick?.(record) + }, + [onRowClick], + ) + const table = useTableManager({ datasetStore: datasetStore as never, scopeId: isArchived ? "query-registry-archived" : "query-registry", pageSize: 50, - onRowClick, + onRowClick: handleRowClick, searchDeps, columnVisibilityStorageKey: isArchived ? "agenta:query-registry-archived:column-visibility" : "agenta:query-registry:column-visibility", }) + const fetchRevisions = useCallback( + async (row: QueryRegistryRow) => { + if (!projectId || childrenByQueryId[row.queryId]) return + try { + const revisions = await queryQueryRevisions({projectId, queryId: row.queryId}) + // Drop the head revision (already shown as the parent row) — children + // are the earlier versions only. + const children: QueryRegistryRow[] = revisions + .filter((rev) => rev.revisionId !== row.revisionId) + .map((rev) => ({ + key: rev.revisionId || `${row.queryId}:${rev.version}`, + queryId: row.queryId, + variantId: row.variantId, + revisionId: rev.revisionId, + name: row.name, + slug: row.slug, + filtering: rev.filtering, + windowing: null, + createdAt: rev.createdAt, + createdById: rev.createdById, + version: rev.version, + __isRevisionChild: true, + })) + setChildrenByQueryId((prev) => ({ + ...prev, + [row.queryId]: children.length ? children : [emptyHistoryRow(row)], + })) + } catch { + setChildrenByQueryId((prev) => ({ + ...prev, + [row.queryId]: [emptyHistoryRow(row)], + })) + } + }, + [projectId, childrenByQueryId], + ) + + const handleExpand = useCallback((expanded: boolean, rowKey: string) => { + setExpandedKeys((prev) => (expanded ? [...prev, rowKey] : prev.filter((k) => k !== rowKey))) + }, []) + + const expandState = useMemo( + () => ({expandedRowKeys: expandedKeys, handleExpand}), + [expandedKeys, handleExpand], + ) + const fieldLabels = useMemo(() => buildFieldLabelMap(getFilterColumns()), []) const columns = useMemo( - () => createQueryRegistryColumns(actions, fieldLabels, isArchived), - [actions, fieldLabels, isArchived], + () => createQueryRegistryColumns(actions, fieldLabels, isArchived, expandState), + [actions, fieldLabels, isArchived, expandState], + ) + + // Attach lazily-loaded revision rows (or a loader placeholder) as antd tree + // children so the virtual table renders the expanded history inline. + const rows = table.shellProps.pagination?.rows ?? [] + const dataSource = useMemo( + () => + rows.map((row) => { + if (row.__isSkeleton || !row.queryId) return row + return {...row, children: childrenByQueryId[row.queryId] ?? [loaderRow(row)]} + }), + [rows, childrenByQueryId], + ) + + const treeExpandable = useMemo( + () => ({ + expandedRowKeys: expandedKeys, + // Drive the fetch off expansion; the toggle lives in the Name cell. + onExpand: (expanded: boolean, record: QueryRegistryRow) => { + if (expanded) void fetchRevisions(record) + }, + // Custom toggle in the Name cell renders the caret instead. + expandIcon: () => null as unknown as null, + rowExpandable: (record: QueryRegistryRow) => + !isRevisionRow(record) && !record.__isSkeleton, + }), + [expandedKeys, fetchRevisions], ) return ( @@ -64,8 +182,10 @@ const QueryRegistryTable = ({ primaryActions={primaryActions} className="flex-1 min-h-0" autoHeight + dataSource={dataSource} tableProps={{ ...table.shellProps.tableProps, + expandable: treeExpandable, ...(emptyState ? {locale: {emptyText: emptyState}} : {}), }} /> diff --git a/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx index f004802360..75c315f37f 100644 --- a/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx +++ b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx @@ -5,12 +5,20 @@ import { ArrowCounterClockwise, CopySimple, Eye, + MinusCircle, PencilSimple, + PlusCircle, } from "@phosphor-icons/react" -import {Popover, Tag, Typography} from "antd" +import {Popover, Spin, Tag, Typography} from "antd" import type {QueryRegistryRow} from "../../store/queryRegistryStore" +/** Controlled expand state for the version-history rows (mirrors the registry table). */ +export interface QueryExpandState { + expandedRowKeys: string[] + handleExpand: (expanded: boolean, rowKey: string) => void +} + const {Text} = Typography interface FilterLeaf { @@ -115,6 +123,10 @@ export interface QueryColumnActions { * Archived tab only restores (editing an archived query is meaningless — it would * commit a revision on a soft-deleted artifact). */ +/** Revision-history (child) and loader rows carry no per-row actions. */ +const isRevisionRow = (record: QueryRegistryRow) => + Boolean(record.__isRevisionChild || record.__isRevisionLoader) + function buildActionItems(actions: QueryColumnActions, isArchived: boolean) { if (isArchived) { return [ @@ -122,6 +134,7 @@ function buildActionItems(actions: QueryColumnActions, isArchived: boolean) { key: "restore", label: "Restore", icon: , + hidden: isRevisionRow, onClick: (record: QueryRegistryRow) => actions.handleRestore?.(record), }, ] @@ -131,26 +144,30 @@ function buildActionItems(actions: QueryColumnActions, isArchived: boolean) { key: "open", label: "Open details", icon: , + hidden: isRevisionRow, onClick: (record: QueryRegistryRow) => actions.handleOpen?.(record), }, { key: "edit", label: "Edit", icon: , + hidden: isRevisionRow, onClick: (record: QueryRegistryRow) => actions.handleEdit?.(record), }, { key: "duplicate", label: "Duplicate", icon: , + hidden: isRevisionRow, onClick: (record: QueryRegistryRow) => actions.handleDuplicate?.(record), }, - {type: "divider" as const}, + {type: "divider" as const, hidden: isRevisionRow}, { key: "archive", label: "Archive", icon: , danger: true, + hidden: isRevisionRow, onClick: (record: QueryRegistryRow) => actions.handleArchive?.(record), }, ] @@ -160,6 +177,7 @@ export function createQueryRegistryColumns( actions: QueryColumnActions, labels?: FieldLabelMap, isArchived = false, + expandState?: QueryExpandState, ) { return createStandardColumns([ { @@ -171,8 +189,46 @@ export function createQueryRegistryColumns( columnVisibilityLocked: true, render: (_value, record) => { if (record.__isSkeleton) return + if (record.__isRevisionLoader) { + return ( +
+ + + Loading versions… + +
+ ) + } + // Revision (child) row: indent to align under the parent, show the + // version badge instead of an expand toggle. + if (record.__isRevisionChild) { + return ( +
+ {record.name} + {record.version ? ( + v{record.version} + ) : null} +
+ ) + } + // Head (parent) row: custom expand toggle + name (mirrors the + // workflow registry table, which hides antd's default caret). + const isExpanded = expandState?.expandedRowKeys.includes(record.key) ?? false return ( -
+
+ {expandState ? ( + + ) : null} {record.name}
) @@ -185,6 +241,7 @@ export function createQueryRegistryColumns( width: 260, render: (_value, record) => { if (record.__isSkeleton) return + if (record.__isRevisionLoader) return null const conditions = flattenConditions(record.filtering) if (conditions.length === 0) { return ( @@ -235,6 +292,7 @@ export function createQueryRegistryColumns( width: 160, render: (_value, record) => { if (record.__isSkeleton) return + if (record.__isRevisionLoader) return null return (
{formatDateCell(record.createdAt)} @@ -249,6 +307,7 @@ export function createQueryRegistryColumns( width: 180, render: (_value, record) => { if (record.__isSkeleton) return + if (record.__isRevisionLoader) return null if (!record.createdById) { return (
diff --git a/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts b/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts index 4037685d35..05ae48f532 100644 --- a/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts +++ b/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts @@ -39,6 +39,14 @@ export interface QueryRegistryRow { windowing: unknown createdAt: string | null createdById: string | null + /** Revision version label, shown as a badge on expanded history rows. */ + version?: string | null + /** True for a lazily-loaded revision (child) row in the version-history expand. */ + __isRevisionChild?: boolean + /** Placeholder row shown while a query's revisions are being fetched. */ + __isRevisionLoader?: boolean + /** Injected revision-history rows (antd tree children). */ + children?: QueryRegistryRow[] [k: string]: unknown } diff --git a/web/packages/agenta-entities/src/query/api/api.ts b/web/packages/agenta-entities/src/query/api/api.ts index 12d9ec67ef..8757c0eaf1 100644 --- a/web/packages/agenta-entities/src/query/api/api.ts +++ b/web/packages/agenta-entities/src/query/api/api.ts @@ -117,23 +117,26 @@ export interface QueryRevisionSummary { message: string | null } -export interface QueryRevisionsByVariantParams { +export interface QueryRevisionsByQueryParams { projectId: string - variantId: string + queryId: string } /** - * List the revision history of one query variant (newest first), for the - * registry's lazy expand-on-click. Returns a flat summary per revision. + * List the revision history of one query artifact (newest first), for the + * registry's lazy expand-on-click. Queries by the artifact ref (`query_refs`) — + * simple queries are single-variant, so this is the full version history and + * needs no variant id (which the list endpoint doesn't return). Flat per-revision + * summary. */ export async function queryQueryRevisions({ projectId, - variantId, -}: QueryRevisionsByVariantParams): Promise { + queryId, +}: QueryRevisionsByQueryParams): Promise { const client = getAgentaSdkClient({host: getAgentaApiUrl()}) const response = await client.queries.queryQueryRevisions( { - query_variant_refs: [{id: variantId}], + query_refs: [{id: queryId}], windowing: {limit: 100, order: "descending"}, }, {queryParams: {project_id: projectId}}, diff --git a/web/packages/agenta-entities/src/query/api/index.ts b/web/packages/agenta-entities/src/query/api/index.ts index aa10d25d59..c5598e0f8c 100644 --- a/web/packages/agenta-entities/src/query/api/index.ts +++ b/web/packages/agenta-entities/src/query/api/index.ts @@ -9,7 +9,7 @@ export { type QueryMatchingTracesParams, queryQueryRevisions, type QueryRevisionSummary, - type QueryRevisionsByVariantParams, + type QueryRevisionsByQueryParams, } from "./api" export { createSimpleQuery, diff --git a/web/packages/agenta-entities/src/query/index.ts b/web/packages/agenta-entities/src/query/index.ts index 952ab27e6f..0384ed266b 100644 --- a/web/packages/agenta-entities/src/query/index.ts +++ b/web/packages/agenta-entities/src/query/index.ts @@ -24,7 +24,7 @@ export { type QueryMatchingTracesParams, queryQueryRevisions, type QueryRevisionSummary, - type QueryRevisionsByVariantParams, + type QueryRevisionsByQueryParams, } from "./api" export { From e4f0f4a4c68451d2811561ee2c11a83e582d85ca Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 16 Jun 2026 00:39:55 +0200 Subject: [PATCH 07/21] fix(frontend): fix query version-history expand interactions - Lazy revisions never loaded: the custom Name-cell toggle drives expansion, but the fetch was wired to antd's onExpand, which never fires when the caret is hidden (expandIcon: null). Move the fetch into the toggle's handleExpand so expanding actually fetches. - Expand toggle showed a bordered/focused box: it was a + ) : null} {record.name}
From 85e0e6911381b1e9a753c0140d39c54b51a2629d Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 16 Jun 2026 01:10:40 +0200 Subject: [PATCH 08/21] feat(frontend): query registry parent row shows head version (mirrors workflow variants) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the parent row represent the latest revision, like the workflow variants table (comp-1 v5 + children v4..v1), instead of a version-less header. - Entity: add queryRevisionsForQueries — one batched queryQueryRevisions over multiple query_refs, grouped client-side by queryId (QueryRevisionSummary now carries queryId). Reuses the existing latest-revision pattern instead of a backend change. - Table: batch-fetch the visible page's revisions, enrich each head row with its head version badge + earlier-revision child rows. The expand toggle shows only when history exists; a spacer keeps single-revision rows aligned. - Replaces the lazy per-expand fetch (and its loader/empty placeholder rows) with the eager batched load. --- .../Table/QueryRegistryTable.tsx | 160 ++++++++---------- .../Table/assets/queryRegistryColumns.tsx | 37 ++-- .../agenta-entities/src/query/api/api.ts | 53 ++++-- .../agenta-entities/src/query/api/index.ts | 2 + .../agenta-entities/src/query/index.ts | 2 + 5 files changed, 129 insertions(+), 125 deletions(-) diff --git a/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx index 28f300b4a7..39e0ac6524 100644 --- a/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx +++ b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx @@ -1,7 +1,7 @@ import type {ReactNode} from "react" -import {useCallback, useMemo, useState} from "react" +import {useCallback, useEffect, useMemo, useState} from "react" -import {queryQueryRevisions} from "@agenta/entities/query" +import {queryRevisionsForQueries, type QueryRevisionSummary} from "@agenta/entities/query" import {projectIdAtom} from "@agenta/shared/state" import {InfiniteVirtualTableFeatureShell, useTableManager} from "@agenta/ui/table" import {useAtomValue} from "jotai" @@ -30,30 +30,7 @@ interface QueryRegistryTableProps { mode?: QueryRegistryStatus } -const isRevisionRow = (row: QueryRegistryRow) => - Boolean(row.__isRevisionChild || row.__isRevisionLoader) - -const loaderRow = (row: QueryRegistryRow): QueryRegistryRow => ({ - key: `${row.queryId}__rev-loader`, - queryId: row.queryId, - variantId: row.variantId, - revisionId: null, - name: "", - slug: null, - filtering: null, - windowing: null, - createdAt: null, - createdById: null, - __isRevisionLoader: true, -}) - -const emptyHistoryRow = (row: QueryRegistryRow): QueryRegistryRow => ({ - ...loaderRow(row), - key: `${row.queryId}__rev-empty`, - name: "No earlier versions", - __isRevisionLoader: false, - __isRevisionChild: true, -}) +const isRevisionRow = (row: QueryRegistryRow) => Boolean(row.__isRevisionChild) const QueryRegistryTable = ({ actions, @@ -68,13 +45,14 @@ const QueryRegistryTable = ({ const projectId = useAtomValue(projectIdAtom) const datasetStore = getQueryRegistryTableState(mode).store - // Lazily-loaded revision history per query (the version-history expand). - const [childrenByQueryId, setChildrenByQueryId] = useState>( - {}, - ) + // Revision history per query, batch-fetched for the visible page (newest + // first). Drives the parent's head-version badge + the expandable child rows. + const [revisionsByQueryId, setRevisionsByQueryId] = useState< + Record + >({}) const [expandedKeys, setExpandedKeys] = useState([]) - // Row click opens the manage drawer, but not for revision/loader rows. + // Row click opens the manage drawer, but not for revision rows. const handleRowClick = useCallback( (record: QueryRegistryRow) => { if (isRevisionRow(record)) return @@ -94,54 +72,45 @@ const QueryRegistryTable = ({ : "agenta:query-registry:column-visibility", }) - const fetchRevisions = useCallback( - async (row: QueryRegistryRow) => { - if (!projectId || childrenByQueryId[row.queryId]) return - try { - const revisions = await queryQueryRevisions({projectId, queryId: row.queryId}) - // Drop the head revision (already shown as the parent row) — children - // are the earlier versions only. - const children: QueryRegistryRow[] = revisions - .filter((rev) => rev.revisionId !== row.revisionId) - .map((rev) => ({ - key: rev.revisionId || `${row.queryId}:${rev.version}`, - queryId: row.queryId, - variantId: row.variantId, - revisionId: rev.revisionId, - name: row.name, - slug: row.slug, - filtering: rev.filtering, - windowing: null, - createdAt: rev.createdAt, - createdById: rev.createdById, - version: rev.version, - __isRevisionChild: true, - })) - setChildrenByQueryId((prev) => ({ - ...prev, - [row.queryId]: children.length ? children : [emptyHistoryRow(row)], - })) - } catch { - setChildrenByQueryId((prev) => ({ - ...prev, - [row.queryId]: [emptyHistoryRow(row)], - })) - } - }, - [projectId, childrenByQueryId], + const rows = table.shellProps.pagination?.rows ?? [] + const headQueryIds = useMemo( + () => rows.filter((row) => !row.__isSkeleton && row.queryId).map((row) => row.queryId), + [rows], ) - // The custom Name-cell toggle drives expansion AND the lazy fetch (antd's - // own onExpand never fires because we hide its caret with expandIcon: null). - const handleExpand = useCallback( - (expanded: boolean, record: QueryRegistryRow) => { - setExpandedKeys((prev) => - expanded ? [...prev, record.key] : prev.filter((k) => k !== record.key), - ) - if (expanded) void fetchRevisions(record) - }, - [fetchRevisions], - ) + // Batch-fetch revisions for any visible queries we haven't loaded yet. + useEffect(() => { + if (!projectId) return + const missing = headQueryIds.filter((id) => !(id in revisionsByQueryId)) + if (!missing.length) return + let cancelled = false + queryRevisionsForQueries({projectId, queryIds: missing}) + .then((revs) => { + if (cancelled) return + // Seed each requested id (so we don't refetch) then group by query. + const grouped: Record = {} + for (const id of missing) grouped[id] = [] + for (const rev of revs) (grouped[rev.queryId] ??= []).push(rev) + setRevisionsByQueryId((prev) => ({...prev, ...grouped})) + }) + .catch(() => { + if (cancelled) return + // Mark as fetched-empty so a transient failure doesn't loop. + setRevisionsByQueryId((prev) => ({ + ...prev, + ...Object.fromEntries(missing.map((id) => [id, prev[id] ?? []])), + })) + }) + return () => { + cancelled = true + } + }, [projectId, headQueryIds, revisionsByQueryId]) + + const handleExpand = useCallback((expanded: boolean, record: QueryRegistryRow) => { + setExpandedKeys((prev) => + expanded ? [...prev, record.key] : prev.filter((k) => k !== record.key), + ) + }, []) const expandState = useMemo( () => ({expandedRowKeys: expandedKeys, handleExpand}), @@ -154,34 +123,49 @@ const QueryRegistryTable = ({ [actions, fieldLabels, isArchived, expandState], ) - // Attach lazily-loaded revision rows (or a loader placeholder) as antd tree - // children so the virtual table renders the expanded history inline. - const rows = table.shellProps.pagination?.rows ?? [] + // Enrich each head row with its head version + earlier-revision child rows. const dataSource = useMemo( () => rows.map((row) => { if (row.__isSkeleton || !row.queryId) return row - return {...row, children: childrenByQueryId[row.queryId] ?? [loaderRow(row)]} + const revs = revisionsByQueryId[row.queryId] + if (!revs?.length) return row + const headVersion = revs[0]?.version ?? null + const children: QueryRegistryRow[] = revs.slice(1).map((rev) => ({ + key: rev.revisionId || `${row.queryId}:${rev.version}`, + queryId: row.queryId, + variantId: row.variantId, + revisionId: rev.revisionId, + name: row.name, + slug: row.slug, + filtering: rev.filtering, + windowing: null, + createdAt: rev.createdAt, + createdById: rev.createdById, + version: rev.version, + __isRevisionChild: true, + })) + return { + ...row, + version: headVersion, + ...(children.length ? {children} : {}), + } }), - [rows, childrenByQueryId], + [rows, revisionsByQueryId], ) const treeExpandable = useMemo( () => ({ expandedRowKeys: expandedKeys, - // Drive the fetch off expansion; the toggle lives in the Name cell. - onExpand: (expanded: boolean, record: QueryRegistryRow) => { - if (expanded) void fetchRevisions(record) - }, // Custom toggle in the Name cell renders the caret instead. expandIcon: () => null as unknown as null, rowExpandable: (record: QueryRegistryRow) => !isRevisionRow(record) && !record.__isSkeleton, }), - [expandedKeys, fetchRevisions], + [expandedKeys], ) - // Revision (child) and loader rows aren't selectable — hide their checkboxes. + // Revision (child) rows aren't selectable — hide their checkboxes. const rowSelection = useMemo( () => ({ diff --git a/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx index cc0ccb631e..e39ef7d174 100644 --- a/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx +++ b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx @@ -9,7 +9,7 @@ import { PencilSimple, PlusCircle, } from "@phosphor-icons/react" -import {Popover, Spin, Tag, Typography} from "antd" +import {Popover, Tag, Typography} from "antd" import type {QueryRegistryRow} from "../../store/queryRegistryStore" @@ -124,8 +124,7 @@ export interface QueryColumnActions { * commit a revision on a soft-deleted artifact). */ /** Revision-history (child) and loader rows carry no per-row actions. */ -const isRevisionRow = (record: QueryRegistryRow) => - Boolean(record.__isRevisionChild || record.__isRevisionLoader) +const isRevisionRow = (record: QueryRegistryRow) => Boolean(record.__isRevisionChild) function buildActionItems(actions: QueryColumnActions, isArchived: boolean) { if (isArchived) { @@ -189,18 +188,8 @@ export function createQueryRegistryColumns( columnVisibilityLocked: true, render: (_value, record) => { if (record.__isSkeleton) return - if (record.__isRevisionLoader) { - return ( -
- - - Loading versions… - -
- ) - } // Revision (child) row: indent to align under the parent, show the - // version badge instead of an expand toggle. + // version badge. if (record.__isRevisionChild) { return (
@@ -211,13 +200,15 @@ export function createQueryRegistryColumns(
) } - // Head (parent) row: custom expand toggle + name (mirrors the - // workflow registry table, which hides antd's default caret). A - // (not - -
- } - > -
+ {isCreate ? "New query" : "Edit query"}} + open={open} + onClose={close} + width={showPreview ? 960 : 520} + destroyOnHidden + closeOnLayoutClick={false} + styles={{body: {padding: 0}, footer: {padding: 8}}} + footer={ +
+ + +
+ } > - - - - -
- {matchLabel ? ( -
- - {matchLabel} - -
- ) : ( - - )} - -
- - {showPreview ? ( -
- + + + +
+ {matchLabel ? ( +
+ + {matchLabel} + +
+ ) : ( + + )} + +
+ + {showPreview ? ( +
+ +
+ ) : null} + + setCommitOpen(false)} + okButtonProps={{loading: saving}} + > +
+ + Saving creates a new version of this query. Add an optional message + describing what changed. + + setCommitMessage(event.target.value)} + placeholder="Commit message (optional)" + maxLength={280} + />
- ) : null} - +
+ ) } diff --git a/web/packages/agenta-entities/src/query/api/index.ts b/web/packages/agenta-entities/src/query/api/index.ts index e6d6e9ba54..1185070b70 100644 --- a/web/packages/agenta-entities/src/query/api/index.ts +++ b/web/packages/agenta-entities/src/query/api/index.ts @@ -19,6 +19,8 @@ export { type EditSimpleQueryParams, archiveSimpleQuery, type ArchiveSimpleQueryParams, + commitQueryRevision, + type CommitQueryRevisionParams, unarchiveSimpleQuery, type UnarchiveSimpleQueryParams, } from "./mutations" diff --git a/web/packages/agenta-entities/src/query/api/mutations.ts b/web/packages/agenta-entities/src/query/api/mutations.ts index af858f46b7..bf7d948c9a 100644 --- a/web/packages/agenta-entities/src/query/api/mutations.ts +++ b/web/packages/agenta-entities/src/query/api/mutations.ts @@ -9,9 +9,15 @@ import {getAgentaSdkClient} from "@agenta/sdk" import {getAgentaApiUrl} from "@agenta/shared/api" +import type {AgentaApi} from "@agentaai/api-client" import {v4 as uuidv4} from "uuid" -import type {CreateSimpleQueryParams, CreateSimpleQueryResult, SimpleQueryEdit} from "../core/types" +import type { + CreateSimpleQueryParams, + CreateSimpleQueryResult, + QueryRevisionDataInput, + SimpleQueryEdit, +} from "../core/types" import {retrieveQueryRevision} from "./api" @@ -80,6 +86,43 @@ export async function editSimpleQuery({ ) } +export interface CommitQueryRevisionParams { + projectId: string + /** The query's head variant to commit a new revision onto. */ + variantId: string + data: QueryRevisionDataInput + name?: string + /** Git-style commit message attached to the new revision. */ + message?: string +} + +/** + * Commit a new revision to a query's variant with an optional commit message — + * the git-style update path (`/queries/revisions/commit`). Unlike + * `editSimpleQuery`, this carries a `message`, so it backs the registry's commit + * modal. Simple queries are single-variant, so the variant id comes from the + * head revision. + */ +export async function commitQueryRevision({ + projectId, + variantId, + data, + name, + message, +}: CommitQueryRevisionParams): Promise { + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + const queryRevision: AgentaApi.QueryRevisionCommit = { + variant_id: variantId, + data, + ...(name != null ? {name} : {}), + ...(message ? {message} : {}), + } + await client.queries.commitQueryRevision( + {query_revision: queryRevision}, + {queryParams: {project_id: projectId}}, + ) +} + export interface ArchiveSimpleQueryParams { projectId: string queryId: string diff --git a/web/packages/agenta-entities/src/query/index.ts b/web/packages/agenta-entities/src/query/index.ts index 49f59201e5..08565d8bc0 100644 --- a/web/packages/agenta-entities/src/query/index.ts +++ b/web/packages/agenta-entities/src/query/index.ts @@ -12,6 +12,8 @@ export { type EditSimpleQueryParams, archiveSimpleQuery, type ArchiveSimpleQueryParams, + commitQueryRevision, + type CommitQueryRevisionParams, unarchiveSimpleQuery, type UnarchiveSimpleQueryParams, retrieveQueryRevision, diff --git a/web/packages/agenta-entities/src/query/state/molecule.ts b/web/packages/agenta-entities/src/query/state/molecule.ts index 8480ba6e72..e06d67fbe7 100644 --- a/web/packages/agenta-entities/src/query/state/molecule.ts +++ b/web/packages/agenta-entities/src/query/state/molecule.ts @@ -15,8 +15,8 @@ import deepEqual from "fast-deep-equal" import {atom} from "jotai" import {createMolecule, type AtomFamily, type QueryState} from "../../shared" -import {editSimpleQuery} from "../api" -import type {QueryRevision, SimpleQueryEdit} from "../core" +import {commitQueryRevision, editSimpleQuery} from "../api" +import type {QueryRevision, QueryRevisionDataInput, SimpleQueryEdit} from "../core" import {invalidateQueryCache, queryHeadDraftAtomFamily, queryHeadQueryAtomFamily} from "./store" @@ -52,16 +52,22 @@ export const queryMolecule = createMolecule => { + async (get, set, {projectId, queryId, message}: SaveQueryHeadParams): Promise => { const draft = get(queryHeadDraftAtomFamily(queryId)) if (!draft) return const serverData = get(queryMolecule.atoms.serverData(queryId)) @@ -69,15 +75,25 @@ export const saveQueryHeadAtom = atom( const data = { filtering: merged.data?.filtering ?? null, windowing: merged.data?.windowing ?? null, - } as NonNullable - await editSimpleQuery({ - projectId, - queryId, - query: { - ...(merged.name != null ? {name: merged.name} : {}), - data, - }, - }) + } + const name = merged.name ?? undefined + const variantId = serverData?.variant_id ?? serverData?.query_variant_id ?? null + + if (variantId) { + await commitQueryRevision({ + projectId, + variantId, + data: data as QueryRevisionDataInput, + name, + message, + }) + } else { + await editSimpleQuery({ + projectId, + queryId, + query: {...(name != null ? {name} : {}), data: data as SimpleQueryEdit["data"]}, + }) + } set(queryHeadDraftAtomFamily(queryId), null) invalidateQueryCache() }, diff --git a/web/packages/agenta-entities/tests/unit/query/commitQueryRevision.test.ts b/web/packages/agenta-entities/tests/unit/query/commitQueryRevision.test.ts new file mode 100644 index 0000000000..d31edf604c --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/query/commitQueryRevision.test.ts @@ -0,0 +1,55 @@ +import {describe, it, expect, vi, beforeEach} from "vitest" + +// Regression guard for the registry commit modal: edits commit a new revision via +// the git endpoint with a commit message + the head variant id. + +const commitQueryRevisionMock = vi.fn() + +vi.mock("@agenta/sdk", () => ({ + getAgentaSdkClient: () => ({ + queries: {commitQueryRevision: commitQueryRevisionMock}, + }), +})) + +vi.mock("@agenta/shared/api", () => ({ + getAgentaApiUrl: () => "http://test", +})) + +import {commitQueryRevision} from "../../../src/query/api/mutations" + +describe("commitQueryRevision", () => { + beforeEach(() => { + commitQueryRevisionMock.mockReset() + commitQueryRevisionMock.mockResolvedValue({}) + }) + + it("commits to the head variant with data, name, and message", async () => { + await commitQueryRevision({ + projectId: "p1", + variantId: "v1", + data: {filtering: {conditions: []}}, + name: "test-3", + message: "tightened the filter", + }) + + expect(commitQueryRevisionMock).toHaveBeenCalledWith( + { + query_revision: { + variant_id: "v1", + data: {filtering: {conditions: []}}, + name: "test-3", + message: "tightened the filter", + }, + }, + {queryParams: {project_id: "p1"}}, + ) + }) + + it("omits the message when none is given", async () => { + await commitQueryRevision({projectId: "p1", variantId: "v1", data: {}}) + + const [payload] = commitQueryRevisionMock.mock.calls[0] + expect(payload.query_revision).not.toHaveProperty("message") + expect(payload.query_revision.variant_id).toBe("v1") + }) +}) From 84ed2f9ffa16fc770b4139206057a58af01aee25 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 16 Jun 2026 11:41:02 +0200 Subject: [PATCH 10/21] feat(frontend): query edits use the shared EntityCommitModal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bespoke commit modal with the reusable @agenta/entity-ui EntityCommitModal that other git-style entities use — version transition (vN → vN+1), filtering/windowing JSON diff preview, and commit message — for visual + behavioural consistency. - entity-ui: add 'query' to EntityType (+ its display-label entry) and a queryModalAdapter (getDisplayName, archive deleteAtom, commitAtom wrapping saveQueryHeadAtom, dataAtom = queryMolecule.atoms.data, commitContextAtom providing the version + diff). Registered alongside the other adapters. - Drawer: render the shared modal externally-controlled with the unwanted flows OFF — no commitModes (no save-mode/new-variant radio) and no createEntityFields (no name editing; the drawer owns the name). onSuccess refreshes the registry store + closes the drawer, solving the modal↔drawer↔list coordination. --- .../Drawer/QueryRegistryDrawer.tsx | 74 ++++++------------ .../agenta-entity-ui/src/adapters/index.ts | 1 + .../src/adapters/queryAdapters.ts | 76 +++++++++++++++++++ .../commit/components/EntityCommitModal.tsx | 8 +- .../agenta-entity-ui/src/modals/types.ts | 2 + 5 files changed, 111 insertions(+), 50 deletions(-) create mode 100644 web/packages/agenta-entity-ui/src/adapters/queryAdapters.ts diff --git a/web/oss/src/components/QueryRegistry/Drawer/QueryRegistryDrawer.tsx b/web/oss/src/components/QueryRegistry/Drawer/QueryRegistryDrawer.tsx index c73f847986..53d47d8ec6 100644 --- a/web/oss/src/components/QueryRegistry/Drawer/QueryRegistryDrawer.tsx +++ b/web/oss/src/components/QueryRegistry/Drawer/QueryRegistryDrawer.tsx @@ -5,17 +5,16 @@ import { createSimpleQuery, invalidateQueryCache, queryMolecule, - saveQueryHeadAtom, type QueryRevision, type SimpleQueryCreate, } from "@agenta/entities/query" +import {EntityCommitModal, type EntityReference} from "@agenta/entity-ui/modals" import {projectIdAtom} from "@agenta/shared/state" import {message} from "@agenta/ui/app-message" import {Button, Form, Input, Typography} from "antd" import {useAtom, useAtomValue, useSetAtom} from "jotai" import EnhancedDrawer from "@/oss/components/EnhancedUIs/Drawer" -import EnhancedModal from "@/oss/components/EnhancedUIs/Modal" import { fromFilteringPayload, parseSamplingRate, @@ -62,9 +61,8 @@ const QueryRegistryDrawer = () => { const [saving, setSaving] = useState(false) const [matchState, setMatchState] = useState({status: "idle"}) const [showPreview, setShowPreview] = useState(false) - // Edit commits go through a commit modal so the user can attach a message. + // Edits commit through the shared EntityCommitModal (version diff + message). const [commitOpen, setCommitOpen] = useState(false) - const [commitMessage, setCommitMessage] = useState("") const watchedName = Form.useWatch("name", form) const watchedRate = Form.useWatch("sampling_rate", form) @@ -79,7 +77,9 @@ const QueryRegistryDrawer = () => { const [editState] = queryMolecule.useController(queryId) const syncDraft = useSetAtom(queryMolecule.reducers.update) const discardDraft = useSetAtom(queryMolecule.reducers.discard) - const saveQueryHead = useSetAtom(saveQueryHeadAtom) + + // Stable reference the shared commit modal resolves through the query adapter. + const commitEntity = useMemo(() => ({type: "query", id: queryId}), [queryId]) // Stable filtering payload for the preview — recomputed only when the filter // conditions change, so the preview table doesn't refetch on every render. @@ -173,7 +173,6 @@ const QueryRegistryDrawer = () => { setMatchState({status: "idle"}) setShowPreview(false) setCommitOpen(false) - setCommitMessage("") form.resetFields() }, [setActiveRow, form, queryId, discardDraft]) @@ -233,31 +232,23 @@ const QueryRegistryDrawer = () => { } // Edit: flush the validated values into the molecule draft, then open the - // commit modal so the user can attach a commit message before committing. + // shared commit modal (it reads the draft via the query adapter and lets + // the user attach a commit message before committing). syncDraft(queryId, { name: values.name, data: {filtering: filtering ?? undefined, windowing: windowing ?? undefined}, } as unknown as Partial) - setCommitMessage("") setCommitOpen(true) }, [projectId, activeRow, form, filters, isCreate, close, queryId, syncDraft]) - const confirmCommit = useCallback(async () => { - if (!projectId || !queryId) return - setSaving(true) - try { - await saveQueryHead({projectId, queryId, message: commitMessage.trim() || undefined}) - message.success("Query updated") - invalidateQueryRegistryStore() - invalidateQueryCache() - setCommitOpen(false) - close() - } catch { - message.error("Could not save query") - } finally { - setSaving(false) - } - }, [projectId, queryId, commitMessage, saveQueryHead, close]) + // After the shared modal commits (it already cleared the molecule draft and + // entity cache), refresh the registry list and close the drawer. + const onCommitSuccess = useCallback(() => { + invalidateQueryRegistryStore() + invalidateQueryCache() + setCommitOpen(false) + close() + }, [close]) return ( <> @@ -332,32 +323,17 @@ const QueryRegistryDrawer = () => {
) : null}
- setCommitOpen(false)} - okButtonProps={{loading: saving}} - > -
- - Saving creates a new version of this query. Add an optional message - describing what changed. - - setCommitMessage(event.target.value)} - placeholder="Commit message (optional)" - maxLength={280} - /> -
-
+ entity={commitEntity} + onClose={() => setCommitOpen(false)} + onSuccess={onCommitSuccess} + successMessage="Query updated" + /> ) } diff --git a/web/packages/agenta-entity-ui/src/adapters/index.ts b/web/packages/agenta-entity-ui/src/adapters/index.ts index ab6580bef3..12b127a09e 100644 --- a/web/packages/agenta-entity-ui/src/adapters/index.ts +++ b/web/packages/agenta-entity-ui/src/adapters/index.ts @@ -9,3 +9,4 @@ export {testsetModalAdapter, revisionModalAdapter} from "./testsetAdapters" export {simpleQueueModalAdapter} from "./simpleQueueAdapters" export {variantModalAdapter} from "./variantAdapters" +export {queryModalAdapter} from "./queryAdapters" diff --git a/web/packages/agenta-entity-ui/src/adapters/queryAdapters.ts b/web/packages/agenta-entity-ui/src/adapters/queryAdapters.ts new file mode 100644 index 0000000000..aa09d8ba4d --- /dev/null +++ b/web/packages/agenta-entity-ui/src/adapters/queryAdapters.ts @@ -0,0 +1,76 @@ +/** + * Query Modal Adapter + * + * Registers the query entity for the unified commit modal, so query edits use the + * same EntityCommitModal as other git-style entities — version transition (vN → + * vN+1), a filtering/windowing JSON diff, and a commit message. + * + * Name editing and the save-mode / new-variant flow are NOT wired here: the + * registry drawer already owns the name field, and simple queries are + * single-variant, so the consuming modal omits those props. + */ + +import { + archiveSimpleQuery, + invalidateQueryCache, + queryMolecule, + saveQueryHeadAtom, + type QueryRevision, +} from "@agenta/entities/query" +import {projectIdAtom} from "@agenta/shared/state" +import {atom} from "jotai" + +import {createAndRegisterEntityAdapter, type CommitContext, type CommitParams} from "../modals" + +// Archive (soft-delete). Required by the adapter contract; the registry archives +// through its own confirm flow, so this is a fallback, not the primary path. +const queryDeleteAtom = atom(null, async (get, _set, ids: string[]): Promise => { + const projectId = get(projectIdAtom) + if (!projectId) throw new Error("No project id for query archive") + await Promise.all(ids.map((queryId) => archiveSimpleQuery({projectId, queryId}))) + invalidateQueryCache() +}) + +// Commit a new head revision with the modal's message (via the molecule). +const queryCommitAtom = atom(null, async (get, set, params: CommitParams): Promise => { + const projectId = get(projectIdAtom) + if (!projectId) throw new Error("No project id for query commit") + await set(saveQueryHeadAtom, {projectId, queryId: params.id, message: params.message}) +}) + +// What the diff preview compares — the committed fields only. +const diffShape = (rev: QueryRevision | null) => ({ + name: rev?.name ?? null, + filtering: rev?.data?.filtering ?? null, + windowing: rev?.data?.windowing ?? null, +}) + +const queryCommitContextAtom = (id: string) => + atom((get): CommitContext | null => { + const serverData = get(queryMolecule.atoms.serverData(id)) ?? null + const merged = get(queryMolecule.atoms.data(id)) ?? null + const currentVersion = Number(serverData?.version ?? 0) || 0 + const original = JSON.stringify(diffShape(serverData), null, 2) + const modified = JSON.stringify(diffShape(merged), null, 2) + return { + versionInfo: { + currentVersion, + targetVersion: currentVersion + 1, + latestVersion: currentVersion, + }, + diffData: {original, modified, language: "json"}, + ...(original !== modified + ? {changesSummary: {description: "Filter / sampling updated"}} + : {}), + } + }) + +export const queryModalAdapter = createAndRegisterEntityAdapter({ + type: "query", + getDisplayName: (q) => q?.name ?? "Untitled query", + getDisplayLabel: (count) => (count === 1 ? "Query" : "Queries"), + deleteAtom: queryDeleteAtom, + commitAtom: queryCommitAtom, + dataAtom: (id) => queryMolecule.atoms.data(id), + commitContextAtom: queryCommitContextAtom, +}) diff --git a/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitModal.tsx b/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitModal.tsx index 115ca8cfd4..33dc5c7881 100644 --- a/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitModal.tsx +++ b/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitModal.tsx @@ -12,7 +12,12 @@ import {message} from "@agenta/ui/app-message" import {EnhancedModal} from "@agenta/ui/components/modal" import {useAtomValue, useSetAtom} from "jotai" -import {revisionModalAdapter, testsetModalAdapter, variantModalAdapter} from "../../../adapters" +import { + queryModalAdapter, + revisionModalAdapter, + testsetModalAdapter, + variantModalAdapter, +} from "../../../adapters" import type { EntityReference, CommitSubmitResult, @@ -48,6 +53,7 @@ import {EntityCommitTitle} from "./EntityCommitTitle" void testsetModalAdapter void revisionModalAdapter void variantModalAdapter +void queryModalAdapter export type {CommitSubmitResult, CommitSubmitParams, CommitCreateFieldsConfig} diff --git a/web/packages/agenta-entity-ui/src/modals/types.ts b/web/packages/agenta-entity-ui/src/modals/types.ts index 809a2823a4..171769318c 100644 --- a/web/packages/agenta-entity-ui/src/modals/types.ts +++ b/web/packages/agenta-entity-ui/src/modals/types.ts @@ -23,6 +23,7 @@ export type EntityType = | "evaluator" | "application" | "simpleQueue" + | "query" /** * Reference to an entity for modal operations @@ -439,6 +440,7 @@ export function getEntityTypeLabel( evaluator: ["Evaluator", "Evaluators"], application: ["Application", "Applications"], simpleQueue: ["Annotation queue", "Annotation queues"], + query: ["Query", "Queries"], } const [singular, plural] = labels[type] From a309f1d7ba7802af8a0895cef4000dda514d90aa Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 16 Jun 2026 11:55:59 +0200 Subject: [PATCH 11/21] fix(frontend): refresh query version history after a commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After committing an edit, the registry's head rows refetch (paginated store invalidate), but the parent version badges + expandable revision rows come from the table's batched revision cache (React state), which was fetched once and never refreshed — so the version stayed stale and the new revision didn't appear. Add a refresh signal (queryRegistryRevisionsRefreshAtom) bumped by invalidateQueryRegistryStore; the table drops its cached revisions when it fires, so the batched fetch re-runs and the version + child rows reflect the new revision. --- .../QueryRegistry/Table/QueryRegistryTable.tsx | 13 ++++++++++++- .../QueryRegistry/store/queryRegistryStore.ts | 11 ++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx index 39e0ac6524..b137c9427b 100644 --- a/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx +++ b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx @@ -10,7 +10,10 @@ import getFilterColumns from "@/oss/components/pages/observability/assets/getFil import type {QueryRegistryStatus} from "../store/queryRegistryFilterAtoms" import type {QueryRegistryRow} from "../store/queryRegistryStore" -import {getQueryRegistryTableState} from "../store/queryRegistryStore" +import { + getQueryRegistryTableState, + queryRegistryRevisionsRefreshAtom, +} from "../store/queryRegistryStore" import { buildFieldLabelMap, @@ -44,6 +47,7 @@ const QueryRegistryTable = ({ const isArchived = mode === "archived" const projectId = useAtomValue(projectIdAtom) const datasetStore = getQueryRegistryTableState(mode).store + const revisionsRefresh = useAtomValue(queryRegistryRevisionsRefreshAtom) // Revision history per query, batch-fetched for the visible page (newest // first). Drives the parent's head-version badge + the expandable child rows. @@ -52,6 +56,13 @@ const QueryRegistryTable = ({ >({}) const [expandedKeys, setExpandedKeys] = useState([]) + // Drop the cached revisions on invalidation (commit/restore) so the batched + // fetch re-runs and the version badges + child rows reflect the new revision. + useEffect(() => { + if (revisionsRefresh === 0) return + setRevisionsByQueryId({}) + }, [revisionsRefresh]) + // Row click opens the manage drawer, but not for revision rows. const handleRowClick = useCallback( (record: QueryRegistryRow) => { diff --git a/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts b/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts index 05ae48f532..e02502ee28 100644 --- a/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts +++ b/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts @@ -15,7 +15,7 @@ import {querySimpleQueries, type SimpleQuery} from "@agenta/entities/query" import {createPaginatedEntityStore} from "@agenta/entities/shared" import type {InfiniteTableFetchResult} from "@agenta/entities/shared" import {projectIdAtom} from "@agenta/shared/state" -import {atom} from "jotai" +import {atom, getDefaultStore} from "jotai" import {emptyFetchResult} from "@/oss/state/entities/shared" @@ -185,6 +185,15 @@ export function getQueryRegistryTableState(status: QueryRegistryStatus) { return store } +/** + * Bumped on every registry invalidation. The table's batched revision-history + * cache (parent version badges + child rows) is React state, not part of the + * paginated store, so it watches this signal to refetch after a commit/restore. + */ +export const queryRegistryRevisionsRefreshAtom = atom(0) + export function invalidateQueryRegistryStore() { _stores.forEach((store) => store.invalidate()) + const store = getDefaultStore() + store.set(queryRegistryRevisionsRefreshAtom, (v) => v + 1) } From 9af64104bcac3dccf01afe96fa9b378af459847f Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 16 Jun 2026 12:02:34 +0200 Subject: [PATCH 12/21] feat(frontend): add commit-message column to query registry QueryRevisionSummary already carries the revision message; thread it onto the head + revision rows and render a 'Commit message' column (ellipsis + tooltip), mirroring the workflow variants table's Commit notes. --- .../Table/QueryRegistryTable.tsx | 3 +++ .../Table/assets/queryRegistryColumns.tsx | 25 +++++++++++++++++++ .../QueryRegistry/store/queryRegistryStore.ts | 2 ++ 3 files changed, 30 insertions(+) diff --git a/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx index b137c9427b..5f4706f884 100644 --- a/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx +++ b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx @@ -142,6 +142,7 @@ const QueryRegistryTable = ({ const revs = revisionsByQueryId[row.queryId] if (!revs?.length) return row const headVersion = revs[0]?.version ?? null + const headMessage = revs[0]?.message ?? null const children: QueryRegistryRow[] = revs.slice(1).map((rev) => ({ key: rev.revisionId || `${row.queryId}:${rev.version}`, queryId: row.queryId, @@ -154,11 +155,13 @@ const QueryRegistryTable = ({ createdAt: rev.createdAt, createdById: rev.createdById, version: rev.version, + message: rev.message, __isRevisionChild: true, })) return { ...row, version: headVersion, + message: headMessage, ...(children.length ? {children} : {}), } }), diff --git a/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx index e39ef7d174..7cd08c113d 100644 --- a/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx +++ b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx @@ -322,6 +322,31 @@ export function createQueryRegistryColumns( ) }, }, + { + type: "text", + key: "message", + title: "Commit message", + width: 220, + render: (_value, record) => { + if (record.__isSkeleton) return + if (!record.message) { + return ( +
+ + — + +
+ ) + } + return ( +
+ + {record.message} + +
+ ) + }, + }, { type: "actions", width: 48, diff --git a/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts b/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts index e02502ee28..863de5dc55 100644 --- a/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts +++ b/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts @@ -41,6 +41,8 @@ export interface QueryRegistryRow { createdById: string | null /** Revision version label, shown as a badge on expanded history rows. */ version?: string | null + /** Git-style commit message for this revision (head row = head revision's). */ + message?: string | null /** True for a lazily-loaded revision (child) row in the version-history expand. */ __isRevisionChild?: boolean /** Placeholder row shown while a query's revisions are being fetched. */ From 3eb546ebd03fb0bac9cc5d402027bd5f35e5ed8a Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 16 Jun 2026 12:09:27 +0200 Subject: [PATCH 13/21] feat(frontend): filter v0 + add 'Last modified' tag in query registry Mirror the workflow registry: drop the auto-created v0 initial revision from the version history (Number(version) > 0), and flag the parent (head) row as the latest with a 'Last modified' tag + dot. --- .../QueryRegistry/Table/QueryRegistryTable.tsx | 8 ++++++-- .../QueryRegistry/Table/assets/queryRegistryColumns.tsx | 9 +++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx index 5f4706f884..66047837a4 100644 --- a/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx +++ b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx @@ -139,8 +139,12 @@ const QueryRegistryTable = ({ () => rows.map((row) => { if (row.__isSkeleton || !row.queryId) return row - const revs = revisionsByQueryId[row.queryId] - if (!revs?.length) return row + // Drop v0 — the auto-created initial revision with no useful data + // (matches the workflow registry). + const revs = (revisionsByQueryId[row.queryId] ?? []).filter( + (rev) => Number(rev.version ?? 0) > 0, + ) + if (!revs.length) return row const headVersion = revs[0]?.version ?? null const headMessage = revs[0]?.message ?? null const children: QueryRegistryRow[] = revs.slice(1).map((rev) => ({ diff --git a/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx index 7cd08c113d..f0fa5bbf5b 100644 --- a/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx +++ b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx @@ -226,6 +226,15 @@ export function createQueryRegistryColumns( {record.version ? ( v{record.version} ) : null} + {/* Parent row is the head revision — flag it as the latest. */} + {record.version ? ( + + + Last modified + + + + ) : null}
) }, From e1061462f6906f1304614aa8c22ec84061f8a85a Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 16 Jun 2026 12:16:01 +0200 Subject: [PATCH 14/21] feat(frontend): allow archiving individual query revisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the workflow registry, where revision rows carry per-revision actions. Revision (child) rows can now be archived via the new archiveQueryRevision (/queries/revisions/{id}/archive) — any version including the head; the parent row still archives the whole query artifact. The archive confirm modal adapts its copy (version vs query). --- .../Table/assets/queryRegistryColumns.tsx | 3 +- .../src/components/QueryRegistry/index.tsx | 28 +++++++++++++++---- .../agenta-entities/src/query/api/index.ts | 2 ++ .../src/query/api/mutations.ts | 18 ++++++++++++ .../agenta-entities/src/query/index.ts | 2 ++ 5 files changed, 46 insertions(+), 7 deletions(-) diff --git a/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx index f0fa5bbf5b..80d39d2a13 100644 --- a/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx +++ b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx @@ -162,11 +162,12 @@ function buildActionItems(actions: QueryColumnActions, isArchived: boolean) { }, {type: "divider" as const, hidden: isRevisionRow}, { + // Visible on revision rows too: the parent archives the whole query, + // a revision row archives just that version. key: "archive", label: "Archive", icon: , danger: true, - hidden: isRevisionRow, onClick: (record: QueryRegistryRow) => actions.handleArchive?.(record), }, ] diff --git a/web/oss/src/components/QueryRegistry/index.tsx b/web/oss/src/components/QueryRegistry/index.tsx index 0c87cd2f8f..765be23c7e 100644 --- a/web/oss/src/components/QueryRegistry/index.tsx +++ b/web/oss/src/components/QueryRegistry/index.tsx @@ -1,6 +1,7 @@ import {useCallback, useMemo, useState} from "react" import { + archiveQueryRevision, archiveSimpleQuery, createSimpleQuery, invalidateQueryCache, @@ -118,16 +119,26 @@ const QueryRegistry = ({mode = "active"}: QueryRegistryProps) => { setArchiveTarget(record) }, []) + const archiveTargetIsRevision = Boolean(archiveTarget?.__isRevisionChild) + const confirmArchive = useCallback(async () => { if (!projectId || !archiveTarget) return + const isRevision = Boolean(archiveTarget.__isRevisionChild) setArchiving(true) try { - await archiveSimpleQuery({projectId, queryId: archiveTarget.queryId}) - message.success("Query archived") + if (isRevision && archiveTarget.revisionId) { + // Revision row → archive just that version. + await archiveQueryRevision({projectId, revisionId: archiveTarget.revisionId}) + message.success("Version archived") + } else { + // Parent row → archive the whole query. + await archiveSimpleQuery({projectId, queryId: archiveTarget.queryId}) + message.success("Query archived") + } setArchiveTarget(null) refresh() } catch { - message.error("Could not archive query") + message.error(isRevision ? "Could not archive version" : "Could not archive query") } finally { setArchiving(false) } @@ -238,7 +249,11 @@ const QueryRegistry = ({mode = "active"}: QueryRegistryProps) => { centered width={480} open={archiveTarget !== null} - title="Archive this query?" + title={ + archiveTargetIsRevision + ? `Archive version v${archiveTarget?.version ?? ""}?` + : "Archive this query?" + } okText="Archive" cancelText="Cancel" onOk={confirmArchive} @@ -246,8 +261,9 @@ const QueryRegistry = ({mode = "active"}: QueryRegistryProps) => { okButtonProps={{danger: true, loading: archiving}} > - This query may be in use by a live evaluation. Archived queries can be restored - later. + {archiveTargetIsRevision + ? "This removes the version from the query's history. It can be restored later." + : "This query may be in use by a live evaluation. Archived queries can be restored later."} diff --git a/web/packages/agenta-entities/src/query/api/index.ts b/web/packages/agenta-entities/src/query/api/index.ts index 1185070b70..bacdbbed3a 100644 --- a/web/packages/agenta-entities/src/query/api/index.ts +++ b/web/packages/agenta-entities/src/query/api/index.ts @@ -18,6 +18,8 @@ export { editSimpleQuery, type EditSimpleQueryParams, archiveSimpleQuery, + archiveQueryRevision, + type ArchiveQueryRevisionParams, type ArchiveSimpleQueryParams, commitQueryRevision, type CommitQueryRevisionParams, diff --git a/web/packages/agenta-entities/src/query/api/mutations.ts b/web/packages/agenta-entities/src/query/api/mutations.ts index bf7d948c9a..f0ff787dc5 100644 --- a/web/packages/agenta-entities/src/query/api/mutations.ts +++ b/web/packages/agenta-entities/src/query/api/mutations.ts @@ -141,6 +141,24 @@ export async function archiveSimpleQuery({ ) } +export interface ArchiveQueryRevisionParams { + projectId: string + revisionId: string +} + +/** Archive (soft-delete) a single query revision — distinct from archiving the + * whole query artifact. Used by the registry's per-version archive. */ +export async function archiveQueryRevision({ + projectId, + revisionId, +}: ArchiveQueryRevisionParams): Promise { + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + await client.queries.archiveQueryRevision( + {query_revision_id: revisionId}, + {queryParams: {project_id: projectId}}, + ) +} + export interface UnarchiveSimpleQueryParams { projectId: string queryId: string diff --git a/web/packages/agenta-entities/src/query/index.ts b/web/packages/agenta-entities/src/query/index.ts index 08565d8bc0..8363ec8e9b 100644 --- a/web/packages/agenta-entities/src/query/index.ts +++ b/web/packages/agenta-entities/src/query/index.ts @@ -11,6 +11,8 @@ export { editSimpleQuery, type EditSimpleQueryParams, archiveSimpleQuery, + archiveQueryRevision, + type ArchiveQueryRevisionParams, type ArchiveSimpleQueryParams, commitQueryRevision, type CommitQueryRevisionParams, From 9b384afb80434fbc20ef54cbe5e36e094039840d Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 16 Jun 2026 12:42:58 +0200 Subject: [PATCH 15/21] feat(frontend): show + restore archived query revisions inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archiving a revision soft-deletes it, but it had no visible home — the Archived tab is artifact-level (whole queries). Queries use soft-delete + restore (unlike the workflow registry's hard delete), so surface archived revisions inline in the version history instead of letting them vanish. - Entity: queryRevisionsForQueries gains includeArchived (the registry passes it); QueryRevisionSummary carries deletedAt; add unarchiveQueryRevision (/queries/revisions/{id}/unarchive). - Table: head = latest non-archived revision; archived revisions render as children tagged 'Archived' (greyed). Their action menu swaps Archive → Restore; handleRestore branches revision vs query. - The refresh signal already re-runs the batched fetch, so archive/restore reflect immediately. --- .../Table/QueryRegistryTable.tsx | 44 ++++++++++--------- .../Table/assets/queryRegistryColumns.tsx | 19 +++++++- .../src/components/QueryRegistry/index.tsx | 13 ++++-- .../QueryRegistry/store/queryRegistryStore.ts | 2 + .../agenta-entities/src/query/api/api.ts | 11 ++++- .../agenta-entities/src/query/api/index.ts | 1 + .../src/query/api/mutations.ts | 12 +++++ .../agenta-entities/src/query/index.ts | 1 + 8 files changed, 77 insertions(+), 26 deletions(-) diff --git a/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx index 66047837a4..01998979e0 100644 --- a/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx +++ b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx @@ -95,7 +95,7 @@ const QueryRegistryTable = ({ const missing = headQueryIds.filter((id) => !(id in revisionsByQueryId)) if (!missing.length) return let cancelled = false - queryRevisionsForQueries({projectId, queryIds: missing}) + queryRevisionsForQueries({projectId, queryIds: missing, includeArchived: true}) .then((revs) => { if (cancelled) return // Seed each requested id (so we don't refetch) then group by query. @@ -145,27 +145,31 @@ const QueryRegistryTable = ({ (rev) => Number(rev.version ?? 0) > 0, ) if (!revs.length) return row - const headVersion = revs[0]?.version ?? null - const headMessage = revs[0]?.message ?? null - const children: QueryRegistryRow[] = revs.slice(1).map((rev) => ({ - key: rev.revisionId || `${row.queryId}:${rev.version}`, - queryId: row.queryId, - variantId: row.variantId, - revisionId: rev.revisionId, - name: row.name, - slug: row.slug, - filtering: rev.filtering, - windowing: null, - createdAt: rev.createdAt, - createdById: rev.createdById, - version: rev.version, - message: rev.message, - __isRevisionChild: true, - })) + // The head is the latest NON-archived revision (an archived head was + // repointed server-side); archived revisions still render, tagged. + const head = revs.find((rev) => !rev.deletedAt) ?? revs[0] + const children: QueryRegistryRow[] = revs + .filter((rev) => rev.revisionId !== head.revisionId) + .map((rev) => ({ + key: rev.revisionId || `${row.queryId}:${rev.version}`, + queryId: row.queryId, + variantId: row.variantId, + revisionId: rev.revisionId, + name: row.name, + slug: row.slug, + filtering: rev.filtering, + windowing: null, + createdAt: rev.createdAt, + createdById: rev.createdById, + version: rev.version, + message: rev.message, + __isRevisionChild: true, + __isArchivedRevision: Boolean(rev.deletedAt), + })) return { ...row, - version: headVersion, - message: headMessage, + version: head.version ?? null, + message: head.message ?? null, ...(children.length ? {children} : {}), } }), diff --git a/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx index 80d39d2a13..2a9f1d0b6c 100644 --- a/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx +++ b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx @@ -125,6 +125,7 @@ export interface QueryColumnActions { */ /** Revision-history (child) and loader rows carry no per-row actions. */ const isRevisionRow = (record: QueryRegistryRow) => Boolean(record.__isRevisionChild) +const isArchivedRevision = (record: QueryRegistryRow) => Boolean(record.__isArchivedRevision) function buildActionItems(actions: QueryColumnActions, isArchived: boolean) { if (isArchived) { @@ -163,13 +164,23 @@ function buildActionItems(actions: QueryColumnActions, isArchived: boolean) { {type: "divider" as const, hidden: isRevisionRow}, { // Visible on revision rows too: the parent archives the whole query, - // a revision row archives just that version. + // a revision row archives just that version. Hidden once a revision is + // already archived (it shows Restore instead). key: "archive", label: "Archive", icon: , danger: true, + hidden: isArchivedRevision, onClick: (record: QueryRegistryRow) => actions.handleArchive?.(record), }, + { + // Restore an archived revision back into the active history. + key: "restore-revision", + label: "Restore", + icon: , + hidden: (record: QueryRegistryRow) => !isArchivedRevision(record), + onClick: (record: QueryRegistryRow) => actions.handleRestore?.(record), + }, ] } @@ -192,12 +203,16 @@ export function createQueryRegistryColumns( // Revision (child) row: indent to align under the parent, show the // version badge. if (record.__isRevisionChild) { + const archived = record.__isArchivedRevision return (
- {record.name} + + {record.name} + {record.version ? ( v{record.version} ) : null} + {archived ? Archived : null}
) } diff --git a/web/oss/src/components/QueryRegistry/index.tsx b/web/oss/src/components/QueryRegistry/index.tsx index 765be23c7e..bc27a85100 100644 --- a/web/oss/src/components/QueryRegistry/index.tsx +++ b/web/oss/src/components/QueryRegistry/index.tsx @@ -5,6 +5,7 @@ import { archiveSimpleQuery, createSimpleQuery, invalidateQueryCache, + unarchiveQueryRevision, unarchiveSimpleQuery, type SimpleQueryCreate, } from "@agenta/entities/query" @@ -147,12 +148,18 @@ const QueryRegistry = ({mode = "active"}: QueryRegistryProps) => { const handleRestore = useCallback( async (record: QueryRegistryRow) => { if (!projectId) return + const isRevision = Boolean(record.__isArchivedRevision) try { - await unarchiveSimpleQuery({projectId, queryId: record.queryId}) - message.success("Query restored") + if (isRevision && record.revisionId) { + await unarchiveQueryRevision({projectId, revisionId: record.revisionId}) + message.success("Version restored") + } else { + await unarchiveSimpleQuery({projectId, queryId: record.queryId}) + message.success("Query restored") + } refresh() } catch { - message.error("Could not restore query") + message.error(isRevision ? "Could not restore version" : "Could not restore query") } }, [projectId, refresh], diff --git a/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts b/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts index 863de5dc55..fa56bc3fa3 100644 --- a/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts +++ b/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts @@ -45,6 +45,8 @@ export interface QueryRegistryRow { message?: string | null /** True for a lazily-loaded revision (child) row in the version-history expand. */ __isRevisionChild?: boolean + /** True when a revision (child) row is archived — shown tagged + restorable. */ + __isArchivedRevision?: boolean /** Placeholder row shown while a query's revisions are being fetched. */ __isRevisionLoader?: boolean /** Injected revision-history rows (antd tree children). */ diff --git a/web/packages/agenta-entities/src/query/api/api.ts b/web/packages/agenta-entities/src/query/api/api.ts index f7ed12e9c6..3a85277f12 100644 --- a/web/packages/agenta-entities/src/query/api/api.ts +++ b/web/packages/agenta-entities/src/query/api/api.ts @@ -117,6 +117,8 @@ export interface QueryRevisionSummary { createdAt: string | null createdById: string | null message: string | null + /** Soft-delete marker — set when the revision has been archived. */ + deletedAt: string | null } const toRevisionSummary = (revision: AgentaApi.QueryRevision): QueryRevisionSummary => ({ @@ -127,11 +129,13 @@ const toRevisionSummary = (revision: AgentaApi.QueryRevision): QueryRevisionSumm createdAt: revision.created_at ?? null, createdById: revision.created_by_id ?? null, message: revision.message ?? null, + deletedAt: revision.deleted_at ?? null, }) export interface QueryRevisionsByQueryParams { projectId: string queryId: string + includeArchived?: boolean } /** @@ -142,14 +146,17 @@ export interface QueryRevisionsByQueryParams { export async function queryQueryRevisions({ projectId, queryId, + includeArchived, }: QueryRevisionsByQueryParams): Promise { - return queryRevisionsForQueries({projectId, queryIds: [queryId]}) + return queryRevisionsForQueries({projectId, queryIds: [queryId], includeArchived}) } export interface QueryRevisionsForQueriesParams { projectId: string queryIds: string[] limit?: number + /** Include archived revisions so they can be shown (tagged) + restored. */ + includeArchived?: boolean } /** @@ -161,12 +168,14 @@ export async function queryRevisionsForQueries({ projectId, queryIds, limit = 500, + includeArchived, }: QueryRevisionsForQueriesParams): Promise { if (!queryIds.length) return [] const client = getAgentaSdkClient({host: getAgentaApiUrl()}) const response = await client.queries.queryQueryRevisions( { query_refs: queryIds.map((id) => ({id})), + ...(includeArchived ? {include_archived: true} : {}), windowing: {limit, order: "descending"}, }, {queryParams: {project_id: projectId}}, diff --git a/web/packages/agenta-entities/src/query/api/index.ts b/web/packages/agenta-entities/src/query/api/index.ts index bacdbbed3a..961ebebd50 100644 --- a/web/packages/agenta-entities/src/query/api/index.ts +++ b/web/packages/agenta-entities/src/query/api/index.ts @@ -19,6 +19,7 @@ export { type EditSimpleQueryParams, archiveSimpleQuery, archiveQueryRevision, + unarchiveQueryRevision, type ArchiveQueryRevisionParams, type ArchiveSimpleQueryParams, commitQueryRevision, diff --git a/web/packages/agenta-entities/src/query/api/mutations.ts b/web/packages/agenta-entities/src/query/api/mutations.ts index f0ff787dc5..a34a143110 100644 --- a/web/packages/agenta-entities/src/query/api/mutations.ts +++ b/web/packages/agenta-entities/src/query/api/mutations.ts @@ -159,6 +159,18 @@ export async function archiveQueryRevision({ ) } +/** Restore a previously archived query revision. */ +export async function unarchiveQueryRevision({ + projectId, + revisionId, +}: ArchiveQueryRevisionParams): Promise { + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + await client.queries.unarchiveQueryRevision( + {query_revision_id: revisionId}, + {queryParams: {project_id: projectId}}, + ) +} + export interface UnarchiveSimpleQueryParams { projectId: string queryId: string diff --git a/web/packages/agenta-entities/src/query/index.ts b/web/packages/agenta-entities/src/query/index.ts index 8363ec8e9b..a3fa945af8 100644 --- a/web/packages/agenta-entities/src/query/index.ts +++ b/web/packages/agenta-entities/src/query/index.ts @@ -12,6 +12,7 @@ export { type EditSimpleQueryParams, archiveSimpleQuery, archiveQueryRevision, + unarchiveQueryRevision, type ArchiveQueryRevisionParams, type ArchiveSimpleQueryParams, commitQueryRevision, From c6bcf53e4a7cdd2df96cef19198a9f73514ee98d Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 16 Jun 2026 13:06:06 +0200 Subject: [PATCH 16/21] feat(frontend): move archived query revisions into the Archived tab Archived revisions previously rendered inline in the active list, which was misleading (no other entity shows soft-deleted revisions among active ones). They now surface as flat, restorable rows in the Archived tab alongside archived queries; the active tab batch-fetches active revisions only. --- .../Table/QueryRegistryTable.tsx | 174 ++++++++++++------ .../Table/assets/queryRegistryColumns.tsx | 38 ++-- 2 files changed, 134 insertions(+), 78 deletions(-) diff --git a/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx index 01998979e0..0f61fb9a04 100644 --- a/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx +++ b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx @@ -1,7 +1,11 @@ import type {ReactNode} from "react" import {useCallback, useEffect, useMemo, useState} from "react" -import {queryRevisionsForQueries, type QueryRevisionSummary} from "@agenta/entities/query" +import { + querySimpleQueries, + queryRevisionsForQueries, + type QueryRevisionSummary, +} from "@agenta/entities/query" import {projectIdAtom} from "@agenta/shared/state" import {InfiniteVirtualTableFeatureShell, useTableManager} from "@agenta/ui/table" import {useAtomValue} from "jotai" @@ -33,7 +37,8 @@ interface QueryRegistryTableProps { mode?: QueryRegistryStatus } -const isRevisionRow = (row: QueryRegistryRow) => Boolean(row.__isRevisionChild) +const isRevisionRow = (row: QueryRegistryRow) => + Boolean(row.__isRevisionChild || row.__isArchivedRevision) const QueryRegistryTable = ({ actions, @@ -49,21 +54,23 @@ const QueryRegistryTable = ({ const datasetStore = getQueryRegistryTableState(mode).store const revisionsRefresh = useAtomValue(queryRegistryRevisionsRefreshAtom) - // Revision history per query, batch-fetched for the visible page (newest - // first). Drives the parent's head-version badge + the expandable child rows. + // Active-tab version history per query (batch-fetched, active revisions only): + // drives the head-version badge + the expandable child rows. const [revisionsByQueryId, setRevisionsByQueryId] = useState< Record >({}) + // Archived-tab top-level rows for individually-archived revisions (of queries + // that are themselves still active). + const [archivedRevisionRows, setArchivedRevisionRows] = useState([]) const [expandedKeys, setExpandedKeys] = useState([]) - // Drop the cached revisions on invalidation (commit/restore) so the batched - // fetch re-runs and the version badges + child rows reflect the new revision. + // Drop caches on invalidation (commit/archive/restore) so the relevant fetch re-runs. useEffect(() => { if (revisionsRefresh === 0) return setRevisionsByQueryId({}) + setArchivedRevisionRows([]) }, [revisionsRefresh]) - // Row click opens the manage drawer, but not for revision rows. const handleRowClick = useCallback( (record: QueryRegistryRow) => { if (isRevisionRow(record)) return @@ -89,16 +96,15 @@ const QueryRegistryTable = ({ [rows], ) - // Batch-fetch revisions for any visible queries we haven't loaded yet. + // ACTIVE tab: batch-fetch each visible query's active revision history. useEffect(() => { - if (!projectId) return + if (isArchived || !projectId) return const missing = headQueryIds.filter((id) => !(id in revisionsByQueryId)) if (!missing.length) return let cancelled = false - queryRevisionsForQueries({projectId, queryIds: missing, includeArchived: true}) + queryRevisionsForQueries({projectId, queryIds: missing}) .then((revs) => { if (cancelled) return - // Seed each requested id (so we don't refetch) then group by query. const grouped: Record = {} for (const id of missing) grouped[id] = [] for (const rev of revs) (grouped[rev.queryId] ??= []).push(rev) @@ -106,7 +112,6 @@ const QueryRegistryTable = ({ }) .catch(() => { if (cancelled) return - // Mark as fetched-empty so a transient failure doesn't loop. setRevisionsByQueryId((prev) => ({ ...prev, ...Object.fromEntries(missing.map((id) => [id, prev[id] ?? []])), @@ -115,7 +120,57 @@ const QueryRegistryTable = ({ return () => { cancelled = true } - }, [projectId, headQueryIds, revisionsByQueryId]) + }, [isArchived, projectId, headQueryIds, revisionsByQueryId]) + + // ARCHIVED tab: surface individually-archived revisions (of still-active queries) + // as their own rows, alongside the archived queries the store already provides. + useEffect(() => { + if (!isArchived || !projectId) return + let cancelled = false + ;(async () => { + try { + const response = await querySimpleQueries({projectId, includeArchived: true}) + const all = response.queries ?? [] + const activeIds = all + .filter((q) => !q.deleted_at && q.id) + .map((q) => q.id as string) + const nameById = new Map(all.map((q) => [q.id ?? "", q.name ?? q.slug ?? ""])) + if (!activeIds.length) { + if (!cancelled) setArchivedRevisionRows([]) + return + } + const revs = await queryRevisionsForQueries({ + projectId, + queryIds: activeIds, + includeArchived: true, + }) + if (cancelled) return + const archivedRows: QueryRegistryRow[] = revs + .filter((rev) => rev.deletedAt && Number(rev.version ?? 0) > 0) + .map((rev) => ({ + key: `arch-rev:${rev.revisionId}`, + queryId: rev.queryId, + variantId: null, + revisionId: rev.revisionId, + name: nameById.get(rev.queryId) || "Query", + slug: null, + filtering: rev.filtering, + windowing: null, + createdAt: rev.createdAt, + createdById: rev.createdById, + version: rev.version, + message: rev.message, + __isArchivedRevision: true, + })) + setArchivedRevisionRows(archivedRows) + } catch { + if (!cancelled) setArchivedRevisionRows([]) + } + })() + return () => { + cancelled = true + } + }, [isArchived, projectId, revisionsRefresh]) const handleExpand = useCallback((expanded: boolean, record: QueryRegistryRow) => { setExpandedKeys((prev) => @@ -130,64 +185,65 @@ const QueryRegistryTable = ({ const fieldLabels = useMemo(() => buildFieldLabelMap(getFilterColumns()), []) const columns = useMemo( - () => createQueryRegistryColumns(actions, fieldLabels, isArchived, expandState), + // No expand toggle in the archived view — its rows are leaf items. + () => + createQueryRegistryColumns( + actions, + fieldLabels, + isArchived, + isArchived ? undefined : expandState, + ), [actions, fieldLabels, isArchived, expandState], ) - // Enrich each head row with its head version + earlier-revision child rows. - const dataSource = useMemo( - () => - rows.map((row) => { - if (row.__isSkeleton || !row.queryId) return row - // Drop v0 — the auto-created initial revision with no useful data - // (matches the workflow registry). - const revs = (revisionsByQueryId[row.queryId] ?? []).filter( - (rev) => Number(rev.version ?? 0) > 0, - ) - if (!revs.length) return row - // The head is the latest NON-archived revision (an archived head was - // repointed server-side); archived revisions still render, tagged. - const head = revs.find((rev) => !rev.deletedAt) ?? revs[0] - const children: QueryRegistryRow[] = revs - .filter((rev) => rev.revisionId !== head.revisionId) - .map((rev) => ({ - key: rev.revisionId || `${row.queryId}:${rev.version}`, - queryId: row.queryId, - variantId: row.variantId, - revisionId: rev.revisionId, - name: row.name, - slug: row.slug, - filtering: rev.filtering, - windowing: null, - createdAt: rev.createdAt, - createdById: rev.createdById, - version: rev.version, - message: rev.message, - __isRevisionChild: true, - __isArchivedRevision: Boolean(rev.deletedAt), - })) - return { - ...row, - version: head.version ?? null, - message: head.message ?? null, - ...(children.length ? {children} : {}), - } - }), - [rows, revisionsByQueryId], - ) + const dataSource = useMemo(() => { + if (isArchived) { + // Archived queries (from the store) + archived revisions, flat. + return [...rows, ...archivedRevisionRows] + } + // Active: enrich each head row with its head version + revision children. + return rows.map((row) => { + if (row.__isSkeleton || !row.queryId) return row + const revs = (revisionsByQueryId[row.queryId] ?? []).filter( + (rev) => Number(rev.version ?? 0) > 0, + ) + if (!revs.length) return row + const head = revs[0] + const children: QueryRegistryRow[] = revs.slice(1).map((rev) => ({ + key: rev.revisionId || `${row.queryId}:${rev.version}`, + queryId: row.queryId, + variantId: row.variantId, + revisionId: rev.revisionId, + name: row.name, + slug: row.slug, + filtering: rev.filtering, + windowing: null, + createdAt: rev.createdAt, + createdById: rev.createdById, + version: rev.version, + message: rev.message, + __isRevisionChild: true, + })) + return { + ...row, + version: head.version ?? null, + message: head.message ?? null, + ...(children.length ? {children} : {}), + } + }) + }, [isArchived, rows, archivedRevisionRows, revisionsByQueryId]) const treeExpandable = useMemo( () => ({ expandedRowKeys: expandedKeys, - // Custom toggle in the Name cell renders the caret instead. expandIcon: () => null as unknown as null, rowExpandable: (record: QueryRegistryRow) => - !isRevisionRow(record) && !record.__isSkeleton, + !isArchived && !isRevisionRow(record) && !record.__isSkeleton, }), - [expandedKeys], + [expandedKeys, isArchived], ) - // Revision (child) rows aren't selectable — hide their checkboxes. + // Revision rows aren't selectable — hide their checkboxes. const rowSelection = useMemo( () => ({ diff --git a/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx index 2a9f1d0b6c..df0086764a 100644 --- a/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx +++ b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx @@ -123,9 +123,8 @@ export interface QueryColumnActions { * Archived tab only restores (editing an archived query is meaningless — it would * commit a revision on a soft-deleted artifact). */ -/** Revision-history (child) and loader rows carry no per-row actions. */ +/** Active expand child rows carry no parent-level actions (Open/Edit/Duplicate). */ const isRevisionRow = (record: QueryRegistryRow) => Boolean(record.__isRevisionChild) -const isArchivedRevision = (record: QueryRegistryRow) => Boolean(record.__isArchivedRevision) function buildActionItems(actions: QueryColumnActions, isArchived: boolean) { if (isArchived) { @@ -163,24 +162,14 @@ function buildActionItems(actions: QueryColumnActions, isArchived: boolean) { }, {type: "divider" as const, hidden: isRevisionRow}, { - // Visible on revision rows too: the parent archives the whole query, - // a revision row archives just that version. Hidden once a revision is - // already archived (it shows Restore instead). + // Visible on revision rows too: the parent archives the whole query, a + // revision row archives just that version (which moves it to Archived). key: "archive", label: "Archive", icon: , danger: true, - hidden: isArchivedRevision, onClick: (record: QueryRegistryRow) => actions.handleArchive?.(record), }, - { - // Restore an archived revision back into the active history. - key: "restore-revision", - label: "Restore", - icon: , - hidden: (record: QueryRegistryRow) => !isArchivedRevision(record), - onClick: (record: QueryRegistryRow) => actions.handleRestore?.(record), - }, ] } @@ -200,19 +189,30 @@ export function createQueryRegistryColumns( columnVisibilityLocked: true, render: (_value, record) => { if (record.__isSkeleton) return - // Revision (child) row: indent to align under the parent, show the - // version badge. + // Revision (child) row in the active expand: indent + version badge. if (record.__isRevisionChild) { - const archived = record.__isArchivedRevision return (
- + {record.name} + {record.version ? ( + v{record.version} + ) : null} +
+ ) + } + // Archived-revision row (archived tab, top-level): name + version + + // an Archived tag, aligned with the non-toggle rows. + if (record.__isArchivedRevision) { + return ( +
+ + {record.name} {record.version ? ( v{record.version} ) : null} - {archived ? Archived : null} + Archived
) } From 0c954f49aa9b6e36b91d8cc70229490b1d5873a2 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 16 Jun 2026 14:09:13 +0200 Subject: [PATCH 17/21] test(frontend): add live-API integration tests for the query entity Exercises the query data atoms and the registry's read/archive logic against a REAL running backend (no mocks): molecule head-revision fetch + isDirty round-trip, querySimpleQueries listing, batched revision history after a commit, single-revision archive/restore via the includeArchived split (the Archived-tab logic), and whole-query archive/restore active-vs-archived split. Reuses the existing ephemeral-account harness; the suite is skipIf(!hasBackend) so it skips (never passes) when no backend is configured. --- .../tests/integration/helpers/fixtures.ts | 48 ++++ .../integration/query.integration.test.ts | 227 ++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 web/packages/agenta-entities/tests/integration/query.integration.test.ts diff --git a/web/packages/agenta-entities/tests/integration/helpers/fixtures.ts b/web/packages/agenta-entities/tests/integration/helpers/fixtures.ts index 812913f7a6..17c5bcd1d7 100644 --- a/web/packages/agenta-entities/tests/integration/helpers/fixtures.ts +++ b/web/packages/agenta-entities/tests/integration/helpers/fixtures.ts @@ -9,7 +9,9 @@ */ import {createEnvironment, archiveEnvironment} from "../../../src/environment/api/mutations" +import {createSimpleQuery, archiveSimpleQuery} from "../../../src/query/api/mutations" import {createTestset, archiveTestsets} from "../../../src/testset/api/mutations" + import {TEST_CONFIG} from "./env" function tag(prefix: string) { @@ -92,3 +94,49 @@ export async function makeEnvironmentFixture(): Promise { }, } } + +// ── Query (saved trace filter) ────────────────────────────────────────────────── + +/** A simple, structurally-valid filtering payload for query fixtures. */ +export const QUERY_FIXTURE_FILTERING = { + conditions: [{field: "trace_type", operator: "is", value: "invocation"}], +} + +export interface QueryFixture { + queryId: string + variantId: string | null + revisionId: string + name: string + filtering: unknown + cleanup: () => Promise +} + +export async function makeQueryFixture( + filtering: unknown = QUERY_FIXTURE_FILTERING, +): Promise { + const name = tag("integration-query") + const result = await createSimpleQuery({ + projectId: TEST_CONFIG.projectId, + query: {name, data: {filtering: filtering as never}}, + }) + + return { + queryId: result.queryId, + variantId: result.variantId, + revisionId: result.revisionId, + name, + filtering, + cleanup: async () => { + // Tolerant: a test may have already archived the query (the archive-split + // case), and re-archiving a soft-deleted artifact is a no-op-or-error. + try { + await archiveSimpleQuery({ + projectId: TEST_CONFIG.projectId, + queryId: result.queryId, + }) + } catch { + /* already archived — leave the backend clean either way */ + } + }, + } +} diff --git a/web/packages/agenta-entities/tests/integration/query.integration.test.ts b/web/packages/agenta-entities/tests/integration/query.integration.test.ts new file mode 100644 index 0000000000..780a16e425 --- /dev/null +++ b/web/packages/agenta-entities/tests/integration/query.integration.test.ts @@ -0,0 +1,227 @@ +/** + * Integration tests for the query entity (saved trace filters) — data atoms and + * the registry's read/archive logic, exercised against a REAL running Agenta + * backend. There are NO mocks here: every assertion below round-trips through + * actual HTTP. The whole suite is `describe.skipIf(!hasBackend)`, so when no + * backend is configured it SKIPS (it never passes against a mock). Compare with + * tests/unit/query/*, which mock `getAgentaSdkClient` and assert request shape. + * + * The Fern client used by these api functions is authenticated from the + * ephemeral account credentials provisioned in setup/global.ts + setup/worker.ts + * (AGENTA_API_KEY / AGENTA_HOST). Each `createIntegrationStore()` builds a real + * TanStack QueryClient (staleTime 0), so `queryMolecule.atoms.query` performs a + * live network fetch. + * + * Coverage: + * • queryMolecule.atoms.query / serverData — head revision from the real API + * • queryMolecule.atoms.isDirty + reducers — update/discard round-trip + * • querySimpleQueries — created query appears in the list + * • queryRevisionsForQueries — head + history after a commit + * • archive/unarchive a single revision — includeArchived split (the + * Archived-tab logic we ship) + * • archive/unarchive the whole query — active vs archived list split + */ + +import {describe, it, expect, beforeEach, afterEach} from "vitest" + +import { + queryMolecule, + commitQueryRevision, + querySimpleQueries, + queryRevisionsForQueries, + archiveQueryRevision, + unarchiveQueryRevision, + archiveSimpleQuery, +} from "../../src/query" + +import {hasBackend} from "./helpers/env" +import {makeQueryFixture, type QueryFixture} from "./helpers/fixtures" +import {createIntegrationStore, waitForAtom} from "./helpers/store" + +const PROJECT_ID = process.env.AGENTA_TEST_PROJECT_ID || "" + +const firstConditionField = (filtering: unknown): string | undefined => { + const conditions = (filtering as {conditions?: {field?: string}[]} | null)?.conditions + return conditions?.[0]?.field +} + +describe.skipIf(!hasBackend)("query entity integration (real API)", () => { + let fixture: QueryFixture + + beforeEach(async () => { + fixture = await makeQueryFixture() + }) + + afterEach(async () => { + await fixture.cleanup() + }) + + // ── Molecule data atoms ───────────────────────────────────────────────────── + + it("atoms.query resolves from pending to settled against the live backend", async () => { + const {store} = createIntegrationStore() + + const queryAtom = queryMolecule.atoms.query(fixture.queryId) + const settled = await waitForAtom<{isPending: boolean}>( + store, + queryAtom, + (q) => !q.isPending, + ) + + expect(settled.isPending).toBe(false) + }) + + it("atoms.serverData returns the created head revision with its filtering round-tripped", async () => { + const {store} = createIntegrationStore() + + const queryAtom = queryMolecule.atoms.query(fixture.queryId) + await waitForAtom<{isPending: boolean}>(store, queryAtom, (q) => !q.isPending) + + const serverData = store.get(queryMolecule.atoms.serverData(fixture.queryId)) + expect(serverData).not.toBeNull() + expect(serverData?.name).toBe(fixture.name) + // Filtering persisted to the backend and came back on the head revision. + expect(firstConditionField(serverData?.data?.filtering)).toBe("trace_type") + }) + + it("atoms.isDirty is false on a freshly fetched query; update/discard round-trips", async () => { + const {store} = createIntegrationStore() + + const queryAtom = queryMolecule.atoms.query(fixture.queryId) + await waitForAtom<{isPending: boolean}>(store, queryAtom, (q) => !q.isPending) + + expect(store.get(queryMolecule.atoms.isDirty(fixture.queryId))).toBe(false) + + store.set(queryMolecule.reducers.update, fixture.queryId, {name: "Updated Name"}) + expect(store.get(queryMolecule.atoms.isDirty(fixture.queryId))).toBe(true) + expect(store.get(queryMolecule.atoms.data(fixture.queryId))?.name).toBe("Updated Name") + + store.set(queryMolecule.reducers.discard, fixture.queryId) + expect(store.get(queryMolecule.atoms.isDirty(fixture.queryId))).toBe(false) + expect(store.get(queryMolecule.atoms.data(fixture.queryId))?.name).toBe(fixture.name) + }) + + // ── List + revision-history fetchers (back the registry table) ────────────── + + it("querySimpleQueries lists the created query among the project's active queries", async () => { + const response = await querySimpleQueries({projectId: PROJECT_ID}) + const ids = (response.queries ?? []).map((q) => q.id) + expect(ids).toContain(fixture.queryId) + }) + + it("queryRevisionsForQueries returns head + history newest-first after a commit", async () => { + // Need the variant to commit onto; resolve from the head revision if the + // create response did not inline it. + const {store} = createIntegrationStore() + const queryAtom = queryMolecule.atoms.query(fixture.queryId) + await waitForAtom<{isPending: boolean}>(store, queryAtom, (q) => !q.isPending) + const head = store.get(queryMolecule.atoms.serverData(fixture.queryId)) + const variantId = fixture.variantId ?? head?.variant_id ?? head?.query_variant_id ?? null + expect(variantId).toBeTruthy() + + await commitQueryRevision({ + projectId: PROJECT_ID, + variantId: variantId as string, + data: { + filtering: { + conditions: [{field: "trace_type", operator: "is", value: "completion"}], + } as never, + }, + name: fixture.name, + message: "integration: second revision", + }) + + const revs = await queryRevisionsForQueries({ + projectId: PROJECT_ID, + queryIds: [fixture.queryId], + }) + // Two revisions now exist: the new head plus the original. + expect(revs.length).toBeGreaterThanOrEqual(2) + expect(revs.every((r) => r.queryId === fixture.queryId)).toBe(true) + // Distinct revision ids, none archived. + expect(new Set(revs.map((r) => r.revisionId)).size).toBe(revs.length) + expect(revs.every((r) => r.deletedAt === null)).toBe(true) + }) + + // ── Per-revision archive / restore (the Archived-tab split we ship) ───────── + + it("archiving one revision removes it from the active history and surfaces it under includeArchived", async () => { + const {store} = createIntegrationStore() + const queryAtom = queryMolecule.atoms.query(fixture.queryId) + await waitForAtom<{isPending: boolean}>(store, queryAtom, (q) => !q.isPending) + const head = store.get(queryMolecule.atoms.serverData(fixture.queryId)) + const variantId = fixture.variantId ?? head?.variant_id ?? head?.query_variant_id ?? null + expect(variantId).toBeTruthy() + + // Commit a second revision so there's a non-head revision to archive. + await commitQueryRevision({ + projectId: PROJECT_ID, + variantId: variantId as string, + data: {filtering: fixture.filtering as never}, + name: fixture.name, + message: "integration: revision to archive", + }) + + const before = await queryRevisionsForQueries({ + projectId: PROJECT_ID, + queryIds: [fixture.queryId], + }) + // Archive the oldest revision (the original head before the commit). + const target = before[before.length - 1] + expect(target?.revisionId).toBeTruthy() + + await archiveQueryRevision({projectId: PROJECT_ID, revisionId: target.revisionId}) + + // Active history (no includeArchived) no longer contains the archived revision. + const active = await queryRevisionsForQueries({ + projectId: PROJECT_ID, + queryIds: [fixture.queryId], + }) + expect(active.map((r) => r.revisionId)).not.toContain(target.revisionId) + + // includeArchived surfaces it, marked with a deletedAt — exactly what the + // Archived tab filters on (deletedAt && version > 0). + const withArchived = await queryRevisionsForQueries({ + projectId: PROJECT_ID, + queryIds: [fixture.queryId], + includeArchived: true, + }) + const archivedRev = withArchived.find((r) => r.revisionId === target.revisionId) + expect(archivedRev).toBeDefined() + expect(archivedRev?.deletedAt).not.toBeNull() + + // Restore returns it to the active history. + await unarchiveQueryRevision({projectId: PROJECT_ID, revisionId: target.revisionId}) + const restored = await queryRevisionsForQueries({ + projectId: PROJECT_ID, + queryIds: [fixture.queryId], + }) + expect(restored.map((r) => r.revisionId)).toContain(target.revisionId) + }) + + // ── Whole-query archive / restore (active vs archived list split) ─────────── + + it("archiving the query moves it from the active list to the archived list", async () => { + // Sanity: starts active, not archived. + const activeBefore = await querySimpleQueries({projectId: PROJECT_ID}) + expect((activeBefore.queries ?? []).map((q) => q.id)).toContain(fixture.queryId) + + await archiveSimpleQuery({projectId: PROJECT_ID, queryId: fixture.queryId}) + + // Active list (no includeArchived) excludes the archived query. + const activeAfter = await querySimpleQueries({projectId: PROJECT_ID}) + const activeAfterRow = (activeAfter.queries ?? []).find( + (q) => q.id === fixture.queryId && !q.deleted_at, + ) + expect(activeAfterRow).toBeUndefined() + + // includeArchived surfaces it with a deleted_at marker (the Archived tab). + const withArchived = await querySimpleQueries({ + projectId: PROJECT_ID, + includeArchived: true, + }) + const archivedRow = (withArchived.queries ?? []).find((q) => q.id === fixture.queryId) + expect(archivedRow).toBeDefined() + expect(archivedRow?.deleted_at).toBeTruthy() + }) +}) From 06de86a63a41eced047a80e063a311302d59429a Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 16 Jun 2026 18:05:24 +0200 Subject: [PATCH 18/21] perf(frontend): lazily resolve sidebar evaluator switcher list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow switcher's 'Evaluators' group reads nonHumanEvaluatorsAtom, which resolves is_feedback from each evaluator's latest revision — fanning out one batched POST /workflows/revisions/query over every evaluator in the project. That ran on sidebar mount (e.g. opening the playground) just to render a collapsed card. Defer the subscription until the switcher is first opened: a one-way switcherActivated latch (set from both Dropdowns' onOpenChange) swaps the read between nonHumanEvaluatorsAtom and a stable empty atom, so the fan-out never mounts until needed. Reopening is served from cache. The menu={{items}} rendering is untouched, so the sticky group-title styling is unaffected. --- .../Sidebar/components/WorkflowEntityCard.tsx | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/web/oss/src/components/Sidebar/components/WorkflowEntityCard.tsx b/web/oss/src/components/Sidebar/components/WorkflowEntityCard.tsx index fd265ce7d1..0af5afbdbc 100644 --- a/web/oss/src/components/Sidebar/components/WorkflowEntityCard.tsx +++ b/web/oss/src/components/Sidebar/components/WorkflowEntityCard.tsx @@ -14,7 +14,7 @@ import {WorkflowTypeTag} from "@agenta/entity-ui/workflow" import {ArrowsLeftRight, X} from "@phosphor-icons/react" import {Button, Dropdown, type MenuProps, Tooltip} from "antd" import clsx from "clsx" -import {useAtomValue, useSetAtom} from "jotai" +import {atom, useAtomValue, useSetAtom} from "jotai" import useURL from "@/oss/hooks/useURL" import {recentAppIdAtom, routerAppNavigationAtom} from "@/oss/state/app/atoms/fetcher" @@ -29,7 +29,11 @@ interface WorkflowEntityCardProps { collapsed: boolean } -const EMPTY_WORKFLOWS: readonly Workflow[] = [] +// Stable empty atom read while the switcher is dormant, so swapping it in for +// `nonHumanEvaluatorsAtom` keeps the evaluator latest-revision fan-out unmounted +// until the switcher is first opened. +const EMPTY_EVALUATORS: readonly Workflow[] = [] +const EMPTY_EVALUATORS_ATOM = atom(EMPTY_EVALUATORS) /** * Single row inside the switcher dropdown — name + per-kind type tag. @@ -128,13 +132,20 @@ const WorkflowEntityCard = memo(({collapsed}: WorkflowEntityCardProps) => { // evaluators leaked in (QA 2026-06-05). It drops ONLY human (`is_feedback`) // evaluators; navigation lands on the workflow's current sub-page (Overview/ // Evaluations are valid for every evaluator), so matchers no longer dead-end. - const automaticEvaluators = useAtomValue(nonHumanEvaluatorsAtom) as readonly Workflow[] - // Gated by `EVALUATOR_FULL_PAGE_NAV_ENABLED`: while the flag is off, the - // switcher dropdown hides the "Evaluators" group entirely. - const switcherEvaluators: readonly Workflow[] = useMemo(() => { - if (!EVALUATOR_FULL_PAGE_NAV_ENABLED) return EMPTY_WORKFLOWS - return automaticEvaluators - }, [automaticEvaluators]) + // + // LAZY: that latest-revision resolution fans out one batched + // POST /workflows/revisions/query over EVERY evaluator in the project, and + // it's only needed to populate the switcher dropdown. So we defer the + // subscription until the switcher is first opened (`switcherActivated`, + // latched on below) — until then we read a stable empty atom and the + // fan-out never mounts. The `EVALUATOR_FULL_PAGE_NAV_ENABLED` guard also + // hides the "Evaluators" group while the flag is off, so there's nothing to + // resolve in that case either. + const [switcherActivated, setSwitcherActivated] = useState(false) + const wantEvaluators = EVALUATOR_FULL_PAGE_NAV_ENABLED && switcherActivated + const switcherEvaluators = useAtomValue( + wantEvaluators ? nonHumanEvaluatorsAtom : EMPTY_EVALUATORS_ATOM, + ) as readonly Workflow[] const recentAppId = useAtomValue(recentAppIdAtom) const recentEvaluatorId = useAtomValue(recentEvaluatorIdAtom) const navigateToWorkflow = useSetAtom(routerAppNavigationAtom) @@ -213,6 +224,14 @@ const WorkflowEntityCard = memo(({collapsed}: WorkflowEntityCardProps) => { [navigateToWorkflow, workflowId], ) + // Opening the switcher latches the evaluator subscription on (one-way), so + // the batched latest-revision fetch happens on first open instead of on + // sidebar mount. It never resets, so reopening is served from cache. + const handleSwitcherOpenChange = useCallback((open: boolean) => { + setSwitcherOpen(open) + if (open) setSwitcherActivated(true) + }, []) + const handleClose = useCallback(() => { // Exit the entity context — go back to the apps listing. We replace // (not push) so the back button doesn't bring the user straight back @@ -231,7 +250,7 @@ const WorkflowEntityCard = memo(({collapsed}: WorkflowEntityCardProps) => { placement="bottomLeft" destroyOnHidden open={switcherOpen} - onOpenChange={setSwitcherOpen} + onOpenChange={handleSwitcherOpenChange} styles={{root: {zIndex: 2000, minWidth: 280}}} menu={{ items: switcherItems, @@ -270,7 +289,7 @@ const WorkflowEntityCard = memo(({collapsed}: WorkflowEntityCardProps) => { placement="bottomRight" destroyOnHidden open={switcherOpen} - onOpenChange={setSwitcherOpen} + onOpenChange={handleSwitcherOpenChange} styles={{root: {zIndex: 2000, minWidth: 280}}} menu={{ items: switcherItems, From 35f5945a281bf0d472cfc830bb1ad074e393e796 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 16 Jun 2026 19:50:46 +0200 Subject: [PATCH 19/21] perf(frontend): gate evaluator revision fan-out behind a lazy enrichment latch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aggregate evaluator atoms (key map, meta map, non-human list, feedback schemas, full-page list) each resolve EVERY evaluator's latest revision — one batched POST /workflows/revisions/query over the whole project. Several consumers read them eagerly on mount (the playground header's evaluator picker + meta-map read, the sidebar switcher), so a plain playground load fired the whole fan-out before the user touched anything. Add a shared, one-way activation gate in evaluatorUtils: those atoms stay dormant (return stable empty values, mount no revision query) until a consumer that genuinely needs enrichment activates it. - Adapter hooks (useEvaluatorEnrichedData + the enriched evaluator adapters) activate on mount by default, so every existing evaluator picker is unchanged. A new `lazy` option opts out. - Playground: the header's 'Add evaluators' picker activates on pointer-enter/ focus (lazy); the variant-config browse adapter is lazy on app playgrounds (where it's unused) and eager on evaluator entities. A cold app-playground load no longer fires the batch. - Sidebar switcher activates on open (converged onto the shared gate). - Filters + CreateQueueDrawer activate eagerly (they need the data on mount), so observability + annotation behaviour is preserved. --- web/oss/src/components/Filters/Filters.tsx | 6 +++ .../Components/PlaygroundHeader/index.tsx | 18 ++++++++- .../assets/PlaygroundVariantConfigHeader.tsx | 10 ++++- .../Sidebar/components/WorkflowEntityCard.tsx | 36 +++++++++-------- .../components/CreateQueueDrawer/index.tsx | 9 ++++- .../agenta-entities/src/workflow/index.ts | 3 ++ .../src/workflow/state/evaluatorUtils.ts | 40 +++++++++++++++++++ .../src/workflow/state/index.ts | 3 ++ .../src/selection/adapters/index.ts | 1 + .../adapters/useEnrichedEvaluatorAdapter.ts | 33 ++++++++++++--- .../agenta-entity-ui/src/selection/index.ts | 1 + 11 files changed, 135 insertions(+), 25 deletions(-) diff --git a/web/oss/src/components/Filters/Filters.tsx b/web/oss/src/components/Filters/Filters.tsx index 729ab59922..0c5c701bf2 100644 --- a/web/oss/src/components/Filters/Filters.tsx +++ b/web/oss/src/components/Filters/Filters.tsx @@ -1,6 +1,7 @@ import {useMemo, useState} from "react" import {evaluatorsListDataAtom, evaluatorFeedbackSchemasAtom} from "@agenta/entities/workflow" +import {useEnsureEvaluatorEnrichment} from "@agenta/entity-ui/selection" import { ArrowClockwiseIcon, CaretDownIcon, @@ -287,6 +288,11 @@ const Filters: React.FC = ({ inline = false, }) => { const evaluatorPreviews = useAtomValue(evaluatorsListDataAtom) + // The annotation/feedback filter genuinely needs every evaluator's output + // schema to build its options, so activate the shared enrichment gate eagerly + // here (the gate keeps the per-evaluator revision fan-out from running on + // pages that never read this atom, e.g. the playground). + useEnsureEvaluatorEnrichment() const evaluatorFeedbackSchemas = useAtomValue(evaluatorFeedbackSchemasAtom) const annotationEvaluatorOptions = useMemo( diff --git a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx index 5699e58766..efefc5f058 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx @@ -2,6 +2,7 @@ import React, {useCallback, useMemo, useState} from "react" import type {PlaygroundNode} from "@agenta/entities/runnable" import { + activateEvaluatorEnrichmentAtom, deriveWorkflowTypeFromRevision, getWorkflowTypeColor, parseWorkflowKeyFromUri, @@ -262,10 +263,21 @@ const PlaygroundHeader: React.FC = ({className, ...divPro // 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. + // + // `lazy`: the adapter + the `evaluatorWorkflowMetaMapAtom` read above sit + // behind the shared enrichment gate, so they resolve no per-evaluator + // revisions until the user reaches for this "Add evaluators" picker + // (`handleActivateEvaluatorPicker`, on pointer-enter/focus). Keeps a plain + // playground load from firing the batched revision fan-out. const evaluatorWorkflowAdapter = useEvaluatorOnlyAdapter(renderWorkflowRevisionLabel, { showWorkflowMeta: true, splitTypeTag: true, + lazy: true, }) + const activateEvaluatorEnrichment = useSetAtom(activateEvaluatorEnrichmentAtom) + const handleActivateEvaluatorPicker = useCallback(() => { + activateEvaluatorEnrichment() + }, [activateEvaluatorEnrichment]) // Controlled state for EvaluatorTemplateDropdown const [templateDropdownOpen, setTemplateDropdownOpen] = useState(false) @@ -505,7 +517,11 @@ const PlaygroundHeader: React.FC = ({className, ...divPro * playground doesn't make sense (would evaluate itself). */} {currentWorkflowCtx.workflowKind !== "evaluator" && } - + diff --git a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx index 94e2478278..1eac88d107 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx @@ -64,8 +64,14 @@ const PlaygroundVariantConfigHeader = ({ (entityData as {flags?: {is_evaluator?: boolean} | null} | null)?.flags?.is_evaluator, ) - // Browse adapters: evaluator-only or app-only (non-evaluator, non-human) - const evaluatorOnlyAdapter = useEnrichedEvaluatorOnlyAdapter() + // Browse adapters: evaluator-only or app-only (non-evaluator, non-human). + // The evaluator adapter is only USED when this is an evaluator entity (see + // `browseAdapter` below); on an app playground it's built but unused, so keep + // its evaluator-enrichment fan-out dormant (`lazy`) there. For evaluator + // entities it's needed, so activate eagerly. + const evaluatorOnlyAdapter = useEnrichedEvaluatorOnlyAdapter(undefined, { + lazy: !isEvaluatorEntity, + }) const appOnlyAdapter = useMemo( () => createWorkflowRevisionAdapter({ diff --git a/web/oss/src/components/Sidebar/components/WorkflowEntityCard.tsx b/web/oss/src/components/Sidebar/components/WorkflowEntityCard.tsx index 0af5afbdbc..72d81e3701 100644 --- a/web/oss/src/components/Sidebar/components/WorkflowEntityCard.tsx +++ b/web/oss/src/components/Sidebar/components/WorkflowEntityCard.tsx @@ -1,6 +1,7 @@ import {memo, useCallback, useMemo, useState} from "react" import { + activateEvaluatorEnrichmentAtom, nonHumanEvaluatorsAtom, nonArchivedAppWorkflowsAtom, nonArchivedEvaluatorsAtom, @@ -135,21 +136,21 @@ const WorkflowEntityCard = memo(({collapsed}: WorkflowEntityCardProps) => { // // LAZY: that latest-revision resolution fans out one batched // POST /workflows/revisions/query over EVERY evaluator in the project, and - // it's only needed to populate the switcher dropdown. So we defer the - // subscription until the switcher is first opened (`switcherActivated`, - // latched on below) — until then we read a stable empty atom and the - // fan-out never mounts. The `EVALUATOR_FULL_PAGE_NAV_ENABLED` guard also - // hides the "Evaluators" group while the flag is off, so there's nothing to - // resolve in that case either. - const [switcherActivated, setSwitcherActivated] = useState(false) - const wantEvaluators = EVALUATOR_FULL_PAGE_NAV_ENABLED && switcherActivated + // it's only needed to populate the switcher dropdown. `nonHumanEvaluatorsAtom` + // sits behind the shared enrichment gate (dormant → empty until activated), + // and we activate it on first switcher-open (see `handleSwitcherOpenChange`), + // so a plain sidebar mount never triggers the fan-out. While the + // `EVALUATOR_FULL_PAGE_NAV_ENABLED` flag is off we read a stable empty atom + // instead, so the "Evaluators" group stays hidden regardless of whether some + // other consumer has activated the gate. const switcherEvaluators = useAtomValue( - wantEvaluators ? nonHumanEvaluatorsAtom : EMPTY_EVALUATORS_ATOM, + EVALUATOR_FULL_PAGE_NAV_ENABLED ? nonHumanEvaluatorsAtom : EMPTY_EVALUATORS_ATOM, ) as readonly Workflow[] const recentAppId = useAtomValue(recentAppIdAtom) const recentEvaluatorId = useAtomValue(recentEvaluatorIdAtom) const navigateToWorkflow = useSetAtom(routerAppNavigationAtom) const requestNavigation = useSetAtom(requestNavigationAtom) + const activateEvaluatorEnrichment = useSetAtom(activateEvaluatorEnrichmentAtom) const {baseAppURL} = useURL() const [switcherOpen, setSwitcherOpen] = useState(false) @@ -224,13 +225,16 @@ const WorkflowEntityCard = memo(({collapsed}: WorkflowEntityCardProps) => { [navigateToWorkflow, workflowId], ) - // Opening the switcher latches the evaluator subscription on (one-way), so - // the batched latest-revision fetch happens on first open instead of on - // sidebar mount. It never resets, so reopening is served from cache. - const handleSwitcherOpenChange = useCallback((open: boolean) => { - setSwitcherOpen(open) - if (open) setSwitcherActivated(true) - }, []) + // Opening the switcher activates the shared evaluator-enrichment gate (one-way), + // so the batched latest-revision fetch happens on first open instead of on + // sidebar mount. Idempotent + cached, so reopening is instant. + const handleSwitcherOpenChange = useCallback( + (open: boolean) => { + setSwitcherOpen(open) + if (open && EVALUATOR_FULL_PAGE_NAV_ENABLED) activateEvaluatorEnrichment() + }, + [activateEvaluatorEnrichment], + ) const handleClose = useCallback(() => { // Exit the entity context — go back to the apps listing. We replace 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 aa41f8ed18..a6a5390cdb 100644 --- a/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/index.tsx +++ b/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/index.tsx @@ -6,7 +6,10 @@ import { type CreateSimpleQueuePayload, } from "@agenta/entities/simpleQueue" import {evaluatorWorkflowMetaMapAtom} from "@agenta/entities/workflow" -import {type WorkflowRevisionSelectionResult} from "@agenta/entity-ui/selection" +import { + type WorkflowRevisionSelectionResult, + useEnsureEvaluatorEnrichment, +} from "@agenta/entity-ui/selection" import {projectIdAtom} from "@agenta/shared/state" import {ModalContent, ModalFooter, message} from "@agenta/ui" import {Divider, Drawer, Form, Input, Select, Typography} from "antd" @@ -126,6 +129,10 @@ function CreateQueueDrawerContent({ return map }, [selectedEvaluators]) + // This drawer needs every evaluator's version count, so activate the shared + // enrichment gate (the drawer only mounts when opened, so this never runs on + // a plain page load). + useEnsureEvaluatorEnrichment() const evaluatorWorkflowMetaMap = useAtomValue(evaluatorWorkflowMetaMapAtom) const totalRevisionsByEvaluator = useMemo(() => { const map = new Map() diff --git a/web/packages/agenta-entities/src/workflow/index.ts b/web/packages/agenta-entities/src/workflow/index.ts index 01c635ebc3..30ca688417 100644 --- a/web/packages/agenta-entities/src/workflow/index.ts +++ b/web/packages/agenta-entities/src/workflow/index.ts @@ -295,6 +295,9 @@ export { nonArchivedEvaluatorsAtom, fullPagePlaygroundEvaluatorsAtom, nonHumanEvaluatorsAtom, + // Lazy enrichment gate (defers the per-evaluator latest-revision fan-out) + evaluatorEnrichmentActivatedAtom, + activateEvaluatorEnrichmentAtom, // Templates evaluatorTemplatesQueryAtom, evaluatorTemplatesDataAtom, diff --git a/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts b/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts index e36531a10c..f9cf3b4963 100644 --- a/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts +++ b/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts @@ -112,6 +112,37 @@ export const nonArchivedEvaluatorsAtom = atom((get) => { return refs.filter((ref) => !ref.deleted_at) as Workflow[] }) +// ============================================================================ +// LAZY ENRICHMENT GATE +// ============================================================================ + +/** + * The aggregate evaluator atoms below — `fullPagePlaygroundEvaluatorsAtom`, + * `nonHumanEvaluatorsAtom`, `evaluatorKeyMapAtom`, `evaluatorWorkflowMetaMapAtom`, + * `evaluatorFeedbackSchemasAtom` — each resolve EVERY evaluator's LATEST REVISION, + * which fans out one batched `POST /workflows/revisions/query` over the whole + * project. That enrichment is only needed to populate evaluator pickers / + * switchers, so the fan-out stays DORMANT until a consumer that genuinely needs + * it activates the gate (one-way, per session). Until then each atom returns a + * cheap, stable empty value and mounts no revision query. + * + * Activate imperatively via `activateEvaluatorEnrichmentAtom` (e.g. from a + * picker/switcher open handler), or eagerly via the `useEnsureEvaluatorEnrichment` + * hook for consumers that must have the data on mount. + */ +export const evaluatorEnrichmentActivatedAtom = atom(false) + +export const activateEvaluatorEnrichmentAtom = atom(null, (get, set) => { + if (!get(evaluatorEnrichmentActivatedAtom)) { + set(evaluatorEnrichmentActivatedAtom, true) + } +}) + +// Stable empty references returned while the gate is dormant (so subscribers +// don't churn on every read). +const EMPTY_EVALUATOR_LIST: Workflow[] = [] +const EMPTY_EVALUATOR_KEY_MAP = new Map() + /** * Non-archived evaluators whose latest revision has the full-page playground * UX (prompt-authored — `auto_ai_critique` / `llm` — or code-authored — @@ -132,6 +163,7 @@ export const nonArchivedEvaluatorsAtom = atom((get) => { * `nonArchivedEvaluatorsAtom`), so callers can use it as a drop-in filter. */ export const fullPagePlaygroundEvaluatorsAtom = atom((get) => { + if (!get(evaluatorEnrichmentActivatedAtom)) return EMPTY_EVALUATOR_LIST const evaluators = get(nonArchivedEvaluatorsAtom) return evaluators.filter((evaluator) => { if (!evaluator.id) return false @@ -162,6 +194,7 @@ export const fullPagePlaygroundEvaluatorsAtom = atom((get) => { * does, so a human evaluator never briefly leaks into the list. */ export const nonHumanEvaluatorsAtom = atom((get) => { + if (!get(evaluatorEnrichmentActivatedAtom)) return EMPTY_EVALUATOR_LIST const evaluators = get(nonArchivedEvaluatorsAtom) return evaluators.filter((evaluator) => { if (!evaluator.id) return false @@ -233,6 +266,7 @@ export function onEvaluatorMutation(listener: () => void): () => void { * extracts `data.uri`, and parses the evaluator key. */ export const evaluatorKeyMapAtom = atom>((get) => { + if (!get(evaluatorEnrichmentActivatedAtom)) return EMPTY_EVALUATOR_KEY_MAP const evaluators = get(nonArchivedEvaluatorsAtom) const map = new Map() @@ -279,7 +313,10 @@ export interface EvaluatorWorkflowMeta { * Reads the same batched + cached latest-revision queries as `evaluatorKeyMapAtom`, * so subscribing to this atom adds no extra requests. */ +const EMPTY_EVALUATOR_META_MAP = new Map() + export const evaluatorWorkflowMetaMapAtom = atom>((get) => { + if (!get(evaluatorEnrichmentActivatedAtom)) return EMPTY_EVALUATOR_META_MAP const evaluators = get(nonArchivedEvaluatorsAtom) const map = new Map() @@ -331,7 +368,10 @@ export interface EvaluatorFeedbackSchema { /** * Derived atom: every non-archived evaluator paired with its output-metric properties. */ +const EMPTY_EVALUATOR_FEEDBACK: EvaluatorFeedbackSchema[] = [] + export const evaluatorFeedbackSchemasAtom = atom((get) => { + if (!get(evaluatorEnrichmentActivatedAtom)) return EMPTY_EVALUATOR_FEEDBACK const evaluators = get(nonArchivedEvaluatorsAtom) const result: EvaluatorFeedbackSchema[] = [] diff --git a/web/packages/agenta-entities/src/workflow/state/index.ts b/web/packages/agenta-entities/src/workflow/state/index.ts index 08e4f50a06..1541e7e8de 100644 --- a/web/packages/agenta-entities/src/workflow/state/index.ts +++ b/web/packages/agenta-entities/src/workflow/state/index.ts @@ -165,6 +165,9 @@ export { nonArchivedEvaluatorsAtom, fullPagePlaygroundEvaluatorsAtom, nonHumanEvaluatorsAtom, + // Lazy enrichment gate (defers the per-evaluator latest-revision fan-out) + evaluatorEnrichmentActivatedAtom, + activateEvaluatorEnrichmentAtom, // Templates evaluatorTemplatesQueryAtom, evaluatorTemplatesDataAtom, diff --git a/web/packages/agenta-entity-ui/src/selection/adapters/index.ts b/web/packages/agenta-entity-ui/src/selection/adapters/index.ts index 1ea2f30946..2987e91628 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/index.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/index.ts @@ -99,6 +99,7 @@ export {renderEvaluatorPickerLabelNode, buildEvaluatorPickerLabelNode} from "./e // Enriched adapter hooks with auto-fetching evaluator template data export { + useEnsureEvaluatorEnrichment, useEvaluatorEnrichedData, useEnrichedEvaluatorBrowseAdapter, useEnrichedEvaluatorOnlyAdapter, 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 f84893cf29..1d16594f70 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts @@ -13,9 +13,10 @@ */ import type React from "react" -import {useMemo, useRef} from "react" +import {useEffect, useMemo, useRef} from "react" import { + activateEvaluatorEnrichmentAtom, evaluatorKeyMapAtom, evaluatorTemplatesMapAtom, evaluatorTemplatesDataAtom, @@ -25,7 +26,7 @@ import { workflowAppTypeAtomFamily, workflowsListDataAtom, } from "@agenta/entities/workflow" -import {atom, getDefaultStore, useAtomValue} from "jotai" +import {atom, getDefaultStore, useAtomValue, useSetAtom} from "jotai" import { renderEvaluatorPickerLabelNode, @@ -42,13 +43,35 @@ import { // SHARED ENRICHMENT HOOK // ============================================================================ +/** + * Activate the evaluator-enrichment gate (`evaluatorEnrichmentActivatedAtom`). + * + * The aggregate evaluator atoms (key map, meta map, non-human list, feedback + * schemas) stay dormant — and mount no per-evaluator latest-revision fan-out — + * until something activates them. Pickers/switchers call this: eagerly (on mount, + * the default) or lazily (`enabled` gated on first open) so the batched + * `POST /workflows/revisions/query` only runs when the data is actually needed. + * Idempotent and one-way. + */ +export function useEnsureEvaluatorEnrichment(enabled = true) { + const activate = useSetAtom(activateEvaluatorEnrichmentAtom) + useEffect(() => { + if (enabled) activate() + }, [enabled, activate]) +} + /** * Hook that provides the evaluator key map and template definitions map. * * Uses package-level atoms (auto-fetching) instead of legacy SWR hooks, * so it works on any page without manual data population. + * + * Activates the enrichment gate on mount unless `lazy` is set — lazy callers + * (e.g. the playground header) defer activation to first picker-open so a plain + * playground load doesn't trigger the evaluator revision fan-out. */ -export function useEvaluatorEnrichedData() { +export function useEvaluatorEnrichedData(options?: {lazy?: boolean}) { + useEnsureEvaluatorEnrichment(!options?.lazy) const evaluatorKeyMap = useAtomValue(evaluatorKeyMapAtom) const evaluatorDefsByKey = useAtomValue(evaluatorTemplatesMapAtom) @@ -137,9 +160,9 @@ export function useEnrichedEvaluatorBrowseAdapter() { */ export function useEnrichedEvaluatorOnlyAdapter( revisionLabelOverride?: (entity: unknown) => React.ReactNode, - options?: {showWorkflowMeta?: boolean; splitTypeTag?: boolean}, + options?: {showWorkflowMeta?: boolean; splitTypeTag?: boolean; lazy?: boolean}, ) { - const {evaluatorKeyMap, evaluatorDefsByKey} = useEvaluatorEnrichedData() + const {evaluatorKeyMap, evaluatorDefsByKey} = useEvaluatorEnrichedData({lazy: options?.lazy}) const templates = useAtomValue(evaluatorTemplatesDataAtom) const workflowMetaMap = useAtomValue(evaluatorWorkflowMetaMapAtom) const evaluatorKeyMapRef = useRef(evaluatorKeyMap) diff --git a/web/packages/agenta-entity-ui/src/selection/index.ts b/web/packages/agenta-entity-ui/src/selection/index.ts index 6f46018939..e2ef92d2a0 100644 --- a/web/packages/agenta-entity-ui/src/selection/index.ts +++ b/web/packages/agenta-entity-ui/src/selection/index.ts @@ -211,6 +211,7 @@ export type { export { renderEvaluatorPickerLabelNode, buildEvaluatorPickerLabelNode, + useEnsureEvaluatorEnrichment, useEvaluatorEnrichedData, useEnrichedEvaluatorBrowseAdapter, useEnrichedEvaluatorOnlyAdapter, From 62683c87dc79b4c41f4870e98280c22e9665e33a Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 16 Jun 2026 23:25:07 +0200 Subject: [PATCH 20/21] perf(frontend): defer evaluator revision fan-out from picker + annotate drawer mounts Follow-up to the lazy enrichment gate. Two always-mounted consumers still fanned out the per-evaluator latest-revision batch on a plain playground load, because they read evaluator-list atoms that flow through the (ungated) evaluatorRevisionFlagsMapAtom: - The evaluator EntityPicker subscribes to the adapter's list atom on mount (even while closed) via useLevelData. The enriched evaluator adapter now holds that list empty while `lazy` and the enrichment gate is closed, so a closed picker subscribes to nothing. - AnnotateDrawer is mounted (closed) in shared layouts incl. the playground and read humanEvaluatorsListDataAtom unconditionally. It now reads the list only when `open`. Cold playground load no longer fires POST /workflows/revisions/query; the data resolves when the picker is opened / the drawer opens. Evaluation-page consumers are untouched. --- .../SharedDrawers/AnnotateDrawer/index.tsx | 15 ++++++++++++--- .../adapters/useEnrichedEvaluatorAdapter.ts | 12 ++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/web/oss/src/components/SharedDrawers/AnnotateDrawer/index.tsx b/web/oss/src/components/SharedDrawers/AnnotateDrawer/index.tsx index 789c464a59..a52bdef0f3 100644 --- a/web/oss/src/components/SharedDrawers/AnnotateDrawer/index.tsx +++ b/web/oss/src/components/SharedDrawers/AnnotateDrawer/index.tsx @@ -1,7 +1,7 @@ import {useCallback, useEffect, useMemo, useState} from "react" -import {humanEvaluatorsListDataAtom} from "@agenta/entities/workflow" -import {useAtomValue} from "jotai" +import {humanEvaluatorsListDataAtom, type Workflow} from "@agenta/entities/workflow" +import {atom, useAtomValue} from "jotai" import dynamic from "next/dynamic" import {useLocalStorage} from "usehooks-ts" @@ -14,6 +14,13 @@ import {useEvaluatorSchemas} from "./assets/hooks/useEvaluatorSchemas" import {AnnotateDrawerProps, AnnotateDrawerStepsType, UpdatedMetricsType} from "./assets/types" import {isAnnotationCreatedByCurrentUser} from "./assets/utils" +// `humanEvaluatorsListDataAtom` resolves every evaluator's latest revision (a +// batched per-evaluator fan-out). This drawer is mounted (closed) in shared +// layouts incl. the playground, so reading it unconditionally fired that fan-out +// on every page load. Swap in a stable empty atom while the drawer is closed — +// the list is only needed once it opens. +const EMPTY_EVALUATOR_REFS_ATOM = atom([]) + const Annotate = dynamic(() => import("./assets/Annotate"), {ssr: false}) const SelectEvaluators = dynamic(() => import("./assets/SelectEvaluators"), {ssr: false}) const CreateEvaluator = dynamic(() => import("./assets/CreateEvaluator"), {ssr: false}) @@ -30,7 +37,9 @@ const AnnotateDrawer = ({ ...props }: AnnotateDrawerProps) => { const {projectId} = getProjectValues() - const evaluatorRefs = useAtomValue(humanEvaluatorsListDataAtom) + const evaluatorRefs = useAtomValue( + props.open ? humanEvaluatorsListDataAtom : EMPTY_EVALUATOR_REFS_ATOM, + ) const evaluators = useEvaluatorSchemas(evaluatorRefs as any) const evalLSKey = `${projectId}-evaluator` 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 1d16594f70..e75be1cb73 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts @@ -17,6 +17,7 @@ import {useEffect, useMemo, useRef} from "react" import { activateEvaluatorEnrichmentAtom, + evaluatorEnrichmentActivatedAtom, evaluatorKeyMapAtom, evaluatorTemplatesMapAtom, evaluatorTemplatesDataAtom, @@ -178,6 +179,13 @@ export function useEnrichedEvaluatorOnlyAdapter( const hasRevisionLabelOverride = Boolean(revisionLabelOverride) const showWorkflowMeta = Boolean(options?.showWorkflowMeta) const splitTypeTag = Boolean(options?.splitTypeTag) + // The EntityPicker subscribes to the list atom below on MOUNT (even while + // closed), and that list flows through evaluatorConfigsQueryStateAtom → + // evaluatorRevisionFlagsMapAtom, which fans out a latest-revision query per + // evaluator. For lazy callers we hold that list empty until the shared + // enrichment gate opens, so a closed picker mounts no fan-out. + const lazyRef = useRef(Boolean(options?.lazy)) + lazyRef.current = Boolean(options?.lazy) // Build a stable Map from template data const templateCategoryMap = useMemo(() => { @@ -197,6 +205,10 @@ export function useEnrichedEvaluatorOnlyAdapter( const autoEvaluatorsListAtom = useMemo( () => atom((get) => { + // Lazy + gate-closed → don't subscribe to the fan-out list yet. + if (lazyRef.current && !get(evaluatorEnrichmentActivatedAtom)) { + return {data: [] as unknown[], isPending: true, isError: false, error: null} + } const state = get(evaluatorConfigsQueryStateAtom) return { data: state.data as unknown[], From 1f0850fa2259906a6daf2c28e5e245a410264b05 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 16 Jun 2026 23:38:16 +0200 Subject: [PATCH 21/21] perf(frontend): defer evaluator template catalog fetch on app playgrounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The evaluator template catalog (GET /evaluators/catalog/templates) was fetched on every playground load by two always-mounted readers of evaluatorTemplatesDataAtom: - PlaygroundVariantConfig read it unconditionally, though it only uses the catalog once an evaluatorKey resolves (built-in evaluator URI). Now reads the catalog only for evaluator workflows — apps skip it (mirrors the workflow molecule, which already gates its catalog read on evaluatorKey). - The enriched evaluator adapter read the templates map/data on mount even when lazy. Now holds them empty until the enrichment gate opens (same lazy condition as the list/maps), so the playground 'Add evaluators' picker fetches the catalog on open, not on mount. It's a single static, 5-min-cached request (not the per-evaluator fan-out), but this keeps a cold app-playground load free of it. Evaluator playgrounds and other pickers are unchanged. --- .../PlaygroundVariantConfig/index.tsx | 22 ++++++++++++++----- .../adapters/useEnrichedEvaluatorAdapter.ts | 22 ++++++++++++++++--- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx index 90e2bf80ee..16ec379410 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx @@ -4,7 +4,11 @@ import {memo, useCallback, useMemo, useState} from "react" import {testcaseMolecule} from "@agenta/entities/testcase" import {parseEvaluatorKeyFromUri, workflowMolecule} from "@agenta/entities/workflow" -import {evaluatorTemplatesDataAtom, evaluatorPresetsAtomFamily} from "@agenta/entities/workflow" +import { + evaluatorTemplatesDataAtom, + evaluatorPresetsAtomFamily, + type EvaluatorCatalogTemplate, +} from "@agenta/entities/workflow" import { PlaygroundConfigSection, LoadEvaluatorPresetModal, @@ -15,7 +19,7 @@ import { import {hasPendingHydrationAtomFamily} from "@agenta/playground" import {Select} from "antd" import clsx from "clsx" -import {useAtomValue, useSetAtom} from "jotai" +import {atom, useAtomValue, useSetAtom} from "jotai" import dynamic from "next/dynamic" import {extractJsonPaths, safeParseJson} from "@/oss/lib/helpers/extractJsonPaths" @@ -27,6 +31,9 @@ import type {VariantConfigComponentProps} from "./types" const RefinePromptModal = dynamic(() => import("../Modals/RefinePromptModal"), {ssr: false}) +// Stable empty catalog read for non-evaluator workflows (avoids the templates fetch). +const EMPTY_TEMPLATES_DATA_ATOM = atom([]) + /** * PlaygroundVariantConfig manages the configuration interface for a single variant. * All entity types (including ephemeral workflows from traces) go through PlaygroundConfigSection. @@ -64,9 +71,6 @@ const PlaygroundVariantConfig: React.FC< const runnableData = useAtomValue(workflowMolecule.selectors.data(variantId)) const dispatchUpdate = useSetAtom(workflowMolecule.actions.updateConfiguration) - // Read evaluator template definitions (workflow-based) - const evaluatorDefinitions = useAtomValue(evaluatorTemplatesDataAtom) - // Determine if this is an evaluator workflow const evaluatorKey = useMemo(() => { const uri = runnableData?.data?.uri as string | undefined @@ -74,6 +78,14 @@ const PlaygroundVariantConfig: React.FC< return parseEvaluatorKeyFromUri(uri) }, [runnableData?.data?.uri]) + // Read the evaluator template catalog only for evaluator workflows — apps + // never use it, and an unconditional read fetches GET /evaluators/catalog/ + // templates on every playground load (mirrors the workflow molecule, which + // also reads the catalog only once an evaluatorKey is resolved). + const evaluatorDefinitions = useAtomValue( + evaluatorKey ? evaluatorTemplatesDataAtom : EMPTY_TEMPLATES_DATA_ATOM, + ) + const evaluatorDef = useMemo(() => { if (!evaluatorKey) return null return evaluatorDefinitions.find((e) => e.key === evaluatorKey) ?? null 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 e75be1cb73..99ccb7614e 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts @@ -21,6 +21,7 @@ import { evaluatorKeyMapAtom, evaluatorTemplatesMapAtom, evaluatorTemplatesDataAtom, + type EvaluatorCatalogTemplate, evaluatorConfigsQueryStateAtom, evaluatorWorkflowMetaMapAtom, humanEvaluatorsListQueryAtom, @@ -61,6 +62,12 @@ export function useEnsureEvaluatorEnrichment(enabled = true) { }, [enabled, activate]) } +// Stable empties read while a lazy adapter is dormant, so the evaluator template +// catalog (a separate GET /evaluators/catalog/templates fetch) isn't requested +// until the gate opens. +const EMPTY_TEMPLATES_MAP_ATOM = atom>(new Map()) +const EMPTY_TEMPLATES_DATA_ATOM = atom([]) + /** * Hook that provides the evaluator key map and template definitions map. * @@ -69,12 +76,17 @@ export function useEnsureEvaluatorEnrichment(enabled = true) { * * Activates the enrichment gate on mount unless `lazy` is set — lazy callers * (e.g. the playground header) defer activation to first picker-open so a plain - * playground load doesn't trigger the evaluator revision fan-out. + * playground load doesn't trigger the evaluator revision fan-out (and holds the + * template catalog read until the gate opens too). */ export function useEvaluatorEnrichedData(options?: {lazy?: boolean}) { useEnsureEvaluatorEnrichment(!options?.lazy) + const activated = useAtomValue(evaluatorEnrichmentActivatedAtom) + const wantData = !options?.lazy || activated const evaluatorKeyMap = useAtomValue(evaluatorKeyMapAtom) - const evaluatorDefsByKey = useAtomValue(evaluatorTemplatesMapAtom) + const evaluatorDefsByKey = useAtomValue( + wantData ? evaluatorTemplatesMapAtom : EMPTY_TEMPLATES_MAP_ATOM, + ) return {evaluatorKeyMap, evaluatorDefsByKey} } @@ -164,7 +176,11 @@ export function useEnrichedEvaluatorOnlyAdapter( options?: {showWorkflowMeta?: boolean; splitTypeTag?: boolean; lazy?: boolean}, ) { const {evaluatorKeyMap, evaluatorDefsByKey} = useEvaluatorEnrichedData({lazy: options?.lazy}) - const templates = useAtomValue(evaluatorTemplatesDataAtom) + const activated = useAtomValue(evaluatorEnrichmentActivatedAtom) + const wantData = !options?.lazy || activated + const templates = useAtomValue( + wantData ? evaluatorTemplatesDataAtom : EMPTY_TEMPLATES_DATA_ATOM, + ) const workflowMetaMap = useAtomValue(evaluatorWorkflowMetaMapAtom) const evaluatorKeyMapRef = useRef(evaluatorKeyMap) const evaluatorDefsByKeyRef = useRef(evaluatorDefsByKey)