From b37cc8e2e7ed4a546ff63104a1bf96891d0d824f Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 6 Jul 2026 13:19:21 +0200 Subject: [PATCH 01/50] perf(frontend): gate access plans/roles queries on full auth context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plansQueryAtom and rolesQueryAtom were enabled as soon as the session existed, so they fired before the profile resolved and the axios interceptor had the user + project it needs — the request went out and was immediately aborted. Gate `enabled` on user id + projectId as well so each query fires once, only when it can actually succeed. --- web/oss/src/state/access/atoms.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/web/oss/src/state/access/atoms.ts b/web/oss/src/state/access/atoms.ts index 0b5eb8fee81..976be60adee 100644 --- a/web/oss/src/state/access/atoms.ts +++ b/web/oss/src/state/access/atoms.ts @@ -39,6 +39,8 @@ export type RolesCatalog = Record<"organization" | "workspace" | "project", Role export const plansQueryAtom = atomWithQuery((get) => { const sessionExists = get(sessionExistsAtom) + const user = get(profileQueryAtom).data as {id?: string} | undefined + const projectId = get(projectIdAtom) return { queryKey: ["access", "plans"], queryFn: async (): Promise => { @@ -49,7 +51,10 @@ export const plansQueryAtom = atomWithQuery((get) => { refetchOnWindowFocus: false, refetchOnReconnect: false, refetchOnMount: true, - enabled: isEE() && sessionExists, + // Gate on the full auth context the axios interceptor requires (user + + // project), not just the session, so the request isn't fired and aborted + // before the profile resolves. + enabled: isEE() && sessionExists && !!user && !!projectId, retry: (failureCount, error) => { if ((error as any)?.response?.status >= 400 && (error as any)?.response?.status < 500) { return false @@ -214,6 +219,8 @@ export const queueMaxItemsAtom = atom((get): number => { export const rolesQueryAtom = atomWithQuery((get) => { const sessionExists = get(sessionExistsAtom) + const user = get(profileQueryAtom).data as {id?: string} | undefined + const projectId = get(projectIdAtom) return { queryKey: ["access", "roles"], queryFn: async (): Promise => { @@ -224,7 +231,10 @@ export const rolesQueryAtom = atomWithQuery((get) => { refetchOnWindowFocus: false, refetchOnReconnect: false, refetchOnMount: true, - enabled: sessionExists, + // Gate on the full auth context the axios interceptor requires (user + + // project), not just the session, so the request isn't fired and aborted + // before the profile resolves. + enabled: sessionExists && !!user && !!projectId, retry: (failureCount, error) => { if ((error as any)?.response?.status >= 400 && (error as any)?.response?.status < 500) { return false From 5862651ff3329755e1b8dd04aa3d3c3a3cb6f225 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 23 Jun 2026 02:13:43 +0200 Subject: [PATCH 02/50] chore(frontend): defer project-wide list/catalog queries behind lazy gates Stop firing project-wide list/catalog queries on plain page loads; defer them behind gates that open only when a feature needs the data. - Evaluator revision fan-out (POST /workflows/revisions/query): one-way evaluatorEnrichmentActivatedAtom gate; enrichment atoms return stable empties until a picker/switcher activates. - Evaluator catalog (GET /evaluators/catalog/templates): dropdown body mounts only when popover opens; playground + revision drawer gate on the is_evaluator flag (builtin apps share the agenta:builtin: URI prefix). - Current workflow/app by id: workflowDetailQueryAtomFamily + useCurrentAppLite resolve one artifact by id instead of listing the whole apps/evaluator catalog; dedupe duplicate /workflows/query calls. - Sidebar: switcher lists read EMPTY_EVALUATORS_ATOM until opened; suppress recent-evaluator type-tag flash during workflow resolution. - Browse-mode pickers read a stable empty list atom in scoped (app) mode. - Annotation drawer reads empty evaluator refs while closed. - Projects/webhooks: drop workspaceId/projectId params (backend scopes from session). --- .../Scripts/assets/CloudScripts.tsx | 34 +-- .../components/EvaluatorTemplateDropdown.tsx | 270 ++++++++++-------- web/oss/src/components/Filters/Filters.tsx | 6 + .../Components/Menus/SelectVariant/index.tsx | 23 +- .../Components/PlaygroundHeader/index.tsx | 18 +- .../assets/PlaygroundVariantConfigHeader.tsx | 10 +- .../PlaygroundVariantConfig/index.tsx | 36 ++- .../SharedDrawers/AnnotateDrawer/index.tsx | 15 +- .../Sidebar/hooks/useSidebarConfig/index.tsx | 4 +- .../Sidebar/hooks/useWorkflowSwitcher.tsx | 83 ++++-- .../WorkflowRevisionDrawerWrapper/index.tsx | 15 +- .../hooks/useCustomWorkflowConfig.tsx | 12 +- web/oss/src/hooks/usePlaygroundNavigation.ts | 19 +- web/oss/src/lib/atoms/breadcrumb/index.ts | 9 +- web/oss/src/lib/helpers/auth/AuthProvider.tsx | 17 ++ .../p/[project_id]/apps/[app_id]/index.tsx | 34 ++- web/oss/src/state/app/atoms/fetcher.ts | 35 +-- web/oss/src/state/app/hooks.ts | 78 +++-- web/oss/src/state/app/selectors/app.ts | 6 +- .../src/state/workflow/selectors/workflow.ts | 36 ++- .../components/CreateQueueDrawer/index.tsx | 9 +- .../agenta-entities/src/workflow/index.ts | 5 + .../src/workflow/state/evaluatorUtils.ts | 40 +++ .../src/workflow/state/index.ts | 5 + .../src/workflow/state/store.ts | 35 +++ .../src/selection/adapters/index.ts | 1 + .../adapters/useEnrichedEvaluatorAdapter.ts | 65 ++++- .../agenta-entity-ui/src/selection/index.ts | 1 + 28 files changed, 646 insertions(+), 275 deletions(-) diff --git a/web/ee/src/components/Scripts/assets/CloudScripts.tsx b/web/ee/src/components/Scripts/assets/CloudScripts.tsx index ca7c51cbab2..3f3399a9b86 100644 --- a/web/ee/src/components/Scripts/assets/CloudScripts.tsx +++ b/web/ee/src/components/Scripts/assets/CloudScripts.tsx @@ -10,18 +10,18 @@ import {getEnv} from "@/oss/lib/helpers/dynamicEnv" const CloudScripts = () => { const {appTheme} = useAppTheme() - useEffect(() => { - const isCrispEnabled = !!getEnv("NEXT_PUBLIC_CRISP_WEBSITE_ID") + // useEffect(() => { + // const isCrispEnabled = !!getEnv("NEXT_PUBLIC_CRISP_WEBSITE_ID") - if (!isCrispEnabled) { - return - } + // if (!isCrispEnabled) { + // return + // } - Crisp.configure(getEnv("NEXT_PUBLIC_CRISP_WEBSITE_ID")) - }, []) + // Crisp.configure(getEnv("NEXT_PUBLIC_CRISP_WEBSITE_ID")) + // }, []) // The Crisp chatbox renders in its own cross-origin iframe, so we can't style - // its light/dark theme from our CSS, and crisp-sdk-web exposes no runtime + // its light/dark useCrispChat from our CSS, and crisp-sdk-web exposes no runtime // light/dark toggle (only the accent color via setColorTheme). Darken the // accent in dark mode so the launcher/accent reads less out-of-place; light // restores the dashboard's "default" accent. @@ -30,17 +30,17 @@ const CloudScripts = () => { // in the Crisp dashboard (Settings → Chatbox → Appearance). That follows the // visitor's *system* color scheme — the SDK has no API to bind it to our // in-app theme toggle, so this accent tweak is the only code-side lever. - useEffect(() => { - const isCrispEnabled = !!getEnv("NEXT_PUBLIC_CRISP_WEBSITE_ID") + // useEffect(() => { + // const isCrispEnabled = !!getEnv("NEXT_PUBLIC_CRISP_WEBSITE_ID") - if (!isCrispEnabled) { - return - } + // if (!isCrispEnabled) { + // return + // } - Crisp.setColorTheme( - appTheme === ThemeMode.Dark ? ChatboxColors.Black : ChatboxColors.Default, - ) - }, [appTheme]) + // Crisp.setColorTheme( + // appTheme === ThemeMode.Dark ? ChatboxColors.Black : ChatboxColors.Default, + // ) + // }, [appTheme]) return ( <> diff --git a/web/oss/src/components/Evaluators/components/EvaluatorTemplateDropdown.tsx b/web/oss/src/components/Evaluators/components/EvaluatorTemplateDropdown.tsx index c678fa396a5..5e78c19eade 100644 --- a/web/oss/src/components/Evaluators/components/EvaluatorTemplateDropdown.tsx +++ b/web/oss/src/components/Evaluators/components/EvaluatorTemplateDropdown.tsx @@ -35,9 +35,155 @@ interface EvaluatorTemplateDropdownProps { placement?: PopoverProps["placement"] } +interface EvaluatorTemplateDropdownContentProps { + /** Fires with the chosen template (the popover is closed first). */ + onSelect: (template: EvaluatorCatalogTemplate) => void + /** Closes the popover. */ + onClose: () => void +} + +/** + * Popover body: the evaluator template catalog (tabs + filterable list). + * + * The catalog comes from a dedicated GET /evaluators/catalog/templates fetch, and + * this component subscribes to it on mount. The parent renders it ONLY while the + * popover is open, so mounted-but-closed dropdowns (e.g. the playground header's + * always-present "Add evaluators" overlay anchor) never touch the catalog: the + * fetch happens on first open instead of on every page load. React-query caches + * it (5 min staleTime), so reopening is a cache hit with no refetch. + */ +const EvaluatorTemplateDropdownContent = memo( + ({onSelect, onClose}: EvaluatorTemplateDropdownContentProps) => { + const [activeTab, setActiveTab] = useState(DEFAULT_TAB_KEY) + + const templates = useAtomValue(evaluatorTemplatesDataAtom) + const {isPending: isLoadingEvaluators} = useAtomValue(evaluatorTemplatesQueryAtom) + + const tabItems = useMemo(() => buildEvaluatorTabItems(templates), [templates]) + + const filteredEvaluators = useMemo(() => { + const enabledEvaluators = filterEnabledEvaluators(templates) + return filterEvaluatorsByTag(enabledEvaluators, activeTab) + }, [activeTab, templates]) + + const handleTabChange = useCallback((key: string) => { + setActiveTab(key) + }, []) + + const handleTemplateSelect = useCallback( + (template: EvaluatorCatalogTemplate) => { + onClose() + onSelect(template) + }, + [onClose, onSelect], + ) + + const renderList = () => { + if (isLoadingEvaluators) { + return ( +
+ {Array.from({length: 3}).map((_, index) => ( + + ))} +
+ ) + } + + if (!filteredEvaluators.length) { + return ( +
+ +
+ ) + } + + return ( +
+ {filteredEvaluators.map((item) => { + const tagColor = getEvaluatorTagColor(item) + + return ( +
handleTemplateSelect(item)} + className={cn( + "border-0 border-b border-solid last:border-b-0", + borderColors.secondary, + "min-h-[56px] flex flex-col justify-center gap-1 py-2 px-4", + "cursor-pointer group transition-colors", + bgColors.hoverState, + )} + > +
+ + {item.name} + + +
+ + {item.description} + +
+ ) + })} +
+ ) + } + + return ( +
+
+ + Select evaluator type + +
+ + {renderList()} +
+ ) + }, +) +EvaluatorTemplateDropdownContent.displayName = "EvaluatorTemplateDropdownContent" + /** * Dropdown component for selecting an evaluator template. * Shows a filterable list of enabled evaluator types with tab-based category filtering. + * + * The catalog-reading body lives in `EvaluatorTemplateDropdownContent`, which is + * mounted only while the popover is open — so a closed dropdown does no data work + * and fires no network request, even when it stays mounted as an anchor (e.g. the + * playground header's "Add evaluators" overlay). */ const EvaluatorTemplateDropdown = ({ onSelect, @@ -47,7 +193,6 @@ const EvaluatorTemplateDropdown = ({ onOpenChange: controlledOnOpenChange, placement = "bottomRight", }: EvaluatorTemplateDropdownProps) => { - const [activeTab, setActiveTab] = useState(DEFAULT_TAB_KEY) const [internalOpen, setInternalOpen] = useState(false) // Support both controlled and uncontrolled modes @@ -63,121 +208,7 @@ const EvaluatorTemplateDropdown = ({ }, [isControlled, controlledOnOpenChange], ) - const nonArchivedEvaluators = useAtomValue(evaluatorTemplatesDataAtom) - const {isPending: isLoadingEvaluators} = useAtomValue(evaluatorTemplatesQueryAtom) - - const tabItems = useMemo(() => { - return buildEvaluatorTabItems(nonArchivedEvaluators) - }, [nonArchivedEvaluators]) - - const filteredEvaluators = useMemo(() => { - const enabledEvaluators = filterEnabledEvaluators(nonArchivedEvaluators) - return filterEvaluatorsByTag(enabledEvaluators, activeTab) - }, [activeTab, nonArchivedEvaluators]) - - const handleTabChange = useCallback((key: string) => { - setActiveTab(key) - }, []) - - const handleTemplateSelect = useCallback( - (template: EvaluatorCatalogTemplate) => { - setOpen(false) - setActiveTab(DEFAULT_TAB_KEY) - onSelect(template) - }, - [onSelect], - ) - - const renderDropdownContent = () => { - if (isLoadingEvaluators) { - return ( -
- {Array.from({length: 3}).map((_, index) => ( - - ))} -
- ) - } - - if (!filteredEvaluators.length) { - return ( -
- -
- ) - } - - return ( -
- {filteredEvaluators.map((item) => { - const tagColor = getEvaluatorTagColor(item) - - return ( -
handleTemplateSelect(item)} - className={cn( - "border-0 border-b border-solid last:border-b-0", - borderColors.secondary, - "min-h-[56px] flex flex-col justify-center gap-1 py-2 px-4", - "cursor-pointer group transition-colors", - bgColors.hoverState, - )} - > -
- - {item.name} - - -
- - {item.description} - -
- ) - })} -
- ) - } - - const popoverContent = ( -
-
- - Select evaluator type - -
- - {renderDropdownContent()} -
- ) + const handleClose = useCallback(() => setOpen(false), [setOpen]) const defaultTrigger = @@ -186,7 +217,12 @@ const EvaluatorTemplateDropdown = ({ open={open} onOpenChange={setOpen} trigger={["click"]} - content={popoverContent} + destroyOnHidden + content={ + open ? ( + + ) : null + } placement={placement} arrow={false} styles={{container: {padding: 0}}} diff --git a/web/oss/src/components/Filters/Filters.tsx b/web/oss/src/components/Filters/Filters.tsx index d61f056c590..2712679f5da 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, @@ -286,6 +287,11 @@ const Filters: React.FC = ({ reconcileFilterRows, }) => { 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/Menus/SelectVariant/index.tsx b/web/oss/src/components/Playground/Components/Menus/SelectVariant/index.tsx index 7eae12fac40..8c5f6349788 100644 --- a/web/oss/src/components/Playground/Components/Menus/SelectVariant/index.tsx +++ b/web/oss/src/components/Playground/Components/Menus/SelectVariant/index.tsx @@ -27,7 +27,7 @@ import {playgroundController} from "@agenta/playground" import {DownOutlined} from "@ant-design/icons" import {Plus} from "@phosphor-icons/react" import {Button, Popover, Space} from "antd" -import {useAtomValue, useSetAtom} from "jotai" +import {atom, useAtomValue, useSetAtom} from "jotai" import {recordWidgetEventAtom} from "@/oss/lib/onboarding" import {selectedAppIdAtom} from "@/oss/state/app" @@ -36,6 +36,18 @@ import RevisionChildTitle from "./components/RevisionChildTitle" import VariantGroupTitle from "./components/VariantGroupTitle" import {SelectVariantProps} from "./types" +// Stable empty workflow-list state read in non-browse (scoped) mode. The combined +// `workflowsListQueryStateAtom` fetches EVERY app + evaluator in the project; this +// picker only needs it to label the trigger in BROWSE mode. On a scoped (app) +// playground that label is null, so reading the real atom there pulls both full +// catalogs for nothing — hold this empty instead. +const EMPTY_WORKFLOWS_LIST_STATE_ATOM = atom({ + data: [] as {id: string; name?: string | null}[], + isPending: false, + isError: false, + error: null as Error | null, +}) + const SelectVariant = ({ value, showAsCompare = false, @@ -333,8 +345,13 @@ const SelectVariant = ({ return name && name.length > 0 ? name : null }, [selectedVariantId, workflowVariants]) - // Look up the parent workflow name for browse mode trigger label - const workflowsList = useAtomValue(workflowsListQueryStateAtom) + // Look up the parent workflow name for the browse-mode trigger label. Only + // BROWSE mode uses this (scoped mode returns null below), so gate the + // full-catalog read on `mode` — otherwise an app playground fetches every app + // + evaluator just to render this picker's trigger. + const workflowsList = useAtomValue( + mode === "browse" ? workflowsListQueryStateAtom : EMPTY_WORKFLOWS_LIST_STATE_ATOM, + ) as {data: {id: string; name?: string | null}[]} const workflowName = useMemo(() => { if (mode !== "browse") return null if (!selectedWorkflowId) return null diff --git a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx index 34ad3932a78..7ee8f867f89 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, @@ -443,10 +444,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) @@ -709,7 +721,11 @@ const PlaygroundHeader: React.FC = ({className, ...divPro {showEvalActions && ( <> - + 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 65fd4d92e30..ce7af5c5bc9 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/Playground/Components/PlaygroundVariantConfig/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx index eb0d4b9775d..44ea8f4c401 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, isAgentModeAtomFamily} 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. @@ -66,17 +73,32 @@ const PlaygroundVariantConfig: React.FC< // Get workflow data for evaluator detection const runnableData = useAtomValue(workflowMolecule.selectors.data(variantId)) + const isEvaluator = useAtomValue(workflowMolecule.selectors.isEvaluator(variantId)) const dispatchUpdate = useSetAtom(workflowMolecule.actions.updateConfiguration) - // Read evaluator template definitions (workflow-based) - const evaluatorDefinitions = useAtomValue(evaluatorTemplatesDataAtom) - - // Determine if this is an evaluator workflow + // Determine if this is an evaluator workflow. + // + // Gate on the canonical `is_evaluator` FLAG, not the URI prefix alone: + // builtin APPS (chat/completion) also carry an `agenta:builtin:` URI, so a + // prefix-only check misclassifies them as evaluators — which then reads + // `evaluatorTemplatesDataAtom` below and fetches the entire evaluator catalog + // (GET /evaluators/catalog/templates) on a plain app playground load. Mirrors + // the workflow molecule's own gate (`molecule.ts` `parametersSchemaAtomFamily`, + // which checks `entity.flags.is_evaluator`). const evaluatorKey = useMemo(() => { + if (!isEvaluator) return null const uri = runnableData?.data?.uri as string | undefined if (!uri || !uri.startsWith("agenta:builtin:")) return null return parseEvaluatorKeyFromUri(uri) - }, [runnableData?.data?.uri]) + }, [isEvaluator, 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 diff --git a/web/oss/src/components/SharedDrawers/AnnotateDrawer/index.tsx b/web/oss/src/components/SharedDrawers/AnnotateDrawer/index.tsx index 789c464a595..a52bdef0f32 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/oss/src/components/Sidebar/hooks/useSidebarConfig/index.tsx b/web/oss/src/components/Sidebar/hooks/useSidebarConfig/index.tsx index 7dd9873cca1..9d5c56453a4 100644 --- a/web/oss/src/components/Sidebar/hooks/useSidebarConfig/index.tsx +++ b/web/oss/src/components/Sidebar/hooks/useSidebarConfig/index.tsx @@ -15,7 +15,7 @@ import { import {getEntityKindIcon} from "@/oss/components/References" import useURL from "@/oss/hooks/useURL" -import {useAppsData} from "@/oss/state/app" +import {useCurrentAppLite} from "@/oss/state/app" import {useAppState} from "@/oss/state/appState" import { @@ -36,7 +36,7 @@ export interface MainSidebarItems { } export const useSidebarConfig = (): MainSidebarItems => { - const {currentApp, recentlyVisitedAppId} = useAppsData() + const {currentApp, recentlyVisitedAppId} = useCurrentAppLite() const {appId: routedAppId, routeLayer} = useAppState() const {projectURL, baseAppURL, appURL, recentlyVisitedAppURL} = useURL() const dynamicChildren = useSidebarDynamicChildren() diff --git a/web/oss/src/components/Sidebar/hooks/useWorkflowSwitcher.tsx b/web/oss/src/components/Sidebar/hooks/useWorkflowSwitcher.tsx index 7d187d75061..2d2861bff43 100644 --- a/web/oss/src/components/Sidebar/hooks/useWorkflowSwitcher.tsx +++ b/web/oss/src/components/Sidebar/hooks/useWorkflowSwitcher.tsx @@ -8,7 +8,7 @@ import { } from "@agenta/entities/workflow" import type {MenuProps} from "antd" import clsx from "clsx" -import {useAtomValue, useSetAtom} from "jotai" +import {atom, useAtomValue, useSetAtom} from "jotai" import {recentAppIdAtom, routerAppNavigationAtom} from "@/oss/state/app/atoms/fetcher" import { @@ -23,6 +23,10 @@ import WorkflowIdentity from "../components/WorkflowIdentity" import {resolveIsEvaluatorWorkflow} from "./workflowSwitcherHelpers" const EMPTY_WORKFLOWS: readonly Workflow[] = [] +// Stable empty atom read while the switcher is dormant, so the apps/evaluator +// list atoms (and the evaluator latest-revision fan-out behind them) stay +// unsubscribed until the switcher is first opened. +const EMPTY_WORKFLOWS_ATOM = atom(EMPTY_WORKFLOWS) const getWorkflowActivityTime = (workflow: Workflow) => { const timestamp = workflow.updated_at ?? workflow.created_at @@ -39,35 +43,70 @@ export const WORKFLOW_SWITCHER_MENU_CLASS = clsx( export const useWorkflowSwitcher = () => { const context = useAtomValue(currentWorkflowContextAtom) - const apps = useAtomValue(nonArchivedAppWorkflowsAtom) as readonly Workflow[] - const evaluators = useAtomValue(nonArchivedEvaluatorsAtom) as readonly Workflow[] - const nonDeterministicEvaluators = useAtomValue( - nonDeterministicEvaluatorsAtom, + const [open, setOpenState] = useState(false) + // Latch: flips true on first switcher-open and never resets, so the full + // apps/evaluators catalogs (and the evaluator revision fan-out) resolve + // LAZILY on open instead of on every sidebar mount, then stay warm. + const [switcherActivated, setSwitcherActivated] = useState(false) + const setOpen = useCallback((next: boolean) => { + setOpenState(next) + if (next) setSwitcherActivated(true) + }, []) + + // The full apps + evaluators lists are needed only to populate the switcher + // (once opened) or to resolve the recent-workflow fallback on a route that + // points at NO workflow (e.g. /home). On a workflow route with the switcher + // closed we need neither, so we read stable empty atoms to avoid pulling the + // whole apps/evaluator catalogs on every page load. Gate on `workflowId` (the + // URL id, truthy from the first render), NOT on `workflow` (null while + // resolution is in flight, which would still fire the catalogs). + const wantWorkflowLists = !context.workflowId || switcherActivated + const apps = useAtomValue( + wantWorkflowLists ? nonArchivedAppWorkflowsAtom : EMPTY_WORKFLOWS_ATOM, + ) as readonly Workflow[] + const evaluators = useAtomValue( + wantWorkflowLists ? nonArchivedEvaluatorsAtom : EMPTY_WORKFLOWS_ATOM, ) as readonly Workflow[] const recentAppId = useAtomValue(recentAppIdAtom) const recentEvaluatorId = useAtomValue(recentEvaluatorIdAtom) const navigateToWorkflow = useSetAtom(routerAppNavigationAtom) - const [open, setOpen] = useState(false) // Product decision: the workflow switcher is intentionally narrower than // full-page routing. It includes non-deterministic automatic evaluators // (LLM/code/hook/online-capable), but not deterministic matchers or humans. - const switcherEvaluators = EVALUATOR_FULL_PAGE_NAV_ENABLED - ? nonDeterministicEvaluators - : EMPTY_WORKFLOWS - - const workflow = useMemo( - () => - resolveWorkflowEntitySelection({ - currentWorkflow: context.workflow, - currentWorkflowId: context.workflowId, - apps, - evaluators, - recentAppId, - recentEvaluatorId, - }), - [apps, context.workflow, context.workflowId, evaluators, recentAppId, recentEvaluatorId], - ) + // LAZY: reading `nonDeterministicEvaluatorsAtom` fans out one batched + // POST /workflows/revisions/query over every evaluator, and it's only needed + // to populate the switcher dropdown — so subscribe to it only once opened. + const switcherEvaluators = useAtomValue( + EVALUATOR_FULL_PAGE_NAV_ENABLED && switcherActivated + ? nonDeterministicEvaluatorsAtom + : EMPTY_WORKFLOWS_ATOM, + ) as readonly Workflow[] + + const workflow = useMemo(() => { + if (context.workflow) return context.workflow + // While the URL's own workflow is still resolving, do NOT substitute a + // stale recent workflow: on an app route the recent entry is often a + // recent EVALUATOR, and flashing its tag fires the evaluator catalog for + // a card about to swap to the app's own type. Waiting one tick avoids it. + if (context.isResolving) return null + return resolveWorkflowEntitySelection({ + currentWorkflow: context.workflow, + currentWorkflowId: context.workflowId, + apps, + evaluators, + recentAppId, + recentEvaluatorId, + }) + }, [ + apps, + context.workflow, + context.workflowId, + context.isResolving, + evaluators, + recentAppId, + recentEvaluatorId, + ]) const workflowId = workflow?.id ?? null const displayName = workflow?.name ?? workflow?.slug ?? workflowId ?? "Select workflow" diff --git a/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx b/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx index 8fae9fdca2e..07ef49e0b38 100644 --- a/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx +++ b/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx @@ -58,6 +58,7 @@ import {Rocket} from "@phosphor-icons/react" import {Button, message} from "antd" import { Provider, + atom, createStore, getDefaultStore, useAtom, @@ -133,16 +134,26 @@ const HumanEvaluatorDrawer = dynamic( // EVALUATOR TYPE LABEL // ================================================================ +// Stable empty map read for non-evaluator rows so the evaluator template catalog +// (a separate GET /evaluators/catalog/templates fetch) isn't requested just to +// render a type label for an app revision. +const EMPTY_TEMPLATES_MAP_ATOM = atom>(new Map()) + const EvaluatorTypeLabel = memo(({revisionId}: {revisionId: string}) => { + const isEvaluator = useAtomValue(workflowMolecule.selectors.isEvaluator(revisionId)) const data = useAtomValue(workflowMolecule.selectors.data(revisionId)) - const templatesMap = useAtomValue(evaluatorTemplatesMapAtom) + // Gate on the canonical `is_evaluator` FLAG, not the URI prefix: builtin APPS + // (chat/completion) also carry an `agenta:builtin:` URI, so a prefix-only check + // both mislabels them as evaluators AND pulls the whole evaluator catalog. + const templatesMap = useAtomValue(isEvaluator ? evaluatorTemplatesMapAtom : EMPTY_TEMPLATES_MAP_ATOM) const label = useMemo(() => { + if (!isEvaluator) return null const uri = (data?.data as {uri?: string} | undefined)?.uri if (!uri || !uri.startsWith("agenta:builtin:")) return null const key = parseEvaluatorKeyFromUri(uri) return key ? (templatesMap.get(key) ?? key) : null - }, [data?.data, templatesMap]) + }, [isEvaluator, data?.data, templatesMap]) if (!label) return null diff --git a/web/oss/src/components/pages/app-management/modals/CustomWorkflowModal/hooks/useCustomWorkflowConfig.tsx b/web/oss/src/components/pages/app-management/modals/CustomWorkflowModal/hooks/useCustomWorkflowConfig.tsx index b01b070a25c..b51d3eef48c 100644 --- a/web/oss/src/components/pages/app-management/modals/CustomWorkflowModal/hooks/useCustomWorkflowConfig.tsx +++ b/web/oss/src/components/pages/app-management/modals/CustomWorkflowModal/hooks/useCustomWorkflowConfig.tsx @@ -1,15 +1,16 @@ import {useCallback} from "react" import {useVaultSecret} from "@agenta/entities/secret" +import {invalidateWorkflowsListCache} from "@agenta/entities/workflow" import type {LlmProvider} from "@agenta/shared/types" import {removeTrailingSlash} from "@agenta/shared/utils" import {useQueryClient} from "@tanstack/react-query" -import {useSetAtom, useStore} from "jotai" +import {useAtomValue, useSetAtom, useStore} from "jotai" import {usePostHogAg} from "@/oss/lib/helpers/analytics/hooks/usePostHogAg" import {isDemo} from "@/oss/lib/helpers/utils" import {createAppWithTemplate, ServiceType} from "@/oss/services/app-selector/api" -import {useAppsData} from "@/oss/state/app" +import {currentAppAtom} from "@/oss/state/app" import {appCreationStatusAtom} from "@/oss/state/appCreation/status" import { normalizeAppKey, @@ -30,7 +31,10 @@ const useCustomWorkflowConfig = ({ folderId, afterConfigSave, }: useCustomWorkflowConfigProps) => { - const {currentApp, mutate} = useAppsData() + // Resolve the current app by id (read-only) instead of `useAppsData()` — the + // latter subscribes to the whole apps catalog, and this hook is mounted by the + // always-present CustomWorkflowBanner, which would pull every app on page load. + const currentApp = useAtomValue(currentAppAtom) const {secrets} = useVaultSecret() const rawAppId = propsAppId ?? currentApp?.id ?? "" const modalAtomKey = normalizeAppKey(rawAppId) @@ -65,7 +69,7 @@ const useCustomWorkflowConfig = ({ if (["error", "bad_request", "timeout", "success"].includes(status)) setFetchingTemplate(false) if (status === "success") { - await mutate() + await invalidateWorkflowsListCache() await invalidateAppManagementWorkflowQueries() posthog?.capture?.("app_deployment", { properties: { diff --git a/web/oss/src/hooks/usePlaygroundNavigation.ts b/web/oss/src/hooks/usePlaygroundNavigation.ts index 8fdac1b7a41..770b0ffc49e 100644 --- a/web/oss/src/hooks/usePlaygroundNavigation.ts +++ b/web/oss/src/hooks/usePlaygroundNavigation.ts @@ -1,13 +1,18 @@ import {useCallback} from "react" import {message} from "antd" -import {useAtomValue, useSetAtom} from "jotai" +import {atom, useAtomValue, useSetAtom} from "jotai" import {useAppId} from "@/oss/hooks/useAppId" import useURL from "@/oss/hooks/useURL" import {appsQueryAtom, recentAppIdAtom} from "@/oss/state/app/atoms/fetcher" import {useAppNavigation} from "@/oss/state/appState" +// Stable empty apps-query read for when the "first app" fallback isn't needed, so +// this hook doesn't subscribe to the whole apps catalog on mount (it's mounted by +// the always-present OnboardingWidget). +const EMPTY_APPS_QUERY_ATOM = atom({data: [] as {app_id?: string}[], isSuccess: false}) + interface VariantLike { id?: string _revisionId?: string @@ -55,13 +60,21 @@ export const usePlaygroundNavigation = () => { const appId = useAppId() const {push} = useAppNavigation() const {baseAppURL} = useURL() - const appsQuery = useAtomValue(appsQueryAtom) const recentAppId = useAtomValue(recentAppIdAtom) const setRecentAppId = useSetAtom(recentAppIdAtom) + // The apps list is only needed for the "first app" fallback when there's NO + // current and NO recent app (e.g. onboarding from /home). On any normal app + // route `appId` is present, so we read a stable empty atom — avoiding a + // mount-time subscription to the whole apps catalog on every page. + const needsAppFallback = !appId && !recentAppId + const appsQuery = useAtomValue(needsAppFallback ? appsQueryAtom : EMPTY_APPS_QUERY_ATOM) as { + data?: {app_id?: string}[] + isSuccess?: boolean + } const goToPlayground = useCallback( (target?: PlaygroundTarget, options?: GoToPlaygroundOptions) => { - let resolvedAppId = options?.appId ?? appId ?? recentAppId ?? null + let resolvedAppId: string | null = options?.appId ?? appId ?? recentAppId ?? null const apps = appsQuery?.data ?? [] if (!resolvedAppId && appsQuery?.isSuccess) { diff --git a/web/oss/src/lib/atoms/breadcrumb/index.ts b/web/oss/src/lib/atoms/breadcrumb/index.ts index c945a3adcb3..8cb7d9ca767 100644 --- a/web/oss/src/lib/atoms/breadcrumb/index.ts +++ b/web/oss/src/lib/atoms/breadcrumb/index.ts @@ -1,6 +1,6 @@ import {atom} from "jotai" -import {appsAtom} from "@/oss/state/app" +import {currentAppAtom} from "@/oss/state/app" import {appStateSnapshotAtom} from "@/oss/state/appState" import {selectedOrgAtom} from "@/oss/state/org" import {projectsAtom} from "@/oss/state/project" @@ -13,7 +13,12 @@ const breadcrumbOverridesAtom = atom({}) export const defaultBreadcrumbAtom = atom((get) => { const appState = get(appStateSnapshotAtom) - const apps = get(appsAtom) + // The breadcrumb only needs the CURRENT app's name (looked up by id in + // `buildBreadcrumbSegments`), so resolve it by id instead of subscribing to the + // whole apps catalog — which would otherwise fire `GET /workflows/query` (all + // apps) early on every page, just to render one breadcrumb label. + const currentApp = get(currentAppAtom) + const apps = currentApp ? [currentApp] : [] const selectedOrg = get(selectedOrgAtom) const projects = get(projectsAtom) const projectId = appState.projectId diff --git a/web/oss/src/lib/helpers/auth/AuthProvider.tsx b/web/oss/src/lib/helpers/auth/AuthProvider.tsx index 43e162eed37..bfa7cff9729 100644 --- a/web/oss/src/lib/helpers/auth/AuthProvider.tsx +++ b/web/oss/src/lib/helpers/auth/AuthProvider.tsx @@ -1,8 +1,10 @@ import {useEffect, useCallback, useState} from "react" +import {configureAxios} from "@agenta/shared/api" import SuperTokensReact, {SuperTokensWrapper} from "supertokens-auth-react" import {installTurnstileFetchPatch} from "@/oss/lib/helpers/auth/turnstile" +import {getJWT} from "@/oss/services/api" import {AuthProviderType} from "./types" @@ -17,6 +19,21 @@ const AuthProvider: AuthProviderType = ({children, pageProps}) => { // the shared `_app` chunk. The session recipe stays eager via useSession. const {frontendConfig} = await import("@/oss/config/frontendConfig") SuperTokensReact.init(frontendConfig()) + // Wire the shared (`@agenta/shared/api`) axios — used by ALL + // entities-package queries — with the same SuperTokens auth the OSS + // axios has. Without this it never attaches a fresh token: `getJWT()` + // → `Session.getAccessToken()` auto-refreshes an expired access token, + // so entities queries (e.g. the always-mounted sidebar's current- + // workflow by-id query, which fires earliest) stop intermittently + // 401-ing on a stale token. Configured before children mount (and thus + // before any query fires), since we only render once `isInitialized`. + configureAxios({ + requestInterceptor: async (config) => { + const jwt = await getJWT() + if (jwt) config.headers.set("Authorization", `Bearer ${jwt}`) + return config + }, + }) setIsInitialized(true) } if (typeof window !== "undefined" && !isInitialized) { diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/index.tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/index.tsx index 33c53b3ec47..a6c4f953fd5 100644 --- a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/index.tsx +++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/index.tsx @@ -1,6 +1,7 @@ import {useEffect} from "react" -import {appWorkflowsListQueryAtom} from "@agenta/entities/workflow" +import {projectIdAtom} from "@agenta/shared/state" +import {useQueryClient} from "@tanstack/react-query" import {Spin} from "antd" import {useAtomValue} from "jotai" import {useRouter} from "next/router" @@ -8,6 +9,10 @@ import {useRouter} from "next/router" import WorkflowNotFound from "@/oss/components/WorkflowNotFound" import {currentWorkflowContextAtom} from "@/oss/state/workflow" +interface AppsListCache { + refs?: {id: string; flags?: {is_evaluator?: boolean}}[] +} + const ensureString = (value: string | string[] | undefined) => { if (!value) return null return Array.isArray(value) ? value[0] : value @@ -30,20 +35,31 @@ const ensureString = (value: string | string[] | undefined) => { const AppOverviewRedirect = () => { const router = useRouter() const ctx = useAtomValue(currentWorkflowContextAtom) - const appsQuery = useAtomValue(appWorkflowsListQueryAtom) + // Atom projectId matches the apps-list query key (which uses workflowProjectIdAtom). + const cacheProjectId = useAtomValue(projectIdAtom) + const queryClient = useQueryClient() const workspaceId = ensureString(router.query.workspace_id) const projectId = ensureString(router.query.project_id) const appId = ensureString(router.query.app_id) - // Synchronous fast-path: read the app query cache without waiting. - // If the workflow is already there AND it's an app, redirect now. + // Synchronous fast-path: PEEK the apps-list cache without SUBSCRIBING. A + // `useAtomValue(appWorkflowsListQueryAtom)` here would trigger the full apps + // list fetch on every `/apps/[id]` navigation — but on a cold load it isn't + // cached anyway (so the fetch is wasted) and the slow-path below resolves the + // workflow by id via `ctx`. `getQueryData` reads the cache only if it was + // already warmed elsewhere (e.g. coming from app-management), giving the + // flicker-free fast redirect without ever initiating the request. const synchronousAppHit = (() => { - if (!appId || !appsQuery.data?.refs) return null - const refs = appsQuery.data.refs as {id: string; flags?: {is_evaluator?: boolean}}[] - const match = refs.find((w) => w.id === appId) - if (!match) return null - if (match.flags?.is_evaluator) return null + if (!appId) return null + const cached = queryClient.getQueryData([ + "workflows", + "apps", + "list", + cacheProjectId, + ]) + const match = cached?.refs?.find((w) => w.id === appId) + if (!match || match.flags?.is_evaluator) return null return match })() diff --git a/web/oss/src/state/app/atoms/fetcher.ts b/web/oss/src/state/app/atoms/fetcher.ts index d899e88f0d9..970aa95fef5 100644 --- a/web/oss/src/state/app/atoms/fetcher.ts +++ b/web/oss/src/state/app/atoms/fetcher.ts @@ -1,13 +1,12 @@ import { appWorkflowsListQueryAtom, nonArchivedAppWorkflowsAtom, - queryWorkflows, + workflowDetailQueryAtomFamily, } from "@agenta/entities/workflow" import type {Workflow} from "@agenta/entities/workflow" -import {projectIdAtom, sessionAtom, stringStorage} from "@agenta/shared/state" +import {stringStorage} from "@agenta/shared/state" import {atom} from "jotai" import {atomWithStorage} from "jotai/utils" -import {atomWithQuery} from "jotai-tanstack-query" import {appIdentifiersAtom, appStateSnapshotAtom, requestNavigationAtom} from "@/oss/state/appState" @@ -71,30 +70,14 @@ export const routerAppNavigationAtom = atom(null, (get, set, next: string | null export const recentAppIdAtom = atomWithStorage(LS_APP_KEY, null, stringStorage) -export const currentAppQueryAtom = atomWithQuery((get) => { - const projectId = get(projectIdAtom) +export const currentAppQueryAtom = atom((get) => { const appId = get(routerAppIdAtom) || get(recentAppIdAtom) - const liveApps = get(nonArchivedAppWorkflowsAtom) - const liveApp = appId ? (liveApps.find((app) => app.id === appId) ?? null) : null - - return { - queryKey: ["currentApp", projectId, appId], - queryFn: async () => { - if (!projectId || !appId) return null - - const response = await queryWorkflows({ - projectId, - workflowRefs: [{id: appId}], - includeArchived: true, - }) - - return response.workflows.find((workflow) => workflow.id === appId) ?? null - }, - enabled: get(sessionAtom) && !!projectId && !!appId && !liveApp, - initialData: liveApp ?? undefined, - staleTime: 30_000, - refetchOnWindowFocus: false, - } + // Resolve via the SHARED by-id workflow query (`workflowDetailQueryAtomFamily`) + // so app-state dedupes with workflow-state (`currentWorkflowContextAtom`), + // which reads the same family for the same id. Previously this was a separate + // `atomWithQuery` with its own key + `include_archived`, so the current + // workflow was fetched TWICE on every app page (once per state tree). + return get(workflowDetailQueryAtomFamily(appId)) }) interface WorkflowListQueryState { diff --git a/web/oss/src/state/app/hooks.ts b/web/oss/src/state/app/hooks.ts index 80faf0e3051..ad099f7b121 100644 --- a/web/oss/src/state/app/hooks.ts +++ b/web/oss/src/state/app/hooks.ts @@ -1,49 +1,28 @@ import {useCallback, useEffect} from "react" -import {invalidateWorkflowsListCache, type Workflow} from "@agenta/entities/workflow" +import {invalidateWorkflowsListCache} from "@agenta/entities/workflow" import {useAtom, useAtomValue} from "jotai" import {useAppState} from "@/oss/state/appState" -import {appsQueryAtom, recentAppIdAtom} from "./atoms/fetcher" +import {appsQueryAtom, currentAppQueryAtom, recentAppIdAtom} from "./atoms/fetcher" import {currentAppAtom, appsAtom} from "./selectors/app" /** * @deprecated for new code. Use `useWorkflowsData()` from `@/oss/state/workflow` * for workflow-typed access (returns combined apps + evaluators with per-type * filters and unified loading state). Existing callers remain supported — - * `useAppsData()` still returns apps only and is the authoritative writer for - * `recentAppIdAtom`. + * `useAppsData()` still returns apps only. + * + * NOTE: this no longer WRITES `recentAppIdAtom`. Recent-app tracking moved to the + * single always-mounted writer `useCurrentAppLite()` (the sidebar) — two writers + * with different validity criteria (non-archived-list membership here vs by-id + * there) ping-ponged the atom into an infinite render loop. */ export const useAppsData = () => { const {data: apps, isPending, isLoading, error, refetch} = useAtomValue(appsQueryAtom) const currentApp = useAtomValue(currentAppAtom) - const [recentAppId, setRecentAppId] = useAtom(recentAppIdAtom) - const {appId, routeLayer} = useAppState() - - useEffect(() => { - // Only set recent app when user is actually on an app-level route (routeLayer === "app") - // This avoids updating recentAppId when appId comes from query params (e.g., ?app_id=...) - // on project-level pages like evaluation results - if (!appId) return - if (routeLayer !== "app") return - if (Array.isArray(apps)) { - const exists = (apps as Workflow[]).some((app) => app.id === appId) - if (exists) { - if (recentAppId !== appId) setRecentAppId(appId) - } else { - if (recentAppId) setRecentAppId(null) - } - } - // If apps haven't loaded yet, do nothing here; the fallback effect below will enforce validity once loaded - }, [appId, apps, recentAppId, routeLayer, setRecentAppId]) - - useEffect(() => { - if (recentAppId && Array.isArray(apps)) { - const exists = (apps as Workflow[]).some((app) => app.id === recentAppId) - if (!exists) setRecentAppId(null) - } - }, [apps, recentAppId, setRecentAppId]) + const recentAppId = useAtomValue(recentAppIdAtom) const reset = useCallback(() => { invalidateWorkflowsListCache() @@ -63,3 +42,42 @@ export const useAppsData = () => { export const useCurrentApp = () => useAtomValue(currentAppAtom) export const useAppList = () => useAtomValue(appsAtom) + +/** + * Lightweight current-app access for always-mounted consumers (e.g. the sidebar) + * that need only the CURRENT app + recent id — NOT the whole apps catalog. + * + * Unlike `useAppsData`, this does NOT subscribe to the apps list (`appsQueryAtom`), + * so it doesn't force the entire catalog to load on every app-scoped page. The + * current app is resolved by id (`currentAppQueryAtom`), and recent-app + * marking/pruning is derived from that single by-id result instead of full-list + * membership. + */ +export const useCurrentAppLite = () => { + const currentApp = useAtomValue(currentAppAtom) + const {isPending: isCurrentAppPending} = useAtomValue(currentAppQueryAtom) + const [recentAppId, setRecentAppId] = useAtom(recentAppIdAtom) + const {appId, routeLayer} = useAppState() + + // SOLE authoritative writer for `recentAppIdAtom` (the sidebar that mounts this + // is present on every app route). Mirrors the old `useAppsData` marking, but + // resolves "is this a valid app?" by id (`currentApp`) instead of full-list + // membership — so it doesn't force the whole apps catalog to load. + // + // Single writer BY DESIGN: running this alongside another recent-app writer + // with different criteria (by-id here vs non-archived-list membership in the + // old `useAppsData` effects) ping-pongs the atom — one marks `appId`, the other + // prunes it — which is an infinite render loop. `useAppsData`'s writers were + // removed for exactly this reason. + useEffect(() => { + if (routeLayer !== "app" || !appId || isCurrentAppPending) return + const isValidApp = currentApp?.id === appId && !currentApp?.flags?.is_evaluator + if (isValidApp) { + if (recentAppId !== appId) setRecentAppId(appId) + } else if (recentAppId) { + setRecentAppId(null) + } + }, [routeLayer, appId, currentApp, isCurrentAppPending, recentAppId, setRecentAppId]) + + return {currentApp: currentApp ?? null, recentlyVisitedAppId: recentAppId} +} diff --git a/web/oss/src/state/app/selectors/app.ts b/web/oss/src/state/app/selectors/app.ts index 3b819172962..88e3d61d4d8 100644 --- a/web/oss/src/state/app/selectors/app.ts +++ b/web/oss/src/state/app/selectors/app.ts @@ -44,7 +44,9 @@ export {routerAppIdAtom, recentAppIdAtom} export const currentAppContextAtom = eagerAtom((get) => { const currentApp = get(currentAppAtom) const selectedId = get(selectedAppIdAtom) - const {isLoading} = get(appsQueryAtom) + // Loading comes from the by-id current-app query, NOT the full apps list + // (`appsQueryAtom`) — reading the list here would pull the whole apps catalog + // just to expose a loading flag for one app. const currentAppQuery = get(currentAppQueryAtom) as {isPending?: boolean} return { @@ -59,6 +61,6 @@ export const currentAppContextAtom = eagerAtom((get) => { ? "completion" : null, hasApp: !!currentApp, - loading: isLoading || (!currentApp && !!selectedId && !!currentAppQuery.isPending), + loading: !currentApp && !!selectedId && !!currentAppQuery.isPending, } }) diff --git a/web/oss/src/state/workflow/selectors/workflow.ts b/web/oss/src/state/workflow/selectors/workflow.ts index b6910a1c14e..d5399cc52f8 100644 --- a/web/oss/src/state/workflow/selectors/workflow.ts +++ b/web/oss/src/state/workflow/selectors/workflow.ts @@ -1,9 +1,12 @@ -import type {Workflow, WorkflowFlags} from "@agenta/entities/workflow" +import { + workflowDetailQueryAtomFamily, + type Workflow, + type WorkflowFlags, +} from "@agenta/entities/workflow" import {atom} from "jotai" import {routerAppIdAtom} from "@/oss/state/app/atoms/fetcher" -import {workflowsByIdMapAtom} from "../atoms/fetcher" import type {WorkflowKind} from "../destinations" /** @@ -49,13 +52,9 @@ export function deriveWorkflowKind(flags: WorkflowFlags | null | undefined): Wor * `currentWorkflowContextAtom` (which exposes `isResolving` / `isNotFound` / * `isError`). */ -export const currentWorkflowAtom = atom((get) => { - const id = get(routerAppIdAtom) - if (!id) return null - const {data, isLoading} = get(workflowsByIdMapAtom) - if (isLoading) return null - return data.get(id) ?? null -}) +export const currentWorkflowAtom = atom( + (get) => get(currentWorkflowContextAtom).workflow, +) /** * Minimal context shape for current workflow (eng review decision 2.1). @@ -85,7 +84,6 @@ export interface CurrentWorkflowContext { export const currentWorkflowContextAtom = atom((get) => { const id = get(routerAppIdAtom) - const {data, isLoading, isError} = get(workflowsByIdMapAtom) if (!id) { return { @@ -98,7 +96,12 @@ export const currentWorkflowContextAtom = atom((get) => } } - if (isLoading) { + // Resolve THIS ONE workflow by id (app or evaluator) — instead of listing + // every app AND every evaluator in the project just to look one up. The by-id + // artifact carries name + role flags (`is_application`/`is_evaluator`), so it's + // enough to classify the current workflow. + const detail = get(workflowDetailQueryAtomFamily(id)) + if (detail.isPending) { return { workflow: null, workflowId: id, @@ -108,8 +111,7 @@ export const currentWorkflowContextAtom = atom((get) => isError: false, } } - - if (isError) { + if (detail.isError) { return { workflow: null, workflowId: id, @@ -120,8 +122,12 @@ export const currentWorkflowContextAtom = atom((get) => } } - const workflow = data.get(id) ?? null - if (!workflow) { + // The shared by-id query includes archived workflows (so app-state can resolve + // archived apps off the same request). Treat archived as not-found here to + // preserve this atom's non-archived contract — the old list-based resolution + // read non-archived lists, so an archived id reported `isNotFound`. + const workflow = (detail.data ?? null) as (Workflow & {deleted_at?: string | null}) | null + if (!workflow || workflow.deleted_at) { return { workflow: null, workflowId: id, 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 aa41f8ed183..a6a5390cdb6 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 e2ac75cf46a..63caab9c414 100644 --- a/web/packages/agenta-entities/src/workflow/index.ts +++ b/web/packages/agenta-entities/src/workflow/index.ts @@ -216,6 +216,8 @@ export { appWorkflowsListQueryStateAtom, promptWorkflowsListQueryStateAtom, agentWorkflowsListQueryStateAtom, + // Single workflow artifact by id (current-workflow resolution without listing all) + workflowDetailQueryAtomFamily, // Union atoms (app + evaluator combined) workflowsListDataAtom, nonArchivedWorkflowsAtom, @@ -318,6 +320,9 @@ export { fullPagePlaygroundEvaluatorsAtom, nonHumanEvaluatorsAtom, nonDeterministicEvaluatorsAtom, + // 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 0e82a7f01b4..fb98c849e90 100644 --- a/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts +++ b/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts @@ -116,6 +116,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 LLM-based evaluators. * @@ -156,6 +187,7 @@ export const llmEvaluatorsAtom = 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 @@ -186,6 +218,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 @@ -284,6 +317,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() @@ -330,7 +364,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() @@ -382,7 +419,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 cd258ebe558..cd8c8dae8d9 100644 --- a/web/packages/agenta-entities/src/workflow/state/index.ts +++ b/web/packages/agenta-entities/src/workflow/state/index.ts @@ -45,6 +45,8 @@ export { appWorkflowsListQueryStateAtom, promptWorkflowsListQueryStateAtom, agentWorkflowsListQueryStateAtom, + // Single workflow artifact by id (current-workflow resolution without listing all) + workflowDetailQueryAtomFamily, // Variant/Revision list queries (for 3-level hierarchy) workflowVariantsQueryAtomFamily, workflowVariantsListDataAtomFamily, @@ -188,6 +190,9 @@ export { fullPagePlaygroundEvaluatorsAtom, nonHumanEvaluatorsAtom, nonDeterministicEvaluatorsAtom, + // 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/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index 106c92930a4..0fd77dc6e3d 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -423,6 +423,41 @@ export const appWorkflowsListQueryAtom = atomWithQuery((get) => { } }) +/** + * Query atom family for a SINGLE workflow artifact by id. + * + * Resolves one workflow (app OR evaluator) via `workflow_refs`, returning the + * artifact directly — name + role flags (`is_application`/`is_evaluator`), and + * NOT a revision, so it's immune to the version-0 flag-merge gap. Lets callers + * resolve "the current workflow" by id WITHOUT listing the entire evaluator + * catalog: check the (load-bearing) apps list first, and only subscribe here + * when the id isn't an app (i.e. an evaluator or not-found). + */ +export const workflowDetailQueryAtomFamily = atomFamily((workflowId: string | null) => + atomWithQuery((get) => { + const projectId = get(workflowProjectIdAtom) + return { + queryKey: ["workflows", "detail", projectId, workflowId], + queryFn: async (): Promise => { + if (!projectId || !workflowId) return null + // `include_archived: true` so this single query satisfies BOTH + // consumers — `currentWorkflowContextAtom` (which filters archived + // out via `deleted_at`) and the app-state `currentAppQueryAtom` + // (which resolves archived apps). One shared query = one request, + // instead of two by-id fetches differing only on this flag. + const response = await queryWorkflows({ + projectId, + workflowRefs: [{id: workflowId}], + includeArchived: true, + }) + return (response.workflows?.[0] as Workflow | undefined) ?? null + }, + enabled: get(sessionAtom) && !!projectId && !!workflowId, + staleTime: 30_000, + } + }), +) + /** * Derived atom for app (non-evaluator) workflows list data. * Returns workflow-level objects directly from the query cache. 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 1ea2f309464..2987e916284 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 f84893cf298..99ccb7614ee 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts @@ -13,19 +13,22 @@ */ import type React from "react" -import {useMemo, useRef} from "react" +import {useEffect, useMemo, useRef} from "react" import { + activateEvaluatorEnrichmentAtom, + evaluatorEnrichmentActivatedAtom, evaluatorKeyMapAtom, evaluatorTemplatesMapAtom, evaluatorTemplatesDataAtom, + type EvaluatorCatalogTemplate, evaluatorConfigsQueryStateAtom, evaluatorWorkflowMetaMapAtom, humanEvaluatorsListQueryAtom, workflowAppTypeAtomFamily, workflowsListDataAtom, } from "@agenta/entities/workflow" -import {atom, getDefaultStore, useAtomValue} from "jotai" +import {atom, getDefaultStore, useAtomValue, useSetAtom} from "jotai" import { renderEvaluatorPickerLabelNode, @@ -42,15 +45,48 @@ 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]) +} + +// 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. * * 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 (and holds the + * template catalog read until the gate opens too). */ -export function useEvaluatorEnrichedData() { +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} } @@ -137,10 +173,14 @@ 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 templates = useAtomValue(evaluatorTemplatesDataAtom) + const {evaluatorKeyMap, evaluatorDefsByKey} = useEvaluatorEnrichedData({lazy: options?.lazy}) + 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) @@ -155,6 +195,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(() => { @@ -174,6 +221,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[], diff --git a/web/packages/agenta-entity-ui/src/selection/index.ts b/web/packages/agenta-entity-ui/src/selection/index.ts index 6f460189394..e2ef92d2a0e 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 558af81d04e759e3a2f807cde5ddc4d54cbd5cd1 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 7 Jul 2026 22:40:53 +0200 Subject: [PATCH 03/50] perf(frontend): lazy-gate the playground workflow-reference bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The playground drill-in provider (OSSdrillInUIProvider) is always mounted in OSSPlaygroundShell and eagerly subscribed to the project-wide workflow list (apps + evaluators) and the evaluator catalog via useWorkflowReferenceBridge — a big-agents-only feature (workflow-as-tool references) that main never had. So on big-agents those queries fired on every playground load regardless of the list/catalog lazy-gates, since one eager subscriber mounts the shared atoms and makes the other gates no-ops. Gate the bridge behind a one-way activation latch: `workflows`/`workflowsLoading` and the evaluator-catalog name map stay dormant until `activate()` is called — on reference-picker open (AgentTemplateControl) or when an existing reference is displayed (ReferenceToolFormView). A plain playground load with no workflow references now pulls neither the workflow list nor the catalog. --- .../DrillInView/OSSdrillInUIProvider.tsx | 36 ++++++++++++++++--- .../SchemaControls/AgentTemplateControl.tsx | 7 +++- .../SchemaControls/ReferenceToolFormView.tsx | 5 +++ .../src/drill-in/context/DrillInUIContext.tsx | 5 +++ 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx b/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx index b67514a0abb..0337f96f512 100644 --- a/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx +++ b/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx @@ -65,7 +65,7 @@ import {projectIdAtom} from "@agenta/shared/state" import {KNOWN_ENVELOPE_SLOTS} from "@agenta/shared/utils" import {EditorProvider} from "@agenta/ui/editor" import {SharedEditor} from "@agenta/ui/shared-editor" -import {getDefaultStore, useAtomValue, useSetAtom, useStore} from "jotai" +import {atom, getDefaultStore, useAtomValue, useSetAtom, useStore} from "jotai" import {atomFamily} from "jotai/utils" import {atomWithQuery} from "jotai-tanstack-query" @@ -231,6 +231,27 @@ function humanizeEvaluatorKey(key: string): string { .trim() } +// Lazy activation for the workflow-reference bridge. Referencing a workflow as an agent tool is +// the only consumer of the project-wide workflow list + evaluator catalog inside the always-mounted +// playground drill-in provider, and it's needed only once the user opens the reference picker or an +// existing reference is displayed. Until then these stay dormant, so a plain playground load doesn't +// fire the apps/evaluators list + evaluator catalog queries. +const workflowReferenceActivatedAtom = atom(false) +const activateWorkflowReferenceAtom = atom(null, (get, set) => { + if (!get(workflowReferenceActivatedAtom)) set(workflowReferenceActivatedAtom, true) +}) +const EMPTY_WORKFLOW_REFS: Workflow[] = [] +const EMPTY_EVALUATOR_NAMES = new Map() +const workflowReferenceWorkflowsAtom = atom((get) => + get(workflowReferenceActivatedAtom) ? get(nonArchivedWorkflowsAtom) : EMPTY_WORKFLOW_REFS, +) +const workflowReferenceLoadingAtom = atom((get) => + get(workflowReferenceActivatedAtom) ? get(workflowsListQueryStateAtom).isPending : false, +) +const workflowReferenceEvaluatorNamesAtom = atom((get) => + get(workflowReferenceActivatedAtom) ? get(evaluatorTemplatesMapAtom) : EMPTY_EVALUATOR_NAMES, +) + function useWorkflowReferenceTypes(workflows: WorkflowReferenceUI[]): { typeBySlug: Record labelBySlug?: Record @@ -247,7 +268,8 @@ function useWorkflowReferenceTypes(workflows: WorkflowReferenceUI[]): { ) const res = useAtomValue(referenceTypesQueryAtomFamily(slugsKey)) // Evaluator template catalog (key → display name), for the evaluator sub-type badge. - const evaluatorNames = useAtomValue(evaluatorTemplatesMapAtom) + // Gated behind the bridge activation so it doesn't fire the catalog on a plain playground load. + const evaluatorNames = useAtomValue(workflowReferenceEvaluatorNamesAtom) return useMemo(() => { const data = (res.data ?? {}) as Record @@ -549,13 +571,17 @@ function readWorkflowPorts( function useWorkflowReferenceBridge(): WorkflowReferenceBridge { const projectId = useAtomValue(projectIdAtom) - const workflows = useAtomValue(nonArchivedWorkflowsAtom) - const workflowsLoading = useAtomValue(workflowsListQueryStateAtom).isPending + // Lazy: `workflows` stays empty (no apps/evaluators list query) until `activate()` is called + // — on reference-picker open or when displaying an existing reference (see the consumers). + const workflows = useAtomValue(workflowReferenceWorkflowsAtom) + const workflowsLoading = useAtomValue(workflowReferenceLoadingAtom) + const activate = useSetAtom(activateWorkflowReferenceAtom) const store = useStore() return useMemo( () => ({ enabled: true, + activate, // All project workflows are referenceable (apps + evaluators + …), not just apps. Type // (incl. `evaluator`) is resolved per-slug via useWorkflowTypes. workflows: workflows @@ -626,7 +652,7 @@ function useWorkflowReferenceBridge(): WorkflowReferenceBridge { useWorkflowEnvironments, useWorkflowTypes: useWorkflowReferenceTypes, }), - [workflows, workflowsLoading, projectId, store], + [activate, workflows, workflowsLoading, projectId, store], ) } diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx index 5969142327e..c935a17488f 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx @@ -519,7 +519,12 @@ export function AgentTemplateControl({ existingToolCount: tools.length, gatewayTools, onReferenceWorkflow: workflowReference?.enabled - ? () => setReferenceSelectorOpen(true) + ? () => { + // Opening the picker is the point the workflow list is actually needed — activate + // the (lazy) bridge so it resolves now instead of on every playground load. + workflowReference.activate?.() + setReferenceSelectorOpen(true) + } : undefined, // Route the integration row to the agent-scoped drawer instead of the shared global catalog. onOpenIntegration: gatewayTools?.enabled ? openIntegration : undefined, diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ReferenceToolFormView.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ReferenceToolFormView.tsx index 8ca13a512b6..1f3084e0bd6 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ReferenceToolFormView.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ReferenceToolFormView.tsx @@ -176,6 +176,11 @@ export function ReferenceToolFormView({value, onChange, disabled}: ReferenceTool : null const {workflowReference} = useDrillInUI() + // Displaying an existing reference needs the workflow list to resolve its name — activate the + // (lazy) bridge. Configs with no reference never mount this view, so they never pull the list. + useEffect(() => { + if (slug) workflowReference?.activate?.() + }, [slug, workflowReference]) const workflow = useMemo( () => workflowReference?.workflows.find((w) => w.slug === slug) ?? null, [workflowReference, slug], diff --git a/web/packages/agenta-ui/src/drill-in/context/DrillInUIContext.tsx b/web/packages/agenta-ui/src/drill-in/context/DrillInUIContext.tsx index 334747b2e4d..408606b51e0 100644 --- a/web/packages/agenta-ui/src/drill-in/context/DrillInUIContext.tsx +++ b/web/packages/agenta-ui/src/drill-in/context/DrillInUIContext.tsx @@ -172,6 +172,11 @@ export interface WorkflowReferenceBridge { enabled: boolean workflows: WorkflowReferenceUI[] workflowsLoading: boolean + /** Activate the workflow list + evaluator catalog behind this bridge. Lazy: the underlying + * project-wide list/catalog queries stay dormant (`workflows` empty) until a consumer that + * actually needs them calls this — on reference-picker open or when displaying an existing + * reference. One-way; stays warm after the first call. */ + activate?: () => void /** Resolve the referenced workflow's input JSON-schema to pre-fill the tool's `input_schema`. * Returns null when unavailable; the caller falls back to an empty object schema. */ resolveInputSchema: (workflow: WorkflowReferenceUI) => Promise | null> From 43f3546a5a7650483c3da87cc81932a35a3c6bc3 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 03:01:46 +0200 Subject: [PATCH 04/50] perf(frontend): hoist agent drawer data hooks into open-gated content Move the Model & harness draft useModelHarness into a ModelHarnessSectionDrawerBody that only mounts while the section drawer is open (SectionDrawer destroyOnClose), and extract the app-trigger provider groups into AppTriggerProviderGroups that fetches the connections + ~90-app catalog only when there are app subscriptions to decorate. Both previously fired on every agent-config render with the drawer/section collapsed. --- .../SchemaControls/AgentTemplateControl.tsx | 70 ++++-- .../TriggerManagementSection.tsx | 217 ++++++++++-------- 2 files changed, 168 insertions(+), 119 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx index c935a17488f..a6cbc3d695d 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx @@ -106,6 +106,19 @@ export interface AgentTemplateControlProps { className?: string } +// Draft body for the Model & harness / Advanced section drawers. Isolated into its own component so +// `useModelHarness` (harness-catalog + vault-secrets + build-kit-overlay subscriptions) runs ONLY +// while a section drawer is open — `SectionDrawer` uses `destroyOnClose`, so this mounts on open and +// unmounts on close. Previously a second `useModelHarness` ran in the always-mounted parent, +// subscribing on every agent-config render even with both drawers shut. +const ModelHarnessSectionDrawerBody = ({ + section, + ...params +}: {section: "model-harness" | "advanced"} & Parameters[0]) => { + const mh = useModelHarness(params) + return <>{section === "advanced" ? mh.advancedDrawerBody : mh.modelHarnessDrawerBody} +} + export function AgentTemplateControl({ schema, value, @@ -175,10 +188,6 @@ export function AgentTemplateControl({ (next: Record) => setDraftConfig(next), [], ) - // Swallow writes from the draft hook while its drawer is closed (its body isn't rendered, so an - // internal auto-correction effect must not leak into `draftConfig`). The live `mh` handles any - // real auto-correction against the entity. - const noopConfigChange = useCallback(() => {}, []) // Single source of truth for "the currently open section has unsaved edits" — shared by the // open-a-new-section guard below and the Save-button gate (`sectionDirty`) so they can't drift. const isCurrentSectionDirty = useCallback( @@ -284,24 +293,19 @@ export function AgentTemplateControl({ // - `mh` is bound to the LIVE entity — it drives the accordion header summaries + the inline // tabs bodies. Keeping it live means a section header NEVER reflects the drawer's unsaved draft // (the reported bug: editing in the open drawer updated the background summary). - // - `mhDraft` is bound to the DRAFT (config + build-kit) — it drives the OPEN section drawer's - // body, so its forms edit the buffer and Save relays it to the entity/atom. When no drawer is - // open its `onChange` is a no-op (and its body isn't rendered), so the extra hook is inert. + // - The DRAFT instance (config + build-kit buffer) that drives the OPEN section drawer's body + // now lives inside `ModelHarnessSectionDrawerBody`, mounted only while the drawer is open, so + // its harness/vault/overlay subscriptions don't run in the background. const mh = useModelHarness({schema, config, onChange, disabled, withTooltip, revisionId}) - const mhDraft = useModelHarness({ - schema, - config: draftConfig ?? config, - onChange: openSection !== null ? applyDraftConfig : noopConfigChange, - disabled, - withTooltip, - revisionId, - buildKitEnabledOverride: + const draftBuildKitOverride = useMemo( + () => draftBuildKit !== null ? {value: draftBuildKit, onChange: setDraftBuildKit} : undefined, - // "Current" marks the SAVED harness (from the live entity), not the draft pick. - savedHarnessValue: - ((config.harness as Record | undefined)?.kind as string | undefined) ?? - null, - }) + [draftBuildKit], + ) + // "Current" marks the SAVED harness (from the live entity), not the draft pick. + const savedHarnessValue = + ((config.harness as Record | undefined)?.kind as string | undefined) ?? + null // Tool add/remove (inline function, builtin, gateway, workflow reference) lives in its own hook. const { @@ -888,9 +892,19 @@ export function AgentTemplateControl({ onSave={saveSection} disabled={disabled || !sectionDirty} dirty={sectionDirty} - width={mhDraft.modelHarnessDrawerWidth} + width={mh.modelHarnessDrawerWidth} > - {mhDraft.modelHarnessDrawerBody} + - {mhDraft.advancedDrawerBody} + {workflowReference?.enabled && ( diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/TriggerManagementSection.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/TriggerManagementSection.tsx index 31b7ec7ed32..98d501e087a 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/TriggerManagementSection.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/TriggerManagementSection.tsx @@ -470,13 +470,30 @@ function TriggerRow({ ) } -export function TriggerManagementSection({entityId, disabled}: TriggerManagementSectionProps) { - const {scopedSubscriptions, scopedSchedules, count, defaultReferences, defaultBoundLabel} = - useAgentTriggers(entityId) - +// App triggers grouped by provider. Extracted into its own component so the connections + +// catalog-integrations queries (the heavy ~90-app catalog fetch) mount ONLY when there are app +// subscriptions to decorate — they are the sole consumers of that data. The always-mounted Triggers +// section otherwise fired both on every agent-playground load just to render a count badge / empty +// state, before the user ever opened the trigger picker. +function AppTriggerProviderGroups({ + scopedSubscriptions, + entityId, + disabled, + defaultReferences, + defaultBoundLabel, + subscriptionMenu, +}: { + scopedSubscriptions: TriggerSubscription[] + entityId: string | null + disabled?: boolean + defaultReferences: Record + defaultBoundLabel: string + subscriptionMenu: (record: TriggerSubscription) => MenuProps["items"] +}) { const {connections} = useTriggerConnectionsQuery() const {integrations} = useTriggerCatalogIntegrations() const [groupsExpanded, setGroupsExpanded] = useAtom(triggerGroupsExpandedAtom) + const openSubscriptionDrawer = useSetAtom(triggerSubscriptionDrawerAtom) // Subscriptions grouped by provider (connection.integration_key); name/logo from the // catalog when loaded, else a prettified key + plug icon. @@ -515,6 +532,94 @@ export function TriggerManagementSection({entityId, disabled}: TriggerManagement }, [entityId, setGroupsExpanded], ) + const connectionLabel = useCallback( + (connectionId?: string) => { + const c = connections.find((conn) => conn.id === connectionId) + return c ? c.name || c.slug || c.integration_key : undefined + }, + [connections], + ) + + if (providerGroups.length === 0) return null + + return ( +
+ + {providerGroups.map((group) => { + const open = isGroupOpen(group) + const activeCount = group.subs.filter(isEntityActive).length + return ( + toggleGroup(group)} + onAdd={ + !disabled + ? () => + openSubscriptionDrawer({ + defaultReferences, + defaultBoundLabel, + playgroundEntityId: entityId ?? undefined, + integrationKey: group.key, + integrationName: group.name, + }) + : undefined + } + addLabel={`Add ${group.name} trigger`} + > + {group.subs.map((record) => { + const named = !!record.name?.trim() + const eventLabel = prettifyEventKey(record.data?.event_key ?? "") + const primary = named + ? (record.name as string) + : eventLabel || "Untitled subscription" + const secondary = named + ? eventLabel || undefined + : connectionLabel(record.connection_id) || + record.description || + undefined + return ( + + } + onOpen={() => + record.id && + openSubscriptionDrawer({ + subscriptionId: record.id, + playgroundEntityId: entityId ?? undefined, + }) + } + menuItems={subscriptionMenu(record)} + /> + ) + })} + + ) + })} +
+ ) +} + +export function TriggerManagementSection({entityId, disabled}: TriggerManagementSectionProps) { + const {scopedSubscriptions, scopedSchedules, count, defaultReferences, defaultBoundLabel} = + useAgentTriggers(entityId) + const { remove: removeSubscription, refresh: refreshSubscription, @@ -551,14 +656,6 @@ export function TriggerManagementSection({entityId, disabled}: TriggerManagement [entityId, setPendingRun], ) - const connectionLabel = useCallback( - (connectionId?: string) => { - const c = connections.find((conn) => conn.id === connectionId) - return c ? c.name || c.slug || c.integration_key : undefined - }, - [connections], - ) - // ---- subscription actions ---- const subscriptionMenu = useCallback( (record: TriggerSubscription): MenuProps["items"] => [ @@ -719,90 +816,18 @@ export function TriggerManagementSection({entityId, disabled}: TriggerManagement ) : null ) : (
- {/* App triggers — grouped by provider (subscriptions first). */} - {providerGroups.length > 0 && ( -
- - {providerGroups.map((group) => { - const open = isGroupOpen(group) - const activeCount = group.subs.filter(isEntityActive).length - return ( - toggleGroup(group)} - onAdd={ - !disabled - ? () => - openSubscriptionDrawer({ - defaultReferences, - defaultBoundLabel, - playgroundEntityId: entityId ?? undefined, - integrationKey: group.key, - integrationName: group.name, - }) - : undefined - } - addLabel={`Add ${group.name} trigger`} - > - {group.subs.map((record) => { - const named = !!record.name?.trim() - const eventLabel = prettifyEventKey( - record.data?.event_key ?? "", - ) - const primary = named - ? (record.name as string) - : eventLabel || "Untitled subscription" - const secondary = named - ? eventLabel || undefined - : connectionLabel(record.connection_id) || - record.description || - undefined - return ( - - } - onOpen={() => - record.id && - openSubscriptionDrawer({ - subscriptionId: record.id, - playgroundEntityId: - entityId ?? undefined, - }) - } - menuItems={subscriptionMenu(record)} - /> - ) - })} - - ) - })} -
+ {/* App triggers — grouped by provider (subscriptions first). The connections + + catalog queries live inside this child so they only fire when there ARE app + subscriptions to decorate, not on every playground load. */} + {scopedSubscriptions.length > 0 && ( + )} {/* Schedules — flat (no provider), listed last. */} From effc55b2db8bda3c3638eab05d2b2bc7a6ef45cf Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 03:02:03 +0200 Subject: [PATCH 05/50] perf(frontend): persist agent catalogs, inspect & type across reloads Disk-backed SWR (localStorage seed + one background revalidate) for the static agent-template schema and harness catalog, per-revision inspect (bounded LRU, the reload long pole), and a {workflowId: type} map so playgroundEarlyAgentStateAtom knows agent-ness synchronously on reload. Revalidations that serve from disk are demoted to low network priority (fetch-adapter Fetch Priority hint) so they don't compete with the critical-path queries. Versioned cache keys allow hard invalidation on response-shape changes. --- .../agenta-entities/src/workflow/api/api.ts | 20 +++-- .../agenta-entities/src/workflow/index.ts | 2 + .../src/workflow/state/index.ts | 5 ++ .../src/workflow/state/inspectMeta.ts | 37 +++++++-- .../src/workflow/state/persistedAgentType.ts | 52 +++++++++++++ .../src/workflow/state/persistedCatalog.ts | 60 ++++++++++++++ .../src/workflow/state/persistedInspect.ts | 78 +++++++++++++++++++ .../src/workflow/state/store.ts | 73 +++++++++++++---- web/packages/agenta-shared/src/api/axios.ts | 12 +++ web/packages/agenta-shared/src/api/index.ts | 8 +- 10 files changed, 316 insertions(+), 31 deletions(-) create mode 100644 web/packages/agenta-entities/src/workflow/state/persistedAgentType.ts create mode 100644 web/packages/agenta-entities/src/workflow/state/persistedCatalog.ts create mode 100644 web/packages/agenta-entities/src/workflow/state/persistedInspect.ts diff --git a/web/packages/agenta-entities/src/workflow/api/api.ts b/web/packages/agenta-entities/src/workflow/api/api.ts index 9090d96ede0..0dc05f638e6 100644 --- a/web/packages/agenta-entities/src/workflow/api/api.ts +++ b/web/packages/agenta-entities/src/workflow/api/api.ts @@ -16,7 +16,7 @@ import {getAgentaSdkClient} from "@agenta/sdk" import {getWorkflowsClient} from "@agenta/sdk/resources" -import {getAgentaApiUrl, axios} from "@agenta/shared/api" +import {getAgentaApiUrl, axios, lowPriorityWhenCached} from "@agenta/shared/api" import {dereferenceSchema, generateId} from "@agenta/shared/utils" import {z} from "zod" @@ -504,6 +504,7 @@ export async function inspectWorkflow( uri: string, projectId: string, serviceUrl?: string | null, + opts?: {lowPriority?: boolean}, ): Promise { if (!projectId || !uri) { return {} @@ -519,7 +520,7 @@ export async function inspectWorkflow( { revision: {uri}, }, - {params: {project_id: projectId}}, + {params: {project_id: projectId}, ...lowPriorityWhenCached(opts?.lowPriority)}, ) return response.data ?? {} @@ -1378,9 +1379,13 @@ export async function fetchWorkflowRevisionsByIdsBatch( * @param agType - The referenced ag-type key, e.g. "prompt-template" * @returns The dereferenced JSON Schema for the ag-type */ -export async function fetchAgTypeSchema(agType: string): Promise> { +export async function fetchAgTypeSchema( + agType: string, + opts?: {lowPriority?: boolean}, +): Promise> { const response = await axios.get( `${getAgentaApiUrl()}/workflows/catalog/types/${encodeURIComponent(agType)}`, + lowPriorityWhenCached(opts?.lowPriority), ) const jsonSchema = response.data?.type?.json_schema @@ -1399,8 +1404,13 @@ export async function fetchAgTypeSchema(agType: string): Promise>> { - const response = await axios.get(`${getAgentaApiUrl()}/workflows/catalog/harnesses/`) +export async function fetchHarnessCapabilities(opts?: { + lowPriority?: boolean +}): Promise>> { + const response = await axios.get( + `${getAgentaApiUrl()}/workflows/catalog/harnesses/`, + lowPriorityWhenCached(opts?.lowPriority), + ) const harnesses = (response.data?.harnesses ?? []) as { key?: string capabilities?: Record diff --git a/web/packages/agenta-entities/src/workflow/index.ts b/web/packages/agenta-entities/src/workflow/index.ts index 63caab9c414..3c7d185e700 100644 --- a/web/packages/agenta-entities/src/workflow/index.ts +++ b/web/packages/agenta-entities/src/workflow/index.ts @@ -263,6 +263,8 @@ export { workflowLatestRevisionIdAtomFamily, workflowAppTypeAtomFamily, workflowLatestRevisionQueryAtomFamily, + agTypeSchemaAtomFamily, + readPersistedAgentType, // Artifact (workflow-level container — entity display name) workflowArtifactQueryAtomFamily, workflowArtifactScopedQueryAtomFamily, diff --git a/web/packages/agenta-entities/src/workflow/state/index.ts b/web/packages/agenta-entities/src/workflow/state/index.ts index cd8c8dae8d9..a58adde46f6 100644 --- a/web/packages/agenta-entities/src/workflow/state/index.ts +++ b/web/packages/agenta-entities/src/workflow/state/index.ts @@ -92,6 +92,8 @@ export { workflowLatestRevisionIdAtomFamily, workflowAppTypeAtomFamily, workflowLatestRevisionQueryAtomFamily, + // Static catalog schema (agent-template etc.) — exported for early prefetch + agTypeSchemaAtomFamily, // Artifact (workflow-level container — entity display name) workflowArtifactQueryAtomFamily, workflowArtifactScopedQueryAtomFamily, @@ -99,6 +101,9 @@ export { primeWorkflowArtifactCacheImperative, } from "./store" +// Persisted agent-type map (cold-reload fallback for playgroundEarlyAgentStateAtom) +export {readPersistedAgentType} from "./persistedAgentType" + // Union atoms (app + evaluator combined) export { workflowsListDataAtom, diff --git a/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts b/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts index 07784d12c41..70e037490fb 100644 --- a/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts +++ b/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts @@ -18,6 +18,8 @@ import {atomWithQuery} from "jotai-tanstack-query" import {fetchHarnessCapabilities} from "../api" +import {persistedCatalogSeed, writePersistedCatalog} from "./persistedCatalog" + /** One harness's connection-relevant capabilities, as served by the `harnesses` catalog. */ export interface HarnessCapabilities { /** Provider families the harness can reach (a literal list; never `"*"`). */ @@ -36,15 +38,34 @@ export interface HarnessCapabilities { export type HarnessCapabilitiesMap = Record /** - * The harness catalog, fetched once and cached. Global and project-independent (the catalog is - * static), so it is not keyed by anything. + * The harness catalog. Global and project-independent (the catalog is static), so it is not keyed + * by anything. + * + * Persisted to localStorage (`persistedCatalogSeed`) so an agent-playground reload has the harness + * capabilities available for first paint (model picker + collapsed "Unavailable"/"Connect key" + * badges) without a blocking fetch, then revalidates once in the background. NOT `staleTime: + * Infinity` — harness capabilities are still evolving, so the disk seed is treated as stale-on-reload. */ -export const harnessCatalogQueryAtom = atomWithQuery(() => ({ - queryKey: ["workflows", "catalog", "harnesses"], - queryFn: async () => (await fetchHarnessCapabilities()) as unknown as HarnessCapabilitiesMap, - staleTime: Infinity, - refetchOnWindowFocus: false, -})) +const HARNESS_CATALOG_CACHE_KEY = "harness-catalog" +export const harnessCatalogQueryAtom = atomWithQuery(() => { + const seed = persistedCatalogSeed(HARNESS_CATALOG_CACHE_KEY) + // Disk seed present → the model picker / badges already painted, so this fetch is a background + // revalidation; demote it to low priority so it yields to the critical-path queries. + const lowPriority = seed.initialData !== undefined + return { + queryKey: ["workflows", "catalog", "harnesses"], + queryFn: async () => { + const catalog = (await fetchHarnessCapabilities({ + lowPriority, + })) as unknown as HarnessCapabilitiesMap + writePersistedCatalog(HARNESS_CATALOG_CACHE_KEY, catalog) + return catalog + }, + ...seed, + staleTime: 5 * 60_000, + refetchOnWindowFocus: false, + } +}) /** * The per-harness capability map from the `harnesses` catalog. `null` until the catalog resolves. diff --git a/web/packages/agenta-entities/src/workflow/state/persistedAgentType.ts b/web/packages/agenta-entities/src/workflow/state/persistedAgentType.ts new file mode 100644 index 00000000000..308cd69c1f0 --- /dev/null +++ b/web/packages/agenta-entities/src/workflow/state/persistedAgentType.ts @@ -0,0 +1,52 @@ +/** + * Persisted `{workflowId: workflowType}` map so the playground knows agent-ness SYNCHRONOUSLY on a + * cold reload — before the latest-revision query resolves — killing the eval-chrome / split-layout + * flash. `playgroundEarlyAgentStateAtom` reads it as a fallback while the live query is pending; the + * live query then rewrites the entry, so a type change self-heals within the session. + * + * Best-effort localStorage, bounded so it can't grow without limit. Values are the raw + * `deriveWorkflowTypeFromRevision` output ("agent" | "chat" | "completion" | ...); consumers only + * care whether it is `"agent"`. + */ + +const STORAGE_KEY = "agenta:agent-type-by-app:1" +const MAX_ENTRIES = 500 + +type AgentTypeMap = Record + +function readMap(): AgentTypeMap { + if (typeof window === "undefined") return {} + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) + return parsed && typeof parsed === "object" ? (parsed as AgentTypeMap) : {} + } catch { + return {} + } +} + +export function readPersistedAgentType(workflowId: string): string | undefined { + if (!workflowId) return undefined + return readMap()[workflowId] +} + +export function writePersistedAgentType(workflowId: string, type: string | null | undefined): void { + if (typeof window === "undefined" || !workflowId || !type) return + try { + const map = readMap() + if (map[workflowId] === type) return + map[workflowId] = type + // FIFO trim: JSON preserves string-key insertion order, so keep the most-recently-inserted. + const keys = Object.keys(map) + const bounded = + keys.length > MAX_ENTRIES + ? Object.fromEntries( + keys.slice(keys.length - MAX_ENTRIES).map((k) => [k, map[k]] as const), + ) + : map + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(bounded)) + } catch { + // quota / serialization — best-effort, ignore. + } +} diff --git a/web/packages/agenta-entities/src/workflow/state/persistedCatalog.ts b/web/packages/agenta-entities/src/workflow/state/persistedCatalog.ts new file mode 100644 index 00000000000..f80c0caa844 --- /dev/null +++ b/web/packages/agenta-entities/src/workflow/state/persistedCatalog.ts @@ -0,0 +1,60 @@ +/** + * Disk-backed SWR seed for the static workflow catalogs (ag-type schema, harness capabilities). + * + * These responses are global and immutable-per-key, so a cold page reload re-fetching them is pure + * waterfall latency before the agent playground can paint its config sections / model picker. We + * persist them to `localStorage` and feed the last value back as the query's `initialData`, so a + * reload paints from cache INSTANTLY instead of waiting on the network. + * + * The seed is deliberately marked immediately stale (`initialDataUpdatedAt: 0`) rather than pinned + * (`staleTime: Infinity`): the agent-template schema is still evolving, so on the first mount after + * a reload the query paints from disk AND fires one background (non-blocking) revalidation that + * rewrites the cache. A finite `staleTime` on the consuming query then dedupes in-session remounts. + * + * Best-effort: any SSR / storage / parse / quota failure silently falls back to a normal fetch. + */ + +const CACHE_PREFIX = "agenta:catalog-swr" +// Bump to hard-invalidate every persisted catalog after a breaking response-shape change. +const CACHE_VERSION = "1" + +interface PersistedCatalogEntry { + v: string + ts: number + data: T +} + +const storageKey = (key: string) => `${CACHE_PREFIX}:${CACHE_VERSION}:${key}` + +/** + * TanStack query options to spread for a disk-seeded, background-revalidating catalog query. + * Empty when there is nothing persisted (or no `window`), so the query fetches normally. + */ +export function persistedCatalogSeed(key: string): { + initialData?: T + initialDataUpdatedAt?: number +} { + if (typeof window === "undefined") return {} + try { + const raw = window.localStorage.getItem(storageKey(key)) + if (!raw) return {} + const parsed = JSON.parse(raw) as PersistedCatalogEntry + if (parsed?.v !== CACHE_VERSION || parsed.data == null) return {} + // `initialDataUpdatedAt: 0` = treat the disk value as ancient: paint from it immediately, + // but always revalidate once on the first post-reload mount to catch schema changes. + return {initialData: parsed.data, initialDataUpdatedAt: 0} + } catch { + return {} + } +} + +/** Persist a freshly-fetched catalog value. Call from the query's `queryFn` after a successful fetch. */ +export function writePersistedCatalog(key: string, data: T): void { + if (typeof window === "undefined" || data == null) return + try { + const entry: PersistedCatalogEntry = {v: CACHE_VERSION, ts: Date.now(), data} + window.localStorage.setItem(storageKey(key), JSON.stringify(entry)) + } catch { + // quota exceeded / serialization failure — the cache is best-effort, so ignore. + } +} diff --git a/web/packages/agenta-entities/src/workflow/state/persistedInspect.ts b/web/packages/agenta-entities/src/workflow/state/persistedInspect.ts new file mode 100644 index 00000000000..ee24b28eb1b --- /dev/null +++ b/web/packages/agenta-entities/src/workflow/state/persistedInspect.ts @@ -0,0 +1,78 @@ +/** + * Disk-backed SWR for `inspectWorkflow` — the long pole of the agent-playground reload waterfall + * (it leaves the Agenta API host and hits the agent-service container directly, and it gates which + * config sections render). The inspect response is keyed by `uri + serviceUrl` (per SERVICE, shared + * across a workflow's revisions), so one persisted entry serves every revision of an agent. + * + * Persisted so a reload paints the config sections from disk instantly, then revalidates once in the + * background (`initialDataUpdatedAt: 0`) — a committed revision's config is immutable, but its + * resolved schema can shift when the service redeploys, so we never pin it. Payloads are large, so + * this uses a bounded LRU (per-entry storage + a small index) instead of one growing blob. + * + * Best-effort: any SSR / storage / parse / quota failure silently falls back to a normal fetch. + */ + +const PREFIX = "agenta:inspect-swr" +// Bump to hard-invalidate every persisted inspect after a breaking response-shape change. +const VERSION = "1" +const MAX_ENTRIES = 15 +const INDEX_KEY = `${PREFIX}:${VERSION}:__index` + +const entryKey = (key: string) => `${PREFIX}:${VERSION}:${key}` + +interface PersistedInspectEntry { + v: string + data: T +} + +function readIndex(): string[] { + try { + const raw = window.localStorage.getItem(INDEX_KEY) + const parsed = raw ? JSON.parse(raw) : [] + return Array.isArray(parsed) ? (parsed as string[]) : [] + } catch { + return [] + } +} + +/** + * TanStack query options to spread for a disk-seeded, background-revalidating inspect query. + * Empty when nothing is persisted for `key` (or no `window`), so the query fetches normally. + */ +export function persistedInspectSeed(key: string): { + initialData?: T + initialDataUpdatedAt?: number +} { + if (typeof window === "undefined" || !key) return {} + try { + const raw = window.localStorage.getItem(entryKey(key)) + if (!raw) return {} + const parsed = JSON.parse(raw) as PersistedInspectEntry + if (parsed?.v !== VERSION || parsed.data == null) return {} + // Paint from disk immediately, but always revalidate once (schema can shift on redeploy). + return {initialData: parsed.data, initialDataUpdatedAt: 0} + } catch { + return {} + } +} + +/** Persist a freshly-fetched inspect value. Call from the query's `queryFn` after a successful fetch. */ +export function writePersistedInspect(key: string, data: T): void { + if (typeof window === "undefined" || !key || data == null) return + try { + const entry: PersistedInspectEntry = {v: VERSION, data} + window.localStorage.setItem(entryKey(key), JSON.stringify(entry)) + // LRU: move this key to the front, evict overflow entries. + const index = [key, ...readIndex().filter((id) => id !== key)] + for (const id of index.slice(MAX_ENTRIES)) { + try { + window.localStorage.removeItem(entryKey(id)) + } catch { + // ignore + } + } + window.localStorage.setItem(INDEX_KEY, JSON.stringify(index.slice(0, MAX_ENTRIES))) + } catch { + // quota / serialization — the cache is best-effort, so ignore. + } +} diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index 0fd77dc6e3d..08a16f600ca 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -61,6 +61,9 @@ import { deriveWorkflowTypeFromRevision, withLatestAgentFlags, } from "./helpers" +import {writePersistedAgentType} from "./persistedAgentType" +import {persistedCatalogSeed, writePersistedCatalog} from "./persistedCatalog" +import {persistedInspectSeed, writePersistedInspect} from "./persistedInspect" // ============================================================================ // HELPERS @@ -872,13 +875,22 @@ export const workflowLatestRevisionQueryAtomFamily = atomFamily((workflowId: str projectId, workflowId, ) - if (cached) return cached - - return await workflowLatestRevisionBatchFetcher({ - projectId, - workflowId, - queryClient, - }) + const revision = + cached ?? + (await workflowLatestRevisionBatchFetcher({ + projectId, + workflowId, + queryClient, + })) + // Remember the workflow type so the next cold reload knows agent-ness instantly + // (see playgroundEarlyAgentStateAtom) instead of waiting on this round-trip. + if (revision) { + writePersistedAgentType( + workflowId, + deriveWorkflowTypeFromRevision(revision), + ) + } + return revision } catch { return null } @@ -1179,12 +1191,24 @@ export const workflowInspectAtomFamily = atomFamily((revisionId: string) => hasUrl && (isAgent || !hasAllSchemas) + // Per-SERVICE cache key (inspect is keyed by uri+serviceUrl, shared across revisions), so one + // persisted entry serves every revision of an agent. Only seed when inspect actually runs. + const inspectCacheKey = + isEnabled && uri && serviceUrl ? `${projectId}::${uri}::${serviceUrl}` : "" + const inspectSeed = persistedInspectSeed(inspectCacheKey) + // Disk seed present → the config sections already painted, so this is a background + // revalidation; demote it to low priority so it yields to the critical-path queries. + const lowPriority = inspectSeed.initialData !== undefined return { queryKey: ["workflows", "inspect", revisionId, uri, serviceUrl, projectId], queryFn: async (): Promise => { if (!projectId || !uri || !serviceUrl) return null - return inspectWorkflow(uri, projectId, serviceUrl) + const result = await inspectWorkflow(uri, projectId, serviceUrl, {lowPriority}) + if (result) writePersistedInspect(inspectCacheKey, result) + return result }, + // Disk seed → paint config sections instantly on reload, then revalidate once (SWR). + ...inspectSeed, enabled: isEnabled, staleTime: 60_000, } @@ -1284,17 +1308,32 @@ export const workflowBuildKitEnabledAtomFamily = atomFamily((_revisionId: string * * When the frontend encounters a schema property with `x-ag-type-ref` but no * sub-properties, it calls this to get the full schema from the backend. - * The schema is immutable per ag-type, so `staleTime: Infinity`. + * + * Persisted to localStorage (`persistedCatalogSeed`) so a cold reload paints the + * agent-template config sections from disk instantly, then revalidates once in the + * background. NOT `staleTime: Infinity` — the agent-template schema is still evolving, + * so the disk seed is treated as stale-on-reload; the finite `staleTime` only dedupes + * in-session remounts (e.g. revision switches). */ export const agTypeSchemaAtomFamily = atomFamily((agType: string) => - atomWithQuery((_get) => ({ - queryKey: ["workflows", "schemas", "ag-types", agType], - queryFn: async (): Promise> => { - return fetchAgTypeSchema(agType) - }, - staleTime: Infinity, - refetchOnWindowFocus: false, - })), + atomWithQuery((_get) => { + const cacheKey = `ag-type-schema:${agType}` + const seed = persistedCatalogSeed>(cacheKey) + // With a disk seed the UI already painted, so this fetch is a background revalidation — + // demote it to low network priority so it doesn't compete with the critical-path queries. + const lowPriority = seed.initialData !== undefined + return { + queryKey: ["workflows", "schemas", "ag-types", agType], + queryFn: async (): Promise> => { + const schema = await fetchAgTypeSchema(agType, {lowPriority}) + writePersistedCatalog(cacheKey, schema) + return schema + }, + ...seed, + staleTime: 5 * 60_000, + refetchOnWindowFocus: false, + } + }), ) // ============================================================================ diff --git a/web/packages/agenta-shared/src/api/axios.ts b/web/packages/agenta-shared/src/api/axios.ts index 92b32d8a658..0f5b3649160 100644 --- a/web/packages/agenta-shared/src/api/axios.ts +++ b/web/packages/agenta-shared/src/api/axios.ts @@ -27,12 +27,24 @@ import axiosApi, { type AxiosInstance, + type AxiosRequestConfig, type AxiosResponse, type InternalAxiosRequestConfig, } from "axios" import {getAgentaApiUrl} from "./env" +/** + * Per-request config that demotes a request to low network priority when we're only revalidating + * data we already have from a persisted cache. The browser can't set a priority on an XHR (axios's + * default adapter — Chrome shows it as "High"), so we route these through axios's fetch adapter, + * which forwards `fetchOptions.priority: "low"` as the Fetch Priority hint. Interceptors (auth) still + * run. When `cached` is false the request is on the critical path, so we leave it at the XHR default. + */ +export function lowPriorityWhenCached(cached: boolean | undefined): AxiosRequestConfig { + return cached ? ({adapter: "fetch", fetchOptions: {priority: "low"}} as AxiosRequestConfig) : {} +} + /** * Create a new axios instance with Agenta API defaults. */ diff --git a/web/packages/agenta-shared/src/api/index.ts b/web/packages/agenta-shared/src/api/index.ts index 8108a750e15..55aee43c5d8 100644 --- a/web/packages/agenta-shared/src/api/index.ts +++ b/web/packages/agenta-shared/src/api/index.ts @@ -3,7 +3,13 @@ */ export {getEnv, getAgentaApiUrl, getAgentaWebUrl, processEnv} from "./env" -export {axios, createAxiosInstance, configureAxios, resetAxiosConfig} from "./axios" +export { + axios, + createAxiosInstance, + configureAxios, + resetAxiosConfig, + lowPriorityWhenCached, +} from "./axios" export type {AxiosInterceptorConfig} from "./axios" export type { AxiosInstance, From dfcea352a86da002c741a207a9d57253dda693c8 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 03:02:20 +0200 Subject: [PATCH 06/50] perf(frontend): detect agent mode early to remove load-time chrome flash Add playgroundEarlyAgentStateAtom (app-id keyed, backed by the persisted type map) so the playground commits to the agent layout up front instead of defaulting to non-agent and flipping once the revision's is_agent flag loads. Gate the page-header eval chrome, the config-panel header (variant selector / view-type), the loading shell, and the MainLayout split on this signal, staying neutral until agent-ness is confirmed (settled) so neither the eval stack nor the prompt config header flashes on an agent reload. --- .../Components/MainLayout/index.tsx | 9 +++- .../Components/PlaygroundHeader/index.tsx | 30 +++++++++-- .../assets/PlaygroundVariantConfigHeader.tsx | 20 ++++++-- .../PlaygroundVariantConfig/index.tsx | 11 +++- .../src/components/PlaygroundRouter/index.tsx | 51 +++++++++---------- web/oss/src/state/workflow/index.ts | 2 + .../src/state/workflow/selectors/workflow.ts | 31 +++++++++++ 7 files changed, 117 insertions(+), 37 deletions(-) diff --git a/web/oss/src/components/Playground/Components/MainLayout/index.tsx b/web/oss/src/components/Playground/Components/MainLayout/index.tsx index acd7781360e..f539a02baf6 100644 --- a/web/oss/src/components/Playground/Components/MainLayout/index.tsx +++ b/web/oss/src/components/Playground/Components/MainLayout/index.tsx @@ -25,6 +25,7 @@ import dynamic from "next/dynamic" import {chatPanelMaximizedAtom} from "@/oss/components/AgentChatSlice/state/panelLayout" import {PanelSessionInspectorButton} from "@/oss/components/SessionInspector" import {routerAppIdAtom} from "@/oss/state/app/selectors/app" +import {playgroundEarlyAgentStateAtom} from "@/oss/state/workflow" import {usePlaygroundScrollSync} from "../../hooks/usePlaygroundScrollSync" import PromptComparisonVariantNavigation from "../PlaygroundPromptComparisonView/PromptComparisonVariantNavigation" @@ -178,7 +179,13 @@ const PlaygroundMainView = ({ // key change the panel would keep the initial (pre-resolution) 50% split on reload. const primaryConfigId = !isComparisonView && configEntityIds.length > 0 ? configEntityIds[0]! : "" - const isAgentConfig = useAtomValue(isAgentModeAtomFamily(primaryConfigId)) + // Seed the agent geometry from the early app-id signal so the splitter mounts at the + // 440px agent split instead of flashing the prompt 50/50 while the revision loads. + // Single-view only (agents are excluded from comparison); the per-entity value still + // wins once the config revision resolves. + const earlyIsAgent = useAtomValue(playgroundEarlyAgentStateAtom) === "agent" + const isAgentConfig = + useAtomValue(isAgentModeAtomFamily(primaryConfigId)) || (!isComparisonView && earlyIsAgent) const configDefaultSize = isAgentConfig ? 440 : "50%" const configMaxSize = isAgentConfig ? 450 : "70%" // Let the runs panel auto-fill in agent mode. A px config default + a "50%" runs default diff --git a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx index 7ee8f867f89..a8f48ae6621 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx @@ -58,7 +58,11 @@ import useCustomWorkflowConfig from "@/oss/components/pages/app-management/modal import {routerAppIdAtom} from "@/oss/state/app/selectors/app" import {openEvaluatorDrawerAtom} from "@/oss/state/evaluator/evaluatorDrawerStore" import {writePlaygroundSelectionToQuery} from "@/oss/state/url/playground" -import {currentWorkflowAtom, currentWorkflowContextAtom} from "@/oss/state/workflow" +import { + currentWorkflowAtom, + currentWorkflowContextAtom, + playgroundEarlyAgentStateAtom, +} from "@/oss/state/workflow" import {workspaceMemberByIdFamily} from "@/oss/state/workspace/atoms/selectors" import AgentRevisionSelector from "../AgentRevisionSelector" @@ -219,13 +223,33 @@ const PlaygroundHeader: React.FC = ({className, ...divPro // Agent workflows hide the evaluation-flow actions (Compare / Test set / // Evaluator / New Evaluation) — those flows aren't wired for agents yet. const rootEntityId = useMemo(() => nodes.find((n) => n.depth === 0)?.entityId ?? null, [nodes]) - const isAgentWorkflow = useAtomValue( + const nodeIsAgent = useAtomValue( useMemo( () => (rootEntityId ? isAgentModeAtomFamily(rootEntityId) : atom(false)), [rootEntityId], ), ) - const showEvalActions = !isAgentWorkflow + // Loading state of the root revision entity. Critical for the gate below: `nodeIsAgent` + // reads `workflowType`, which falls back to "completion" until the revision's flags load — + // so a mid-load agent looks identical to a prompt app. We must not treat "not yet known" as + // "confirmed prompt". + const rootEntityQuery = useAtomValue( + useMemo(() => workflowMolecule.selectors.query(rootEntityId ?? ""), [rootEntityId]), + ) + // Early app-id signal resolves agent-ness before the heavy node graph loads, so + // the layout commits to the right chrome up front instead of defaulting to the + // non-agent stack and unmounting it on reload. + const earlyAgentState = useAtomValue(playgroundEarlyAgentStateAtom) + const isAgentWorkflow = nodeIsAgent || earlyAgentState === "agent" + // Neutral until CONFIRMED prompt: show the eval chrome only when a definitive signal says + // non-agent — the early app-id query resolved to non-agent, OR the root revision has fully + // SETTLED (not pending) and isn't an agent. The `!isPending` guard is what prevents the + // agent-reload flash: without it, `hasRootNode && !nodeIsAgent` is true during the flags-load + // window (node graph resolved, is_agent not yet loaded) and the eval stack pops in then vanishes. + const showEvalActions = + !isAgentWorkflow && + (earlyAgentState === "non-agent" || + (hasRootNode && !nodeIsAgent && !rootEntityQuery.isPending)) // Build/Chat mode: "chat" maximizes the chat pane (config hidden, session rail shown); "build" // is the 2-panel edit view. The boolean maximize atom is the single source of truth (also read 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 ce7af5c5bc9..4c524cbce53 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx @@ -17,7 +17,7 @@ import {useAtomValue, useSetAtom} from "jotai" import dynamic from "next/dynamic" import {routerAppIdAtom} from "@/oss/state/app/atoms/fetcher" -import {currentWorkflowContextAtom} from "@/oss/state/workflow" +import {currentWorkflowContextAtom, playgroundEarlyAgentStateAtom} from "@/oss/state/workflow" import SelectVariant from "../../Menus/SelectVariant" import CommitVariantChangesButton from "../../Modals/CommitVariantChangesModal/assets/CommitVariantChangesButton" @@ -98,6 +98,16 @@ const PlaygroundVariantConfigHeader = ({ // Agent workflows dropped the top-level "Agent" section header, so the config bar carries the // only "this is an agent" signal — a small badge next to the variant details. const isAgent = useAtomValue(isAgentModeAtomFamily(variantId || "")) + // `isAgentModeAtomFamily` is false until the revision's is_agent flag loads, so on load this bar + // would flash the heavy prompt header (SelectVariant + variant details) for an agent. Use the + // agent-style "Configuration" header when it's an agent, the early app-id signal says agent, OR + // agent-ness is still unknown (variant not settled); the prompt header waits for a confirmed prompt. + const earlyAgentState = useAtomValue(playgroundEarlyAgentStateAtom) + const isAgentEffective = isAgent || earlyAgentState === "agent" + const variantQueryPending = useAtomValue( + useMemo(() => workflowMolecule.selectors.query(variantId || ""), [variantId]), + ).isPending + const showAgentHeader = isAgentEffective || variantQueryPending // Deployment info: look up which environments this revision is deployed to // Local drafts have no deployments @@ -175,16 +185,18 @@ const PlaygroundVariantConfigHeader = ({ // Give it a subtly tinted surface (vs the plain content): an opaque container base // (background-color) with the translucent fill layered on top (background-image), so // this sticky header stays opaque and scrolled content can't bleed through it. - isAgent && !embedded + showAgentHeader && !embedded ? "bg-[var(--ag-c-FFFFFF)] bg-[image:linear-gradient(var(--ant-color-fill-tertiary),var(--ant-color-fill-tertiary))]" : "bg-[var(--ag-c-FFFFFF)]" } ${className ?? ""}`} {...divProps} >
- {isAgent && !embedded ? ( + {showAgentHeader && !embedded ? ( // Agent playground: the revision selector moved up to the page header (next to the // agent name), so this bar reads as the config panel's "Configuration" header. + // Also the neutral header while agent-ness is unknown, so the prompt chrome below + // never flashes on load for an agent. Configuration @@ -284,7 +296,7 @@ const PlaygroundVariantConfigHeader = ({ // surfaces keep the icon-only deploy. diff --git a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx index 44ea8f4c401..f4d5e9d5a40 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx @@ -23,6 +23,7 @@ import {atom, useAtomValue, useSetAtom} from "jotai" import dynamic from "next/dynamic" import {extractJsonPaths, safeParseJson} from "@/oss/lib/helpers/extractJsonPaths" +import {playgroundEarlyAgentStateAtom} from "@/oss/state/workflow" import {PlaygroundNodeTokenPathProvider} from "../../PlaygroundTokenPath" @@ -66,6 +67,14 @@ const PlaygroundVariantConfig: React.FC< // The agent config panel is a read-only summary that edits via section drawers, so the // form/JSON/YAML view switch doesn't apply — hide it for agents (kept for prompt/eval variants). const isAgent = useAtomValue(isAgentModeAtomFamily(variantId)) + // `isAgentModeAtomFamily` is false until the revision's is_agent flag loads, so on load the heavy + // prompt chrome (view switcher) would flash for an agent. Treat as agent-header mode when it's an + // agent, the early app-id signal says agent, OR agent-ness is still unknown (variant not settled). + const earlyAgentState = useAtomValue(playgroundEarlyAgentStateAtom) + const variantQueryPending = useAtomValue( + useMemo(() => workflowMolecule.selectors.query(variantId || ""), [variantId]), + ).isPending + const isAgentHeaderMode = isAgent || earlyAgentState === "agent" || variantQueryPending // Refine prompt modal state const [refineModalOpen, setRefineModalOpen] = useState(false) @@ -221,7 +230,7 @@ const PlaygroundVariantConfig: React.FC< evaluatorLabel={evaluatorInfo?.label} hasPresets={hasPresets} onLoadPreset={() => setIsPresetModalOpen(true)} - extraActions={isAgent ? undefined : viewModeSelector} + extraActions={isAgentHeaderMode ? undefined : viewModeSelector} /> {hasPendingHydration ? (
diff --git a/web/oss/src/components/PlaygroundRouter/index.tsx b/web/oss/src/components/PlaygroundRouter/index.tsx index ba1ee178734..9b74ad2a760 100644 --- a/web/oss/src/components/PlaygroundRouter/index.tsx +++ b/web/oss/src/components/PlaygroundRouter/index.tsx @@ -1,47 +1,42 @@ import {memo} from "react" import {bgColors} from "@agenta/ui" -import {DownOutlined} from "@ant-design/icons" -import {Flask, Plus} from "@phosphor-icons/react" -import {Button, Space, Typography} from "antd" +import {Robot} from "@phosphor-icons/react" +import {Typography} from "antd" import {useAtomValue} from "jotai" import dynamic from "next/dynamic" import {useRouter} from "next/router" import {PLAYGROUND_NATIVE_ONBOARDING} from "@/oss/components/pages/agent-home/assets/constants" import OnboardingLoader from "@/oss/components/pages/agent-home/PlaygroundOnboarding/OnboardingLoader" -import {currentWorkflowContextAtom} from "@/oss/state/workflow" +import {currentWorkflowContextAtom, playgroundEarlyAgentStateAtom} from "@/oss/state/workflow" +// Neutral chunk-download fallback. It must NOT prejudge the app as non-agent — the old +// shell hardcoded the eval stack (New Evaluation / Compare), which then vanished on agent +// reloads. Read the early app-id agent signal so an agent app shows the agent-flavored +// header from the first paint, and never render the eval actions here (the real header +// commits them once the workflow type is confirmed). const PlaygroundLoadingShell = () => { + const isAgent = useAtomValue(playgroundEarlyAgentStateAtom) === "agent" return (
- - Playground - -
- - - -
+ {isAgent ? ( +
+ + + + + Agent + +
+ ) : ( + + Playground + + )}
) diff --git a/web/oss/src/state/workflow/index.ts b/web/oss/src/state/workflow/index.ts index 6be39bd48d0..66239216c97 100644 --- a/web/oss/src/state/workflow/index.ts +++ b/web/oss/src/state/workflow/index.ts @@ -19,7 +19,9 @@ export { currentWorkflowAtom, currentWorkflowContextAtom, deriveWorkflowKind, + playgroundEarlyAgentStateAtom, type CurrentWorkflowContext, + type PlaygroundAgentState, } from "./selectors/workflow" export { resolveWorkflowDestination, diff --git a/web/oss/src/state/workflow/selectors/workflow.ts b/web/oss/src/state/workflow/selectors/workflow.ts index d5399cc52f8..92446f99482 100644 --- a/web/oss/src/state/workflow/selectors/workflow.ts +++ b/web/oss/src/state/workflow/selectors/workflow.ts @@ -1,4 +1,6 @@ import { + readPersistedAgentType, + workflowAppTypeAtomFamily, workflowDetailQueryAtomFamily, type Workflow, type WorkflowFlags, @@ -147,3 +149,32 @@ export const currentWorkflowContextAtom = atom((get) => isError: false, } }) + +/** + * Early, app-id-keyed agent signal for the playground shell/header/layout. + * + * The node-derived `isAgentModeAtomFamily(rootEntityId)` only resolves after the + * heavy playground graph + root revision load, so the layout would default to the + * non-agent (prompt) chrome and flip once it turns out to be an agent — mounting + * then unmounting the eval stack. This reads the lightweight latest-revision query + * (already warmed by the sidebar) keyed by the URL app id, giving a definitive + * answer *before* the graph resolves. + * + * "unknown" = no app in URL (project-level) OR the latest-revision query still + * pending AND nothing persisted from a prior session. Consumers render neutral + * chrome while unknown, committing to the agent or prompt layout only once confirmed. + */ +export type PlaygroundAgentState = "agent" | "non-agent" | "unknown" + +export const playgroundEarlyAgentStateAtom = atom((get) => { + const appId = get(routerAppIdAtom) + if (!appId) return "unknown" + const appType = get(workflowAppTypeAtomFamily(appId)) + if (appType != null) return appType === "agent" ? "agent" : "non-agent" + // Live query still pending on a cold reload — fall back to the last-known type persisted from a + // prior session (see persistedAgentType) so the layout commits immediately instead of flashing + // neutral/non-agent chrome. The live query rewrites the entry, so a stale value self-heals. + const cached = readPersistedAgentType(appId) + if (cached) return cached === "agent" ? "agent" : "non-agent" + return "unknown" +}) From 1d8a23c3fd32c774333682071dfdb16423f922c0 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 03:03:03 +0200 Subject: [PATCH 07/50] perf(frontend): prefetch agent catalogs in parallel with the config waterfall Mount AgentCatalogPrefetcher on the playground when it's an agent (onboarding, or the early signal), warming the agent-template schema + harness catalog alongside the revision/inspect waterfall. On a cold first load the schema no longer fetches last (after inspect resolves its ref); on a warm reload it just kicks the background revalidate a little earlier. --- .../Components/AgentCatalogPrefetcher.tsx | 20 +++++++++++++++++++ .../src/components/Playground/Playground.tsx | 9 +++++++++ 2 files changed, 29 insertions(+) create mode 100644 web/oss/src/components/Playground/Components/AgentCatalogPrefetcher.tsx diff --git a/web/oss/src/components/Playground/Components/AgentCatalogPrefetcher.tsx b/web/oss/src/components/Playground/Components/AgentCatalogPrefetcher.tsx new file mode 100644 index 00000000000..f68109d7266 --- /dev/null +++ b/web/oss/src/components/Playground/Components/AgentCatalogPrefetcher.tsx @@ -0,0 +1,20 @@ +import {agTypeSchemaAtomFamily, harnessCapabilitiesAtomFamily} from "@agenta/entities/workflow" +import {useAtomValue} from "jotai" + +/** + * Warms the two static agent catalogs (agent-template schema + harness capabilities) as soon as the + * playground is known to be an agent — in PARALLEL with the revision/inspect waterfall — so a cold + * first load (empty persisted cache) doesn't gate the config sections behind `inspect` resolving + * (the agent-template schema ref only surfaces after inspect, so it would otherwise fetch last). + * + * On a warm reload the persisted seed already paints instantly; this just fires the background + * revalidate a little earlier. Renders nothing — it exists only to subscribe to (and thus trigger) + * the queries. Mount it ONLY for agent playgrounds so prompt playgrounds never fetch agent-only data. + */ +const AgentCatalogPrefetcher = () => { + useAtomValue(agTypeSchemaAtomFamily("agent-template")) + useAtomValue(harnessCapabilitiesAtomFamily("")) + return null +} + +export default AgentCatalogPrefetcher diff --git a/web/oss/src/components/Playground/Playground.tsx b/web/oss/src/components/Playground/Playground.tsx index b2ad85c6e1d..52833997f95 100644 --- a/web/oss/src/components/Playground/Playground.tsx +++ b/web/oss/src/components/Playground/Playground.tsx @@ -21,7 +21,9 @@ import {useAgentOnboarding} from "@/oss/components/pages/agent-home/PlaygroundOn import {SessionInspectorDrawer} from "@/oss/components/SessionInspector" import SharedGenerationResultUtils from "@/oss/components/SharedGenerationResultUtils" import {playgroundSyncAtom} from "@/oss/state/url/playground" +import {playgroundEarlyAgentStateAtom} from "@/oss/state/workflow" +import AgentCatalogPrefetcher from "./Components/AgentCatalogPrefetcher" import PlaygroundMainView from "./Components/MainLayout" import PlaygroundHeader from "./Components/PlaygroundHeader" import {OSSPlaygroundShell} from "./OSSPlaygroundShell" @@ -81,6 +83,12 @@ const Playground: FC<{onboarding?: boolean}> = ({onboarding = false}) => { // reuses all the machinery above). Fully inert when `onboarding` is false — normal playground path. const agentOnboarding = useAgentOnboarding(onboarding) + // Once we know this is an agent playground (instant on reload via the persisted agent-type map), + // warm the static agent catalogs in parallel with the revision/inspect waterfall — see + // AgentCatalogPrefetcher. Onboarding is always an agent, so prefetch there too. + const earlyAgentState = useAtomValue(playgroundEarlyAgentStateAtom) + const prefetchAgentCatalogs = onboarding || earlyAgentState === "agent" + // Preload lazy editor plugins ASAP to reduce first-render editor suspense jank. useEffect(() => { void preloadEditorPlugins() @@ -110,6 +118,7 @@ const Playground: FC<{onboarding?: boolean}> = ({onboarding = false}) => { const content = (
+ {prefetchAgentCatalogs ? : null} Date: Wed, 8 Jul 2026 03:07:41 +0200 Subject: [PATCH 08/50] chore: gitignore skills-lock.json --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4d2ad92adba..312510e71b9 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,6 @@ hosting/kubernetes/**/values.*.yaml # IDE/LSP config (local tooling) pyrightconfig.json .gstack/ + +# Agent skills lockfile (npx skills add) +skills-lock.json From 5a52018d82ab22e1660449788e02e63f4a51486c Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 03:07:56 +0200 Subject: [PATCH 09/50] feat(frontend): background trace-summary hydration Hydrate per-run trace summaries in the background without competing with render-critical traffic. Adds a low-priority traces client (withLowPriorityFetch / getLowPriorityTracesClient sending the priority: "low" fetch hint) and a lighter traceSummaryQueryAtomFamily that the loadable controller reads to derive a run's error/root-span from the summary instead of the full trace. A just-finished run's trace may not be ingested yet, so markTraceAsFresh/isTraceFresh give fresh traces an aggressive not-found retry grace (5x vs once), marked at run-finish in the agent chat panel and execution runners. createBatchFetcher gains an idle flush mode (requestIdleCallback) so background-hydration batches coalesce and yield to render. --- .../AgentChatSlice/AgentChatPanel.tsx | 5 + .../src/loadable/controller.ts | 85 ++++---- .../agenta-entities/src/trace/api/api.ts | 15 +- .../agenta-entities/src/trace/api/client.ts | 10 +- .../agenta-entities/src/trace/index.ts | 6 + .../agenta-entities/src/trace/state/index.ts | 5 + .../agenta-entities/src/trace/state/store.ts | 189 +++++++++++++++++- .../src/state/execution/executionItems.ts | 4 + .../src/state/execution/executionRunner.ts | 7 + web/packages/agenta-sdk/src/config.ts | 15 ++ web/packages/agenta-sdk/src/resources.ts | 9 +- .../src/utils/createBatchFetcher.ts | 24 ++- 12 files changed, 306 insertions(+), 68 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx index 5305c967a59..de521d68e00 100644 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx @@ -1,5 +1,6 @@ import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from "react" +import {markTraceAsFresh} from "@agenta/entities/trace" import {invalidateAgentCommittedRevisionCache, workflowMolecule} from "@agenta/entities/workflow" import { agentShouldResumeAfterApproval, @@ -417,6 +418,10 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: // Approve AND deny both resume — a deny-only decision must re-send so the runner // gets the denial round-trip and the model continues (no `approval-responded` limbo). sendAutomaticallyWhen: agentShouldResumeAfterApproval, + // The turn's trace may not be ingested yet when the row asks for its summary — + // marking it fresh lets the trace queries retry through the ingestion lag + // (historical traces get no such grace; a 404 there means the trace is gone). + onFinish: ({message}) => markTraceAsFresh(getMessageTraceId(message)), onError: (err) => { // Render the error in-chat (the `error` alert below); swallow it here so an // aborted/errored stream doesn't bubble unhandled to the Next.js dev overlay (F-033). diff --git a/web/packages/agenta-entities/src/loadable/controller.ts b/web/packages/agenta-entities/src/loadable/controller.ts index ee659639f48..977e5b8051e 100644 --- a/web/packages/agenta-entities/src/loadable/controller.ts +++ b/web/packages/agenta-entities/src/loadable/controller.ts @@ -58,7 +58,7 @@ import {pendingColumnOpsAtomFamily} from "../testset/state" import {saveNewTestsetAtom, saveTestsetAtom} from "../testset/state/mutations" import {revisionMolecule} from "../testset/state/revisionMolecule" import { - traceEntityAtomFamily, + traceSummaryQueryAtomFamily, extractAgData, collectKeyPaths, filterDataPaths, @@ -232,15 +232,14 @@ const connectedRowsAtomFamily = atomFamily((loadableId: string) => let dataSource: Record | null = null if (executionResult.traceId) { - const traceQuery = get(traceEntityAtomFamily(executionResult.traceId)) - if (traceQuery.data) { - const rootSpan = getRootSpanFromTraceResponse(traceQuery.data) - if (rootSpan) { - const agData = extractAgData(rootSpan) - if (agData && Object.keys(agData).length > 0) { - // Wrap in { data: ... } to match path format (data.inputs.*, data.outputs.*) - dataSource = {data: agData} - } + // Root span only — output mapping never needs the full span tree. + const rootSpan = get(traceSummaryQueryAtomFamily(executionResult.traceId)).data + ?.rootSpan + if (rootSpan) { + const agData = extractAgData(rootSpan) + if (agData && Object.keys(agData).length > 0) { + // Wrap in { data: ... } to match path format (data.inputs.*, data.outputs.*) + dataSource = {data: agData} } } } @@ -1571,31 +1570,6 @@ const clearOutputMappingsAtom = atom(null, (get, set, loadableId: string) => { }) }) -/** - * Helper to extract the root span from a TracesApiResponse. - * The response format is: { traces: { [traceId]: { spans: { [spanId]: TraceSpan } } } } - * Returns the root span (one with no parent_id) or the first span. - */ -const getRootSpanFromTraceResponse = ( - traceResponse: TracesApiResponse | null, -): TraceSpan | null => { - if (!traceResponse?.traces) return null - - // Get the first trace entry - const traceEntries = Object.values(traceResponse.traces) - if (traceEntries.length === 0) return null - - const traceEntry = traceEntries[0] - if (!traceEntry?.spans) return null - - // Get all spans - const spans = Object.values(traceEntry.spans) as TraceSpan[] - if (spans.length === 0) return null - - // Find the root span (no parent_id) or use the first one - return spans.find((s) => !s.parent_id) || spans[0] -} - /** * A tool span's error is a STEP failure, not a run failure: the agent gets the tool error back * as output and keeps going (retry, another path, or answer around it), so it must not read as @@ -1674,6 +1648,16 @@ export const getTraceErrorFromResponse = ( return undefined } +/** Flat-span variant of `getTraceErrorFromResponse` — same tool-span exclusion. */ +const getTraceErrorFromSpans = (spans: TraceSpan[]): string | undefined => { + for (const span of spans) { + if (!isRunFailureSpanType(span)) continue + const message = spanErrorMessage(span) + if (message) return message + } + return undefined +} + // ============================================================================ // TRACE DATA SUMMARY - Single source of truth for trace-derived data // ============================================================================ @@ -1776,23 +1760,23 @@ export const traceDataSummaryAtomFamily = atomFamily((traceId: string | null) => if (!traceId) return emptyResult - // Fetch trace data (cached by TanStack Query) - const traceQuery = get(traceEntityAtomFamily(traceId)) + // Lightweight fetch: root span + errored spans only (cached by TanStack Query). + // Everything below derives from the root span, so the full tree is never needed here. + const summaryQuery = get(traceSummaryQueryAtomFamily(traceId)) - if (traceQuery.isPending) { + if (summaryQuery.isPending) { return {...emptyResult, isPending: true} } - if (!traceQuery.data) { + if (!summaryQuery.data) { return emptyResult } // A failed model/tool call (e.g. quota error) lands on a leaf span — capture it so the // caller can surface it even if the run otherwise looks like an empty turn. - const error = getTraceErrorFromResponse(traceQuery.data) + const error = getTraceErrorFromSpans(summaryQuery.data.errorSpans) - // Get the root span - const rootSpan = getRootSpanFromTraceResponse(traceQuery.data) + const rootSpan = summaryQuery.data.rootSpan if (!rootSpan) { return {...emptyResult, error} } @@ -1948,15 +1932,14 @@ const derivedOutputValuesAtomFamily = atomFamily( let dataSource: Record | null = null if (executionResult.traceId) { - const traceQuery = get(traceEntityAtomFamily(executionResult.traceId)) - if (traceQuery.data) { - const rootSpan = getRootSpanFromTraceResponse(traceQuery.data) - if (rootSpan) { - const agData = extractAgData(rootSpan) - if (agData && Object.keys(agData).length > 0) { - // Wrap in { data: ... } to match path format (data.inputs.*, data.outputs.*) - dataSource = {data: agData} - } + // Root span only — output mapping never needs the full span tree. + const rootSpan = get(traceSummaryQueryAtomFamily(executionResult.traceId)).data + ?.rootSpan + if (rootSpan) { + const agData = extractAgData(rootSpan) + if (agData && Object.keys(agData).length > 0) { + // Wrap in { data: ... } to match path format (data.inputs.*, data.outputs.*) + dataSource = {data: agData} } } } diff --git a/web/packages/agenta-entities/src/trace/api/api.ts b/web/packages/agenta-entities/src/trace/api/api.ts index a44e236bf31..9c74b13db7c 100644 --- a/web/packages/agenta-entities/src/trace/api/api.ts +++ b/web/packages/agenta-entities/src/trace/api/api.ts @@ -36,7 +36,13 @@ import {fernTracesToLegacyTraceMap} from "./adapters" // AGE-3788: all trace api functions are migrated to the Fern client // (Phases 1-5): sessions, delete, single-trace, flat-span (querySpans) and // trace-tree (queryTraces). No raw axios remains in this module. -import {callFern, getTracesClient, isAbortError, projectScopedRequest} from "./client" +import { + callFern, + getLowPriorityTracesClient, + getTracesClient, + isAbortError, + projectScopedRequest, +} from "./client" import {buildSpansQueryRequest, buildTracesQueryRequest} from "./request" /** @@ -59,12 +65,14 @@ export interface TraceQueryParams { * @param params - Query parameters for filtering * @param appId - Application ID (optional) * @param projectId - Project ID (required) + * @param opts.lowPriority - Send with the `priority: "low"` fetch hint (background hydration) * @returns API response with spans (validated) */ export async function fetchAllPreviewTraces( params: TraceQueryParams = {}, appId: string, projectId: string, + {lowPriority = false}: {lowPriority?: boolean} = {}, ): Promise { // AGE-3788 Phases 4-5: flat-span queries (focus !== "trace") go through Fern // querySpans (POST /spans/query, flat SpansResponse); trace-tree queries @@ -78,10 +86,11 @@ export async function fetchAllPreviewTraces( // /traces/query accepts undashed ids in `filtering` must be confirmed // against a live backend — preserved as-is; covered by integration, not units. const opts = projectScopedRequest(projectId, appId) + const client = lowPriority ? getLowPriorityTracesClient() : getTracesClient() const data = await callFern("[fetchAllPreviewTraces]", () => params.focus !== "trace" - ? getTracesClient().querySpans(buildSpansQueryRequest(params), opts) - : getTracesClient().queryTraces(buildTracesQueryRequest(params), opts), + ? client.querySpans(buildSpansQueryRequest(params), opts) + : client.queryTraces(buildTracesQueryRequest(params), opts), ) if (!data) return null return parseSpansOrTraces(params.focus, data) diff --git a/web/packages/agenta-entities/src/trace/api/client.ts b/web/packages/agenta-entities/src/trace/api/client.ts index c1fc1da2686..b3f7ece7b16 100644 --- a/web/packages/agenta-entities/src/trace/api/client.ts +++ b/web/packages/agenta-entities/src/trace/api/client.ts @@ -9,13 +9,21 @@ * * Pattern mirrors `secret/api/client.ts` and `workflow/api/api.ts`. */ -import {getTracesClient as getSdkTracesClient} from "@agenta/sdk/resources" +import { + getLowPriorityTracesClient as getSdkLowPriorityTracesClient, + getTracesClient as getSdkTracesClient, +} from "@agenta/sdk/resources" /** The Fern `traces` resource client (spans, traces, sessions, analytics). */ export function getTracesClient() { return getSdkTracesClient() } +/** Same client with the `priority: "low"` fetch hint — background hydration only. */ +export function getLowPriorityTracesClient() { + return getSdkLowPriorityTracesClient() +} + /** * Per-request options that scope a Fern call to a project (and optionally an * application). The new endpoints do NOT model `project_id`/`application_id` diff --git a/web/packages/agenta-entities/src/trace/index.ts b/web/packages/agenta-entities/src/trace/index.ts index 7b076011bf9..86af0f92349 100644 --- a/web/packages/agenta-entities/src/trace/index.ts +++ b/web/packages/agenta-entities/src/trace/index.ts @@ -181,12 +181,18 @@ export { export { // Trace-level query atom (for fetching entire traces with all spans) traceEntityAtomFamily, + // Lightweight summary query (root span + errored spans, flat /spans/query) + traceSummaryQueryAtomFamily, + type TraceSummarySpans, // Trace-level derived atoms (convenience: traceId → rootSpan / inputs / outputs) traceRootSpanAtomFamily, traceInputsAtomFamily, traceOutputsAtomFamily, // Cache invalidation utility invalidateTraceEntityCache, + // Freshness mark: call at run completion so not-found retries stay aggressive + // only for traces that may still be ingesting + markTraceAsFresh, // Error classes SpanNotFoundError, TraceNotFoundError, diff --git a/web/packages/agenta-entities/src/trace/state/index.ts b/web/packages/agenta-entities/src/trace/state/index.ts index 5dae76d98da..89fd3814e38 100644 --- a/web/packages/agenta-entities/src/trace/state/index.ts +++ b/web/packages/agenta-entities/src/trace/state/index.ts @@ -17,11 +17,16 @@ export {traceSpanMolecule, type TraceSpanMolecule} from "./molecule" export { // Cache invalidation invalidateTraceEntityCache, + // Freshness (gates not-found retries to just-finished runs) + markTraceAsFresh, // Error classes SpanNotFoundError, TraceNotFoundError, // Trace-level query atom traceEntityAtomFamily, + // Lightweight summary query (root span + errored spans, flat) + traceSummaryQueryAtomFamily, + type TraceSummarySpans, // Trace-level derived atoms (convenience selectors) traceRootSpanAtomFamily, traceInputsAtomFamily, diff --git a/web/packages/agenta-entities/src/trace/state/store.ts b/web/packages/agenta-entities/src/trace/state/store.ts index f56623df4fc..1bafb87fdcf 100644 --- a/web/packages/agenta-entities/src/trace/state/store.ts +++ b/web/packages/agenta-entities/src/trace/state/store.ts @@ -51,8 +51,40 @@ export const invalidateTraceEntityCache = () => { const store = getDefaultStore() const queryClient = store.get(queryClientAtom) queryClient.invalidateQueries({queryKey: ["trace-entity"]}) + queryClient.invalidateQueries({queryKey: ["trace-summary"]}) } +// ============================================================================ +// TRACE FRESHNESS +// Traces are immutable once ingested; only a trace whose run JUST finished in +// this session may legitimately 404 (ingestion lag) and earn the full retry +// ladder. Historical traces that 404 are gone — retrying hammers the API. +// ============================================================================ + +const FRESH_TRACE_WINDOW_MS = 2 * 60_000 +const freshTraceMarks = new Map() + +const canonicalTraceKey = (traceId: string) => traceId.replace(/-/g, "") + +/** Call at run completion (stream finish / invocation success) with the run's trace id. */ +export const markTraceAsFresh = (traceId: string | null | undefined) => { + if (!traceId) return + freshTraceMarks.set(canonicalTraceKey(traceId), Date.now()) +} + +const isTraceFresh = (traceId: string) => { + const markedAt = freshTraceMarks.get(canonicalTraceKey(traceId)) + return markedAt !== undefined && Date.now() - markedAt < FRESH_TRACE_WINDOW_MS +} + +/** Retry not-found aggressively only for fresh traces; once for everything else. */ +const traceNotFoundRetry = + (traceId: string | null) => + (failureCount: number, error: Error): boolean => { + if (!(error instanceof TraceNotFoundError) || !traceId) return false + return failureCount < (isTraceFresh(traceId) ? 5 : 1) + } + // ============================================================================ // BATCH FETCHER FOR SPANS // Collects concurrent single-span requests and batches them @@ -220,6 +252,108 @@ export const traceBatchFetcher = createBatchFetcher< maxBatchSize: 50, }) +// ============================================================================ +// BATCH FETCHER FOR TRACE SUMMARIES (root span + errored spans, flat) +// The chat transcript / result chips only need the root span (timing, +// cumulative metrics, ag.data) plus any errored span for the failure message. +// Fetching full trace trees for that pulled megabytes per transcript; this +// flat /spans/query fetches orders of magnitude less. +// ============================================================================ + +/** The flat spans a trace summary needs: the root span + errored spans. */ +export interface TraceSummarySpans { + rootSpan: TraceSpan | null + errorSpans: TraceSpan[] +} + +const traceSummaryBatchFetcher = createBatchFetcher< + TraceRequest, + TraceSummarySpans | null, + Map +>({ + serializeKey: ({projectId, traceId}) => `${projectId}:${canonicalTraceKey(traceId)}`, + batchFn: async (requests, serializedKeys) => { + const results = new Map() + serializedKeys.forEach((key) => results.set(key, null)) + + // Exactly one project is in scope at a time in the web app. + const projectId = requests[0]?.projectId + if (!projectId) return results + if (requests.some((req) => req.projectId !== projectId)) { + throw new Error("traceSummaryBatchFetcher: requests span multiple projects") + } + + const canonicalIds = [ + ...new Set(requests.map((req) => canonicalTraceKey(req.traceId)).filter(Boolean)), + ] + if (canonicalIds.length === 0) return results + + try { + const filter = { + operator: "and", + conditions: [ + {field: "trace_id", operator: "in", value: canonicalIds}, + { + operator: "or", + conditions: [ + // parent_id rejects existence operators; `is` + null → IS NULL. + {field: "parent_id", operator: "is", value: null}, + {field: "status_code", operator: "is", value: "STATUS_CODE_ERROR"}, + ], + }, + ], + } + + const data = await fetchAllPreviewTraces( + // Roots = one per trace; errored spans are rare. 500 leaves headroom + // for pathological runs without approaching a full-tree payload. + {size: 500, focus: "span", filter: JSON.stringify(filter)}, + "", + projectId, + // Transcript chrome, not user-initiated — yield to critical traffic. + {lowPriority: true}, + ) + + const byTrace = new Map() + if (isSpansResponse(data)) { + // Spans are already schema-validated by fetchAllPreviewTraces. + data.spans.forEach((span) => { + const key = canonicalTraceKey(span.trace_id) + let entry = byTrace.get(key) + if (!entry) { + entry = {rootSpan: null, errorSpans: []} + byTrace.set(key, entry) + } + if (!span.parent_id && !entry.rootSpan) entry.rootSpan = span + if (span.status_code === "STATUS_CODE_ERROR") entry.errorSpans.push(span) + }) + } + + requests.forEach((req, idx) => { + results.set( + serializedKeys[idx], + byTrace.get(canonicalTraceKey(req.traceId)) ?? null, + ) + }) + } catch (error) { + console.error( + `[traceSummaryBatchFetcher] Failed to fetch trace summaries:`, + error instanceof Error ? error.message : String(error), + error, + ) + } + + return results + }, + resolveResult: (response, _request, serializedKey) => { + return response.get(serializedKey) ?? null + }, + maxBatchSize: 50, + // Background hydration: wait for main-thread idle so boot-critical queries + // win the race, and more concurrent turns coalesce into one batch. + flushScheduling: "idle", +}) + // ============================================================================ // CACHE REDIRECT - Check if span exists in cached data // ============================================================================ @@ -563,9 +697,12 @@ export const traceEntityAtomFamily = instrumentedAtomFamily( return { queryKey: ["trace-entity", projectId, traceId ?? "none"], enabled: Boolean(get(sessionAtom) && traceId && projectId), - staleTime: 60_000, + // Traces are immutable once ingested — never refetch a found one. + // invalidateTraceEntityCache still forces a refresh after runs. + staleTime: Infinity, gcTime: 5 * 60_000, refetchOnWindowFocus: false, + retryOnMount: false, structuralSharing: true, queryFn: async () => { if (!traceId || !projectId) return null @@ -599,19 +736,55 @@ export const traceEntityAtomFamily = instrumentedAtomFamily( return response }, // Retry configuration for traces not yet ingested - retry: (failureCount, error) => { - // Only retry TraceNotFoundError, not other errors - if (error instanceof TraceNotFoundError && failureCount < 5) { - return true - } - return false - }, + retry: traceNotFoundRetry(traceId), retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // 1s, 2s, 4s, 8s, 10s } }), {name: "trace.traceEntityAtomFamily"}, ) +// ============================================================================ +// TRACE SUMMARY QUERY ATOM FAMILY (root span + errored spans, flat) +// ============================================================================ + +/** + * Lightweight per-trace summary query: the root span (timing, cumulative + * metrics, ag.data) plus errored spans (run-failure message). Backed by a + * flat /spans/query, batched across concurrent traces — use this instead of + * `traceEntityAtomFamily` when the full span tree isn't needed (transcript + * rows, result chips). The full tree stays an on-demand fetch (trace drawer). + * + * Usage: const summary = useAtomValue(traceSummaryQueryAtomFamily(traceId)) + */ +export const traceSummaryQueryAtomFamily = instrumentedAtomFamily( + (traceId: string | null) => + atomWithQuery((get) => { + const projectId = get(projectIdAtom) + + return { + queryKey: ["trace-summary", projectId, traceId ?? "none"], + enabled: Boolean(get(sessionAtom) && traceId && projectId), + // Immutable once ingested; invalidateTraceEntityCache covers reruns. + staleTime: Infinity, + gcTime: 5 * 60_000, + refetchOnWindowFocus: false, + retryOnMount: false, + queryFn: async (): Promise => { + if (!traceId || !projectId) return null + const result = await traceSummaryBatchFetcher({projectId, traceId}) + // Throw if not found - triggers retry (trace may not be ingested yet) + if (!result || (!result.rootSpan && result.errorSpans.length === 0)) { + throw new TraceNotFoundError(traceId) + } + return result + }, + retry: traceNotFoundRetry(traceId), + retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 10000), + } + }), + {name: "trace.traceSummaryQueryAtomFamily"}, +) + // ============================================================================ // DERIVED ATOM FAMILIES FOR TRACE-LEVEL DATA EXTRACTION // Convenience selectors: traceId → rootSpan / inputs / outputs diff --git a/web/packages/agenta-playground/src/state/execution/executionItems.ts b/web/packages/agenta-playground/src/state/execution/executionItems.ts index 17cd63617f3..c7871a6ca22 100644 --- a/web/packages/agenta-playground/src/state/execution/executionItems.ts +++ b/web/packages/agenta-playground/src/state/execution/executionItems.ts @@ -8,6 +8,7 @@ import { type TransformVariantInput, } from "@agenta/entities/shared/execution" import type {OpenAPISpec} from "@agenta/entities/shared/openapi" +import {markTraceAsFresh} from "@agenta/entities/trace" import {workflowMolecule} from "@agenta/entities/workflow" import {getAgentaApiUrl} from "@agenta/shared/api/env" import {generateId} from "@agenta/shared/utils" @@ -1522,6 +1523,8 @@ export const handleExecutionResultAtom = atom( // Write execution state on the assistant message const traceId = extractTraceIdFromPayload(testResult) ?? undefined + // Just-finished run: the trace may still be ingesting — keep retries aggressive. + if (traceId) markTraceAsFresh(traceId) const errorMessage = lastMessage.role === "Error" ? typeof lastMessage.content === "string" @@ -1629,6 +1632,7 @@ export const handleExecutionResultAtom = atom( // Completion mode: register result const completionTraceId = extractTraceIdFromPayload(testResult) + if (completionTraceId) markTraceAsFresh(completionTraceId) set(completeRunAtom, { loadableId, stepId: rowId, diff --git a/web/packages/agenta-playground/src/state/execution/executionRunner.ts b/web/packages/agenta-playground/src/state/execution/executionRunner.ts index e99e1b342a8..7a14cc057c0 100644 --- a/web/packages/agenta-playground/src/state/execution/executionRunner.ts +++ b/web/packages/agenta-playground/src/state/execution/executionRunner.ts @@ -15,6 +15,7 @@ import { describeUnreachableService, isHtmlBody, } from "@agenta/entities/shared/execution/invocationErrors" +import {markTraceAsFresh} from "@agenta/entities/trace" import {workflowMolecule} from "@agenta/entities/workflow" import {generateId} from "@agenta/shared/utils" import type {Getter, Setter} from "jotai" @@ -966,6 +967,8 @@ async function executeViaFetch(params: { } } + if (traceId) markTraceAsFresh(traceId) + return { executionId, status: "error", @@ -1008,6 +1011,10 @@ async function executeViaFetch(params: { const sessionId = extractSessionIdFromPayload(responseData) ?? undefined + // The trace may not be ingested yet — mark it fresh so the summary/entity + // queries keep retrying through the ingestion lag. + if (trace?.id) markTraceAsFresh(trace.id) + return { executionId, status: "success", diff --git a/web/packages/agenta-sdk/src/config.ts b/web/packages/agenta-sdk/src/config.ts index 30177144b40..20a8a0f8d2e 100644 --- a/web/packages/agenta-sdk/src/config.ts +++ b/web/packages/agenta-sdk/src/config.ts @@ -66,3 +66,18 @@ export function buildClientOptions(options: AgentaInitOptions = {}): AgentaApiCl : undefined, } } + +/** + * Wrap a client's fetch so its requests carry the `priority: "low"` hint — + * Chromium schedules them behind render-critical traffic; other engines + * ignore the hint. Composes with the auth-sanitizing fetch when present. + * Use for background hydration (e.g. per-turn trace summaries), never for + * user-initiated loads. + */ +export function withLowPriorityFetch(options: AgentaApiClient.Options): AgentaApiClient.Options { + const baseFetch = options.fetch ?? fetch + return { + ...options, + fetch: (input, requestInit) => baseFetch(input, {...requestInit, priority: "low"}), + } +} diff --git a/web/packages/agenta-sdk/src/resources.ts b/web/packages/agenta-sdk/src/resources.ts index 99c7a05b477..b2b5ab8d45e 100644 --- a/web/packages/agenta-sdk/src/resources.ts +++ b/web/packages/agenta-sdk/src/resources.ts @@ -16,13 +16,20 @@ import {ToolsClient} from "@agentaai/api-client/resources/tools" import {TracesClient} from "@agentaai/api-client/resources/traces" import {WorkflowsClient} from "@agentaai/api-client/resources/workflows" -import {buildClientOptions} from "./config" +import {buildClientOptions, withLowPriorityFetch} from "./config" let _traces: TracesClient | undefined export function getTracesClient(): TracesClient { return (_traces ??= new TracesClient(buildClientOptions())) } +let _tracesLowPriority: TracesClient | undefined +/** Same host/auth as `getTracesClient`, but requests carry `priority: "low"` — + * for background hydration that must yield to render-critical traffic. */ +export function getLowPriorityTracesClient(): TracesClient { + return (_tracesLowPriority ??= new TracesClient(withLowPriorityFetch(buildClientOptions()))) +} + let _tools: ToolsClient | undefined export function getToolsClient(): ToolsClient { return (_tools ??= new ToolsClient(buildClientOptions())) diff --git a/web/packages/agenta-shared/src/utils/createBatchFetcher.ts b/web/packages/agenta-shared/src/utils/createBatchFetcher.ts index 17cc8409e24..99212f09c2e 100644 --- a/web/packages/agenta-shared/src/utils/createBatchFetcher.ts +++ b/web/packages/agenta-shared/src/utils/createBatchFetcher.ts @@ -19,6 +19,13 @@ export interface BatchFetcherOptions> { serializeKey?: (key: K) => string resolveResult?: (response: R, key: K, serializedKey: string) => V | undefined flushDelay?: number + /** + * "timer" (default) flushes `flushDelay` ms after the first request. + * "idle" waits for a main-thread idle period (capped at 1s) — batches + * yield to render-critical work and coalesce wider. Background-hydration + * fetchers only; falls back to the timer where rIC is unavailable. + */ + flushScheduling?: "timer" | "idle" onError?: (error: unknown, keys: K[]) => void maxBatchSize?: number } @@ -32,6 +39,9 @@ interface PendingEntry { const DEFAULT_FLUSH_DELAY = 16 * 5 // approx. one frame at 60Hz +// Upper bound for "idle" flushes: never hold a batch longer than this. +const IDLE_FLUSH_TIMEOUT_MS = 1_000 + const defaultSerializeKey = (key: K) => { if (typeof key === "string" || typeof key === "number" || typeof key === "boolean") { return String(key) @@ -89,16 +99,22 @@ export const createBatchFetcher = >({ serializeKey = defaultSerializeKey, resolveResult = defaultResolveResult, flushDelay = DEFAULT_FLUSH_DELAY, + flushScheduling = "timer", onError, maxBatchSize, }: BatchFetcherOptions): BatchFetcher => { let pending = new Map>() const inflight = new Map>() - let flushTimer: ReturnType | null = null + let flushScheduled = false const scheduleFlush = () => { - if (flushTimer) return - flushTimer = setTimeout(flushPending, flushDelay) + if (flushScheduled) return + flushScheduled = true + if (flushScheduling === "idle" && typeof requestIdleCallback === "function") { + requestIdleCallback(() => flushPending(), {timeout: IDLE_FLUSH_TIMEOUT_MS}) + } else { + setTimeout(flushPending, flushDelay) + } } const runBatch = async (entries: PendingEntry[]) => { @@ -131,7 +147,7 @@ export const createBatchFetcher = >({ } const flushPending = () => { - flushTimer = null + flushScheduled = false if (pending.size === 0) { return } From d0bc197fb590b956fb5bf557ee3d5f2748f74aad Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 03:24:50 +0200 Subject: [PATCH 10/50] perf(frontend): demote secondary playground requests to low network priority The tool connections/catalog, trigger subscriptions/schedules, deploy environments, and build-kit overlay all fire on agent-playground load but none are on the critical path to first interactivity, so they shouldn't compete at High priority with the config/chat queries. Add low-priority variants of the Fern tools/workflows clients (getLowPriority*Client, reusing withLowPriorityFetch) and thread a lowPriority option through the axios fetchers via lowPriorityWhenCached, then send all six with the priority: "low" fetch hint. Low priority only bites under contention, so pages where this data is primary are unaffected. --- .../agenta-entities/src/environment/api/api.ts | 7 ++++--- .../src/environment/state/store.ts | 4 ++++ .../agenta-entities/src/gatewayTool/api/api.ts | 10 +++++++--- .../src/gatewayTool/api/client.ts | 11 ++++++++++- .../hooks/useToolCatalogIntegrations.ts | 3 +++ .../gatewayTool/hooks/useToolConnectionsQuery.ts | 3 ++- .../src/gatewayTrigger/api/api.ts | 8 ++++++-- .../gatewayTrigger/hooks/useTriggerSchedules.ts | 3 ++- .../hooks/useTriggerSubscriptions.ts | 3 ++- .../agenta-entities/src/workflow/api/api.ts | 11 +++++++++-- web/packages/agenta-sdk/src/resources.ts | 16 ++++++++++++++++ 11 files changed, 65 insertions(+), 14 deletions(-) diff --git a/web/packages/agenta-entities/src/environment/api/api.ts b/web/packages/agenta-entities/src/environment/api/api.ts index c990a1a06ee..43b6bc83dd9 100644 --- a/web/packages/agenta-entities/src/environment/api/api.ts +++ b/web/packages/agenta-entities/src/environment/api/api.ts @@ -7,7 +7,7 @@ * Uses the new SimpleEnvironment API from PR #3627. */ -import {getAgentaApiUrl, axios} from "@agenta/shared/api" +import {getAgentaApiUrl, axios, lowPriorityWhenCached} from "@agenta/shared/api" import {safeParseWithLogging} from "../../shared" import { @@ -36,7 +36,8 @@ import type { export async function fetchEnvironmentsList({ projectId, includeArchived = false, -}: EnvironmentListParams): Promise { + lowPriority, +}: EnvironmentListParams & {lowPriority?: boolean}): Promise { if (!projectId) { return {environments: [], count: 0} } @@ -46,7 +47,7 @@ export async function fetchEnvironmentsList({ { include_archived: includeArchived, }, - {params: {project_id: projectId}}, + {params: {project_id: projectId}, ...lowPriorityWhenCached(lowPriority)}, ) const validated = safeParseWithLogging( diff --git a/web/packages/agenta-entities/src/environment/state/store.ts b/web/packages/agenta-entities/src/environment/state/store.ts index f77e5e04431..b82015f15a5 100644 --- a/web/packages/agenta-entities/src/environment/state/store.ts +++ b/web/packages/agenta-entities/src/environment/state/store.ts @@ -210,6 +210,10 @@ export const environmentsListQueryAtomFamily = atomFamily((includeArchived: bool const response = await fetchEnvironmentsList({ projectId, includeArchived: includeArchived ?? false, + // Secondary on the playground (Deploy button targets); yield to the + // render-critical config/chat queries. Low priority only bites under contention, + // so pages where environments are primary are unaffected. + lowPriority: true, }) for (const environment of response.environments ?? []) { primeEnvironmentDetailCache(queryClient, projectId, environment) diff --git a/web/packages/agenta-entities/src/gatewayTool/api/api.ts b/web/packages/agenta-entities/src/gatewayTool/api/api.ts index a114cd8eafc..69fb7433bba 100644 --- a/web/packages/agenta-entities/src/gatewayTool/api/api.ts +++ b/web/packages/agenta-entities/src/gatewayTool/api/api.ts @@ -27,7 +27,7 @@ import type { ToolConnectionsResponse, } from "../core/types" -import {getToolsClient, projectScopedRequest} from "./client" +import {getLowPriorityToolsClient, getToolsClient, projectScopedRequest} from "./client" // --- Catalog browse --- @@ -43,6 +43,7 @@ export const fetchToolIntegrations = async ( category?: string limit?: number cursor?: string + lowPriority?: boolean }, ): Promise => { // `category` isn't modelled on Fern's ListToolIntegrationsRequest yet (the @@ -52,7 +53,8 @@ export const fetchToolIntegrations = async ( const requestOptions = params?.category ? {queryParams: {...(scope?.queryParams ?? {}), category: params.category}} : scope - return getToolsClient().listToolIntegrations( + const client = params?.lowPriority ? getLowPriorityToolsClient() : getToolsClient() + return client.listToolIntegrations( { provider_key: providerKey, search: params?.search, @@ -154,8 +156,10 @@ export const fetchToolActionDetail = async ( export const queryToolConnections = async (params?: { provider_key?: string integration_key?: string + lowPriority?: boolean }): Promise => { - return getToolsClient().queryToolConnections( + const client = params?.lowPriority ? getLowPriorityToolsClient() : getToolsClient() + return client.queryToolConnections( { provider_key: params?.provider_key, integration_key: params?.integration_key, diff --git a/web/packages/agenta-entities/src/gatewayTool/api/client.ts b/web/packages/agenta-entities/src/gatewayTool/api/client.ts index c09837792dd..874c9616093 100644 --- a/web/packages/agenta-entities/src/gatewayTool/api/client.ts +++ b/web/packages/agenta-entities/src/gatewayTool/api/client.ts @@ -1,4 +1,7 @@ -import {getToolsClient as getSdkToolsClient} from "@agenta/sdk/resources" +import { + getToolsClient as getSdkToolsClient, + getLowPriorityToolsClient as getSdkLowPriorityToolsClient, +} from "@agenta/sdk/resources" import {projectIdAtom} from "@agenta/shared/state" import {getDefaultStore} from "jotai" @@ -14,6 +17,12 @@ export function getToolsClient() { return getSdkToolsClient() } +/** Same client with the `priority: "low"` fetch hint — for secondary playground data + * (connections/catalog) that must yield to render-critical traffic. */ +export function getLowPriorityToolsClient() { + return getSdkLowPriorityToolsClient() +} + /** * Per-request options that scope a Fern call to the current project. * diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogIntegrations.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogIntegrations.ts index dadf28777a9..927893138ca 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogIntegrations.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogIntegrations.ts @@ -47,6 +47,9 @@ export const toolCatalogIntegrationsInfiniteAtom = category: category || undefined, limit: CHUNK_SIZE, cursor: (pageParam as string) || undefined, + // Secondary (tool catalog / connected-tool names); yield to the render-critical + // playground queries. Low priority is a no-op once the drawer is open (no contention). + lowPriority: true, }), initialPageParam: "", getNextPageParam: (lastPage) => lastPage.cursor ?? undefined, diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolConnectionsQuery.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolConnectionsQuery.ts index c2cf171df39..28de151e337 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolConnectionsQuery.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolConnectionsQuery.ts @@ -6,7 +6,8 @@ import type {ToolConnectionsResponse} from "../core/types" export const toolConnectionsQueryAtom = atomWithQuery(() => ({ queryKey: ["tools", "connections"], - queryFn: () => queryToolConnections(), + // Secondary (tool selector); yield to the render-critical playground queries on load. + queryFn: () => queryToolConnections({lowPriority: true}), staleTime: 30_000, refetchOnWindowFocus: false, })) diff --git a/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts b/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts index 53cb111ab74..cf91bd595b0 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts @@ -11,6 +11,8 @@ * gatewayTool so the two lists stay byte-compatible. */ +import {lowPriorityWhenCached} from "@agenta/shared/api" + import {safeParseWithLogging} from "../../shared" import { triggerCatalogEventResponseSchema, @@ -265,11 +267,12 @@ export const revokeTriggerConnection = async ( export const queryTriggerSubscriptions = async ( subscription?: TriggerSubscriptionQuery, + opts?: {lowPriority?: boolean}, ): Promise => { const {data} = await axios.post( `${triggersBaseUrl()}/subscriptions/query`, {subscription: subscription ?? null}, - projectScopedParams(), + {...projectScopedParams(), ...lowPriorityWhenCached(opts?.lowPriority)}, ) return ( safeParseWithLogging( @@ -435,11 +438,12 @@ export const stopTriggerSubscription = async ( export const queryTriggerSchedules = async ( schedule?: TriggerScheduleQuery, + opts?: {lowPriority?: boolean}, ): Promise => { const {data} = await axios.post( `${triggersBaseUrl()}/schedules/query`, {schedule: schedule ?? null}, - projectScopedParams(), + {...projectScopedParams(), ...lowPriorityWhenCached(opts?.lowPriority)}, ) return ( safeParseWithLogging(triggerSchedulesResponseSchema, data, "[queryTriggerSchedules]") ?? { diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSchedules.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSchedules.ts index a6f48a897b7..7ca05055023 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSchedules.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSchedules.ts @@ -9,7 +9,8 @@ import type {TriggerSchedule, TriggerSchedulesResponse} from "../core/types" // Distinct from subscription/catalog/connection keys. export const triggerSchedulesQueryAtom = atomWithQuery(() => ({ queryKey: ["triggers", "schedules"], - queryFn: () => queryTriggerSchedules(), + // Secondary (trigger count badge / section); yield to the render-critical playground queries. + queryFn: () => queryTriggerSchedules(undefined, {lowPriority: true}), staleTime: 30_000, refetchOnWindowFocus: false, })) diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscriptions.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscriptions.ts index bdd34ddf5ae..9613232e132 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscriptions.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscriptions.ts @@ -10,7 +10,8 @@ import type {TriggerSubscription, TriggerSubscriptionsResponse} from "../core/ty // Distinct from the catalog/connection keys (["triggers", "catalog"|"connections"]). export const triggerSubscriptionsQueryAtom = atomWithQuery(() => ({ queryKey: ["triggers", "subscriptions"], - queryFn: () => queryTriggerSubscriptions(), + // Secondary (trigger count badge / section); yield to the render-critical playground queries. + queryFn: () => queryTriggerSubscriptions(undefined, {lowPriority: true}), staleTime: 30_000, refetchOnWindowFocus: false, })) diff --git a/web/packages/agenta-entities/src/workflow/api/api.ts b/web/packages/agenta-entities/src/workflow/api/api.ts index 0dc05f638e6..f8d9bb6414c 100644 --- a/web/packages/agenta-entities/src/workflow/api/api.ts +++ b/web/packages/agenta-entities/src/workflow/api/api.ts @@ -15,7 +15,7 @@ */ import {getAgentaSdkClient} from "@agenta/sdk" -import {getWorkflowsClient} from "@agenta/sdk/resources" +import {getLowPriorityWorkflowsClient, getWorkflowsClient} from "@agenta/sdk/resources" import {getAgentaApiUrl, axios, lowPriorityWhenCached} from "@agenta/shared/api" import {dereferenceSchema, generateId} from "@agenta/shared/utils" import {z} from "zod" @@ -299,11 +299,14 @@ export async function retrieveWorkflowRevision({ workflowRef, workflowVariantRef, workflowRevisionRef, + lowPriority, }: { projectId: string workflowRef?: {id?: string; slug?: string; version?: string} workflowVariantRef?: {id?: string; slug?: string; version?: string} workflowRevisionRef?: {id?: string; slug?: string; version?: string} + /** Send with the `priority: "low"` fetch hint (secondary/background load). */ + lowPriority?: boolean }): Promise { if (!projectId) return null // The backend needs at least one identifying ref (id or slug at any @@ -318,7 +321,8 @@ export async function retrieveWorkflowRevision({ // Use the Fern-generated client (single source of truth for the // request/response shape, kept in sync with the backend OpenAPI spec). - const data = await getWorkflowsClient().retrieveWorkflowRevision( + const client = lowPriority ? getLowPriorityWorkflowsClient() : getWorkflowsClient() + const data = await client.retrieveWorkflowRevision( { ...(workflowRef ? {workflow_ref: workflowRef} : {}), ...(workflowVariantRef ? {workflow_variant_ref: workflowVariantRef} : {}), @@ -362,6 +366,9 @@ export async function fetchAgentBuildKitOverlay( const revision = await retrieveWorkflowRevision({ projectId, workflowRef: {slug: AGENT_BUILD_KIT_WORKFLOW_SLUG}, + // Secondary: only feeds the optional Advanced "build kit" sub-block, so it must yield to the + // config/chat critical path on playground load. + lowPriority: true, }) const overlay = revision?.data?.parameters?.agent if (overlay == null) return null diff --git a/web/packages/agenta-sdk/src/resources.ts b/web/packages/agenta-sdk/src/resources.ts index b2b5ab8d45e..8cfa3cbaf26 100644 --- a/web/packages/agenta-sdk/src/resources.ts +++ b/web/packages/agenta-sdk/src/resources.ts @@ -35,6 +35,13 @@ export function getToolsClient(): ToolsClient { return (_tools ??= new ToolsClient(buildClientOptions())) } +let _toolsLowPriority: ToolsClient | undefined +/** Same host/auth as `getToolsClient`, but requests carry `priority: "low"` — for secondary + * playground data (connections/catalog) that must yield to render-critical traffic. */ +export function getLowPriorityToolsClient(): ToolsClient { + return (_toolsLowPriority ??= new ToolsClient(withLowPriorityFetch(buildClientOptions()))) +} + let _secrets: SecretsClient | undefined export function getSecretsClient(): SecretsClient { return (_secrets ??= new SecretsClient(buildClientOptions())) @@ -45,6 +52,15 @@ export function getWorkflowsClient(): WorkflowsClient { return (_workflows ??= new WorkflowsClient(buildClientOptions())) } +let _workflowsLowPriority: WorkflowsClient | undefined +/** Same host/auth as `getWorkflowsClient`, but requests carry `priority: "low"` — for secondary + * playground data (e.g. the build-kit overlay) that must yield to render-critical traffic. */ +export function getLowPriorityWorkflowsClient(): WorkflowsClient { + return (_workflowsLowPriority ??= new WorkflowsClient( + withLowPriorityFetch(buildClientOptions()), + )) +} + let _testsets: TestsetsClient | undefined export function getTestsetsClient(): TestsetsClient { return (_testsets ??= new TestsetsClient(buildClientOptions())) From 68af68dd2f5263c81dbbe585d8d2746cb741e981 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 03:37:29 +0200 Subject: [PATCH 11/50] perf(frontend): demote entitlement/billing bootstrap, fail fast on gateway errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /billing/subscription, /billing/{catalog,pricing}, /access/{plans,roles} queries are entitlement bootstrap that gate no render-critical UI, yet fired at High priority on every load — and a degraded billing service 502-ing retried 3x at High (0.5-4s each), competing with the config/chat critical path. Send them with the priority: "low" hint and share a retry policy that fails fast on gateway errors (502/503/504) instead of hammering — the entitlements fall back to hobby/free anyway. --- web/oss/src/state/access/atoms.ts | 68 +++++++++++++++---------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/web/oss/src/state/access/atoms.ts b/web/oss/src/state/access/atoms.ts index 976be60adee..50818c45294 100644 --- a/web/oss/src/state/access/atoms.ts +++ b/web/oss/src/state/access/atoms.ts @@ -1,4 +1,5 @@ import {inferQueueMaxFromPlan} from "@agenta/entities/trace/etl" +import {lowPriorityWhenCached} from "@agenta/shared/api" import {atom} from "jotai" import {atomWithQuery} from "jotai-tanstack-query" @@ -37,6 +38,17 @@ export interface RoleEntry { export type RolesCatalog = Record<"organization" | "workspace" | "project", RoleEntry[]> +// Entitlement/billing bootstrap: never retry a 4xx, and DON'T hammer a gateway-unavailable billing +// service (502/503/504). Entitlements degrade gracefully (fall back to hobby/free), so failing fast +// beats firing several slow attempts that compete with render-critical traffic — the symptom that +// prompted this was /billing/subscription 502-ing three times at High priority on playground load. +const entitlementRetry = (failureCount: number, error: unknown) => { + const status = (error as {response?: {status?: number}})?.response?.status + if (status != null && status >= 400 && status < 500) return false + if (status === 502 || status === 503 || status === 504) return false + return failureCount < 2 +} + export const plansQueryAtom = atomWithQuery((get) => { const sessionExists = get(sessionExistsAtom) const user = get(profileQueryAtom).data as {id?: string} | undefined @@ -44,7 +56,10 @@ export const plansQueryAtom = atomWithQuery((get) => { return { queryKey: ["access", "plans"], queryFn: async (): Promise => { - const response = await axios.get(`${getAgentaApiUrl()}/access/plans`) + const response = await axios.get( + `${getAgentaApiUrl()}/access/plans`, + lowPriorityWhenCached(true), + ) return response.data }, staleTime: 1000 * 60 * 10, @@ -55,12 +70,7 @@ export const plansQueryAtom = atomWithQuery((get) => { // project), not just the session, so the request isn't fired and aborted // before the profile resolves. enabled: isEE() && sessionExists && !!user && !!projectId, - retry: (failureCount, error) => { - if ((error as any)?.response?.status >= 400 && (error as any)?.response?.status < 500) { - return false - } - return failureCount < 2 - }, + retry: entitlementRetry, } }) @@ -88,6 +98,7 @@ export const currentSubscriptionQueryAtom = atomWithQuery((get) => { queryFn: async (): Promise => { const response = await axios.get( `${getAgentaApiUrl()}/billing/subscription?project_id=${projectId}`, + lowPriorityWhenCached(true), ) return response.data }, @@ -96,12 +107,7 @@ export const currentSubscriptionQueryAtom = atomWithQuery((get) => { refetchOnReconnect: false, refetchOnMount: true, enabled: isEE() && sessionExists && !!organizationId && !!user && !!projectId, - retry: (failureCount, error) => { - if ((error as any)?.response?.status >= 400 && (error as any)?.response?.status < 500) { - return false - } - return failureCount < 2 - }, + retry: entitlementRetry, } }) @@ -128,7 +134,10 @@ export const catalogQueryAtom = atomWithQuery((get) => { return { queryKey: ["billing", "catalog"], queryFn: async (): Promise => { - const response = await axios.get(`${getAgentaApiUrl()}/billing/catalog`) + const response = await axios.get( + `${getAgentaApiUrl()}/billing/catalog`, + lowPriorityWhenCached(true), + ) return response.data }, staleTime: 1000 * 60 * 10, @@ -136,12 +145,7 @@ export const catalogQueryAtom = atomWithQuery((get) => { refetchOnReconnect: false, refetchOnMount: true, enabled: isEE() && sessionExists, - retry: (failureCount, error) => { - if ((error as any)?.response?.status >= 400 && (error as any)?.response?.status < 500) { - return false - } - return failureCount < 2 - }, + retry: entitlementRetry, } }) @@ -150,7 +154,10 @@ export const pricingQueryAtom = atomWithQuery((get) => { return { queryKey: ["billing", "pricing"], queryFn: async (): Promise => { - const response = await axios.get(`${getAgentaApiUrl()}/billing/pricing`) + const response = await axios.get( + `${getAgentaApiUrl()}/billing/pricing`, + lowPriorityWhenCached(true), + ) return response.data }, staleTime: 1000 * 60 * 10, @@ -158,12 +165,7 @@ export const pricingQueryAtom = atomWithQuery((get) => { refetchOnReconnect: false, refetchOnMount: true, enabled: isEE() && sessionExists, - retry: (failureCount, error) => { - if ((error as any)?.response?.status >= 400 && (error as any)?.response?.status < 500) { - return false - } - return failureCount < 2 - }, + retry: entitlementRetry, } }) @@ -224,7 +226,10 @@ export const rolesQueryAtom = atomWithQuery((get) => { return { queryKey: ["access", "roles"], queryFn: async (): Promise => { - const response = await axios.get(`${getAgentaApiUrl()}/access/roles`) + const response = await axios.get( + `${getAgentaApiUrl()}/access/roles`, + lowPriorityWhenCached(true), + ) return response.data }, staleTime: 1000 * 60 * 10, @@ -235,11 +240,6 @@ export const rolesQueryAtom = atomWithQuery((get) => { // project), not just the session, so the request isn't fired and aborted // before the profile resolves. enabled: sessionExists && !!user && !!projectId, - retry: (failureCount, error) => { - if ((error as any)?.response?.status >= 400 && (error as any)?.response?.status < 500) { - return false - } - return failureCount < 2 - }, + retry: entitlementRetry, } }) From d6084733f001805065c6ccf1ac57026d30eb0950 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 03:57:26 +0200 Subject: [PATCH 12/50] perf(frontend): demote sidebar/picker workflow queries off the playground critical path On an agent-playground load, only the by-id workflow detail, the current-app latest revision, and the current-app revision detail are needed for first paint. The sidebar apps list (queryWorkflows), the sidebar prompt/agent-split batch that fetches every app's latest revision for the is_agent badge (fetchWorkflowsBatch), and the variant-picker label query (queryWorkflowVariants) were all firing High and competing with that critical path. Thread a lowPriority option through the shared functions and pass it only at those non-critical call sites. The agent-flags batch still primes the per-app caches, so the critical current-app fetch can share it. (Left the per-revision detail batch's comparison wave for a dedup follow-up.) --- .../agenta-entities/src/workflow/api/api.ts | 11 +++++++---- .../agenta-entities/src/workflow/state/store.ts | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/web/packages/agenta-entities/src/workflow/api/api.ts b/web/packages/agenta-entities/src/workflow/api/api.ts index f8d9bb6414c..c341c2759db 100644 --- a/web/packages/agenta-entities/src/workflow/api/api.ts +++ b/web/packages/agenta-entities/src/workflow/api/api.ts @@ -98,7 +98,8 @@ export async function queryWorkflows({ folderId, includeArchived = false, windowing, -}: WorkflowListParams): Promise { + lowPriority, +}: WorkflowListParams & {lowPriority?: boolean}): Promise { if (!projectId) { return {count: 0, workflows: []} } @@ -124,7 +125,7 @@ export async function queryWorkflows({ include_archived: includeArchived, windowing: windowing ?? undefined, }, - {params: {project_id: projectId}}, + {params: {project_id: projectId}, ...lowPriorityWhenCached(lowPriority)}, ) const validated = safeParseWithLogging( @@ -156,6 +157,7 @@ export async function queryWorkflowVariants( workflowId: string, projectId: string, flags?: WorkflowQueryFlags, + opts?: {lowPriority?: boolean}, ): Promise { if (!projectId || !workflowId) { return {count: 0, workflow_variants: []} @@ -167,7 +169,7 @@ export async function queryWorkflowVariants( workflow_refs: [{id: workflowId}], workflow_variant: flags ? {flags} : undefined, }, - {params: {project_id: projectId}}, + {params: {project_id: projectId}, ...lowPriorityWhenCached(opts?.lowPriority)}, ) const validated = safeParseWithLogging( @@ -1275,6 +1277,7 @@ export async function unarchiveWorkflow( export async function fetchWorkflowsBatch( projectId: string, workflowIds: string[], + opts?: {lowPriority?: boolean}, ): Promise> { const results = new Map() const groupedByWorkflowId = new Map() @@ -1289,7 +1292,7 @@ export async function fetchWorkflowsBatch( // With multiple workflows the global limit would cut across all, so skip it. ...(workflowIds.length === 1 ? {windowing: {limit: 1, order: "descending"}} : {}), }, - {params: {project_id: projectId}}, + {params: {project_id: projectId}, ...lowPriorityWhenCached(opts?.lowPriority)}, ) const validated = safeParseWithLogging( diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index 08a16f600ca..bda4871d08f 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -413,7 +413,13 @@ export const appWorkflowsListQueryAtom = atomWithQuery((get) => { queryKey: ["workflows", "apps", "list", projectId], queryFn: async (): Promise => { if (!projectId) return {count: 0, refs: []} - const response = await queryWorkflows({projectId, flags: {is_evaluator: false}}) + // Sidebar workflow list — not on the playground's first-paint critical path, so yield to + // the render-critical config/chat queries. + const response = await queryWorkflows({ + projectId, + flags: {is_evaluator: false}, + lowPriority: true, + }) const workflows = response.workflows ?? [] return { @@ -491,9 +497,13 @@ const appWorkflowsWithAgentFlagsQueryAtom = atomWithQuery((get) => { queryKey: ["workflows", "apps", "agentFlags", projectId, workflowVersionKey], queryFn: async (): Promise => { if (!projectId || workflows.length === 0) return workflows + // Sidebar prompt/agent split needs every app's latest revision just for the is_agent + // badge — heavy and not on the playground critical path, so demote it. It still primes the + // per-app latest-revision + detail caches, so the critical current-app fetch can share it. const latestRevisions = await fetchWorkflowsBatch( projectId, workflows.map((workflow) => workflow.id), + {lowPriority: true}, ) return withLatestAgentFlags(workflows, latestRevisions) }, @@ -517,7 +527,9 @@ export const workflowVariantsScopedQueryAtomFamily = atomFamily( queryKey: ["workflows", "variants", workflowId, projectId], queryFn: async (): Promise => { if (!projectId || !workflowId) return {count: 0, workflow_variants: []} - return queryWorkflowVariants(workflowId, projectId) + // Variant picker/header label chrome — the config panel + agent chat render from the + // revision detail, not this, so yield to the render-critical queries. + return queryWorkflowVariants(workflowId, projectId, undefined, {lowPriority: true}) }, enabled: !!projectId && !!workflowId, staleTime: 30_000, From f17e889dbdc9936bb310be971d63a0abfaef9afc Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 04:07:09 +0200 Subject: [PATCH 13/50] perf(frontend): dedup the current-app revision detail against the revisions list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a cold playground first paint the displayed revision was fetched twice: the revisions-by-workflow list fetches it (and primes the per-revision detail cache under the same key), while workflowQueryAtomFamily races ahead and fires its own by-id round-trip. Before that standalone fetch, await any in-flight revisions-by-workflow query and re-check the primed cache, so the revision resolves from the list instead. Best-effort and wrapped in try/catch — any miss or error falls through to the direct fetch, so the molecule's data path is never at risk. --- .../src/workflow/state/store.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index bda4871d08f..3d60f2152b4 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -1121,6 +1121,30 @@ export const workflowQueryAtomFamily = atomFamily((revisionId: string) => if (!projectId || !revisionId) return null const cached = findWorkflowRevisionInCache(queryClient, projectId, revisionId) if (cached) return cached + // Dedup vs the revisions-by-workflow list: that query primes this revision's detail + // cache under the SAME key (primeWorkflowRevisionDetailCache), so on a cold first + // paint the current app's displayed revision would otherwise be fetched twice — once + // by the list, once here. Await any in-flight list query and re-check the cache before + // firing a standalone by-id round-trip. Best-effort: any miss (unrelated workflow, or + // the list omitted it) or error falls through to the direct fetch, so correctness is + // never at risk. + try { + const inflightLists = queryClient.getQueryCache().findAll({ + queryKey: ["workflows", "revisionsByWorkflow"], + fetchStatus: "fetching", + }) + if (inflightLists.length > 0) { + await Promise.allSettled(inflightLists.map((query) => query.promise)) + const primed = findWorkflowRevisionInCache( + queryClient, + projectId, + revisionId, + ) + if (primed) return primed + } + } catch { + // fall through to the direct fetch below + } return workflowRevisionBatchFetcher({projectId, revisionId}) }, initialData: detailCached ?? undefined, From add38e89102de477b556f26057b0e48aaa42a560 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 12:03:27 +0200 Subject: [PATCH 14/50] perf(frontend): defer entitlement/billing bootstrap queries to browser idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The access/plans, access/roles, billing/{subscription,catalog,pricing} queries are entitlement chrome — never needed for first paint, and their permission/entitlement consumers already default to not-ready until loaded. Add an idleReadyAtom (flips true on the first requestIdleCallback, ~2s timeout fallback, then sticky) and gate these queries' enabled on it, so they fire once the browser has a spare moment instead of joining the load burst. Complements the earlier priority demotion: demote reorders, this keeps them out of the concurrent flood that saturates a capacity-limited backend. --- web/oss/src/state/access/atoms.ts | 24 +++++++++++++++----- web/oss/src/state/boot/idleReady.ts | 34 +++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 web/oss/src/state/boot/idleReady.ts diff --git a/web/oss/src/state/access/atoms.ts b/web/oss/src/state/access/atoms.ts index 50818c45294..936ec8be85b 100644 --- a/web/oss/src/state/access/atoms.ts +++ b/web/oss/src/state/access/atoms.ts @@ -6,6 +6,7 @@ import {atomWithQuery} from "jotai-tanstack-query" import axios from "@/oss/lib/api/assets/axiosConfig" import {getAgentaApiUrl} from "@/oss/lib/helpers/api" import {isBillingEnabled, isEE} from "@/oss/lib/helpers/isEE" +import {idleReadyAtom} from "@/oss/state/boot/idleReady" import {selectedOrgIdAtom} from "@/oss/state/org" import {profileQueryAtom} from "@/oss/state/profile/selectors/user" import {projectIdAtom} from "@/oss/state/project" @@ -69,7 +70,9 @@ export const plansQueryAtom = atomWithQuery((get) => { // Gate on the full auth context the axios interceptor requires (user + // project), not just the session, so the request isn't fired and aborted // before the profile resolves. - enabled: isEE() && sessionExists && !!user && !!projectId, + // Deferred to browser idle: entitlement chrome, never needed for first paint, so it yields + // the load burst to the render-critical requests. + enabled: isEE() && sessionExists && !!user && !!projectId && get(idleReadyAtom), retry: entitlementRetry, } }) @@ -106,7 +109,14 @@ export const currentSubscriptionQueryAtom = atomWithQuery((get) => { refetchOnWindowFocus: true, refetchOnReconnect: false, refetchOnMount: true, - enabled: isEE() && sessionExists && !!organizationId && !!user && !!projectId, + // Deferred to browser idle (billing chrome, not first-paint critical). + enabled: + isEE() && + sessionExists && + !!organizationId && + !!user && + !!projectId && + get(idleReadyAtom), retry: entitlementRetry, } }) @@ -144,7 +154,8 @@ export const catalogQueryAtom = atomWithQuery((get) => { refetchOnWindowFocus: false, refetchOnReconnect: false, refetchOnMount: true, - enabled: isEE() && sessionExists, + // Deferred to browser idle (billing chrome, not first-paint critical). + enabled: isEE() && sessionExists && get(idleReadyAtom), retry: entitlementRetry, } }) @@ -164,7 +175,8 @@ export const pricingQueryAtom = atomWithQuery((get) => { refetchOnWindowFocus: false, refetchOnReconnect: false, refetchOnMount: true, - enabled: isEE() && sessionExists, + // Deferred to browser idle (billing chrome, not first-paint critical). + enabled: isEE() && sessionExists && get(idleReadyAtom), retry: entitlementRetry, } }) @@ -239,7 +251,9 @@ export const rolesQueryAtom = atomWithQuery((get) => { // Gate on the full auth context the axios interceptor requires (user + // project), not just the session, so the request isn't fired and aborted // before the profile resolves. - enabled: sessionExists && !!user && !!projectId, + // Deferred to browser idle: permission gates already default to not-ready until loaded, so + // holding the roles fetch off the load burst doesn't change first-paint behavior. + enabled: sessionExists && !!user && !!projectId && get(idleReadyAtom), retry: entitlementRetry, } }) diff --git a/web/oss/src/state/boot/idleReady.ts b/web/oss/src/state/boot/idleReady.ts new file mode 100644 index 00000000000..7089d8fdcd7 --- /dev/null +++ b/web/oss/src/state/boot/idleReady.ts @@ -0,0 +1,34 @@ +import {atom} from "jotai" + +/** + * One-shot "the browser has had a spare (idle) moment since load" flag. + * + * Gate NON-critical bootstrap queries (entitlements, billing, permission catalogs) on this so they + * don't fire in the same burst as the first-paint-critical requests — on a capacity-limited backend + * a flood of concurrent requests on load saturates the workers and slows the critical ones. Flips + * true on the first `requestIdleCallback` (or within ~2s via its timeout, whichever comes first), + * then stays true for the session, so it defers the FIRST load without re-deferring on every read. + * + * SSR-safe: resolves immediately when there is no `window` (deferral is a client-only concern). + */ +const idleReadyStateAtom = atom(false) + +idleReadyStateAtom.onMount = (set) => { + if (typeof window === "undefined") { + set(true) + return + } + const w = window as Window & { + requestIdleCallback?: (cb: () => void, opts?: {timeout: number}) => number + cancelIdleCallback?: (handle: number) => void + } + if (typeof w.requestIdleCallback === "function") { + const handle = w.requestIdleCallback(() => set(true), {timeout: 2000}) + return () => w.cancelIdleCallback?.(handle) + } + // Safari (no rIC): fall back to a short timeout. + const timer = window.setTimeout(() => set(true), 1500) + return () => window.clearTimeout(timer) +} + +export const idleReadyAtom = atom((get) => get(idleReadyStateAtom)) From fa56d9f5e6f1fd02ed23fc14dcccb9b1a15a3219 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 12:19:40 +0200 Subject: [PATCH 15/50] perf(frontend): dedup the selected-org detail double-fetch on cold load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a cold load with no cached workspace->org mapping, selectedOrgQueryAtom first keys by the workspace id, resolves the org via the project, and fetches the org; caching the workspace->org pair then lets selectedOrgIdAtom resolve the org id, so the atom re-keys to ["selectedOrg", orgId] and refetches the same org — two identical /organizations/{id} round trips on load. Seed the org-id-keyed cache from the first fetch so the re-keyed query (refetchOnMount:false) and resolveWorkspaceIdForOrg's getQueryData fast-path serve it from cache. --- web/oss/src/state/org/selectors/org.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/web/oss/src/state/org/selectors/org.ts b/web/oss/src/state/org/selectors/org.ts index 3db5557895b..0d631124f0e 100644 --- a/web/oss/src/state/org/selectors/org.ts +++ b/web/oss/src/state/org/selectors/org.ts @@ -346,8 +346,19 @@ export const selectedOrgQueryAtom = atomWithQuery((get) => { if (!id) return null const {orgId} = await normalizeOrgIdentifier(id, get) const org = await fetchSingleOrg({organizationId: orgId}) - if (org?.default_workspace?.id && org?.id) { - cacheWorkspaceOrgPair(org.default_workspace.id, org.id) + if (org?.id) { + // Dedup: on a cold load with no cached workspace→org mapping this atom keys by the + // workspace id, then re-keys to ["selectedOrg", orgId] once cacheWorkspaceOrgPair + // below lets selectedOrgIdAtom resolve the org id — which would refetch the same org. + // Seed the org-id-keyed cache with this response so the re-keyed query + // (refetchOnMount:false) and resolveWorkspaceIdForOrg's getQueryData fast-path serve + // from cache instead of a second round-trip. + if (org.id !== id) { + queryClient.setQueryData(["selectedOrg", org.id], org) + } + if (org?.default_workspace?.id) { + cacheWorkspaceOrgPair(org.default_workspace.id, org.id) + } } return org }, From dee2323847d3b123d5e92a0353d81ac907c168a4 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 12:39:55 +0200 Subject: [PATCH 16/50] feat(frontend): agent playground loading skeletons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hold the agent panes' real shape while they load instead of a blank canvas. AgentChatSkeleton mirrors the chat layout (session tabs, transcript turns, composer) and fills the two gaps — the revision still resolving the agent flag, and the lazy AgentChatPanel (AI SDK) chunk loading — as the dynamic-import fallback and the pending agent-generation host. AgentConfigSkeleton mirrors the config section-row list (Model & harness, Instructions, Tools, MCP, Skills, Triggers, Advanced), surfaced via a new loadingFallback prop on PlaygroundConfigSection so the panel shows a layout-matched skeleton instead of the generic pulse boxes. --- .../components/AgentChatSkeleton.tsx | 62 +++++++++++++++++++ .../Components/MainLayout/index.tsx | 7 +++ .../assets/AgentConfigSkeleton.tsx | 46 ++++++++++++++ .../PlaygroundVariantConfig/index.tsx | 21 +++++-- .../src/components/Playground/Playground.tsx | 5 +- .../components/PlaygroundConfigSection.tsx | 10 +++ 6 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 web/oss/src/components/AgentChatSlice/components/AgentChatSkeleton.tsx create mode 100644 web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/AgentConfigSkeleton.tsx diff --git a/web/oss/src/components/AgentChatSlice/components/AgentChatSkeleton.tsx b/web/oss/src/components/AgentChatSlice/components/AgentChatSkeleton.tsx new file mode 100644 index 00000000000..43e49025e51 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/AgentChatSkeleton.tsx @@ -0,0 +1,62 @@ +import {Skeleton} from "antd" + +/** + * Structural placeholder for the agent chat pane, shown in the two gaps before the real panel + * can mount: (1) the workflow revision is still resolving the agent flag, (2) the lazy + * AgentChatPanel chunk (AI SDK) is still loading. Mirrors the real layout — session tab strip + * with trailing actions, transcript turns (user bubble + avatar right, assistant avatar + text + * lines left), composer — so the pane reads as "chat, loading" instead of sitting black and + * popping in all at once. + */ +const AgentChatSkeleton = () => ( +
+ {/* Session tab strip: tab pills left, add/search/history actions right */} +
+ + +
+ + + +
+
+ {/* Transcript column — same width cap as the real chat (CHAT_COLUMN) */} +
+ {/* User turn: bubble justified right with the avatar outside it */} +
+
+ +
+ +
+ {/* Assistant turn: square avatar left, plain text lines (no bubble) */} +
+ +
+ +
+
+ {/* Second, shorter exchange */} +
+ + +
+
+ +
+ +
+
+
+ {/* Composer (input area + toolbar lane) */} +
+ +
+
+) + +export default AgentChatSkeleton diff --git a/web/oss/src/components/Playground/Components/MainLayout/index.tsx b/web/oss/src/components/Playground/Components/MainLayout/index.tsx index f539a02baf6..e6b294a80e6 100644 --- a/web/oss/src/components/Playground/Components/MainLayout/index.tsx +++ b/web/oss/src/components/Playground/Components/MainLayout/index.tsx @@ -22,6 +22,7 @@ import clsx from "clsx" import {useAtomValue, useSetAtom} from "jotai" import dynamic from "next/dynamic" +import AgentChatSkeleton from "@/oss/components/AgentChatSlice/components/AgentChatSkeleton" import {chatPanelMaximizedAtom} from "@/oss/components/AgentChatSlice/state/panelLayout" import {PanelSessionInspectorButton} from "@/oss/components/SessionInspector" import {routerAppIdAtom} from "@/oss/state/app/selectors/app" @@ -446,6 +447,12 @@ const PlaygroundMainView = ({ /> ) } + // Agent identified early (persisted agent-type map) but the + // revision hasn't resolved the flag yet — hold the chat pane's + // shape instead of a blank canvas until the host mounts. + if (isAgentConfig && singleEntityQuery.isPending) { + return + } return displayedEntities.includes(variantId) || isEvaluatorMode ? ( ( +
+ {ROWS.map((row, i) => ( +
+ + +
+ + {row.withAdd ? : null} + +
+
+ ))} +
+) + +export default AgentConfigSkeleton diff --git a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx index f4d5e9d5a40..54d84bf9344 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx @@ -27,6 +27,7 @@ import {playgroundEarlyAgentStateAtom} from "@/oss/state/workflow" import {PlaygroundNodeTokenPathProvider} from "../../PlaygroundTokenPath" +import AgentConfigSkeleton from "./assets/AgentConfigSkeleton" import PlaygroundVariantConfigHeader from "./assets/PlaygroundVariantConfigHeader" import type {VariantConfigComponentProps} from "./types" @@ -233,11 +234,15 @@ const PlaygroundVariantConfig: React.FC< extraActions={isAgentHeaderMode ? undefined : viewModeSelector} /> {hasPendingHydration ? ( -
-
-
-
-
+ isAgentHeaderMode ? ( + + ) : ( +
+
+
+
+
+ ) ) : ( <> @@ -256,6 +261,12 @@ const PlaygroundVariantConfig: React.FC< // header non-sticky, so the section headers have // nothing to clear — pin them at the scroll top. stickyHeaderTop={embedded ? 0 : 48} + // Agent (known or early-signalled): hold the panel's real + // section-row shape while the schema loads, instead of the + // generic prompt-config pulse boxes. + loadingFallback={ + isAgentHeaderMode ? : undefined + } /> diff --git a/web/oss/src/components/Playground/Playground.tsx b/web/oss/src/components/Playground/Playground.tsx index 52833997f95..9523e1dc166 100644 --- a/web/oss/src/components/Playground/Playground.tsx +++ b/web/oss/src/components/Playground/Playground.tsx @@ -10,6 +10,7 @@ import {preloadEditorPlugins, SyncStateTag} from "@agenta/ui" import {useAtomValue, useSetAtom} from "jotai" import dynamic from "next/dynamic" +import AgentChatSkeleton from "@/oss/components/AgentChatSlice/components/AgentChatSkeleton" import { AgentChatScopeProvider, ONBOARDING_SCOPE_KEY, @@ -30,9 +31,11 @@ import {OSSPlaygroundShell} from "./OSSPlaygroundShell" import PlaygroundOnboarding from "./PlaygroundOnboarding" // Agent-chat surface (third generation arm). Lazy — only loads the AI SDK when an -// agent workflow is opened in the playground. +// agent workflow is opened in the playground. The skeleton holds the pane's shape while +// the chunk loads, so the chat doesn't sit blank and pop in wholesale. const AgentChatPanel = dynamic(() => import("@/oss/components/AgentChatSlice/AgentChatPanel"), { ssr: false, + loading: () => , }) /** diff --git a/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx b/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx index d3da81c41ad..f0a86f63609 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx @@ -617,6 +617,12 @@ export interface PlaygroundConfigSectionProps { * of floating 48px down into the editor content. */ stickyHeaderTop?: number + /** + * Rendered instead of the generic pulse boxes while the config/schema is + * loading. Lets the caller show a layout-matched skeleton (e.g. the agent + * section-row list) when it knows the entity's shape before the data lands. + */ + loadingFallback?: React.ReactNode } function PlaygroundConfigSection({ @@ -628,6 +634,7 @@ function PlaygroundConfigSection({ onRefinePrompt, viewMode: externalViewMode, stickyHeaderTop = 48, + loadingFallback, }: PlaygroundConfigSectionProps) { const {llmProviderConfig} = useDrillInUI() @@ -1805,6 +1812,9 @@ function PlaygroundConfigSection({ const isConfigLoading = schemaQuery.isPending && !hasRenderableConfigSections(activeData) if (isConfigLoading) { + if (loadingFallback) { + return
{loadingFallback}
+ } return (
From 75f1b06157c5b93311a599faa0a53bc6b0d449bb Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 12:40:08 +0200 Subject: [PATCH 17/50] fix(frontend): graceful trace-pending states in agent chat messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A restored turn's first-seen timestamp is the reload moment, not the turn's send time — stamping it made old turns read "just now" until (or forever if) their trace loaded. Track restored ids and skip stamping them, so their timestamp slot holds a placeholder until the real trace time arrives (settled-with-no-trace shows nothing, never a wrong time). Likewise the per-message metrics: usage renders immediately while only the latency slot waits on the trace, held by a fixed-size Skeleton placeholder so the row neither shifts nor blanks known data. --- .../AgentChatSlice/AgentChatPanel.tsx | 12 ++++++++-- .../components/AgentMessage.tsx | 24 +++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx index de521d68e00..3e9deadc3d1 100644 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx @@ -322,6 +322,9 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: const [initialMessages] = useState(() => store.get(sessionMessagesAtom)[sessionId] ?? []) // Ids already on screen — restored/settled turns don't re-animate; only turns added live fade in. const seenIdsRef = useRef>(new Set(initialMessages.map((m) => m.id))) + // Immutable snapshot of the restored ids (seenIdsRef grows) — the first-seen stamping + // effect below skips these so a reload can't masquerade as the turns' send time. + const restoredIdsRef = useRef>(new Set(initialMessages.map((m) => m.id))) // Themed confirm dialogs. The static `Modal.confirm` renders detached from the app's // ConfigProvider, so it loses the theme (white box in dark mode). The hook form's // `contextHolder` is rendered in-tree, so its dialogs inherit the theme — same look as the @@ -833,9 +836,14 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: pruneExpanded(live) }, [messages, status, store, pruneExpanded]) - // Stamp a first-seen timestamp on any newly-appeared message (user + assistant). + // Stamp a first-seen timestamp on any newly-appeared LIVE message (user + assistant). + // Restored rows are excluded: their first-seen is the reload moment, not the turn's time — + // stamping them made old turns read "just now" until (or forever if) the trace never loads. + // Unstamped, their timestamp slot shows a pending placeholder, then the trace's real time. useEffect(() => { - stampMessagesCreatedAt(messages.map((m) => m.id)) + stampMessagesCreatedAt( + messages.filter((m) => !restoredIdsRef.current.has(m.id)).map((m) => m.id), + ) }, [messages, stampMessagesCreatedAt]) // ── #4920 Application 1: refresh the config on a committed revision ── diff --git a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx index 3d2a6e20ca4..ce45fc826ed 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx @@ -16,7 +16,7 @@ import { XCircle, } from "@phosphor-icons/react" import type {FileUIPart, ReasoningUIPart, ToolUIPart, UIMessage} from "ai" -import {Avatar, Tooltip, Typography} from "antd" +import {Avatar, Skeleton, Tooltip, Typography} from "antd" import {useAtomValue, useSetAtom} from "jotai" import {openTraceDrawerAtom} from "@/oss/components/SharedDrawers/TraceDrawer/store/traceDrawerStore" @@ -71,8 +71,17 @@ const TraceMetrics = ({traceId, usage}: {traceId: string; usage?: MessageUsageMe // Latency comes from the trace; tokens/cost come from the streamed message usage // (the agent-run trace summary doesn't surface them on the Pi/local path). Usage // wins where both exist so the figures match what the model actually reported. - const metrics = {...summary.metrics, ...usage} - return + // Only the latency slot waits on the trace — usage renders immediately, and a fixed-size + // placeholder holds latency's spot so the row doesn't shift (or blank known data) meanwhile. + if (summary.isPending) { + return ( +
+ + {usage ? : null} +
+ ) + } + return } interface AgentMessageProps { @@ -536,7 +545,14 @@ const AgentMessage = ({ onItemClick: () => onRewind(message), } - const timestamp = messageTime ? : null + // Restored turns have no first-seen stamp (a reload isn't their send time), so until their + // trace time arrives the slot holds a placeholder — never a wrong "just now". Settled with no + // trace (deleted/expired) → no stamp at all. Live turns show first-seen instantly as before. + const timestamp = messageTime ? ( + + ) : timeSummary.isPending ? ( + + ) : null const toolbar = isUser ? ( <> From 057d6068fb2a9b91983c536bb1028f8a2987b403 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 14:02:04 +0200 Subject: [PATCH 18/50] fix(frontend): re-enable Crisp chat widget on cloud An earlier change commented out the two Crisp effects (configure + accent-color-on-theme), which also left useEffect/Crisp/ChatboxColors/ThemeMode/getEnv/appTheme unused and failed the lint check. Restore the effects (both still gated on NEXT_PUBLIC_CRISP_WEBSITE_ID). --- .../Scripts/assets/CloudScripts.tsx | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/web/ee/src/components/Scripts/assets/CloudScripts.tsx b/web/ee/src/components/Scripts/assets/CloudScripts.tsx index 3f3399a9b86..1f92b3d20e8 100644 --- a/web/ee/src/components/Scripts/assets/CloudScripts.tsx +++ b/web/ee/src/components/Scripts/assets/CloudScripts.tsx @@ -10,15 +10,15 @@ import {getEnv} from "@/oss/lib/helpers/dynamicEnv" const CloudScripts = () => { const {appTheme} = useAppTheme() - // useEffect(() => { - // const isCrispEnabled = !!getEnv("NEXT_PUBLIC_CRISP_WEBSITE_ID") + useEffect(() => { + const isCrispEnabled = !!getEnv("NEXT_PUBLIC_CRISP_WEBSITE_ID") - // if (!isCrispEnabled) { - // return - // } + if (!isCrispEnabled) { + return + } - // Crisp.configure(getEnv("NEXT_PUBLIC_CRISP_WEBSITE_ID")) - // }, []) + Crisp.configure(getEnv("NEXT_PUBLIC_CRISP_WEBSITE_ID")) + }, []) // The Crisp chatbox renders in its own cross-origin iframe, so we can't style // its light/dark useCrispChat from our CSS, and crisp-sdk-web exposes no runtime @@ -30,17 +30,17 @@ const CloudScripts = () => { // in the Crisp dashboard (Settings → Chatbox → Appearance). That follows the // visitor's *system* color scheme — the SDK has no API to bind it to our // in-app theme toggle, so this accent tweak is the only code-side lever. - // useEffect(() => { - // const isCrispEnabled = !!getEnv("NEXT_PUBLIC_CRISP_WEBSITE_ID") + useEffect(() => { + const isCrispEnabled = !!getEnv("NEXT_PUBLIC_CRISP_WEBSITE_ID") - // if (!isCrispEnabled) { - // return - // } + if (!isCrispEnabled) { + return + } - // Crisp.setColorTheme( - // appTheme === ThemeMode.Dark ? ChatboxColors.Black : ChatboxColors.Default, - // ) - // }, [appTheme]) + Crisp.setColorTheme( + appTheme === ThemeMode.Dark ? ChatboxColors.Black : ChatboxColors.Default, + ) + }, [appTheme]) return ( <> From 61ee874fec6a1c7e4822908f3f2a597f31eba370 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 14:02:04 +0200 Subject: [PATCH 19/50] fix(frontend): keyboard-accessible evaluator rows, prettier formatting Evaluator template rows were a clickable
with no keyboard affordance; add role=button, tabIndex, and an Enter/Space handler so keyboard-only users can select a template. Also run prettier on WorkflowRevisionDrawerWrapper to fix the failing format check (a line over print width). --- .../Evaluators/components/EvaluatorTemplateDropdown.tsx | 8 ++++++++ .../components/WorkflowRevisionDrawerWrapper/index.tsx | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/web/oss/src/components/Evaluators/components/EvaluatorTemplateDropdown.tsx b/web/oss/src/components/Evaluators/components/EvaluatorTemplateDropdown.tsx index 5e78c19eade..e28e56e02e9 100644 --- a/web/oss/src/components/Evaluators/components/EvaluatorTemplateDropdown.tsx +++ b/web/oss/src/components/Evaluators/components/EvaluatorTemplateDropdown.tsx @@ -109,7 +109,15 @@ const EvaluatorTemplateDropdownContent = memo( return (
handleTemplateSelect(item)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + handleTemplateSelect(item) + } + }} className={cn( "border-0 border-b border-solid last:border-b-0", borderColors.secondary, diff --git a/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx b/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx index 07ef49e0b38..10ec3245718 100644 --- a/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx +++ b/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx @@ -145,7 +145,9 @@ const EvaluatorTypeLabel = memo(({revisionId}: {revisionId: string}) => { // Gate on the canonical `is_evaluator` FLAG, not the URI prefix: builtin APPS // (chat/completion) also carry an `agenta:builtin:` URI, so a prefix-only check // both mislabels them as evaluators AND pulls the whole evaluator catalog. - const templatesMap = useAtomValue(isEvaluator ? evaluatorTemplatesMapAtom : EMPTY_TEMPLATES_MAP_ATOM) + const templatesMap = useAtomValue( + isEvaluator ? evaluatorTemplatesMapAtom : EMPTY_TEMPLATES_MAP_ATOM, + ) const label = useMemo(() => { if (!isEvaluator) return null From a6710432c0e13cf7fe0283b04da8f0d4a928f93e Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 21:59:05 +0200 Subject: [PATCH 20/50] perf(frontend): code-split prompt generation UIs, comparison view, and drawers out of the playground chunk ChatMode/CompletionMode load lazily inside ExecutionItems (agents never render them), the comparison view loads on first 2-entity selection, and the catalog + session-inspector drawers load on open. Barrel value re-exports of the split modules are pruned (playground-ui has no sideEffects config, so a static re-export would pull them back into every consumer chunk); SessionInspector buttons are imported per-file for the same reason. Playground dynamic chunk: 2774 kB -> 2501 kB; ~490 kB now loads on demand. --- .../AgentChatSlice/AgentChatPanel.tsx | 3 +- .../AgentChatSlice/components/SessionRail.tsx | 3 +- .../Components/MainLayout/index.tsx | 38 ++++++++-- .../src/components/Playground/Playground.tsx | 13 +++- .../src/components/ExecutionItems/index.tsx | 69 ++++++++++--------- .../src/components/index.ts | 18 ++--- 6 files changed, 88 insertions(+), 56 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx index 3e9deadc3d1..623f1054717 100644 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx @@ -42,7 +42,8 @@ import {type AgentTemplate} from "@/oss/components/pages/agent-home/assets/templ import OnboardingBrowseTemplates from "@/oss/components/pages/agent-home/PlaygroundOnboarding/OnboardingBrowseTemplates" import {useOptionalOnboardingContext} from "@/oss/components/pages/agent-home/PlaygroundOnboarding/OnboardingContext" import Reveal from "@/oss/components/pages/agent-home/PlaygroundOnboarding/Reveal" -import {SessionInspectorButton} from "@/oss/components/SessionInspector" +// Direct file import — the barrel would statically pull the inspector drawer into this chunk. +import SessionInspectorButton from "@/oss/components/SessionInspector/SessionInspectorButton" import {openTraceDrawerAtom} from "@/oss/components/SharedDrawers/TraceDrawer/store/traceDrawerStore" import TemplateStrip from "@/oss/components/TemplateStrip" import {buildCodingAgentClipboard} from "@/oss/components/TemplateStrip/assets/codingAgentClipboard" diff --git a/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx b/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx index ed7913242e2..45094110116 100644 --- a/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx +++ b/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx @@ -5,7 +5,8 @@ import {Button, Empty, Input, Tooltip} from "antd" import clsx from "clsx" import {useAtomValue, useSetAtom} from "jotai" -import {SessionInspectorButton} from "@/oss/components/SessionInspector" +// Direct file import — the barrel would statically pull the inspector drawer into this chunk. +import SessionInspectorButton from "@/oss/components/SessionInspector/SessionInspectorButton" import {useChatScopeKey} from "../state/scope" import { diff --git a/web/oss/src/components/Playground/Components/MainLayout/index.tsx b/web/oss/src/components/Playground/Components/MainLayout/index.tsx index e6b294a80e6..f0e31dbeece 100644 --- a/web/oss/src/components/Playground/Components/MainLayout/index.tsx +++ b/web/oss/src/components/Playground/Components/MainLayout/index.tsx @@ -9,11 +9,6 @@ import { playgroundController, } from "@agenta/playground" import {EmptyState, ExecutionHeader, useEntitySelector} from "@agenta/playground-ui/components" -import { - GenerationComparisonOutput, - GenerationComparisonOutputHeader, - GenerationComparisonInputHeader as PlaygroundComparisonGenerationInputHeader, -} from "@agenta/playground-ui/execution-item-comparison-view" import ExecutionItems, { type PlaygroundGenerationsProps, } from "@agenta/playground-ui/execution-items" @@ -24,18 +19,47 @@ import dynamic from "next/dynamic" import AgentChatSkeleton from "@/oss/components/AgentChatSlice/components/AgentChatSkeleton" import {chatPanelMaximizedAtom} from "@/oss/components/AgentChatSlice/state/panelLayout" -import {PanelSessionInspectorButton} from "@/oss/components/SessionInspector" +// Direct file import — the SessionInspector barrel would statically pull the (dynamic, +// open-on-demand) inspector drawer back into this chunk. +import PanelSessionInspectorButton from "@/oss/components/SessionInspector/PanelSessionInspectorButton" import {routerAppIdAtom} from "@/oss/state/app/selectors/app" import {playgroundEarlyAgentStateAtom} from "@/oss/state/workflow" import {usePlaygroundScrollSync} from "../../hooks/usePlaygroundScrollSync" -import PromptComparisonVariantNavigation from "../PlaygroundPromptComparisonView/PromptComparisonVariantNavigation" import PlaygroundVariantConfig from "../PlaygroundVariantConfig" import type {BaseContainerProps} from "../types" const PlaygroundFocusDrawer = dynamic(() => import("../PlaygroundFocusDrawerAdapter"), { ssr: false, }) +// The comparison view only mounts with 2+ selected entities — never for agents, and +// rarely at first paint — so its whole subtree loads on demand. +const GenerationComparisonOutput = dynamic( + () => + import("@agenta/playground-ui/execution-item-comparison-view").then( + (m) => m.GenerationComparisonOutput, + ), + {ssr: false}, +) +const GenerationComparisonOutputHeader = dynamic( + () => + import("@agenta/playground-ui/execution-item-comparison-view").then( + (m) => m.GenerationComparisonOutputHeader, + ), + {ssr: false}, +) +const PlaygroundComparisonGenerationInputHeader = dynamic( + () => + import("@agenta/playground-ui/execution-item-comparison-view").then( + (m) => m.GenerationComparisonInputHeader, + ), + {ssr: false}, +) +const PromptComparisonVariantNavigation = dynamic( + () => import("../PlaygroundPromptComparisonView/PromptComparisonVariantNavigation"), + {ssr: false}, +) + type MainLayoutProps = BaseContainerProps & { /** "app" (default) = standard app playground. "evaluator" = evaluator config playground. */ mode?: "app" | "evaluator" diff --git a/web/oss/src/components/Playground/Playground.tsx b/web/oss/src/components/Playground/Playground.tsx index 9523e1dc166..e47d119750a 100644 --- a/web/oss/src/components/Playground/Playground.tsx +++ b/web/oss/src/components/Playground/Playground.tsx @@ -3,7 +3,6 @@ import {type FC, useCallback, useEffect, useMemo} from "react" import {executeToolCall} from "@agenta/entities/gatewayTool" import {loadableController} from "@agenta/entities/loadable" import {testcaseMolecule} from "@agenta/entities/testcase" -import {CatalogDrawer} from "@agenta/entity-ui/gatewayTool" import {GatewayToolAssistantActions, type PlaygroundUIProviders} from "@agenta/playground-ui" import {useLocalDraftWarning} from "@agenta/playground-ui/hooks" import {preloadEditorPlugins, SyncStateTag} from "@agenta/ui" @@ -19,7 +18,6 @@ import SimpleSharedEditor from "@/oss/components/EditorViews/SimpleSharedEditor" import {OnboardingContext} from "@/oss/components/pages/agent-home/PlaygroundOnboarding/OnboardingContext" import OnboardingLoader from "@/oss/components/pages/agent-home/PlaygroundOnboarding/OnboardingLoader" import {useAgentOnboarding} from "@/oss/components/pages/agent-home/PlaygroundOnboarding/useAgentOnboarding" -import {SessionInspectorDrawer} from "@/oss/components/SessionInspector" import SharedGenerationResultUtils from "@/oss/components/SharedGenerationResultUtils" import {playgroundSyncAtom} from "@/oss/state/url/playground" import {playgroundEarlyAgentStateAtom} from "@/oss/state/workflow" @@ -38,6 +36,17 @@ const AgentChatPanel = dynamic(() => import("@/oss/components/AgentChatSlice/Age loading: () => , }) +// Open-on-demand drawers: mounted closed until the user opens them, so their subtrees +// load lazily instead of riding the playground's initial chunk. +const CatalogDrawer = dynamic( + () => import("@agenta/entity-ui/gatewayTool").then((m) => m.CatalogDrawer), + {ssr: false}, +) +const SessionInspectorDrawer = dynamic( + () => import("@/oss/components/SessionInspector/SessionInspectorDrawer"), + {ssr: false}, +) + /** * Sync state tag slot — renders the sync state badge in each row header. * Shown only when connected to an API-backed testset. diff --git a/web/packages/agenta-playground-ui/src/components/ExecutionItems/index.tsx b/web/packages/agenta-playground-ui/src/components/ExecutionItems/index.tsx index b19205c1a8c..40ad90cba05 100644 --- a/web/packages/agenta-playground-ui/src/components/ExecutionItems/index.tsx +++ b/web/packages/agenta-playground-ui/src/components/ExecutionItems/index.tsx @@ -1,4 +1,4 @@ -import {useMemo, useRef} from "react" +import {lazy, Suspense, useMemo, useRef} from "react" import {workflowMolecule} from "@agenta/entities/workflow" import {executionController} from "@agenta/playground" @@ -9,9 +9,24 @@ import type {ExecutionHeaderProps} from "../ExecutionHeader" import ExecutionHeader from "../ExecutionHeader" import type {ChatModeProps} from "./assets/ChatMode" -import ChatMode from "./assets/ChatMode" import type {CompletionModeProps} from "./assets/CompletionMode" -import CompletionMode from "./assets/CompletionMode" + +// The prompt-playground generation UIs are code-split: agents never render them, and +// prompt playgrounds load them behind the same placeholder the pending state shows. +const ChatMode = lazy(() => import("./assets/ChatMode")) +const CompletionMode = lazy(() => import("./assets/CompletionMode")) + +const ExecutionLoadingPlaceholder = () => ( +
+
+
+
+
+
+
+
+
+) export interface PlaygroundGenerationsProps { entityId: string @@ -80,17 +95,7 @@ const PlaygroundGenerations: React.FC = ({ } if (isExecutionLoading) { - return ( -
-
-
-
-
-
-
-
-
- ) + return } return ( @@ -111,19 +116,23 @@ const PlaygroundGenerations: React.FC = ({
) : null - ) : isChat ? ( - ) : ( - + }> + {isChat ? ( + + ) : ( + + )} + )}
) @@ -131,13 +140,11 @@ const PlaygroundGenerations: React.FC = ({ export default PlaygroundGenerations -// Re-export sub-components (canonical names only) -export {default as ChatMode} from "./assets/ChatMode" +// Type-only re-exports for the generation modes; their VALUE exports are deliberately +// absent — the modes are code-split above (a static re-export would pull them back into +// every chunk that touches this entry, since the package has no sideEffects config). export type {ChatModeProps} from "./assets/ChatMode" -export {default as ChatTurnView} from "./assets/ChatTurnView" -export {default as CompletionMode} from "./assets/CompletionMode" export type {CompletionModeProps} from "./assets/CompletionMode" -export {default as ExecutionRow} from "./assets/ExecutionRow" export type {ExecutionRowProps} from "./assets/ExecutionRow" export {default as GatewayToolAssistantActions} from "./GatewayToolAssistantActions" export {default as GatewayToolExecuteButton} from "./GatewayToolExecuteButton" diff --git a/web/packages/agenta-playground-ui/src/components/index.ts b/web/packages/agenta-playground-ui/src/components/index.ts index 6c729616d0d..7f37afc76fc 100644 --- a/web/packages/agenta-playground-ui/src/components/index.ts +++ b/web/packages/agenta-playground-ui/src/components/index.ts @@ -35,13 +35,12 @@ export {default as ControlsBar, type ControlsBarProps} from "./ControlsBar" export {default as PlaygroundOutputs} from "./PlaygroundOutputs" export type {PlaygroundOutputsProps} from "./PlaygroundOutputs" -// Execution items +// Execution items. ChatMode/CompletionMode are code-split inside ExecutionItems and the +// comparison view is loaded on demand by its consumers via the subpath entry — neither is +// re-exported here, because a barrel value re-export would statically pull them into every +// chunk that imports this entry (the package has no sideEffects config to tree-shake it). export { - ChatMode, - ChatTurnView, - CompletionMode, default as ExecutionItems, - ExecutionRow, GatewayToolAssistantActions, GatewayToolExecuteButton, type ChatModeProps, @@ -50,15 +49,6 @@ export { type ExecutionRowProps, } from "./ExecutionItems" -// Execution item comparison view -export { - GenerationComparisonChatOutput, - GenerationComparisonCompletionOutput, - GenerationComparisonInputHeader, - GenerationComparisonOutput, - GenerationComparisonOutputHeader, -} from "./ExecutionItemComparisonView" - // Testset selection modal (entity-based, for load/edit modes) // For saving new testsets, use EntityCommitModal from @agenta/entity-ui with renderModeContent export { From 1f42a1417ce0926242aafc6caa23f732996b9447 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 8 Jul 2026 22:59:54 +0200 Subject: [PATCH 21/50] perf(frontend): code-split agent chat panel regions (session bar, rail, composer) and crossfade-mount the panel Lazy-load SessionTagBar, SessionRail, and RichChatInput (Lexical) behind Suspense boundaries; each region's fallback is the same skeleton the pane-level loading gates render for that slot, so hydration never shifts the layout. The panel itself loads via next/dynamic with a persistent skeleton overlay that crossfades out after the real panel commits (onMounted callback + 350ms dissolve), --- .../AgentChatSlice/AgentChatPanel.tsx | 266 ++++++++++-------- .../AgentChatSlice/AgentChatPanelHost.tsx | 54 ++++ .../components/AgentChatSkeleton.tsx | 115 ++++---- .../components/SessionTagBar.tsx | 15 +- .../PlaygroundVariantConfig/index.tsx | 2 +- .../src/components/Playground/Playground.tsx | 14 +- .../SchemaControls/AgentTemplateControl.tsx | 9 +- .../SchemaControls/SchemaPropertyRenderer.tsx | 35 ++- .../agentTemplate}/AgentConfigSkeleton.tsx | 0 .../agenta-entity-ui/src/DrillInView/index.ts | 4 + web/packages/agenta-entity-ui/src/index.ts | 1 + .../section/ConfigAccordionSection.tsx | 18 +- 12 files changed, 340 insertions(+), 193 deletions(-) create mode 100644 web/oss/src/components/AgentChatSlice/AgentChatPanelHost.tsx rename web/{oss/src/components/Playground/Components/PlaygroundVariantConfig/assets => packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate}/AgentConfigSkeleton.tsx (100%) diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx index f3aca6eb659..43c6b2c3174 100644 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx @@ -1,4 +1,13 @@ -import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from "react" +import { + lazy, + Suspense, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react" import {markTraceAsFresh} from "@agenta/entities/trace" import {invalidateAgentCommittedRevisionCache, workflowMolecule} from "@agenta/entities/workflow" @@ -11,7 +20,7 @@ import { import {simulatedAgentRunAtomFamily} from "@agenta/shared/state" import {generateId} from "@agenta/shared/utils" import {HeightCollapse} from "@agenta/ui" -import {RichChatInput, type RichChatInputHandle} from "@agenta/ui/rich-chat-input" +import {type RichChatInputHandle} from "@agenta/ui/rich-chat-input" import {useChat} from "@ai-sdk/react" import {Bubble} from "@ant-design/x" import { @@ -62,6 +71,7 @@ import {filesToParts} from "./assets/files" import {messageText, sideEffectingToolsInRange} from "./assets/rewind" import {getMessageTraceId} from "./assets/trace" import AgentChatEmptyState from "./components/AgentChatEmptyState" +import {ComposerSkeleton, SessionBarSkeleton} from "./components/AgentChatSkeleton" import AgentMessage from "./components/AgentMessage" import ApprovalDock, {getPendingApprovals} from "./components/ApprovalDock" import type {ClientToolOutputHandler} from "./components/clientTools" @@ -70,8 +80,6 @@ import ConnectModelBanner from "./components/ConnectModelBanner" import QueuedMessages from "./components/QueuedMessages" import RevealCollapse from "./components/RevealCollapse" import SessionHistoryMenu from "./components/SessionHistoryMenu" -import SessionRail from "./components/SessionRail" -import SessionTagBar from "./components/SessionTagBar" import TurnInspector from "./components/TurnInspector/TurnInspector" import {useAgentChatQueue, type QueuedMessage} from "./hooks/useAgentChatQueue" import {useAgentModelKeyStatus} from "./hooks/useAgentModelKeyStatus" @@ -101,6 +109,16 @@ import { isAgentChatVirtualizationAvailable, } from "./state/virtualization" +// Lazy regions: each hydrates independently behind the SAME skeleton the loading gates show +// for its slot, so the pane's structure never blocks on (or shifts around) a sibling region. +// The composer carries Lexical — the heaviest dependency of this chunk — out of the panel's +// synchronous mount; React.lazy (not next/dynamic) so the imperative handle ref forwards. +const RichChatInput = lazy(() => + import("@agenta/ui/rich-chat-input").then((m) => ({default: m.RichChatInput})), +) +const SessionTagBar = lazy(() => import("./components/SessionTagBar")) +const SessionRail = lazy(() => import("./components/SessionRail")) + /** A stream error/abort is already surfaced via `useChat`'s `onError` + the in-chat `error` * alert; swallow the floating `sendMessage`/`regenerate` rejection so it doesn't bubble to the * Next.js dev Runtime Error overlay (F-033). */ @@ -1758,100 +1776,105 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: />
) : null} - handleCreateAgent() : handleSubmit} - disabled={onboardingActive ? ideHandoffActive : modelBlocked} - hideSendButton={onboardingActive} - submitOnEnter={!onboardingActive} - placeholder={ - onboardingActive - ? ideHandoffActive - ? "Continue in your IDE from the steps above — or start over." - : "e.g. Watch our #support channel, triage each thread by urgency, and route it to the right owner — ask me before closing anything." - : modelBlocked - ? "Connect a model to start chatting…" - : "Ask the agent… (Enter to send, ⌘/Ctrl+Enter for newline)" - } - onPasteFile={(pasted) => addFiles(Array.from(pasted))} - sendForceEnabled={files.length > 0} - streaming={busy} - onStop={handleStop} - prefix={ - // Attach button is gated until the agent service is ready for inline - // file parts (big-agents d4b119af26); paste / drag-to-add still work. - - - ) : ( -
- {TEMPLATE_STRIP_MODE ? ( - // Strip era: the IDE handoff is a one-click copy + toast, no modal/bubble. - - ) : ( + {/* Composer region hydrates independently (Lexical chunk); the fallback is the + same skeleton the pane-level gates render for this slot, so the box never + changes shape — the editor just materializes inside it. */} + }> + handleCreateAgent() : handleSubmit} + disabled={onboardingActive ? ideHandoffActive : modelBlocked} + hideSendButton={onboardingActive} + submitOnEnter={!onboardingActive} + placeholder={ + onboardingActive + ? ideHandoffActive + ? "Continue in your IDE from the steps above — or start over." + : "e.g. Watch our #support channel, triage each thread by urgency, and route it to the right owner — ask me before closing anything." + : modelBlocked + ? "Connect a model to start chatting…" + : "Ask the agent… (Enter to send, ⌘/Ctrl+Enter for newline)" + } + onPasteFile={(pasted) => addFiles(Array.from(pasted))} + sendForceEnabled={files.length > 0} + streaming={busy} + onStop={handleStop} + prefix={ + // Attach button is gated until the agent service is ready for inline + // file parts (big-agents d4b119af26); paste / drag-to-add still work. + + + ) : ( +
+ {TEMPLATE_STRIP_MODE ? ( + // Strip era: the IDE handoff is a one-click copy + toast, no modal/bubble. + + ) : ( + + )} - )} - -
- ) - ) : undefined - } - /> +
+ ) + ) : undefined + } + /> +
@@ -1878,8 +1901,18 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: * preserves a session's live stream / approval state. Each tab is its own `useChat` driven by * `buildAgentRequest` against the current `entityId` (so the run always uses the live draft config). */ -const AgentChatPanel = ({entityId}: {entityId: string}) => { +const AgentChatPanel = ({ + entityId, + onMounted, +}: { + entityId: string + /** Fired once after first commit — lets the crossfade host dissolve its skeleton overlay. */ + onMounted?: () => void +}) => { const scope = useChatScopeKey() + useEffect(() => { + onMounted?.() + }, [onMounted]) // Pre-commit onboarding: one ephemeral session, no multi-session UX — hide the whole session bar // (tabs / new / search / history). Stays hidden through the commit + first send, then eases in a beat // later (`chromeRevealed`) so the bar doesn't push the transcript down mid-send. @@ -1930,7 +1963,10 @@ const AgentChatPanel = ({entityId}: {entityId: string}) => { style={{width: chatMaximized ? RAIL_WIDTH : 0}} inert={!chatMaximized} > - + {/* Rail is width-0 unless maximized, so no visible fallback is needed while it loads. */} + + +
{ className="min-w-0 shrink-0 overflow-hidden motion-safe:transition-[height] motion-safe:duration-[240ms] motion-safe:ease-[cubic-bezier(0.4,0,0.2,1)]" style={{height: chromeHidden || chatMaximized ? 0 : 48}} > - renameSession({id, title})} - showSessions={!chatMaximized} - extra={ - chatMaximized ? undefined : ( - <> - - - - ) - } - /> + {/* Region fallback = the same bar skeleton the pane-level gates render, + so the strip's lane holds its shape while this chunk loads. */} + }> + renameSession({id, title})} + showSessions={!chatMaximized} + extra={ + chatMaximized ? undefined : ( + <> + + + + ) + } + /> +
)} items={sessions.map((session) => ({ diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanelHost.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanelHost.tsx new file mode 100644 index 00000000000..43f5a31c6c7 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanelHost.tsx @@ -0,0 +1,54 @@ +import {useCallback, useEffect, useState} from "react" + +import dynamic from "next/dynamic" + +import AgentChatSkeleton from "./components/AgentChatSkeleton" + +// No `loading` fallback here on purpose — the skeleton below is a persistent overlay, +// not a discarded placeholder, so the swap can be a crossfade instead of a replace. +const AgentChatPanel = dynamic(() => import("./AgentChatPanel"), {ssr: false}) + +/** + * Crossfade host for the lazy agent chat panel. The skeleton stays mounted while the + * heavy chunk loads AND while the real panel commits beneath it at opacity 0; once the + * panel signals mounted, the skeleton dissolves and the panel fades in — the components + * materialize through the skeleton in place, instead of a discard → gap → sudden pop. + * The overlay never intercepts pointer events, so the panel is interactive the moment + * it exists. + */ +const AgentChatPanelHost = ({entityId}: {entityId: string}) => { + const [ready, setReady] = useState(false) + // Unmount the overlay only after the fade has played (timeout, not transitionend — + // reduced-motion environments may never fire the event). + const [overlayGone, setOverlayGone] = useState(false) + const onMounted = useCallback(() => setReady(true), []) + useEffect(() => { + if (!ready) return + const t = window.setTimeout(() => setOverlayGone(true), 350) + return () => window.clearTimeout(t) + }, [ready]) + + return ( +
+
+ +
+ {overlayGone ? null : ( +
+ +
+ )} +
+ ) +} + +export default AgentChatPanelHost diff --git a/web/oss/src/components/AgentChatSlice/components/AgentChatSkeleton.tsx b/web/oss/src/components/AgentChatSlice/components/AgentChatSkeleton.tsx index 43e49025e51..11b075e5823 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentChatSkeleton.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentChatSkeleton.tsx @@ -1,60 +1,79 @@ import {Skeleton} from "antd" /** - * Structural placeholder for the agent chat pane, shown in the two gaps before the real panel - * can mount: (1) the workflow revision is still resolving the agent flag, (2) the lazy - * AgentChatPanel chunk (AI SDK) is still loading. Mirrors the real layout — session tab strip - * with trailing actions, transcript turns (user bubble + avatar right, assistant avatar + text - * lines left), composer — so the pane reads as "chat, loading" instead of sitting black and - * popping in all at once. + * Region skeletons for the agent chat pane. Each region (session bar / transcript / + * composer) exports its own skeleton, and the pane-level default composes them — so the + * pre-panel loading gates and each lazy region's Suspense fallback render the SAME + * component, and a region hydrating never shifts or restyles its neighbours. */ -const AgentChatSkeleton = () => ( -
- {/* Session tab strip: tab pills left, add/search/history actions right */} -
- - -
- - - -
+ +/** Session tab strip: tab pills left, add/search/history actions right. Matches the + * real bar's 48px lane and h-7 pills. */ +export const SessionBarSkeleton = () => ( +
+ + +
+ + +
- {/* Transcript column — same width cap as the real chat (CHAT_COLUMN) */} -
- {/* User turn: bubble justified right with the avatar outside it */} -
-
- -
- -
- {/* Assistant turn: square avatar left, plain text lines (no bubble) */} -
- -
- -
+
+) + +/** Transcript column: user bubbles (content-hugging, avatar outside) alternating with + * assistant turns (square avatar + bare text lines). Same 880px cap as CHAT_COLUMN. */ +export const TranscriptSkeleton = () => ( +
+
+
+
- {/* Second, shorter exchange */} -
- - + +
+
+ +
+
-
- -
- -
+
+
+ + +
+
+ +
+
- {/* Composer (input area + toolbar lane) */} -
- +
+) + +/** Composer box — measured 114px tall, rounded-lg (8px) in the live panel. The caller + * supplies the column/margin classes so it can sit in either the pane skeleton's gutter + * or the real composer's slot (`CHAT_COLUMN mb-3`). */ +export const ComposerSkeleton = ({className}: {className?: string}) => ( +
+ +
+) + +/** + * Whole-pane placeholder, shown before the panel itself can mount: (1) the workflow + * revision is still resolving the agent flag, (2) the lazy AgentChatPanel chunk is + * loading (the crossfade host keeps it as a dissolving overlay). + */ +const AgentChatSkeleton = () => ( +
+ + +
+
) diff --git a/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx b/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx index 59986893ddd..46f59fb5b5a 100644 --- a/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx +++ b/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx @@ -71,10 +71,19 @@ const SessionTag = ({ const text = useAtomValue(sessionFirstUserTextAtomFamily(session.id)) const label = session.title || text || `Chat ${index + 1}` const tabRef = useRef(null) - // Keep the active tab visible: a freshly-added session lands past the strip's overflow edge. - // Smooth vs instant comes from the strip's motion-safe:scroll-smooth, not JS. + // Keep the active tab visible. On the tab's FIRST reveal (reload restoring a far-away active + // session) jump instantly — the strip's scroll-smooth would otherwise play a long scroll across + // the whole strip. Later activations (user switching) keep the CSS smooth nudge. + const mountedRef = useRef(false) useEffect(() => { - if (active) tabRef.current?.scrollIntoView({block: "nearest", inline: "nearest"}) + if (active) { + tabRef.current?.scrollIntoView({ + block: "nearest", + inline: "nearest", + behavior: mountedRef.current ? undefined : "instant", + }) + } + mountedRef.current = true }, [active]) return (
import("@/oss/components/AgentChatSlice/AgentChatPanel"), { - ssr: false, - loading: () => , -}) +// Agent-chat surface (third generation arm). The host is a LIGHT static import that lazy-loads +// the AI-SDK panel internally and crossfades it in through a persistent skeleton overlay — +// components materialize in place instead of a skeleton-discard → sudden pop. // Open-on-demand drawers: mounted closed until the user opens them, so their subtrees // load lazily instead of riding the playground's initial chunk. @@ -123,7 +119,7 @@ const Playground: FC<{onboarding?: boolean}> = ({onboarding = false}) => { // Third generation arm: agent-type entities render the agent-chat surface. // Lazy — pulls in the AI SDK only when an agent workflow is open. While onboarding, this is // the onboarding composer that hands off to the live chat once the ephemeral is committed. - AgentGenerationPanel: agentOnboarding.agentPanel ?? AgentChatPanel, + AgentGenerationPanel: agentOnboarding.agentPanel ?? AgentChatPanelHost, renderSyncStateTag: PlaygroundSyncStateTag, } as unknown as PlaygroundUIProviders diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx index 99a75a62704..178853f82f5 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx @@ -785,8 +785,6 @@ export function AgentTemplateControl({ collapsible={false} noDivider className={sectionCardClass} - revealOnMount - revealDelayMs={Math.min(index, 6) * 45} > {s.content} @@ -805,10 +803,9 @@ export function AgentTemplateControl({ onOpen={s.onOpen} defaultOpen={s.defaultOpen} noDivider={index === sections.length - 1} - // Fade the sections in with a light stagger so they don't pop when the panel - // resolves (esp. after an onboarding commit). Animates once on mount. - revealOnMount - revealDelayMs={Math.min(index, 6) * 45} + // Mount collapsed, then unfold via the normal collapse transition — first + // paint matches the skeleton's collapsed rows instead of shifting the layout. + animateInitialOpen > {s.content} diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SchemaPropertyRenderer.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SchemaPropertyRenderer.tsx index c5d2c919354..659c0348b78 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SchemaPropertyRenderer.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SchemaPropertyRenderer.tsx @@ -12,14 +12,14 @@ * - Works with any entity controller pattern */ -import {memo, useMemo} from "react" +import {lazy, memo, Suspense, useMemo} from "react" import type {SchemaProperty} from "@agenta/entities/shared" import {formatLabel} from "@agenta/ui/drill-in" import {Typography} from "antd" import clsx from "clsx" -import {AgentTemplateControl} from "./AgentTemplateControl" +import AgentConfigSkeleton from "./agentTemplate/AgentConfigSkeleton" import {BooleanToggleControl} from "./BooleanToggleControl" import {CodeEditorControl} from "./CodeEditorControl" import {EnumSelectControl} from "./EnumSelectControl" @@ -33,6 +33,13 @@ import {PromptSchemaControl, isPromptSchema, isPromptValue} from "./PromptSchema import {hasGroupedChoices, resolveAnyOfSchema, shouldRenderObjectInline} from "./schemaUtils" import {TextInputControl} from "./TextInputControl" +// The agent config composite (sections + item drawers + markdown editor) is the heaviest +// control in the registry and only renders for agent-template schemas — code-split it so +// non-agent config panels never load it and agent panels load it behind its skeleton. +const AgentTemplateControl = lazy(() => + import("./AgentTemplateControl").then((m) => ({default: m.AgentTemplateControl})), +) + export interface SchemaPropertyRendererProps { /** The schema property defining the field */ schema: SchemaProperty | null | undefined @@ -430,17 +437,21 @@ export const SchemaPropertyRenderer = memo(function SchemaPropertyRenderer({ case "agent-template": // Render the whole agent config (instructions, model, tools, runtime) as one // composite control that reuses the model selector, tool picker, and enums. + // The Suspense fallback is the SAME skeleton the schema-loading gate shows, + // so the two gates read as one continuous frame while the chunk loads. return ( - | null} - onChange={(v) => onChange(v)} - description={tooltipDesc} - withTooltip={withTooltip} - disabled={disabled} - className={className} - /> + }> + | null} + onChange={(v) => onChange(v)} + description={tooltipDesc} + withTooltip={withTooltip} + disabled={disabled} + className={className} + /> + ) case "prompt": diff --git a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/AgentConfigSkeleton.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/AgentConfigSkeleton.tsx similarity index 100% rename from web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/AgentConfigSkeleton.tsx rename to web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/AgentConfigSkeleton.tsx diff --git a/web/packages/agenta-entity-ui/src/DrillInView/index.ts b/web/packages/agenta-entity-ui/src/DrillInView/index.ts index 0562d258be5..3232459f1c0 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/index.ts +++ b/web/packages/agenta-entity-ui/src/DrillInView/index.ts @@ -44,6 +44,10 @@ export { MoleculeDrillInProvider, PlaygroundConfigSection, } from "./components" +// Loading placeholder for the agent config section list — shared by the schema-loading +// gate (PlaygroundVariantConfig's loadingFallback) and the lazy AgentTemplateControl's +// Suspense fallback, so both gates render the identical frame. +export {default as AgentConfigSkeleton} from "./SchemaControls/agentTemplate/AgentConfigSkeleton" export type { MoleculeDrillInProviderProps, PlaygroundConfigSectionProps, diff --git a/web/packages/agenta-entity-ui/src/index.ts b/web/packages/agenta-entity-ui/src/index.ts index b81f526a67c..c89fcf3b544 100644 --- a/web/packages/agenta-entity-ui/src/index.ts +++ b/web/packages/agenta-entity-ui/src/index.ts @@ -51,6 +51,7 @@ export { MoleculeDrillInFieldItem, MoleculeDrillInProvider, PlaygroundConfigSection, + AgentConfigSkeleton, useDrillIn, type PlaygroundConfigSectionProps, type ConfigViewMode, diff --git a/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx b/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx index 786b708b06e..bf7e3220b6c 100644 --- a/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx +++ b/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx @@ -121,6 +121,13 @@ export interface ConfigAccordionSectionProps { revealOnMount?: boolean /** Stagger for `revealOnMount` — delay (ms) before this section fades in. @default 0 */ revealDelayMs?: number + /** + * Mount the section COLLAPSED and expand it a beat later through the normal collapse + * transition. First paint then matches a collapsed-rows skeleton (no layout shift when the + * panel resolves); the content unfolds instead of appearing pre-expanded. Uncontrolled, + * collapsible, `defaultOpen` sections only — a no-op everywhere else. + */ + animateInitialOpen?: boolean /** Section body. */ children?: ReactNode } @@ -149,6 +156,7 @@ export function ConfigAccordionSection({ className, revealOnMount = false, revealDelayMs = 0, + animateInitialOpen = false, children, }: ConfigAccordionSectionProps) { // Height (0→auto via the grid `0fr`→`1fr` trick) + opacity reveal on mount (opt-in). `revealed` @@ -177,7 +185,15 @@ export function ConfigAccordionSection({ ? "var(--ag-colorWarning)" : "var(--ag-c-586673,#586673)" const isControlled = open !== undefined - const [internalOpen, setInternalOpen] = useState(defaultOpen) + // With `animateInitialOpen`, a default-open section still MOUNTS closed and expands via the + // effect below, so its first paint is the collapsed row (matching skeletons), not the content. + const [internalOpen, setInternalOpen] = useState(animateInitialOpen ? false : defaultOpen) + useEffect(() => { + if (!animateInitialOpen || !defaultOpen || isControlled || !collapsible) return + const t = window.setTimeout(() => setInternalOpen(true), 120) + return () => window.clearTimeout(t) + // Mount-only: this drives a one-shot entrance, never reacts to later prop changes. + }, []) // A section can either open a drawer (onOpen) or expand inline (the accordion default). const opensDrawer = onOpen !== undefined && !locked // Non-collapsible sections (e.g. the "cards" layout) stay open; locked sections stay shut. From 1d6c1b5901a7bf4e3e16164fae8e25212fc721c2 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 9 Jul 2026 15:31:36 +0200 Subject: [PATCH 22/50] feat(frontend): humanize agent tool-step names in chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a shared toolDisplay resolver (per-tool registry override → mcp/gateway name-shape heuristics → title-cased raw name) and wire it into ToolActivity and the ApprovalDock so chat shows friendly labels + source badges while Build keeps the raw wire name. Adds a generic {source}__ACTION short-form branch to parseGatewayToolName (with a unit test). --- .../AgentChatSlice/assets/toolDisplay.ts | 87 +++++++++++++++++++ .../components/ApprovalDock.tsx | 43 ++------- .../components/ToolActivity.tsx | 41 +++++---- .../src/workflow/commitDiff/gatewayName.ts | 8 ++ .../tests/unit/agent-commit-diff.test.ts | 6 ++ 5 files changed, 133 insertions(+), 52 deletions(-) create mode 100644 web/oss/src/components/AgentChatSlice/assets/toolDisplay.ts diff --git a/web/oss/src/components/AgentChatSlice/assets/toolDisplay.ts b/web/oss/src/components/AgentChatSlice/assets/toolDisplay.ts new file mode 100644 index 00000000000..0af9db669d7 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/toolDisplay.ts @@ -0,0 +1,87 @@ +/** + * Tool-step display foundation: the one place a raw runtime tool name (AI SDK part) becomes what + * the chat UI shows. Resolution order: per-tool registry override → name-shape heuristics + * (`mcp__…`, gateway double-underscore forms) → title-cased raw name. Same dispatch idea as the + * approvals/clientTools registries — grow BY_TOOL_NAME for special cases; nothing here is + * load-bearing for unknown tools. Raw names stay reachable via tooltips and Build mode. + */ +import {parseGatewayToolName} from "@agenta/entities/workflow/commitDiff" +import type {ToolUIPart} from "ai" + +/** Best-effort tool family, inferred from the wire-name shape only. */ +export type ToolKind = "gateway" | "mcp" | "platform" + +export interface ToolDisplay { + /** Humanized action label ("Fetch emails"). */ + label: string + /** Where the tool comes from ("Gmail", "Linear · MCP"). */ + source?: string + /** The wire name — always kept reachable (tooltips, Build mode, traces). */ + raw: string + kind: ToolKind + /** Friendly one-liner for a settled row; null/absent falls back to the generic summary. */ + summary?: (input: unknown, output: unknown) => string | null +} + +interface ToolDisplayOverride { + label?: string + source?: string + summary?: (input: unknown, output: unknown) => string | null +} + +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === "object" && !Array.isArray(value)) + +/** Special cases, keyed by wire name. */ +const BY_TOOL_NAME: Record = { + commit_revision: { + summary: (input) => { + const commit = + isRecord(input) && isRecord(input.workflow_revision) + ? input.workflow_revision + : null + return typeof commit?.message === "string" && commit.message ? commit.message : null + }, + }, +} + +const parseNameShape = (raw: string): {label: string; source?: string; kind: ToolKind} => { + // mcp__{server}__{tool} → tool from "Server · MCP". + if (raw.startsWith("mcp__")) { + const parts = raw.split("__").filter(Boolean) + const tool = parts[parts.length - 1] + const server = parts.length >= 3 ? parts[1] : undefined + return { + label: parseGatewayToolName(tool).label, + source: server ? `${parseGatewayToolName(server).label} · MCP` : "MCP", + kind: "mcp", + } + } + const parsed = parseGatewayToolName(raw) + return {...parsed, kind: parsed.source ? "gateway" : "platform"} +} + +/** Resolve display info for a raw runtime tool name. Pure and total — never throws. */ +export const resolveToolDisplay = (raw: string): ToolDisplay => { + const override = BY_TOOL_NAME[raw] + const parsed = parseNameShape(raw) + return { + raw, + kind: parsed.kind, + label: override?.label ?? parsed.label, + source: override?.source ?? parsed.source, + summary: override?.summary, + } +} + +/** Wire name of a tool part. `dynamic-tool` carries it on `toolName`; typed parts encode it as + * `tool-`. */ +export const partToolName = (part: ToolUIPart): string => { + // `dynamic-tool` parts reach here via the grouping cast in AgentMessage but sit outside + // ToolUIPart's static union — read `type` as a string. + const type = part.type as string + if (type === "dynamic-tool") { + return (part as {toolName?: string}).toolName || "tool" + } + return type.replace(/^tool-/, "") +} diff --git a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx index 2539ef5baa2..ad4a9865c32 100644 --- a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx @@ -1,12 +1,12 @@ import {memo, useEffect, useMemo, useRef, useState} from "react" -import {parseGatewayToolName, type ParsedToolName} from "@agenta/entities/workflow/commitDiff" import {HeightCollapse} from "@agenta/ui" import {ArrowSquareOut, CaretRight, ShieldCheck} from "@phosphor-icons/react" import type {ToolUIPart, UIMessage} from "ai" import {Button, Typography} from "antd" import {useAtomValue} from "jotai" +import {partToolName, resolveToolDisplay} from "../assets/toolDisplay" import {chatPanelMaximizedAtom} from "../state/panelLayout" import {resolveApprovalRenderer} from "./approvals/registry" @@ -25,14 +25,6 @@ interface ApprovalRef { const isToolPart = (type: string) => type.startsWith("tool-") || type === "dynamic-tool" -/** Friendly name for a tool part — mirrors ToolActivity: `dynamic-tool` carries `toolName`, typed - * parts encode it as `tool-`. */ -const partToolName = (part: ToolUIPart): string => { - const type = part.type as string - if (type === "dynamic-tool") return (part as {toolName?: string}).toolName || "tool" - return type.replace(/^tool-/, "") -} - /** * Approvals the run is currently blocked on. HITL only ever pauses the LAST assistant turn (see * `isHitlPending`), so we read pending tool gates off that turn — a turn can request several at @@ -52,28 +44,6 @@ export const getPendingApprovals = (messages: UIMessage[]): PendingApproval[] => return out } -/** A source label we can state factually from the tool name — not a guessed risk level. */ -const sourceLabel = (name: string): string | null => { - if (name.startsWith("mcp__")) return "MCP tool" - return null -} - -/** Chat-mode display name: raw "scary" names stay Build-only; here we humanize gateway/MCP/plain - * names (`mcp__linear__create_issue` → "Create issue" from Linear · MCP). Raw name stays reachable - * via the tooltip and the payload expander. */ -const friendlyToolName = (name: string): ParsedToolName => { - if (name.startsWith("mcp__")) { - const parts = name.split("__").filter(Boolean) - const tool = parts[parts.length - 1] - const server = parts.length >= 3 ? parts[1] : undefined - return { - label: parseGatewayToolName(tool).label, - source: server ? `${parseGatewayToolName(server).label} · MCP` : "MCP", - } - } - return parseGatewayToolName(name) -} - const formatInput = (input: unknown): string => { if (input == null) return "" // Keep the exact string — the user must approve the payload the tool actually receives. The @@ -176,8 +146,11 @@ const ApprovalDock = ({ const renderer = current && entityId && chatMode ? resolveApprovalRenderer(current.toolName) : null - const source = current ? sourceLabel(current.toolName) : null - const friendly: ParsedToolName = current ? friendlyToolName(current.toolName) : {label: ""} + // Chat-mode display name: raw "scary" names stay Build-only; the shared resolver humanizes + // gateway/MCP/plain names. Raw name stays reachable via the tooltip and the payload expander. + const friendly = current ? resolveToolDisplay(current.toolName) : null + // A source badge we can state factually from the tool name — not a guessed risk level. + const source = friendly?.kind === "mcp" ? "MCP tool" : null const respond = (approved: boolean) => { if (responding || !current) return @@ -257,9 +230,9 @@ const ApprovalDock = ({ > The agent wants to use{" "} - {friendly.label} + {friendly?.label} - {friendly.source ? ` from ${friendly.source}` : ""} before it + {friendly?.source ? ` from ${friendly.source}` : ""} before it can keep going. ) : ( diff --git a/web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx b/web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx index f629a4d6d82..58aeb83df21 100644 --- a/web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx +++ b/web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx @@ -16,6 +16,7 @@ import type {ToolUIPart} from "ai" import {Typography} from "antd" import {useAtomValue, useSetAtom} from "jotai" +import {partToolName, resolveToolDisplay, type ToolDisplay} from "../assets/toolDisplay" import {formatToolValue, stripFence} from "../assets/toolFormat" import { expandedValueAtomFamily, @@ -26,18 +27,6 @@ import { const {Text} = Typography -/** Friendly name for a tool part. `dynamic-tool` carries the name on `toolName`; the typed - * tool parts encode it as `tool-`. */ -const partToolName = (part: ToolUIPart): string => { - // `dynamic-tool` parts (name on `toolName`) reach here via the grouping cast in - // AgentMessage, but they're outside ToolUIPart's static union — read `type` as a string. - const type = part.type as string - if (type === "dynamic-tool") { - return (part as {toolName?: string}).toolName || "tool" - } - return type.replace(/^tool-/, "") -} - // A tool has finished when it produced output, errored, or was denied. Everything else // (preparing input, running, awaiting/just-answered an approval) is still in flight. const SETTLED = new Set(["output-available", "output-error", "output-denied"]) @@ -80,9 +69,15 @@ const summarizeOutput = (output: unknown): string | null => { return String(output) } -const rowSummary = (part: ToolUIPart): string | null => { +const rowSummary = (part: ToolUIPart, display?: ToolDisplay): string | null => { if (part.state === "output-available") { if (isNotHandledOutput(part.output)) return "not handled by this client" + // A registered per-tool summary wins; run it through the generic normalizer for the + // same whitespace/length clamp. Falls back to shape heuristics when it returns null. + const custom = display?.summary?.((part as {input?: unknown}).input, part.output) + if (typeof custom === "string" && custom.trim()) { + return summarizeOutput(custom) ?? summarizeOutput(part.output) + } return summarizeOutput(part.output) } if (part.state === "output-error") { @@ -147,6 +142,10 @@ const ToolRow = ({ detailed?: boolean }) => { const name = partToolName(part) + // Build keeps the raw wire name (debuggers steer by it); Chat shows the humanized label with + // the raw name on the tooltip — same split the ApprovalDock made for HITL gates. + const display = resolveToolDisplay(name) + const shownName = detailed ? name : display.label const state = part.state as string const input = (part as {input?: unknown}).input const output = (part as {output?: unknown}).output @@ -176,7 +175,7 @@ const ToolRow = ({ : notHandled ? "not handled by this client" : null - : rowSummary(part) + : rowSummary(part, display) // Track presence explicitly: a legit `null` output is real (don't hide it), and // `output-available` with no `output` key must not open an empty expander. @@ -196,8 +195,13 @@ const ToolRow = ({ <> - {name} + {shownName} + {!detailed && display.source ? ( + + {display.source} + + ) : null} {midText ? ( 0 ? Warning : CheckCircle return ( @@ -349,7 +356,7 @@ const ToolActivity = ({ weight="fill" className={`shrink-0 ${failed > 0 ? "text-colorError" : "text-colorSuccess"}`} /> - + {label} {failed > 0 ? ` · ${failed} failed` : ""} diff --git a/web/packages/agenta-entities/src/workflow/commitDiff/gatewayName.ts b/web/packages/agenta-entities/src/workflow/commitDiff/gatewayName.ts index 07c4c51c904..6f137e2790e 100644 --- a/web/packages/agenta-entities/src/workflow/commitDiff/gatewayName.ts +++ b/web/packages/agenta-entities/src/workflow/commitDiff/gatewayName.ts @@ -29,6 +29,14 @@ export function parseGatewayToolName(name: string): ParsedToolName { } } + // Generic short form: {source}__ACTION (e.g. "gmail__FETCH_EMAILS"). + if (parts.length >= 2) { + return { + label: titleCase(parts[parts.length - 1]), + source: titleCase(parts[parts.length - 2]), + } + } + // Plain function name (e.g. "gmail_search_emails") — humanize it. return {label: titleCase(name)} } diff --git a/web/packages/agenta-entities/tests/unit/agent-commit-diff.test.ts b/web/packages/agenta-entities/tests/unit/agent-commit-diff.test.ts index c86daf09e3c..9e8d155b15e 100644 --- a/web/packages/agenta-entities/tests/unit/agent-commit-diff.test.ts +++ b/web/packages/agenta-entities/tests/unit/agent-commit-diff.test.ts @@ -400,6 +400,12 @@ describe("parseGatewayToolName", () => { source: "Gmail", }) }) + it("humanizes the generic {source}__ACTION short form", () => { + expect(parseGatewayToolName("gmail__FETCH_EMAILS")).toEqual({ + label: "Fetch emails", + source: "Gmail", + }) + }) it("humanizes a plain function name", () => { expect(parseGatewayToolName("gmail_search_emails")).toEqual({label: "Gmail search emails"}) }) From 70c17f27a12fcd3843e9d52886e799a1c1507e93 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 9 Jul 2026 15:31:45 +0200 Subject: [PATCH 23/50] perf(frontend): kill the warm client-side re-entry flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop reparenting {children} on the route-committed layout flags (Layout renders ONE stable content tree for app/non-app routes; the flags now flip classNames only, not the element tree — the old fork remounted the whole page a beat after nav). Scope agent sessions from the live URL before conceding to the global scope so a mounted panel never briefly binds an empty store. Restore the playground's last selection synchronously from localStorage (and fix the dead _selectedVariantsAtom URL-sync block) so nothing waits on the revisions-list query. Skip the chat-panel crossfade overlay once its chunk is warm. --- .../AgentChatSlice/AgentChatPanelHost.tsx | 34 ++++--- .../AgentChatSlice/state/sessions.ts | 18 +++- web/oss/src/components/Layout/Layout.tsx | 88 ++++++++----------- web/oss/src/state/url/playground.ts | 86 +++++++++++++++--- 4 files changed, 148 insertions(+), 78 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanelHost.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanelHost.tsx index 43f5a31c6c7..5eafa4c208b 100644 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanelHost.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanelHost.tsx @@ -6,27 +6,39 @@ import AgentChatSkeleton from "./components/AgentChatSkeleton" // No `loading` fallback here on purpose — the skeleton below is a persistent overlay, // not a discarded placeholder, so the swap can be a crossfade instead of a replace. -const AgentChatPanel = dynamic(() => import("./AgentChatPanel"), {ssr: false}) +// Once the module has loaded ONCE, later host mounts (navigate away and back) render the +// panel synchronously — the overlay must not re-arm, or every re-entry flashes a skeleton +// over content that is already available. +let agentChatPanelModuleLoaded = false +const AgentChatPanel = dynamic( + () => + import("./AgentChatPanel").then((m) => { + agentChatPanelModuleLoaded = true + return m + }), + {ssr: false}, +) /** - * Crossfade host for the lazy agent chat panel. The skeleton stays mounted while the - * heavy chunk loads AND while the real panel commits beneath it at opacity 0; once the - * panel signals mounted, the skeleton dissolves and the panel fades in — the components - * materialize through the skeleton in place, instead of a discard → gap → sudden pop. - * The overlay never intercepts pointer events, so the panel is interactive the moment - * it exists. + * Crossfade host for the lazy agent chat panel. On the FIRST load the skeleton stays + * mounted while the heavy chunk loads AND while the real panel commits beneath it at + * opacity 0; once the panel signals mounted, the skeleton dissolves and the panel fades + * in — the components materialize through the skeleton in place, instead of a + * discard → gap → sudden pop. On later mounts (module warm) the overlay is skipped + * entirely and the panel paints in the first frame. The overlay never intercepts pointer + * events, so the panel is interactive the moment it exists. */ const AgentChatPanelHost = ({entityId}: {entityId: string}) => { - const [ready, setReady] = useState(false) + const [ready, setReady] = useState(() => agentChatPanelModuleLoaded) // Unmount the overlay only after the fade has played (timeout, not transitionend — // reduced-motion environments may never fire the event). - const [overlayGone, setOverlayGone] = useState(false) + const [overlayGone, setOverlayGone] = useState(() => agentChatPanelModuleLoaded) const onMounted = useCallback(() => setReady(true), []) useEffect(() => { - if (!ready) return + if (!ready || overlayGone) return const t = window.setTimeout(() => setOverlayGone(true), 350) return () => window.clearTimeout(t) - }, [ready]) + }, [ready, overlayGone]) return (
diff --git a/web/oss/src/components/AgentChatSlice/state/sessions.ts b/web/oss/src/components/AgentChatSlice/state/sessions.ts index 5f855189682..5debf6649d8 100644 --- a/web/oss/src/components/AgentChatSlice/state/sessions.ts +++ b/web/oss/src/components/AgentChatSlice/state/sessions.ts @@ -43,8 +43,24 @@ export const GLOBAL_APP_KEY = "__global__" * Default scope key when a surface provides no override: the current app (or `__global__` off an * app page). Kept as the bare app id (no prefix) so sessions persisted before scoping was * introduced still resolve under the same storage key. + * + * Fallback order matters: `routerAppIdAtom` derives from the app-state snapshot, which updates + * on routeChangeComplete — AFTER the destination page has rendered. During a client-side nav + * onto an app playground, a mounted chat panel would briefly scope to `__global__` (wrong/empty + * session store, stray seeded tab), then swap to the app scope when the snapshot settles — + * remounting the transcript (the warm re-entry "flash"). The live URL never lags, so parse the + * app id from it before conceding to the global scope. The non-reactive window read is safe: + * when the router atom catches up it yields the SAME id, so the scope value never swaps. */ -export const defaultScopeKeyAtom = atom((get) => get(routerAppIdAtom) || GLOBAL_APP_KEY) +export const defaultScopeKeyAtom = atom((get) => { + const routed = get(routerAppIdAtom) + if (routed) return routed + if (typeof window !== "undefined") { + const fromUrl = window.location.pathname.match(/\/apps\/([^/]+)/)?.[1] + if (fromUrl) return fromUrl + } + return GLOBAL_APP_KEY +}) // One source of truth per concern, keyed by scope key. Scoped accessors below derive a single // scope's slice (mirrors the playground's `selectedVariantsByAppAtom` pattern). diff --git a/web/oss/src/components/Layout/Layout.tsx b/web/oss/src/components/Layout/Layout.tsx index 00e358a4812..68b508109ea 100644 --- a/web/oss/src/components/Layout/Layout.tsx +++ b/web/oss/src/components/Layout/Layout.tsx @@ -310,16 +310,34 @@ const AppWithVariants = memo( appTheme={appTheme} appName={currentApp?.name ?? currentApp?.slug ?? ""} /> - {isAppRoute && !getProjectValues().projectId ? null : isAppRoute ? ( + {/* ONE stable tree for both app and non-app routes: the layout flags + (committed at routeChangeComplete, AFTER the destination page has + rendered) may flip a beat after a client-side nav — as CLASSNAME + changes only. The previous per-flag branches reparented {children}, + so that late flip unmounted and remounted the ENTIRE page (the + warm re-entry "flash"). Never fork the element tree on these flags. */} + {isAppRoute && !getProjectValues().projectId ? null : ( <> - + {isAppRoute ? : null} - {isFullHeight ? ( -
- {children} -
- ) : ( - children - )} +
+ {children} +
- ) : ( - - - -
- {children} -
-
-
-
)}
diff --git a/web/oss/src/state/url/playground.ts b/web/oss/src/state/url/playground.ts index 576a9582240..bf992fb315b 100644 --- a/web/oss/src/state/url/playground.ts +++ b/web/oss/src/state/url/playground.ts @@ -140,17 +140,44 @@ const getLastWrittenSnapshotHash = () => _store().get(_lastWrittenSnapshotHashAt const setLastWrittenSnapshotHash = (v: string | null) => _store().set(_lastWrittenSnapshotHashAtom, v) -/** - * Track the current selection in memory to survive HMR. - * Replaces the old OSS selectedVariantsAtom bridge. - */ -const _selectedVariantsAtom = atom([]) - /** * Track the last processed URL revisions to prevent loops. */ const _urlRevisionsAtom = atom([]) +// --------------------------------------------------------------------------- +// PERSISTED LAST SELECTION (per app) +// --------------------------------------------------------------------------- +// Restores the previous selection SYNCHRONOUSLY on entry. A client-side nav back to +// the playground carries a bare URL, and without a restore nothing is selected until +// the app's revisions LIST query resolves — at which point the default selection, the +// config panel, and the chat pane all mount in one long-task burst (the "flash"). +// Stale ids are safe: the validation sub (SUB 4) corrects them once queries settle. +const PERSISTED_SELECTION_KEY = "agenta:playground:last-selection" + +const readPersistedSelection = (appId: string): string[] => { + if (!isBrowser) return [] + try { + const raw = window.localStorage.getItem(PERSISTED_SELECTION_KEY) + const map = raw ? (JSON.parse(raw) as Record) : {} + return sanitizeRevisionList(Array.isArray(map[appId]) ? map[appId] : []) + } catch { + return [] + } +} + +const writePersistedSelection = (appId: string, ids: string[]) => { + if (!isBrowser) return + try { + const raw = window.localStorage.getItem(PERSISTED_SELECTION_KEY) + const map = raw ? (JSON.parse(raw) as Record) : {} + map[appId] = ids + window.localStorage.setItem(PERSISTED_SELECTION_KEY, JSON.stringify(map)) + } catch { + // Quota/serialization failures are non-fatal — the next entry just waits for defaults. + } +} + const sanitizeRevisionList = (values: (string | null | undefined)[]) => { const seen = new Set() const result: string[] = [] @@ -593,6 +620,14 @@ export const ensurePlaygroundDefaults = (store: Store): boolean => { return true // Mark as "applied" so we don't keep retrying } + // Synchronous restore of this app's last selection — no waiting on the revisions + // list query. Stale ids self-correct via the validation sub once queries settle. + const persisted = readPersistedSelection(appId) + if (persisted.length > 0) { + applyPlaygroundSelection(store, persisted) + return true + } + const revisions = store.get(workflowRevisionsByWorkflowListDataAtomFamily(appId)) const latest = revisions[0] if (latest) { @@ -989,15 +1024,18 @@ playgroundSyncAtom.onMount = (set) => { store.set(playgroundInitializedAtom, true) } } else { + // Deferred by a microtask: these subs fire inside TanStack query notifications, + // i.e. mid atom-read — applying the selection there mutates the store during a + // read (jotai's "Detected store mutation during atom read"). currentRevReadyUnsub = store.sub(playgroundController.selectors.revisionsReady(), () => - tryApplyDefaults(), + queueMicrotask(tryApplyDefaults), ) // Subscribe to entity data so we retry when it finishes loading. // Only needed when no URL selection exists and we must find a default. if (currentAppId) { currentLatestRevUnsub = store.sub( workflowRevisionsByWorkflowListDataAtomFamily(currentAppId), - () => tryApplyDefaults(), + () => queueMicrotask(tryApplyDefaults), ) } // Immediate check in case already ready @@ -1049,6 +1087,19 @@ playgroundSyncAtom.onMount = (set) => { }) unsubs.push(unsubValidation) + // ----------------------------------------------------------------------- + // SUB 4b: Persist the selection per app + // ----------------------------------------------------------------------- + // Written on every selection change so the NEXT entry restores it synchronously + // (see readPersistedSelection in ensurePlaygroundDefaults). + const unsubPersistSelection = store.sub(playgroundController.selectors.entityIds(), () => { + const appId = store.get(routerAppIdAtom) as string | null + if (!appId) return + const ids = sanitizeRevisionList(store.get(playgroundController.selectors.entityIds())) + if (ids.length > 0) writePersistedSelection(appId, ids) + }) + unsubs.push(unsubPersistSelection) + // ----------------------------------------------------------------------- // SUB 5: Update URL when testset connection changes // ----------------------------------------------------------------------- @@ -1289,14 +1340,21 @@ playgroundSyncAtom.onMount = (set) => { // ----------------------------------------------------------------------- // INITIAL URL SYNC: ensure URL reflects in-memory selection // ----------------------------------------------------------------------- - // When navigating away from the playground, urlRevisionsAtom is cleared but - // selectedVariantsAtom persists in memory. On return, the URL has no - // ?revisions param even though a selection exists. Write synchronously + // On client-side return to the playground, the in-memory selection (controller + // nodes) persists but the URL has no ?revisions param. Write synchronously // (bypassing RAF) so a subsequent RAF-based call cannot cancel this write. + // NOTE: this previously read `_selectedVariantsAtom`, which nothing ever wrote + // — the block was dead and the URL never regained ?revisions on re-entry. { - const initialSelection = sanitizeRevisionList(store.get(_selectedVariantsAtom)) - const initialUrlRevisions = sanitizeRevisionList(store.get(_urlRevisionsAtom)) - if (initialSelection.length > 0 && initialUrlRevisions.length === 0) { + const initialSelection = sanitizeRevisionList( + store.get(playgroundController.selectors.entityIds()), + ) + const currentUrlRevisions = sanitizeRevisionList( + (new URL(window.location.href).searchParams.get(REVISIONS_QUERY_PARAM) ?? "") + .split(",") + .filter(Boolean), + ) + if (initialSelection.length > 0 && currentUrlRevisions.length === 0) { store.set(_urlRevisionsAtom, initialSelection) writeUrlNow(initialSelection) } From 438d1c4b2252e29d3f83bb2e5e80944d3b04a900 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 9 Jul 2026 15:31:51 +0200 Subject: [PATCH 24/50] perf(frontend): idle-warm the code-split agent-template control Export preloadAgentTemplateControl from @agenta/entity-ui and call it from AgentCatalogPrefetcher during requestIdleCallback, so the chunk's download + execution doesn't land in the same main-thread burst as the revision/schema resolving right as the panels paint. --- .../Components/AgentCatalogPrefetcher.tsx | 15 +++++++++++++++ .../SchemaControls/SchemaPropertyRenderer.tsx | 5 +++++ .../agenta-entity-ui/src/DrillInView/index.ts | 2 ++ web/packages/agenta-entity-ui/src/index.ts | 1 + 4 files changed, 23 insertions(+) diff --git a/web/oss/src/components/Playground/Components/AgentCatalogPrefetcher.tsx b/web/oss/src/components/Playground/Components/AgentCatalogPrefetcher.tsx index f68109d7266..07b0eea72ef 100644 --- a/web/oss/src/components/Playground/Components/AgentCatalogPrefetcher.tsx +++ b/web/oss/src/components/Playground/Components/AgentCatalogPrefetcher.tsx @@ -1,4 +1,7 @@ +import {useEffect} from "react" + import {agTypeSchemaAtomFamily, harnessCapabilitiesAtomFamily} from "@agenta/entities/workflow" +import {preloadAgentTemplateControl} from "@agenta/entity-ui" import {useAtomValue} from "jotai" /** @@ -14,6 +17,18 @@ import {useAtomValue} from "jotai" const AgentCatalogPrefetcher = () => { useAtomValue(agTypeSchemaAtomFamily("agent-template")) useAtomValue(harnessCapabilitiesAtomFamily("")) + // Warm the code-split agent-template control during idle time, so its download + + // execution doesn't land in the same main-thread burst as the revision/schema + // resolving (which froze the paint right as the panels transitioned to content). + useEffect(() => { + const idle = (window as Window & typeof globalThis).requestIdleCallback + if (typeof idle === "function") { + const id = idle(() => void preloadAgentTemplateControl(), {timeout: 2000}) + return () => window.cancelIdleCallback?.(id) + } + const t = window.setTimeout(() => void preloadAgentTemplateControl(), 300) + return () => window.clearTimeout(t) + }, []) return null } diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SchemaPropertyRenderer.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SchemaPropertyRenderer.tsx index 659c0348b78..434fbecd951 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SchemaPropertyRenderer.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SchemaPropertyRenderer.tsx @@ -40,6 +40,11 @@ const AgentTemplateControl = lazy(() => import("./AgentTemplateControl").then((m) => ({default: m.AgentTemplateControl})), ) +/** Warm the agent-template chunk during idle time (e.g. as soon as the playground knows the + * app is an agent), so its download + execution doesn't coincide with the revision/schema + * resolving — that combination is a main-thread burst right as the panel paints. */ +export const preloadAgentTemplateControl = () => import("./AgentTemplateControl") + export interface SchemaPropertyRendererProps { /** The schema property defining the field */ schema: SchemaProperty | null | undefined diff --git a/web/packages/agenta-entity-ui/src/DrillInView/index.ts b/web/packages/agenta-entity-ui/src/DrillInView/index.ts index 3232459f1c0..bc5011a313b 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/index.ts +++ b/web/packages/agenta-entity-ui/src/DrillInView/index.ts @@ -48,6 +48,8 @@ export { // gate (PlaygroundVariantConfig's loadingFallback) and the lazy AgentTemplateControl's // Suspense fallback, so both gates render the identical frame. export {default as AgentConfigSkeleton} from "./SchemaControls/agentTemplate/AgentConfigSkeleton" +// Idle warm-up for the code-split agent-template control chunk. +export {preloadAgentTemplateControl} from "./SchemaControls/SchemaPropertyRenderer" export type { MoleculeDrillInProviderProps, PlaygroundConfigSectionProps, diff --git a/web/packages/agenta-entity-ui/src/index.ts b/web/packages/agenta-entity-ui/src/index.ts index c89fcf3b544..d8d2ff08873 100644 --- a/web/packages/agenta-entity-ui/src/index.ts +++ b/web/packages/agenta-entity-ui/src/index.ts @@ -52,6 +52,7 @@ export { MoleculeDrillInProvider, PlaygroundConfigSection, AgentConfigSkeleton, + preloadAgentTemplateControl, useDrillIn, type PlaygroundConfigSectionProps, type ConfigViewMode, From 8fe701d20987236e11baaf7a8ca21d22ea48851c Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 9 Jul 2026 15:32:01 +0200 Subject: [PATCH 25/50] feat(frontend): persist chat session state across pane remounts; resizable session rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-session in-memory stores survive route re-entry / tab close-reopen: composer drafts (seeded via RichChatInput initialMarkdown), pending attachments, held message queue, and the Virtuoso scroll snapshot (restoreStateFrom paints true row geometry in the first frame instead of the estimate → measure reshuffle). The composer entrance Reveal now plays once per panel mount (enabled prop) rather than on every session switch. Rail becomes a resizable antd Splitter matching the config pane; globals.css splitter rules are scoped to direct children so nested agent-mode splitters don't clobber each other, and the config splitter's animate-hold effect no longer cancels its own timer. --- .../AgentChatSlice/AgentChatPanel.tsx | 309 ++++++++++++++---- .../AgentChatSlice/hooks/useAgentChatQueue.ts | 18 +- .../Components/MainLayout/index.tsx | 12 +- .../PlaygroundOnboarding/Reveal.tsx | 15 +- web/oss/src/styles/globals.css | 23 +- .../src/RichChatInput/RichChatInput.tsx | 16 + 6 files changed, 308 insertions(+), 85 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx index 43c6b2c3174..90aa2ce30da 100644 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx @@ -7,6 +7,7 @@ import { useMemo, useRef, useState, + type MutableRefObject, } from "react" import {markTraceAsFresh} from "@agenta/entities/trace" @@ -33,10 +34,12 @@ import { UploadSimple, } from "@phosphor-icons/react" import {type UIMessage} from "ai" -import {App, Button, Modal, Tabs, Tag, Tooltip} from "antd" +import {App, Button, Modal, Splitter, Tabs, Tag, Tooltip} from "antd" import type {UploadFile} from "antd" +import clsx from "clsx" import {useAtom, useAtomValue, useSetAtom, useStore} from "jotai" -import {Virtuoso, type Components, type VirtuosoHandle} from "react-virtuoso" +import {useRouter} from "next/router" +import {Virtuoso, type Components, type StateSnapshot, type VirtuosoHandle} from "react-virtuoso" import { IDE_INSTALL_COMMAND, @@ -124,6 +127,21 @@ const SessionRail = lazy(() => import("./components/SessionRail")) * Next.js dev Runtime Error overlay (F-033). */ const ignoreStreamRejection = () => {} +// Virtuoso state (measured row heights + scrollTop) per session, captured before a route +// change unmounts the transcript. A fresh Virtuoso mount otherwise renders with height +// ESTIMATES, measures the real rows async, then corrects — a visible reshuffle on every +// re-entry (rows here span 85–1022px, so the correction is large). Restoring the snapshot +// paints the transcript at its true geometry and scroll position in the first frame. +const virtStateBySession = new Map() + +// Unsent composer drafts per session — survive pane remounts (route re-entry, tab +// close/reopen), so switching back to a session restores its in-progress message. +const composerDraftBySession = new Map() + +// Pending (not yet sent) attachments per session — same lifetime as the drafts. In-memory +// only: `UploadFile.originFileObj` holds live File blobs, which can't be serialized anyway. +const attachmentsBySession = new Map() + /** Height of the top-edge fade, in px. Shared by the CSS mask and the SC-1 pin so a pinned turn * lands BELOW the fade (otherwise the freshly-asked question renders partially faded). */ const TOP_FADE_PX = 28 @@ -143,9 +161,13 @@ const CHAT_COLUMN = "mx-auto w-full max-w-[880px]" * while off neither the styling nor the measurement runs. Typed `boolean` so the guards aren't * flagged as always-false. Under Virtuoso it must stay off regardless (it corrupts item measurement). */ const CONTENT_VISIBILITY_ENABLED = false as boolean -/** Full-screen session rail width. The rail slides between this and 0 (rather than mounting) so the - * Build/Chat transition stays cohesive with the config pane's animated collapse. */ -const RAIL_WIDTH = 248 +/** Chat-mode session rail: default/min/max widths of its resizable splitter pane. The pane + * collapses to 0 in build mode (rather than unmounting) so the Build/Chat toggle animates in + * lockstep with the config pane. Min also pins the rail's content width, so collapsing clips + * instead of squishing. */ +const RAIL_WIDTH = 300 +const RAIL_MIN_WIDTH = 240 +const RAIL_MAX_WIDTH = 480 /** * One agent conversation for a single session tab. A `useChat` whose transport is fed by the @@ -321,7 +343,16 @@ const WorkingDots = () => ( ) -const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: string}) => { +const AgentConversation = ({ + entityId, + sessionId, + revealPlayedRef, +}: { + entityId: string + sessionId: string + /** Shared across the panel's session panes: the composer entrance plays only once. */ + revealPlayedRef: MutableRefObject +}) => { const store = useStore() const persistMessages = useSetAtom(persistSessionMessagesAtom) const stampMessagesCreatedAt = useSetAtom(stampMessagesCreatedAtAtom) @@ -337,7 +368,15 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: if (!buildMode && inspectorOpen) openTurnInspector(null) }, [buildMode, inspectorOpen, openTurnInspector]) - const [files, setFiles] = useState([]) + // Restored from the per-session store on remount (route re-entry, tab close/reopen) — + // pending attachments survive alongside the composer draft. Rejections stay transient. + const [files, setFiles] = useState( + () => attachmentsBySession.get(sessionId) ?? [], + ) + useEffect(() => { + if (files.length > 0) attachmentsBySession.set(sessionId, files) + else attachmentsBySession.delete(sessionId) + }, [files, sessionId]) // Files turned away by the guardrails (too big, wrong type, over the count), shown inline. const [rejections, setRejections] = useState([]) const [attachmentsOpen, setAttachmentsOpen] = useState(false) @@ -366,6 +405,44 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: const [modal, modalContextHolder] = Modal.useModal() const richInputRef = useRef(null) + + // Composer entrance plays once per PANEL mount — additional session panes mount the + // composer fully shown (the replayed fade read as a "composer reload" on session switch). + // Frozen at mount: recomputing per render would flip Reveal's `enabled` mid-entrance + // (the latch effect below runs before the fade completes). + const [playComposerEntrance] = useState(() => !revealPlayedRef.current) + useEffect(() => { + revealPlayedRef.current = true + }, [revealPlayedRef]) + + // Per-session unsent draft: restore once at mount (initialMarkdown is mount-only) and + // capture edits debounced — markdown is read from the handle at capture time, not per + // keystroke (serialization isn't free). + const [initialDraft] = useState(() => composerDraftBySession.get(sessionId)) + const draftTimerRef = useRef(0) + const handleComposerChange = useCallback( + (text: string) => { + window.clearTimeout(draftTimerRef.current) + draftTimerRef.current = window.setTimeout(() => { + const md = richInputRef.current?.getMarkdown() ?? text + if (md.trim()) composerDraftBySession.set(sessionId, md) + else composerDraftBySession.delete(sessionId) + }, 400) + }, + [sessionId], + ) + useEffect( + () => () => { + window.clearTimeout(draftTimerRef.current) + // Best-effort final capture on unmount (guarded — the editor may be detached). + const md = richInputRef.current?.getMarkdown() + if (md !== undefined) { + if (md.trim()) composerDraftBySession.set(sessionId, md) + else composerDraftBySession.delete(sessionId) + } + }, + [sessionId], + ) const scrollRef = useRef(null) // ── SPIKE(react-virtuoso): windowing variant, evaluated against content-visibility. ── // Controlled live from the playground settings dropdown (Virtualization section). When on, the @@ -377,6 +454,29 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: const virtOverscan = useAtomValue(agentChatOverscanAtom) const virtItemEstimate = useAtomValue(agentChatItemEstimateAtom) const virtuosoRef = useRef(null) + // Snapshot captured by a previous mount of this session (route re-entry). Read once at + // mount — `restoreStateFrom` is a mount-time-only Virtuoso prop. + const [virtRestoreState] = useState(() => + useVirtuoso ? virtStateBySession.get(sessionId) : undefined, + ) + const router = useRouter() + useEffect(() => { + if (!useVirtuoso) return + const capture = () => { + // getState is synchronous; guard the handle for the unmount-cleanup path. + virtuosoRef.current?.getState((snapshot) => { + virtStateBySession.set(sessionId, snapshot) + }) + } + // routeChangeStart fires while the transcript is still mounted and measured — the + // reliable capture point. The cleanup capture is best-effort (the handle may already + // be detached there), covering non-route unmounts like a revision-type swap. + router.events.on("routeChangeStart", capture) + return () => { + router.events.off("routeChangeStart", capture) + capture() + } + }, [useVirtuoso, sessionId, router]) // Stick to the bottom of the scrollable area. This is the ONE source of truth for auto-scroll: // the active turn reserves a viewport (min-h-full), so "bottom" puts the latest question at the // top with the answer streaming into the space below — the pin is emergent, not computed. A real @@ -767,6 +867,7 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: messages, stopped, sendQueued, + sessionId, }) // Latch the last non-empty queue so the row keeps its content while it animates closed on release. const shownQueuedRef = useRef(queued) @@ -1096,6 +1197,7 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: if (CONTENT_VISIBILITY_ENABLED) { for (const e of entries) { const node = e.target as HTMLElement + if (node === el) continue // the viewport itself is not a row — never pin it const check = node.checkVisibility as | ((o?: {contentVisibilityAuto?: boolean}) => boolean) | undefined @@ -1135,6 +1237,10 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: } } const ro = new ResizeObserver(onResize) + // Observe the VIEWPORT too: the lazy composer/session-bar regions outside it hydrate a + // beat after mount and change this element's clientHeight — rows alone don't resize then, + // so without this the clamp shifts the view (following → re-pin; reading → hold anchor). + ro.observe(el) el.querySelectorAll("[data-mid]").forEach((w) => ro.observe(w)) return () => ro.disconnect() }, [messages.length, scrollToBottom, useVirtuoso]) @@ -1345,6 +1451,9 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: setStopped(false) // One path: `submit` sends now or queues behind held messages via the shared release gate. submit({text: trimmed, fileParts}) + // The message left the composer — drop its persisted draft (and any pending capture). + window.clearTimeout(draftTimerRef.current) + composerDraftBySession.delete(sessionId) // Sending consumes the template provenance along with the composer text. if (TEMPLATE_STRIP_MODE) stripProvenance.clear() setFiles([]) @@ -1572,10 +1681,17 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: bottom: Math.round(virtOverscan * 0.66), }} defaultItemHeight={virtItemEstimate} - initialTopMostItemIndex={{ - index: Math.max(0, activeStart - 1), - align: "end", - }} + // A prior mount's snapshot restores true row heights + scroll in the + // first frame; only a genuinely first visit anchors by index (the two + // props conflict, so exactly one is passed). + {...(virtRestoreState + ? {restoreStateFrom: virtRestoreState} + : { + initialTopMostItemIndex: { + index: Math.max(0, activeStart - 1), + align: "end" as const, + }, + })} computeItemKey={(_i, m) => m.id} itemContent={(index, m) => (
{renderMessage(m, index)}
@@ -1589,7 +1705,8 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: footer: activeStart < messages.length ? (
+ {/* Agent empty-chat strip (S6): docked above the composer, unmounts once a message exists or a first-run prompt is pending. Build-mode + fresh-agent only — never in maximized chat mode, and gone for good after any commit. */} @@ -1798,6 +1916,8 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: ? "Connect a model to start chatting…" : "Ask the agent… (Enter to send, ⌘/Ctrl+Enter for newline)" } + initialMarkdown={initialDraft} + onChange={handleComposerChange} onPasteFile={(pasted) => addFiles(Array.from(pasted))} sendForceEnabled={files.length > 0} streaming={busy} @@ -1925,6 +2045,9 @@ const AgentChatPanel = ({ const renameSession = useSetAtom(renameSessionAtomFamily(scope)) const setActiveSession = useSetAtom(setActiveSessionAtomFamily(scope)) const chatMaximized = useAtomValue(chatPanelMaximizedAtom) + // Shared entrance latch: the composer's Reveal plays for the first conversation this + // panel mounts; every additional session pane skips it (no per-switch flash). + const composerRevealPlayedRef = useRef(false) // Always keep at least one tab. Re-arms when the list drains without double-firing // under StrictMode. @@ -1953,67 +2076,109 @@ const AgentChatPanel = ({ setPendingRun({text: pendingRun.text, nonce: pendingRun.nonce}) }, [pendingRun, addSession, setPendingRun]) + // Same render-time toggle detection as MainLayout's config pane: the `-animated` class must land + // in the SAME commit as the size flip (else it snaps), then held ~280ms; off during drag/resize. + const prevMaximizedRef = useRef(chatMaximized) + const [holdAnimate, setHoldAnimate] = useState(false) + const justToggled = prevMaximizedRef.current !== chatMaximized + // Deps = toggle value ONLY: with `justToggled` in deps, the holdAnimate re-render re-ran the + // effect and its cleanup cancelled the timer — the class stuck on and every drag lagged. + useEffect(() => { + if (prevMaximizedRef.current === chatMaximized) return + prevMaximizedRef.current = chatMaximized + setHoldAnimate(true) + const t = setTimeout(() => setHoldAnimate(false), 280) + return () => clearTimeout(t) + }, [chatMaximized]) + const animateRailSplit = justToggled || holdAnimate + return ( -
- {/* Rail stays mounted and slides (width 0↔RAIL_WIDTH) so it animates in lockstep with the - config pane instead of snapping. Clipped while collapsed; `inert` drops it from tab - order + a11y when hidden. */} - - ( - // Kept mounted in ALL states so its height ANIMATES on transitions rather than the node - // mounting at full height (which snapped the content down). Collapsed to 0 in chat mode - // (controls live in the SessionRail) AND during onboarding (single ephemeral session); - // expands to 48 when the committed build view takes over — same eased height transition - // as the rail/config panes. -
- {/* Region fallback = the same bar skeleton the pane-level gates render, + {/* `inert` drops the clipped rail from tab order + a11y while collapsed. */} +
+ {/* Rail pane is width-0 unless maximized, so no visible fallback is needed. */} + + {/* min-w matches RAIL_MIN_WIDTH (Tailwind needs the literal). */} + + +
+ + + ( + // Kept mounted in ALL states so its height ANIMATES on transitions rather than the node + // mounting at full height (which snapped the content down). Collapsed to 0 in chat mode + // (controls live in the SessionRail) AND during onboarding (single ephemeral session); + // expands to 48 when the committed build view takes over — same eased height transition + // as the rail/config panes. +
+ {/* Region fallback = the same bar skeleton the pane-level gates render, so the strip's lane holds its shape while this chunk loads. */} - }> - renameSession({id, title})} - showSessions={!chatMaximized} - extra={ - chatMaximized ? undefined : ( - <> - - - - ) - } + }> + renameSession({id, title})} + showSessions={!chatMaximized} + extra={ + chatMaximized ? undefined : ( + <> + + + + ) + } + /> + +
+ )} + items={sessions.map((session) => ({ + key: session.id, + // Bar is rendered by `renderTabBar` (SessionTagBar); the per-item label is unused. + label: null, + children: ( + - -
- )} - items={sessions.map((session) => ({ - key: session.id, - // Bar is rendered by `renderTabBar` (SessionTagBar); the per-item label is unused. - label: null, - children: , - }))} - /> -
+ ), + }))} + /> + + ) } diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatQueue.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatQueue.ts index 8f611130946..c24ceff244b 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatQueue.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatQueue.ts @@ -20,8 +20,14 @@ interface UseAgentChatQueueArgs { /** Send one released message into the conversation (wraps `useChat`'s `sendMessage`). Must be * referentially stable so the release effect doesn't churn on every streamed token. */ sendQueued: (item: QueuedMessage) => void + /** Persist held messages under this key across pane remounts (route re-entry, tab + * close/reopen) — a restored queue releases normally once the conversation settles. */ + sessionId?: string } +// In-memory, page-session lifetime — same as the composer drafts it accompanies. +const queuedBySession = new Map() + /** * Holds user messages typed while a turn is in flight and releases them ONE AT A TIME once the * stream truly settles. It never releases mid human-in-the-loop (a tool-approval gate) — that @@ -38,8 +44,18 @@ export const useAgentChatQueue = ({ messages, stopped, sendQueued, + sessionId, }: UseAgentChatQueueArgs) => { - const [queued, setQueued] = useState([]) + const [queued, setQueued] = useState( + () => (sessionId && queuedBySession.get(sessionId)) || [], + ) + + // Mirror every queue change into the per-session store so a remount restores it. + useEffect(() => { + if (!sessionId) return + if (queued.length > 0) queuedBySession.set(sessionId, queued) + else queuedBySession.delete(sessionId) + }, [queued, sessionId]) // Settled = the stream is over (done or failed). A stop lands here (abort → "ready"). const settled = status === "ready" || status === "error" diff --git a/web/oss/src/components/Playground/Components/MainLayout/index.tsx b/web/oss/src/components/Playground/Components/MainLayout/index.tsx index f0e31dbeece..5d69072f1b8 100644 --- a/web/oss/src/components/Playground/Components/MainLayout/index.tsx +++ b/web/oss/src/components/Playground/Components/MainLayout/index.tsx @@ -211,8 +211,12 @@ const PlaygroundMainView = ({ const earlyIsAgent = useAtomValue(playgroundEarlyAgentStateAtom) === "agent" const isAgentConfig = useAtomValue(isAgentModeAtomFamily(primaryConfigId)) || (!isComparisonView && earlyIsAgent) + // Agent max = default on purpose: the summary panel mounts at its cap, so the drag handle only + // shrinks it. (A larger max just teased a few px of "expansion" — antd counts px sizes against + // the full container INCLUDING the 12px gutter bar, whose overflow flex-shrink taxes both + // panels, so the panel never even reached the old 450.) const configDefaultSize = isAgentConfig ? 440 : "50%" - const configMaxSize = isAgentConfig ? 450 : "70%" + const configMaxSize = isAgentConfig ? 440 : "70%" // Let the runs panel auto-fill in agent mode. A px config default + a "50%" runs default // don't sum to 100%, so antd scales BOTH up to fill the container — pushing config past its // px max on mount, which then snaps down on the first drag. An undefined runs default fills @@ -235,13 +239,15 @@ const PlaygroundMainView = ({ const prevMaximizedRef = useRef(chatMaximized) const [holdAnimate, setHoldAnimate] = useState(false) const justToggled = prevMaximizedRef.current !== chatMaximized + // Deps = toggle value ONLY: with `justToggled` in deps, the holdAnimate re-render re-ran the + // effect and its cleanup cancelled the timer — the class stuck on and every drag lagged. useEffect(() => { - if (!justToggled) return + if (prevMaximizedRef.current === chatMaximized) return prevMaximizedRef.current = chatMaximized setHoldAnimate(true) const t = setTimeout(() => setHoldAnimate(false), 280) return () => clearTimeout(t) - }, [justToggled, chatMaximized]) + }, [chatMaximized]) const animateSplit = justToggled || holdAnimate const variantRefs = useRef<(HTMLDivElement | null)[]>([]) diff --git a/web/oss/src/components/pages/agent-home/PlaygroundOnboarding/Reveal.tsx b/web/oss/src/components/pages/agent-home/PlaygroundOnboarding/Reveal.tsx index 5281bc33857..d19a9b14499 100644 --- a/web/oss/src/components/pages/agent-home/PlaygroundOnboarding/Reveal.tsx +++ b/web/oss/src/components/pages/agent-home/PlaygroundOnboarding/Reveal.tsx @@ -9,16 +9,27 @@ const Reveal = ({ children, className = "", delay = 0, + enabled = true, }: { children: ReactNode className?: string delay?: number + /** When false, render fully shown with no entrance — for repeat mounts that already + * played it once (e.g. each additional chat session pane's composer). */ + enabled?: boolean }) => { - const [shown, setShown] = useState(false) + const [shown, setShown] = useState(!enabled) useEffect(() => { + // `enabled` flipping false MUST still land on shown — if it flips while the entrance + // timeout is pending, the cleanup cancels it and an early return would strand the + // content at opacity-0 permanently. + if (!enabled) { + setShown(true) + return + } const id = window.setTimeout(() => setShown(true), delay) return () => window.clearTimeout(id) - }, [delay]) + }, [delay, enabled]) return (
.ant-splitter-bar { background: var(--ag-surface-gutter) !important; flex-basis: 12px !important; width: 12px !important; @@ -396,21 +396,30 @@ body { inert split bar instead of snapping. `-animated` is added by MainLayout only around a toggle (off during drag, so resizing stays 1:1). Gated on `prefers-reduced-motion` for parity with the rail's `motion-safe:` animations. */ +/* Splitters NEST in agent mode (the chat panel hosts its own rail splitter inside MainLayout's), + so every state-dependent bar/panel rule below is scoped to DIRECT children — a collapsed outer + splitter must not hide the inner one's live drag bar. */ @media (prefers-reduced-motion: no-preference) { - .playground-splitter-animated .ant-splitter-panel { + .playground-splitter-animated > .ant-splitter-panel { transition: flex-basis 240ms cubic-bezier(0.4, 0, 0.2, 1); } - .playground-splitter .ant-splitter-bar { - transition: opacity 200ms ease; + .playground-splitter > .ant-splitter-bar { + transition: + opacity 200ms ease, + flex-basis 240ms cubic-bezier(0.4, 0, 0.2, 1), + width 240ms cubic-bezier(0.4, 0, 0.2, 1); } } -/* Full-screen chat: hide the (now inert) split bar. End state stays unguarded so the bar is hidden - even under reduced motion — only the fade above is motion-gated. */ -.playground-splitter-collapsed .ant-splitter-bar { +/* Collapsed pane's split bar: hidden AND width 0 — opacity alone leaves an invisible 12px strip of + dead space beside the surviving pane. End state stays unguarded so the bar is gone even under + reduced motion — only the fades above are motion-gated. */ +.playground-splitter-collapsed > .ant-splitter-bar { opacity: 0; pointer-events: none; + flex-basis: 0 !important; + width: 0 !important; } /* Ensure onboarding overlay stays above modals */ diff --git a/web/packages/agenta-ui/src/RichChatInput/RichChatInput.tsx b/web/packages/agenta-ui/src/RichChatInput/RichChatInput.tsx index ce6b673caa2..97c02b024ba 100644 --- a/web/packages/agenta-ui/src/RichChatInput/RichChatInput.tsx +++ b/web/packages/agenta-ui/src/RichChatInput/RichChatInput.tsx @@ -74,6 +74,8 @@ export interface RichChatInputProps { submitOnEnter?: boolean /** Reports the current plain text on every edit (e.g. to detect the composer going empty). */ onChange?: (text: string) => void + /** Seed the editor once on mount (e.g. a restored per-session draft). Later changes ignored. */ + initialMarkdown?: string } // Static: RichText gives Cmd+B/I + block behavior, History gives undo/redo, list @@ -120,6 +122,7 @@ export const RichChatInput = forwardRef hideShortcutHints = false, submitOnEnter = true, onChange, + initialMarkdown, }, ref, ) { @@ -133,6 +136,19 @@ export const RichChatInput = forwardRef } }, []) + // Seed once at mount. EditorRefBridge (a child) binds the editor in its own effect, + // which runs before this one, so the ref is live here. Mount-only by design — the + // ref freezes the first value so a re-render can't re-apply it over user edits. + const initialMarkdownRef = useRef(initialMarkdown) + useEffect(() => { + const md = initialMarkdownRef.current + if (!md?.trim()) return + editorRef.current?.update(() => { + $convertFromMarkdownString(md, CHAT_TRANSFORMERS) + $getRoot().selectEnd() + }) + }, []) + useImperativeHandle( ref, () => ({ From 0444f36a1c4ab20547a892d879876107705c458c Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 9 Jul 2026 15:32:07 +0200 Subject: [PATCH 26/50] polish(frontend): agent chat + revision UI details Hide the rewind action on the last turn (re-running the current turn is a no-op). Restyle the session-tab rename input as a quiet inline editor (borderless, select-all on focus, Escape to cancel). Show the commit message on the revision vN badge via tooltip. Tighten the provider-group header spacing/logo so its + button lines up with the section header's. --- .../components/AgentMessage.tsx | 6 +++-- .../components/SessionTabLabel.tsx | 15 +++++++++-- .../AgentRevisionSelector/index.tsx | 25 ++++++++++++++++--- .../SchemaControls/sectionGroups.tsx | 6 +++-- 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx index 89b5fe26cda..87c300c767f 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx @@ -561,6 +561,8 @@ const AgentMessage = ({ icon: , onItemClick: () => onRewind(message), } + // Rewinding the LAST turn just re-runs the turn that's already current — redundant, so hide it. + const rewindItems = isLastMessage ? [] : [rewindAction] // Restored turns have no first-seen stamp (a reload isn't their send time), so until their // trace time arrives the slot holds a placeholder — never a wrong "just now". Settled with no @@ -574,7 +576,7 @@ const AgentMessage = ({ const toolbar = isUser ? ( <> {timestamp} - + {rewindItems.length > 0 && } ) : ( <> @@ -596,7 +598,7 @@ const AgentMessage = ({ icon: copied ? : , onItemClick: handleCopy, }, - rewindAction, + ...rewindItems, ...(traceId ? [ { diff --git a/web/oss/src/components/AgentChatSlice/components/SessionTabLabel.tsx b/web/oss/src/components/AgentChatSlice/components/SessionTabLabel.tsx index 59e5a22897a..8887639ccd6 100644 --- a/web/oss/src/components/AgentChatSlice/components/SessionTabLabel.tsx +++ b/web/oss/src/components/AgentChatSlice/components/SessionTabLabel.tsx @@ -29,14 +29,25 @@ const SessionTabLabel = ({ return ( setDraft(e.target.value)} onPressEnter={commit} onBlur={commit} + onFocus={(e) => e.target.select()} onClick={(e) => e.stopPropagation()} // Keep typing (Space/Enter) inside the rename input; don't let it reach the tab. - onKeyDown={(e) => e.stopPropagation()} - className="!h-6 !w-28 !px-1 !text-xs" + onKeyDown={(e) => { + if (e.key === "Escape") { + setDraft(label) + setEditing(false) + } + e.stopPropagation() + }} + // Quiet in-place editor sized to the label it replaces: fills the row (no fixed + // width overflowing the chip), same 12px type, subtle inset well instead of + // antd's bordered box + primary focus ring. + className="!h-5 !w-full !min-w-0 flex-1 !rounded !border !border-solid !border-[var(--ag-surface-inset-border)] !bg-[var(--ag-surface-inset)] !px-1 !py-0 !text-xs !text-colorText !shadow-none" /> ) } diff --git a/web/oss/src/components/Playground/Components/AgentRevisionSelector/index.tsx b/web/oss/src/components/Playground/Components/AgentRevisionSelector/index.tsx index ef13a9c7312..074d242e331 100644 --- a/web/oss/src/components/Playground/Components/AgentRevisionSelector/index.tsx +++ b/web/oss/src/components/Playground/Components/AgentRevisionSelector/index.tsx @@ -29,6 +29,7 @@ const AgentRevisionSelector = ({variantId}: {variantId: string}) => { const _variantId = runnableData?.id ?? null const variantRevision = (runnableData?.version as number | null) ?? null + const commitMessage = runnableData?.message?.trim() || null const hasChanges = isDirty // App browse picker (project-scoped only) — skip-variant, non-evaluator. @@ -64,9 +65,27 @@ const AgentRevisionSelector = ({variantId}: {variantId: string}) => { borderlessTrigger /> {variantRevision !== null && variantRevision !== undefined && ( - - v{variantRevision} - + + + Commit message + +
+ {commitMessage} +
+
+ ) : ( + No commit message + ) + } + > + + v{variantRevision} + + )} diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/sectionGroups.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/sectionGroups.tsx index c2605af8e83..24b434b80df 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/sectionGroups.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/sectionGroups.tsx @@ -85,7 +85,9 @@ export function CollapsibleProviderGroup({ onToggle() } }} - className="flex cursor-pointer items-center gap-2.5 bg-[var(--ag-colorFillQuaternary)] px-3 py-2 transition-colors hover:bg-[var(--ag-colorFillSecondary)]" + // pr = section header's caret gutter (14px caret + 8px gap) minus the card border, + // so the group's + button sits in the same column as the section header's +. + className="flex cursor-pointer items-center gap-2.5 bg-[var(--ag-colorFillQuaternary)] py-2 pl-3 pr-[21px] transition-colors hover:bg-[var(--ag-colorFillSecondary)]" > {open ? ( @@ -95,7 +97,7 @@ export function CollapsibleProviderGroup({ className="shrink-0 text-[var(--ag-colorTextSecondary)]" /> )} - + {name} {countText} From 556866d802fa33c6f1d2fe1e67cc65f5123247cc Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 9 Jul 2026 19:30:32 +0200 Subject: [PATCH 27/50] feat(frontend): seamless agent self-commit transition + changed-section indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the agent commits a new revision of itself, the config panel previously remounted (per-revision key) and dropped to its loading skeleton while the new revision's queries resolved — sections replayed their collapsed->open entrance. - MainLayout gives the agent config panel a STABLE key (mirroring the chat host), so a self-commit flows down as a revisionId prop update, not a remount. - PlaygroundConfigSection latches its last renderable {data, schema} snapshot and holds it through the pending window of a revision switch — section values then update in place when the new data lands. Keyed per-variant usages are unaffected (their remount resets the latch). - New agentSelfCommitSignalAtom (shared state): the chat raises it at switch time with the outgoing revision's configuration. AgentTemplateControl diffs it against the committed config via classifyAgentChanges and marks the changed sections (indicator dots + a dismissible 'Agent updated this configuration' strip). The diff freezes on first compute so the user's own follow-up edits don't drift into the agent-change marks. --- .../AgentChatSlice/AgentChatPanel.tsx | 16 ++++- .../Components/MainLayout/index.tsx | 10 ++- .../SchemaControls/AgentTemplateControl.tsx | 70 ++++++++++++++++++- .../components/PlaygroundConfigSection.tsx | 24 ++++++- .../src/state/agentCommitSignal.ts | 20 ++++++ web/packages/agenta-shared/src/state/index.ts | 2 + 6 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 web/packages/agenta-shared/src/state/agentCommitSignal.ts diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx index 90aa2ce30da..fe7b2c50dd4 100644 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx @@ -18,7 +18,7 @@ import { buildTurnCapture, playgroundController, } from "@agenta/playground" -import {simulatedAgentRunAtomFamily} from "@agenta/shared/state" +import {agentSelfCommitSignalAtom, simulatedAgentRunAtomFamily} from "@agenta/shared/state" import {generateId} from "@agenta/shared/utils" import {HeightCollapse} from "@agenta/ui" import {type RichChatInputHandle} from "@agenta/ui/rich-chat-input" @@ -988,6 +988,7 @@ const AgentConversation = ({ // inspect caches so the config panel, section drawers, and build-kit view all re-read the new // config. Deduped by revision id so a re-render (token stream) doesn't re-invalidate. const committedRevisionsSeenRef = useRef>(new Set()) + const setAgentCommitSignal = useSetAtom(agentSelfCommitSignalAtom) useEffect(() => { for (const message of messages) { for (const part of message.parts) { @@ -999,11 +1000,22 @@ const AgentConversation = ({ committedRevisionsSeenRef.current.add(key) invalidateAgentCommittedRevisionCache() if (data?.revisionId && data.revisionId !== entityId) { + // Capture the OUTGOING revision's parameters before switching, so the config + // panel can show what the agent changed (per-section indicators + summary). + const prevParameters = store.get( + workflowMolecule.selectors.configuration(entityId), + ) + setAgentCommitSignal({ + revisionId: data.revisionId, + version: data.version, + prevParameters: prevParameters ?? null, + at: Date.now(), + }) switchEntity({currentEntityId: entityId, newEntityId: data.revisionId}) } } } - }, [messages, entityId, switchEntity]) + }, [messages, entityId, switchEntity, store, setAgentCommitSignal]) // ── DT3 cancelled state: wrap stop() to mark the in-flight assistant turn ── const markStopped = useCallback(() => { diff --git a/web/oss/src/components/Playground/Components/MainLayout/index.tsx b/web/oss/src/components/Playground/Components/MainLayout/index.tsx index 5d69072f1b8..de0f4bd2b67 100644 --- a/web/oss/src/components/Playground/Components/MainLayout/index.tsx +++ b/web/oss/src/components/Playground/Components/MainLayout/index.tsx @@ -378,7 +378,15 @@ const PlaygroundMainView = ({ ) : configEntityIds.length > 0 ? ( configEntityIds.map((variantId, index) => (
() const revisionId = drillIn?.entityId ?? null revisionIdRef.current = revisionId + + // ── Agent self-commit: surface WHAT the agent just changed ────────────────────────── + // The chat raises the signal (with the outgoing revision's parameters) when the agent + // commits itself and the playground switches in place. Once this control renders the + // NEW revision, diff the configs per section and mark the changed ones. The computed + // set is FROZEN on first non-empty result so the user's own subsequent edits don't + // drift into the "agent changed this" indication. Dismiss (or the next commit) clears. + const [commitSignal, setCommitSignal] = useAtom(agentSelfCommitSignalAtom) + const frozenAgentDiffRef = useRef<{signalAt: number; keys: Set} | null>(null) + const agentChangedKeys = useMemo(() => { + if (!commitSignal || !revisionId || commitSignal.revisionId !== revisionId) return null + if (frozenAgentDiffRef.current?.signalAt === commitSignal.at) { + return frozenAgentDiffRef.current.keys + } + try { + const sectionIdToKey: Record = { + model: "model-harness", + instructions: "instructions", + tools: "tools", + mcps: "mcp", + skills: "skills", + params: "advanced", + } + const changed = classifyAgentChanges(commitSignal.prevParameters, {agent: value}) + const keys = new Set( + changed.map((s) => sectionIdToKey[s.id]).filter((k): k is string => Boolean(k)), + ) + if (keys.size === 0) return null + frozenAgentDiffRef.current = {signalAt: commitSignal.at, keys} + return keys + } catch { + return null + } + }, [commitSignal, revisionId, value]) + const agentChangeIndicator = useCallback( + (sectionKey: string) => + agentChangedKeys?.has(sectionKey) + ? { + tone: "draft" as const, + tooltip: `Updated by the agent${commitSignal?.version ? ` in ${commitSignal.version}` : ""}`, + } + : undefined, + [agentChangedKeys, commitSignal?.version], + ) // Triggers bound to this agent (for the section count badge). The section body and the header // add-dropdown derive scoping from the same hook. const {count: triggerCount} = useAgentTriggers(revisionId) @@ -720,6 +764,26 @@ export function AgentTemplateControl({ return (
+ {agentChangedKeys ? ( +
+ + Agent updated this configuration + {commitSignal?.version ? ` (${commitSignal.version})` : ""} — the marked + sections changed + + +
+ ) : null} {sections.length === 0 ? ( No agent configuration fields are available for this schema. @@ -780,7 +844,7 @@ export function AgentTemplateControl({ titleBadge={sectionBadge(s.key)} summary={s.summary} extra={s.extra} - indicator={s.indicator} + indicator={s.indicator ?? agentChangeIndicator(s.key)} onOpen={s.onOpen} collapsible={false} noDivider @@ -799,7 +863,7 @@ export function AgentTemplateControl({ titleBadge={sectionBadge(s.key)} summary={s.summary} extra={s.extra} - indicator={s.indicator} + indicator={s.indicator ?? agentChangeIndicator(s.key)} onOpen={s.onOpen} defaultOpen={s.defaultOpen} noDivider={index === sections.length - 1} diff --git a/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx b/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx index f0a86f63609..1da442771a9 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx @@ -658,16 +658,36 @@ function PlaygroundConfigSection({ // Schema for model config popover const schemaAtom = useMemo(() => mol.atoms.agConfigSchema(revisionId), [mol, revisionId]) - const schema = useAtomValue(schemaAtom) + const schemaLive = useAtomValue(schemaAtom) // Choose the best available data for loading checks - const activeData = useMemo(() => { + const activeDataLive = useMemo(() => { if (useServerData) return serverData if (hasParameters(data)) return data if (hasParameters(serverData)) return serverData return data ?? serverData }, [useServerData, data, serverData]) + // Revision switches (e.g. the agent committing itself) keep this component mounted and + // swap `revisionId` in place. While the NEW revision's data/schema queries are pending, + // keep rendering the LAST renderable snapshot instead of dropping to the loading skeleton + // — the sections then update their values in place when the data lands (no teardown, no + // collapsed→open replay). The snapshot ref resets with the component, so keyed usages + // (per-variant configs in prompt playgrounds) are unaffected. + const renderSnapshotRef = useRef<{data: typeof activeDataLive; schema: typeof schemaLive}>({ + data: null, + schema: null, + }) + if (hasRenderableConfigSections(activeDataLive)) { + renderSnapshotRef.current = {data: activeDataLive, schema: schemaLive} + } + const holdPrevious = + schemaQuery.isPending && + !hasRenderableConfigSections(activeDataLive) && + hasRenderableConfigSections(renderSnapshotRef.current.data) + const activeData = holdPrevious ? renderSnapshotRef.current.data : activeDataLive + const schema = holdPrevious ? renderSnapshotRef.current.schema : schemaLive + const parameters = (activeData?.parameters ?? {}) as Record // Sibling group (hook/code) surfaced as a section, if present. diff --git a/web/packages/agenta-shared/src/state/agentCommitSignal.ts b/web/packages/agenta-shared/src/state/agentCommitSignal.ts new file mode 100644 index 00000000000..12fb5ef71bf --- /dev/null +++ b/web/packages/agenta-shared/src/state/agentCommitSignal.ts @@ -0,0 +1,20 @@ +import {atom} from "jotai" + +/** + * Raised by the agent chat when the agent commits a NEW revision of itself + * (`data-committed-revision` stream part) and the playground switches to it in place. + * Carries the pre-commit parameters so the config panel can show WHAT the agent + * changed (per-section indicators / summary) once the new revision's data lands. + * Cleared by user dismissal or overwritten by the next self-commit. + */ +export interface AgentSelfCommitSignal { + /** The newly committed revision the playground switched to. */ + revisionId: string + /** Human version tag when the stream part carried one (e.g. "v7"). */ + version?: string + /** The previous revision's parameters, captured just before the switch. */ + prevParameters: unknown + at: number +} + +export const agentSelfCommitSignalAtom = atom(null) diff --git a/web/packages/agenta-shared/src/state/index.ts b/web/packages/agenta-shared/src/state/index.ts index c1cc76ca1b9..793d32c3261 100644 --- a/web/packages/agenta-shared/src/state/index.ts +++ b/web/packages/agenta-shared/src/state/index.ts @@ -9,6 +9,8 @@ export {simulatedAgentRunAtomFamily} from "./simulatedAgentRun" export type {SimulatedAgentRunRequest} from "./simulatedAgentRun" export {openAgentConfigSectionAtom} from "./openConfigSection" export type {AgentConfigSection} from "./openConfigSection" +export {agentSelfCommitSignalAtom} from "./agentCommitSignal" +export type {AgentSelfCommitSignal} from "./agentCommitSignal" export {atomWithRefresh} from "jotai/utils" export { atomWithCompare, From 752211f9c6c034256e00d0a7e7193d6336881e37 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 9 Jul 2026 21:12:55 +0200 Subject: [PATCH 28/50] perf(frontend): frame-split the agent chat panel; region skeletons + fixes Split AgentChatPanel into a synchronous frame (Splitter + Tabs + region slots) and a lazy AgentConversation body, so the real structure paints immediately and only the heavy AI-SDK/useChat chunk loads behind a transcript/composer skeleton. Session bar and rail stay lazy inside the frame, each behind its own region skeleton, and every region eases in via a new opacity-only MountFade instead of a hard Suspense pop. Removes the crossfade AgentChatPanelHost; the composed AgentChatSkeleton now serves only the pre-confirmation gate in MainLayout. Also fixes three skeleton-state defects: - reserve the config-header kebab's footprint (dynamic loading fallback) so it no longer pops in and shifts Commit/Deploy. - trim the Model & harness skeleton row so it stops overflowing the 400px config panel. - restore scroll-to-tab: instant scroll now applies only to the bar's initial reload restore, so a newly-created tab glides into view again. --- .../AgentChatSlice/AgentChatPanel.tsx | 2084 +---------------- .../AgentChatSlice/AgentChatPanelHost.tsx | 66 - .../AgentChatSlice/AgentConversation.tsx | 2005 ++++++++++++++++ .../components/AgentChatSkeleton.tsx | 25 +- .../AgentChatSlice/components/MountFade.tsx | 26 + .../components/SessionTagBar.tsx | 22 +- .../assets/PlaygroundVariantConfigHeader.tsx | 8 +- .../src/components/Playground/Playground.tsx | 12 +- .../agentTemplate/AgentConfigSkeleton.tsx | 4 +- 9 files changed, 2140 insertions(+), 2112 deletions(-) delete mode 100644 web/oss/src/components/AgentChatSlice/AgentChatPanelHost.tsx create mode 100644 web/oss/src/components/AgentChatSlice/AgentConversation.tsx create mode 100644 web/oss/src/components/AgentChatSlice/components/MountFade.tsx diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx index fe7b2c50dd4..6b42c028894 100644 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx @@ -1,166 +1,36 @@ -import { - lazy, - Suspense, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - type MutableRefObject, -} from "react" +import {lazy, Suspense, useEffect, useRef, useState} from "react" -import {markTraceAsFresh} from "@agenta/entities/trace" -import {invalidateAgentCommittedRevisionCache, workflowMolecule} from "@agenta/entities/workflow" -import { - agentShouldResumeAfterApproval, - buildAgentRequest, - buildTurnCapture, - playgroundController, -} from "@agenta/playground" -import {agentSelfCommitSignalAtom, simulatedAgentRunAtomFamily} from "@agenta/shared/state" -import {generateId} from "@agenta/shared/utils" -import {HeightCollapse} from "@agenta/ui" -import {type RichChatInputHandle} from "@agenta/ui/rich-chat-input" -import {useChat} from "@ai-sdk/react" -import {Bubble} from "@ant-design/x" -import { - ArrowDown, - ArrowRight, - Code, - Paperclip, - Terminal, - TreeStructure, - UploadSimple, -} from "@phosphor-icons/react" -import {type UIMessage} from "ai" -import {App, Button, Modal, Splitter, Tabs, Tag, Tooltip} from "antd" -import type {UploadFile} from "antd" +import {simulatedAgentRunAtomFamily} from "@agenta/shared/state" +import {Splitter, Tabs} from "antd" import clsx from "clsx" -import {useAtom, useAtomValue, useSetAtom, useStore} from "jotai" -import {useRouter} from "next/router" -import {Virtuoso, type Components, type StateSnapshot, type VirtuosoHandle} from "react-virtuoso" +import {useAtomValue, useSetAtom} from "jotai" -import { - IDE_INSTALL_COMMAND, - TEMPLATE_STRIP_MODE, -} from "@/oss/components/pages/agent-home/assets/constants" -import { - captureFirstAgentIntent, - classifyAgentIntent, - truncateForCapture, -} from "@/oss/components/pages/agent-home/assets/onboardingAnalytics" -import {type AgentTemplate} from "@/oss/components/pages/agent-home/assets/templates" -import OnboardingBrowseTemplates from "@/oss/components/pages/agent-home/PlaygroundOnboarding/OnboardingBrowseTemplates" import {useOptionalOnboardingContext} from "@/oss/components/pages/agent-home/PlaygroundOnboarding/OnboardingContext" -import Reveal from "@/oss/components/pages/agent-home/PlaygroundOnboarding/Reveal" // Direct file import — the barrel would statically pull the inspector drawer into this chunk. import SessionInspectorButton from "@/oss/components/SessionInspector/SessionInspectorButton" -import {openTraceDrawerAtom} from "@/oss/components/SharedDrawers/TraceDrawer/store/traceDrawerStore" -import TemplateStrip from "@/oss/components/TemplateStrip" -import {buildCodingAgentClipboard} from "@/oss/components/TemplateStrip/assets/codingAgentClipboard" -import {STRIP_COPY} from "@/oss/components/TemplateStrip/assets/constants" -import CopiedToast from "@/oss/components/TemplateStrip/components/CopiedToast" -import {useTemplateProvenance} from "@/oss/components/TemplateStrip/hooks/useTemplateProvenance" -import {usePostHogAg} from "@/oss/lib/helpers/analytics/hooks/usePostHogAg" -import {AgentChatTransport} from "./assets/AgentChatTransport" -import { - type AttachmentRejection, - DEFAULT_ATTACHMENT_LIMITS, - validateIncoming, -} from "./assets/attachments" -import {filesToParts} from "./assets/files" -import {messageText, sideEffectingToolsInRange} from "./assets/rewind" -import {getMessageTraceId} from "./assets/trace" -import AgentChatEmptyState from "./components/AgentChatEmptyState" -import {ComposerSkeleton, SessionBarSkeleton} from "./components/AgentChatSkeleton" -import AgentMessage from "./components/AgentMessage" -import ApprovalDock, {getPendingApprovals} from "./components/ApprovalDock" -import type {ClientToolOutputHandler} from "./components/clientTools" -import ComposerAttachments from "./components/ComposerAttachments" -import ConnectModelBanner from "./components/ConnectModelBanner" -import QueuedMessages from "./components/QueuedMessages" -import RevealCollapse from "./components/RevealCollapse" +import {ConversationSkeleton, SessionBarSkeleton} from "./components/AgentChatSkeleton" +import MountFade from "./components/MountFade" import SessionHistoryMenu from "./components/SessionHistoryMenu" -import TurnInspector from "./components/TurnInspector/TurnInspector" -import {useAgentChatQueue, type QueuedMessage} from "./hooks/useAgentChatQueue" -import {useAgentModelKeyStatus} from "./hooks/useAgentModelKeyStatus" -import {expandedKeysForMessages, pruneExpandedAtom} from "./state/expandState" -import {agentFirstRunSeedAtom} from "./state/firstRunSeed" import {chatPanelMaximizedAtom} from "./state/panelLayout" import {useChatScopeKey} from "./state/scope" import { - type SessionRunStatus, activeSessionIdAtomFamily, addSessionAtomFamily, closeSessionAtomFamily, - persistSessionMessagesAtom, renameSessionAtomFamily, - sessionMessagesAtom, sessionsListAtomFamily, setActiveSessionAtomFamily, - setSessionStatusAtom, - stampMessagesCreatedAtAtom, } from "./state/sessions" -import {captureTurnRequestAtom} from "./state/turnCaptures" -import {turnInspectorAtom} from "./state/turnInspector" -import { - agentChatItemEstimateAtom, - agentChatOverscanAtom, - agentChatVirtualizeAtom, - isAgentChatVirtualizationAvailable, -} from "./state/virtualization" -// Lazy regions: each hydrates independently behind the SAME skeleton the loading gates show -// for its slot, so the pane's structure never blocks on (or shifts around) a sibling region. -// The composer carries Lexical — the heaviest dependency of this chunk — out of the panel's -// synchronous mount; React.lazy (not next/dynamic) so the imperative handle ref forwards. -const RichChatInput = lazy(() => - import("@agenta/ui/rich-chat-input").then((m) => ({default: m.RichChatInput})), -) +// The frame itself is a thin, synchronous shell (Splitter + Tabs + region slots) so the real +// structure paints in the first frame. Only the heavy leaves are lazy: the conversation body +// (useChat + AI SDK + transport + message tree), the session bar, and the rail. Each shows its +// own inline skeleton and eases in (MountFade) — no whole-pane crossfade overlay. +const AgentConversation = lazy(() => import("./AgentConversation")) const SessionTagBar = lazy(() => import("./components/SessionTagBar")) const SessionRail = lazy(() => import("./components/SessionRail")) -/** A stream error/abort is already surfaced via `useChat`'s `onError` + the in-chat `error` - * alert; swallow the floating `sendMessage`/`regenerate` rejection so it doesn't bubble to the - * Next.js dev Runtime Error overlay (F-033). */ -const ignoreStreamRejection = () => {} - -// Virtuoso state (measured row heights + scrollTop) per session, captured before a route -// change unmounts the transcript. A fresh Virtuoso mount otherwise renders with height -// ESTIMATES, measures the real rows async, then corrects — a visible reshuffle on every -// re-entry (rows here span 85–1022px, so the correction is large). Restoring the snapshot -// paints the transcript at its true geometry and scroll position in the first frame. -const virtStateBySession = new Map() - -// Unsent composer drafts per session — survive pane remounts (route re-entry, tab -// close/reopen), so switching back to a session restores its in-progress message. -const composerDraftBySession = new Map() - -// Pending (not yet sent) attachments per session — same lifetime as the drafts. In-memory -// only: `UploadFile.originFileObj` holds live File blobs, which can't be serialized anyway. -const attachmentsBySession = new Map() - -/** Height of the top-edge fade, in px. Shared by the CSS mask and the SC-1 pin so a pinned turn - * lands BELOW the fade (otherwise the freshly-asked question renders partially faded). */ -const TOP_FADE_PX = 28 -/** Height of the bottom-edge fade, matching the top so content dissolves into the composer edge. */ -const BOTTOM_FADE_PX = 28 -/** Edge fades for the message scroll area: transparent at the very top, fully opaque by TOP_FADE_PX, - * then fading back to transparent over the last BOTTOM_FADE_PX. Applied as a CSS mask so the content - * itself fades (correct in any theme). */ -const EDGE_FADE_MASK = `linear-gradient(to bottom, transparent 0, #000 ${TOP_FADE_PX}px, #000 calc(100% - ${BOTTOM_FADE_PX}px), transparent 100%)` -/** Centered reading column for the chat body. Caps line length / bubble width so a wide (maximized) - * panel doesn't sprawl into oversized bubbles and over-spaced turns; freed side space is whitespace. */ -const CHAT_COLUMN = "mx-auto w-full max-w-[880px]" - -/** Single source of truth for the (currently DISABLED) content-visibility optimization. Disabled in - * 5f0fa73d06 — it caused a scrollbar-shrink on first scroll-through — but the mechanism is kept so it - * can be re-enabled with a fix. Gates BOTH the CSS class and the SC-3 intrinsic-size measurement, so - * while off neither the styling nor the measurement runs. Typed `boolean` so the guards aren't - * flagged as always-false. Under Virtuoso it must stay off regardless (it corrupts item measurement). */ -const CONTENT_VISIBILITY_ENABLED = false as boolean /** Chat-mode session rail: default/min/max widths of its resizable splitter pane. The pane * collapses to 0 in build mode (rather than unmounting) so the Build/Chat toggle animates in * lockstep with the config pane. Min also pins the rail's content width, so collapsing clips @@ -169,1858 +39,6 @@ const RAIL_WIDTH = 300 const RAIL_MIN_WIDTH = 240 const RAIL_MAX_WIDTH = 480 -/** - * One agent conversation for a single session tab. A `useChat` whose transport is fed by the - * PLAYGROUND request builder (`buildAgentRequest`) — the entity supplies the config/auth/ - * references, the session id is the tab's id and travels to the backend as `session_id`. - * Messages persist to localStorage (seeded on mount, written when the stream settles) so the - * tab survives a reload / revision swap. - * - * Design decisions baked in (docs/design/agent-workflows/playground-agent-generation.md): - * - D9 teardown: abort the in-flight stream on unmount (tab close / revision swap). - * - DT3 cancelled state: a stopped stream tags its partial bubble "Stopped" + offers Resend. - * - DT4 autoscroll: stick to bottom while streaming; pause when scrolled up; "jump to latest". - * - DT5 a11y: the message log is an aria-live region; controls are keyboard-operable. - */ - -/** A part the transcript actually renders — non-empty text/reasoning, files, sources, tools. */ -const isVisiblePart = (p: UIMessage["parts"][number]): boolean => - (p.type === "text" && Boolean((p as {text?: string}).text?.trim())) || - (p.type === "reasoning" && Boolean((p as {text?: string}).text?.trim())) || - p.type === "file" || - p.type === "source-url" || - p.type.startsWith("tool-") || - p.type === "dynamic-tool" - -/** A settled assistant turn with no content at all — no answer, reasoning, tool, file, or - * source part. Mirrors AgentMessage's `!hasContent`; used to collapse a run of "no response" - * bubbles (e.g. repeated failed runs) down to the first one. */ -const isEmptyAssistantTurn = (m: UIMessage): boolean => - m.role === "assistant" && !m.parts.some(isVisiblePart) - -interface ParsedRunError { - message: string - code?: number -} - -/** - * Best-effort human reason from a useChat stream error. The server may hand us a clean string - * ("Agent run failed: …") or a JSON envelope (`{status:{code,message,…}}` / `{message}`) — pull - * the message out of either and drop the stacktrace / docs-url noise so it reads cleanly inline. - */ -const parseAgentRunError = (err: unknown): ParsedRunError => { - const raw = - err instanceof Error ? err.message : typeof err === "string" ? err : String(err ?? "") - const fallback = raw.trim() || "The agent run failed." - try { - const obj = JSON.parse(raw) as Record - const status = (obj?.status && typeof obj.status === "object" ? obj.status : obj) as Record< - string, - unknown - > - const message = - typeof status?.message === "string" - ? status.message - : typeof obj?.message === "string" - ? (obj.message as string) - : null - if (message) { - return {message, code: typeof status?.code === "number" ? status.code : undefined} - } - } catch { - // raw isn't JSON — it's already the human message. - } - return {message: fallback} -} - -/** The last real content element in the log (the last turn's last child). Used to measure the REAL - * content bottom and ignore the min-h-full reserve that pads a streaming turn — so the jump pill and - * stick-to-bottom track the latest message, not the bottom of the empty reserved space. */ -const lastContentEl = (el: HTMLElement): HTMLElement | null => { - const wrappers = el.querySelectorAll("[data-mid]") - const wrapper = wrappers[wrappers.length - 1] - if (!wrapper) return null - return (wrapper.lastElementChild as HTMLElement | null) ?? wrapper -} - -/** True when the latest message content sits at or above the viewport bottom (i.e. fully visible). */ -const atLiveEdge = (el: HTMLElement): boolean => { - const last = lastContentEl(el) - if (!last) return true - return last.getBoundingClientRect().bottom - el.getBoundingClientRect().bottom < 24 -} - -/** SPIKE(virtuoso): context passed to the Virtuoso Header/Footer slots (top padding + active turn). */ -interface VirtCtx { - header: React.ReactNode - footer: React.ReactNode -} - -/** - * One message row. Carries `data-mid` (load-bearing for the pin / anchor / ResizeObserver, which all - * query it). A message added after mount (`enter`) fades in — OPACITY ONLY, deliberately: opacity - * doesn't change geometry, so it can't move the scroll position or trip the SC-3 ResizeObserver. A - * restored thread's messages render with `enter=false` (no cascade). Honors reduced-motion: the - * initial transparency and the transition are both `motion-safe`, so it's instant-visible otherwise. - */ -const MessageRow = ({ - mid, - enter, - children, - inspected = false, - onInspect, - offscreenSkip = false, -}: { - mid: string - enter: boolean - children: React.ReactNode - /** This turn is the Turn Inspector's current target — tint it. */ - inspected?: boolean - /** Set (assistant turns, inspector open) → click the row to re-focus the inspector on it. */ - onInspect?: () => void - /** Settled row → `content-visibility:auto` so the browser skips its layout/paint while off-screen. - * `contain-intrinsic-size: auto` remembers the real height after first paint, so leaving the - * viewport causes no layout shift (heights here range ~85–1022px; no fixed estimate works). */ - offscreenSkip?: boolean -}) => { - const [shown, setShown] = useState(!enter) - // Reveal one frame after mount so the opacity transition plays. Deps are [] (NOT - // [enter]) on purpose: an `enter` flip when a sibling turn arrives must not cancel - // this rAF, or a just-sent message strands at opacity-0 for the whole agent run. - useEffect(() => { - const raf = requestAnimationFrame(() => setShown(true)) - return () => cancelAnimationFrame(raf) - }, []) - // Click-to-refocus: only while the inspector is open, and never over an interactive control or - // an active text selection (so buttons, links, and copy-select still work). - const handleClick = onInspect - ? (e: React.MouseEvent) => { - if ((e.target as HTMLElement).closest("button, a, input, textarea, [role='button']")) - return - if (!window.getSelection()?.isCollapsed) return - onInspect() - } - : undefined - // While the inspector is open, a turn is interactive: padded + rounded so the fill has breathing - // room. Inspected = a persistent, slightly stronger version of the hover fill (same visual - // language, just "held"). `box-border` is required (preflight off → content-box) so the padding - // doesn't overflow the 880px column. - const interactive = Boolean(onInspect) - // `shown || !enter` is a belt-and-suspenders: a settled row (id seen) is always visible. - return ( -
- {children} -
- ) -} - -/** Compact three-dot pulse for the meta row under the last turn — the run-in-progress signal. - * Deliberately NOT a Bubble: it shares one line with "Inspect turn" instead of adding a - * bubble-sized row of its own. */ -const WorkingDots = () => ( - - - - - -) - -const AgentConversation = ({ - entityId, - sessionId, - revealPlayedRef, -}: { - entityId: string - sessionId: string - /** Shared across the panel's session panes: the composer entrance plays only once. */ - revealPlayedRef: MutableRefObject -}) => { - const store = useStore() - const persistMessages = useSetAtom(persistSessionMessagesAtom) - const stampMessagesCreatedAt = useSetAtom(stampMessagesCreatedAtAtom) - const switchEntity = useSetAtom(playgroundController.actions.switchEntity) - const setSessionStatus = useSetAtom(setSessionStatusAtom) - const openTurnInspector = useSetAtom(turnInspectorAtom) - const inspectorTarget = useAtomValue(turnInspectorAtom) - const buildMode = !useAtomValue(chatPanelMaximizedAtom) - const inspectorOpen = inspectorTarget?.sessionId === sessionId - // Leaving Build for Chat dismisses the inspector — it's a Build-mode tool, and the panel would - // otherwise linger (and keep tinting a turn) in the maximized chat view. - useEffect(() => { - if (!buildMode && inspectorOpen) openTurnInspector(null) - }, [buildMode, inspectorOpen, openTurnInspector]) - - // Restored from the per-session store on remount (route re-entry, tab close/reopen) — - // pending attachments survive alongside the composer draft. Rejections stay transient. - const [files, setFiles] = useState( - () => attachmentsBySession.get(sessionId) ?? [], - ) - useEffect(() => { - if (files.length > 0) attachmentsBySession.set(sessionId, files) - else attachmentsBySession.delete(sessionId) - }, [files, sessionId]) - // Files turned away by the guardrails (too big, wrong type, over the count), shown inline. - const [rejections, setRejections] = useState([]) - const [attachmentsOpen, setAttachmentsOpen] = useState(false) - // Single limits object so it can later be swapped for capability-derived limits. - const limits = DEFAULT_ATTACHMENT_LIMITS - const atMax = files.length >= limits.maxCount - // Drag-over state for the whole-panel drop overlay (depth counter avoids child flicker). - const dragDepthRef = useRef(0) - const [isDragging, setIsDragging] = useState(false) - // Whether the LAST assistant turn was user-stopped. You can only cancel the in-flight (last) turn, - // so this is a single boolean gated on position at render time — independent of message ids (which - // can be missing/duplicated in restore/error paths and would otherwise smear the tag onto every - // turn). Cleared on the next send/resend. - const [stopped, setStopped] = useState(false) - // Seed once from the persisted store (read imperatively so our own writes don't feed back). - const [initialMessages] = useState(() => store.get(sessionMessagesAtom)[sessionId] ?? []) - // Ids already on screen — restored/settled turns don't re-animate; only turns added live fade in. - const seenIdsRef = useRef>(new Set(initialMessages.map((m) => m.id))) - // Immutable snapshot of the restored ids (seenIdsRef grows) — the first-seen stamping - // effect below skips these so a reload can't masquerade as the turns' send time. - const restoredIdsRef = useRef>(new Set(initialMessages.map((m) => m.id))) - // Themed confirm dialogs. The static `Modal.confirm` renders detached from the app's - // ConfigProvider, so it loses the theme (white box in dark mode). The hook form's - // `contextHolder` is rendered in-tree, so its dialogs inherit the theme — same look as the - // declarative EnhancedModal (centered, 16px radius). - const [modal, modalContextHolder] = Modal.useModal() - - const richInputRef = useRef(null) - - // Composer entrance plays once per PANEL mount — additional session panes mount the - // composer fully shown (the replayed fade read as a "composer reload" on session switch). - // Frozen at mount: recomputing per render would flip Reveal's `enabled` mid-entrance - // (the latch effect below runs before the fade completes). - const [playComposerEntrance] = useState(() => !revealPlayedRef.current) - useEffect(() => { - revealPlayedRef.current = true - }, [revealPlayedRef]) - - // Per-session unsent draft: restore once at mount (initialMarkdown is mount-only) and - // capture edits debounced — markdown is read from the handle at capture time, not per - // keystroke (serialization isn't free). - const [initialDraft] = useState(() => composerDraftBySession.get(sessionId)) - const draftTimerRef = useRef(0) - const handleComposerChange = useCallback( - (text: string) => { - window.clearTimeout(draftTimerRef.current) - draftTimerRef.current = window.setTimeout(() => { - const md = richInputRef.current?.getMarkdown() ?? text - if (md.trim()) composerDraftBySession.set(sessionId, md) - else composerDraftBySession.delete(sessionId) - }, 400) - }, - [sessionId], - ) - useEffect( - () => () => { - window.clearTimeout(draftTimerRef.current) - // Best-effort final capture on unmount (guarded — the editor may be detached). - const md = richInputRef.current?.getMarkdown() - if (md !== undefined) { - if (md.trim()) composerDraftBySession.set(sessionId, md) - else composerDraftBySession.delete(sessionId) - } - }, - [sessionId], - ) - const scrollRef = useRef(null) - // ── SPIKE(react-virtuoso): windowing variant, evaluated against content-visibility. ── - // Controlled live from the playground settings dropdown (Virtualization section). When on, the - // SC-1..4 scroll effects below are disabled (Virtuoso owns measurement/anchoring) and the - // transcript renders via ; overscan / row-estimate are tunable there too. - // Virtualize only when the env flag is present AND it's enabled in the settings — no other gates. - const virtEnabledInSettings = useAtomValue(agentChatVirtualizeAtom) - const useVirtuoso = isAgentChatVirtualizationAvailable() && virtEnabledInSettings - const virtOverscan = useAtomValue(agentChatOverscanAtom) - const virtItemEstimate = useAtomValue(agentChatItemEstimateAtom) - const virtuosoRef = useRef(null) - // Snapshot captured by a previous mount of this session (route re-entry). Read once at - // mount — `restoreStateFrom` is a mount-time-only Virtuoso prop. - const [virtRestoreState] = useState(() => - useVirtuoso ? virtStateBySession.get(sessionId) : undefined, - ) - const router = useRouter() - useEffect(() => { - if (!useVirtuoso) return - const capture = () => { - // getState is synchronous; guard the handle for the unmount-cleanup path. - virtuosoRef.current?.getState((snapshot) => { - virtStateBySession.set(sessionId, snapshot) - }) - } - // routeChangeStart fires while the transcript is still mounted and measured — the - // reliable capture point. The cleanup capture is best-effort (the handle may already - // be detached there), covering non-route unmounts like a revision-type swap. - router.events.on("routeChangeStart", capture) - return () => { - router.events.off("routeChangeStart", capture) - capture() - } - }, [useVirtuoso, sessionId, router]) - // Stick to the bottom of the scrollable area. This is the ONE source of truth for auto-scroll: - // the active turn reserves a viewport (min-h-full), so "bottom" puts the latest question at the - // top with the answer streaming into the space below — the pin is emergent, not computed. A real - // user scroll-up releases it (onScroll); jump-to-latest re-arms it. - const stickRef = useRef(true) - const [showJump, setShowJump] = useState(false) - // Arm a one-shot scroll to the bottom: on a fresh submit (glide) and on restoring a saved thread - // (instant). Combined with min-h-full this is the whole SC-1/SC-2 positioning — no per-element pin. - const armBottomRef = useRef(initialMessages.length > 0) - const animateBottomRef = useRef(false) - // Set while WE move the scroll (the bottom glide / SC-3 compensation). onScroll ignores the - // resulting event so our own scroll isn't mistaken for the user reaching/leaving the live edge. - const programmaticScrollRef = useRef(false) - // Teardown for the in-flight smooth scroll (removes its listeners + fallback timer). - const pinCleanupRef = useRef<(() => void) | null>(null) - // Last observed scrollTop. A content shrink (tool gutter collapsing, reasoning folding) clamps - // scrollTop to the new smaller bottom and fires a scroll event that isn't a user gesture; comparing - // against this lets onScroll tell a real scroll-DOWN-to-edge from that clamp (which only decreases). - const lastScrollTopRef = useRef(0) - // rAF handle coalescing the jump-pill measurement (querySelectorAll + getBoundingClientRect) to once - // per frame — a fast wheel/drag and every streamed render would otherwise re-measure a dirtied layout. - const showJumpRafRef = useRef(0) - - // `useChat` pins its `Chat` (and thus this transport) for the life of the session `id`; it is - // NOT recreated when `entityId` changes (only on an `id` change). So the request builder must - // read the CURRENT entity through a ref — capturing `entityId` by value would send every turn - // with the revision that was displayed when the session first mounted, even after a switch or a - // self-commit. Reading `entityIdRef.current` at send time keeps runs on the live revision. - const entityIdRef = useRef(entityId) - entityIdRef.current = entityId - - // Turn Inspector capture write, read via ref so the transport `useMemo` doesn't depend on it. - const captureTurnRequest = useSetAtom(captureTurnRequestAtom) - const captureRef = useRef(captureTurnRequest) - captureRef.current = captureTurnRequest - - // Transport feeds the v6 stream request from the playground pipeline. `api` here is a - // placeholder that `prepareSendMessagesRequest` overrides per request. - const transport = useMemo( - () => - new AgentChatTransport({ - api: "", - prepareSendMessagesRequest: async ({messages, id}) => { - const req = await buildAgentRequest(entityIdRef.current, messages, { - sessionId: id ?? sessionId, - }) - if (!req) { - throw new Error( - "This agent workflow has no invocation URL — it can’t be run yet.", - ) - } - captureRef.current(buildTurnCapture(req, generateId(), Date.now())) - return {api: req.invocationUrl, headers: req.headers, body: req.requestBody} - }, - }), - [sessionId], - ) - - const { - messages, - sendMessage, - status, - stop, - regenerate, - setMessages, - addToolApprovalResponse, - addToolOutput, - error, - } = useChat({ - id: sessionId, - messages: initialMessages, - transport, - // Coalesce stream deltas to ~1 UI commit / 50ms so a fast token stream doesn't drive a - // render per token; caps commit frequency independently of the per-commit memo win. - experimental_throttle: 50, - // Approve AND deny both resume — a deny-only decision must re-send so the runner - // gets the denial round-trip and the model continues (no `approval-responded` limbo). - sendAutomaticallyWhen: agentShouldResumeAfterApproval, - // The turn's trace may not be ingested yet when the row asks for its summary — - // marking it fresh lets the trace queries retry through the ingestion lag - // (historical traces get no such grace; a 404 there means the trace is gone). - onFinish: ({message}) => markTraceAsFresh(getMessageTraceId(message)), - onError: (err) => { - // Render the error in-chat (the `error` alert below); swallow it here so an - // aborted/errored stream doesn't bubble unhandled to the Next.js dev overlay (F-033). - console.warn("[AgentChatPanel] useChat error (rendered in-chat):", err) - }, - }) - - const busy = status === "submitted" || status === "streaming" - - // Settle a parked client tool (#4920). The dispatcher calls this from a widget (e.g. the connect - // widget) with the structured reference; `addToolOutput` matches the part by `toolCallId` on the - // last turn and the resume predicate auto-resends. `tool` is only the typed-tools key — matching - // is by id — so a cast onto the untyped UIMessage tool map is safe. - const handleClientToolOutput = useCallback( - ({toolName, toolCallId, output, errorText}) => { - if (errorText !== undefined) { - addToolOutput({ - state: "output-error", - tool: toolName as never, - toolCallId, - errorText, - }).catch(ignoreStreamRejection) - } else { - addToolOutput({ - tool: toolName as never, - toolCallId, - output: (output ?? {}) as never, - }).catch(ignoreStreamRejection) - } - }, - [addToolOutput], - ) - - // ── "Run in playground" seam (producer: a trigger drawer's Run-in-playground) ── - // A trigger fires server-side and never reaches the playground; this lets a user - // channel a trigger's resolved inputs into the active session. Only the ACTIVE - // session's conversation consumes the pending run (antd Tabs can keep inactive - // panes mounted), sends it as a user turn, and clears it. A monotonic nonce lets - // the same inputs run again; a ref guards double-firing. The consuming effect lives - // below `useAgentChatQueue` so the run goes through the same `submit` path as a manual - // send — respecting a pending HITL approval and any queued messages instead of jumping - // ahead with a raw `sendMessage`. - const scopeKey = useChatScopeKey() - const activeSessionId = useAtomValue(activeSessionIdAtomFamily(scopeKey)) - const pendingRun = useAtomValue(simulatedAgentRunAtomFamily(entityId)) - const setPendingRun = useSetAtom(simulatedAgentRunAtomFamily(entityId)) - - // Model connection: is the project vault empty (no key of any kind), the agent not self-managed, - // and the user never set up a key before? Drives the connect-a-model banner AND disables the - // composer until connected — see `gateActive` on `useAgentModelKeyStatus` for the full chain. - const modelKey = useAgentModelKeyStatus(entityId) - const modelBlocked = modelKey.gateActive - - // ── Playground-native onboarding ────────────────────────────────────────── - // This chat panel IS the onboarding surface while the agent is ephemeral: the empty state shows the - // "what do you want to build?" hero and the composer renders Create-agent / Continue-in-IDE controls - // (submit = commit the ephemeral in place, not send). Read from the OnboardingContext, present ONLY - // inside the onboarding playground — null everywhere else, so every other chat usage is unchanged. - const onboarding = useOptionalOnboardingContext() - const onboardingActive = !!onboarding && !onboarding.realEntityId - // Post-commit chrome (the connect-model banner) stays hidden through the commit + first send, then - // eases in a beat later (see `chromeRevealed`) so it doesn't move the composer during the send. - const chromeHidden = !!onboarding && !onboarding.chromeRevealed - const onboardingPosthog = usePostHogAg() - const {message: appMessage} = App.useApp() - - // ── Template strip (TEMPLATE_STRIP_MODE) ───────────────────────────────── - // One provenance instance per panel, shared by the onboarding hero strip (S5) and the - // agent empty-chat strip (S6): pick fills the composer + docks the chip above it. - const stripProvenance = useTemplateProvenance({ - composerApi: { - setText: (text) => richInputRef.current?.setMarkdown(text), - getText: () => richInputRef.current?.getMarkdown() ?? "", - }, - }) - // Provenance is scoped to ONE agent revision. `AgentConversation` survives an `entityId` - // change in place (see the self-commit `switchEntity` above and a revision swap) — without - // this, a template picked against the old entity would leak its name into the new one. - useEffect(() => { - stripProvenance.clear() - }, [entityId, stripProvenance.clear]) - // S6 gate: fresh agent only (`version` v0/v1 = creation, same seed-vs-history convention used - // elsewhere); unknown while loading counts as not-fresh so the strip never flashes in. - const revisionQuery = useAtomValue(workflowMolecule.selectors.query(entityId)) - const revisionVersion = revisionQuery.data?.version - const isFreshAgentRevision = - !revisionQuery.isPending && typeof revisionVersion === "number" && revisionVersion <= 1 - const [copiedToastOpen, setCopiedToastOpen] = useState(false) - const handleStripPick = useCallback( - (template: AgentTemplate) => { - stripProvenance.pick(template) - captureFirstAgentIntent(onboardingPosthog, { - source: "template", - properties: { - template: template.name, - templateId: template.key, - templateCategory: template.category, - mode: "strip", - surface: onboardingActive ? "onboarding" : "agent-chat", - }, - intentValue: template.category || template.name, - }) - }, - [stripProvenance.pick, onboardingPosthog, onboardingActive], - ) - const handleCodingAgentCopy = useCallback(async () => { - const text = richInputRef.current?.getMarkdown().trim() ?? "" - try { - await navigator.clipboard.writeText(buildCodingAgentClipboard(text)) - setCopiedToastOpen(true) - } catch { - appMessage.error("Couldn't copy — copy it manually") - return - } - captureFirstAgentIntent(onboardingPosthog, { - source: "composer", - properties: {action: "coding_agent_copy", message: truncateForCapture(text)}, - }) - }, [appMessage, onboardingPosthog]) - - // Optimistic first turn: the description the user submitted with "Create agent", shown as a sent - // user message + assistant loading placeholder DURING commit + until the real conversation takes - // over — so the onboarding hero never flashes back and the switch reads as one continuous chat. - const [pendingFirstTurn, setPendingFirstTurn] = useState(null) - - const handleCreateAgent = useCallback(() => { - if (!onboarding || onboarding.committing) return - const text = richInputRef.current?.getMarkdown().trim() ?? "" - // Resolve BEFORE clearing the composer below — `resolveTemplateName` compares against the - // live text, so reading it after the clear would always see "" and never match the seed. - const templateName = stripProvenance.resolveTemplateName(text) - setPendingFirstTurn(text || null) - // The text becomes the sent first turn — clear the composer so it doesn't linger into the chat. - richInputRef.current?.setMarkdown("") - // Free-text submit (never a template — those go straight through `onboarding.commit` from the - // template pickers below, source "template"), so no double-fire with those call sites. - if (text) { - captureFirstAgentIntent(onboardingPosthog, { - source: "composer", - properties: {message: truncateForCapture(text)}, - intentValue: classifyAgentIntent(text), - }) - } - onboarding.commit(text, templateName) - if (TEMPLATE_STRIP_MODE) stripProvenance.clear() - }, [onboarding, onboardingPosthog, stripProvenance.clear, stripProvenance.resolveTemplateName]) - - // Also cover the template-click commit path (which goes straight through `commit()`, not the - // Create button): whenever a commit is in flight, show its seed as the optimistic turn and clear - // any lingering composer text (e.g. a "Try" chip the user had prefilled). - useEffect(() => { - if (onboarding?.committing && onboarding.committingSeed) { - setPendingFirstTurn(onboarding.committingSeed) - richInputRef.current?.setMarkdown("") - } - }, [onboarding?.committing, onboarding?.committingSeed]) - - // Once the real conversation has a message (auto-send fired post-commit), the placeholder handed - // off — drop it so the real turn owns the view. - useEffect(() => { - if (messages.length > 0 && pendingFirstTurn) setPendingFirstTurn(null) - }, [messages.length, pendingFirstTurn]) - - // Commit failed (committing went true→false without producing a real agent): restore the hero so - // the user can retry, rather than stranding the placeholder with an eternal spinner. - const sawCommittingRef = useRef(false) - useEffect(() => { - if (onboarding?.committing) { - sawCommittingRef.current = true - } else if (sawCommittingRef.current && !onboarding?.realEntityId && messages.length === 0) { - sawCommittingRef.current = false - setPendingFirstTurn(null) - } - }, [onboarding?.committing, onboarding?.realEntityId, messages.length]) - - const pendingFirstMessage = useMemo( - () => ({ - id: "pending-first-turn", - role: "user", - parts: [{type: "text", text: pendingFirstTurn ?? ""}], - }), - [pendingFirstTurn], - ) - - // "Continue in IDE" — the user's prompt lands as a real user turn, and a streamed-looking assistant - // bubble hands off the install command + prompt (a pseudo response; there's no agent to run - // pre-commit). Two clear steps: install the skill, then give the coding agent the prompt — the prompt - // is NOT inside the shell block (it's not a command). Clears the composer so the text isn't duplicated. - // Holds the pending IDE-bubble typewriter timer so it can be cancelled on unmount (tab close, - // rewind, route change) — otherwise the recursive chain keeps calling setMessages on a stale closure. - const ideBubbleTimerRef = useRef(null) - const streamIdeBubble = useCallback(() => { - const prompt = richInputRef.current?.getMarkdown().trim() ?? "" - const promptQuote = prompt - .split("\n") - .map((line) => `> ${line}`) - .join("\n") - const full = prompt - ? `Prefer to build in your IDE? Install the Agenta skill for Claude Code, Cursor, or any coding agent:\n\n\`\`\`bash\n${IDE_INSTALL_COMMAND}\n\`\`\`\n\nThen hand it your prompt:\n\n${promptQuote}` - : `Prefer to build in your IDE? Install the Agenta skill for Claude Code, Cursor, or any coding agent:\n\n\`\`\`bash\n${IDE_INSTALL_COMMAND}\n\`\`\`\n\nThen describe the agent you want it to build.` - const id = `ide-${generateId()}` - const userId = `ide-user-${generateId()}` - stickRef.current = false - armBottomRef.current = true - animateBottomRef.current = true - setShowJump(false) - setStopped(false) - // Clear the composer — the prompt is now the sent user turn (and the editor is disabled after this). - richInputRef.current?.setMarkdown("") - setMessages( - (prev) => - [ - ...prev, - ...(prompt - ? [{id: userId, role: "user", parts: [{type: "text", text: prompt}]}] - : []), - {id, role: "assistant", parts: [{type: "text", text: ""}]}, - ] as typeof prev, - ) - let shown = 0 - const chunk = Math.max(3, Math.ceil(full.length / 36)) - const tick = () => { - shown = Math.min(full.length, shown + chunk) - const text = full.slice(0, shown) - setMessages( - (prev) => - prev.map((m) => - m.id === id ? {...m, parts: [{type: "text", text}]} : m, - ) as typeof prev, - ) - if (shown < full.length) ideBubbleTimerRef.current = window.setTimeout(tick, 28) - } - ideBubbleTimerRef.current = window.setTimeout(tick, 120) - }, [setMessages]) - - // Cancel any in-flight IDE-bubble animation on unmount so its timer chain can't fire post-unmount. - useEffect( - () => () => { - if (ideBubbleTimerRef.current) window.clearTimeout(ideBubbleTimerRef.current) - }, - [], - ) - - // After an IDE hand-off (onboarding + messages exist but nothing was committed), the chat is a - // dead-end — there's no agent to talk to. Disable the composer and offer a single "Start over". - const ideHandoffActive = onboardingActive && messages.length > 0 - const handleStartOver = useCallback(() => { - setMessages([]) - richInputRef.current?.setMarkdown("") - }, [setMessages]) - - // First-run seed: a freshly-created agent (from Home's composer/template) surfaces its starting - // prompt in the empty state (see AgentChatEmptyState) rather than pre-filling the composer, so it - // reads as "here's what we'll do" not stray user input. Consumed once by the active session on a - // fresh conversation, matching either the revision or app id, then cleared. - const [firstRunSeed, setFirstRunSeed] = useAtom(agentFirstRunSeedAtom) - const [firstRunPrompt, setFirstRunPrompt] = useState(null) - // An explicit-"go" seed (the onboarding Create-agent click) sends as soon as the model is ready. - const [firstRunAutoSend, setFirstRunAutoSend] = useState(false) - const seedConsumedRef = useRef(false) - useEffect(() => { - if (seedConsumedRef.current || !firstRunSeed) return - if (entityId !== firstRunSeed.revisionId && entityId !== firstRunSeed.appId) return - if (activeSessionId !== sessionId || messages.length > 0) return - seedConsumedRef.current = true - setFirstRunPrompt(firstRunSeed.seedMessage) - setFirstRunAutoSend(!!firstRunSeed.autoSend) - setFirstRunSeed(null) - }, [firstRunSeed, entityId, activeSessionId, sessionId, messages.length, setFirstRunSeed]) - const consumedRunNonceRef = useRef(null) - - // `handleRewind` is passed to every memo'd `AgentMessage`, so it must stay referentially - // stable — a streamed token must not recreate it and re-render the whole list. `messages`/ - // `busy` change every token, so read them through refs instead of capturing them. - const messagesRef = useRef(messages) - messagesRef.current = messages - const busyRef = useRef(busy) - busyRef.current = busy - - // Send one released queued message. Stable (only depends on `sendMessage`) so the queue's - // release effect doesn't churn on every token. - const sendQueued = useCallback( - (item: QueuedMessage) => { - stickRef.current = true - setShowJump(false) - // Any actual send supersedes a prior user-stop, so clear the marker here (covers the - // queue-release path; the manual path also clears it in handleSubmit) — otherwise the - // "Stopped" tag would smear onto the freshly-sent turn. - setStopped(false) - sendMessage( - item.fileParts && item.fileParts.length - ? item.text - ? {text: item.text, files: item.fileParts} - : {files: item.fileParts} - : {text: item.text}, - ).catch(ignoreStreamRejection) - }, - [sendMessage], - ) - - // Queue messages typed while a turn is streaming or paused on a HITL approval; released - // one-by-one once the turn truly settles (never mid-approval). A user stop is the exception — - // it voids the pending gate, so `stopped` lets a fresh send go immediately (not queue). - const {queued, submit, removeQueued, clearQueue, hitlPending} = useAgentChatQueue({ - status, - messages, - stopped, - sendQueued, - sessionId, - }) - // Latch the last non-empty queue so the row keeps its content while it animates closed on release. - const shownQueuedRef = useRef(queued) - if (queued.length > 0) shownQueuedRef.current = queued - - // Pending HITL gates for the paused turn, surfaced in the persistent ApprovalDock above the - // composer (not inline in the transcript, so a paused run can't scroll out of reach). Trace - // opens the paused turn's own trace drawer. - const openTraceDrawer = useSetAtom(openTraceDrawerAtom) - const pendingApprovals = useMemo(() => getPendingApprovals(messages), [messages]) - const openPausedTurnTrace = useMemo(() => { - const last = messages[messages.length - 1] - const traceId = last ? getMessageTraceId(last) : undefined - return traceId ? () => openTraceDrawer({traceId}) : undefined - }, [messages, openTraceDrawer]) - - // Publish this session's run state (single source of truth: drives the tab bar's status dot - // AND the Session inspector's live-watcher signal, which derives "streaming" from `running`). - // Precedence error > awaiting approval > running > idle. Reset to idle on unmount so a closed - // tab keeps no stale dot and stops claiming it's the live watcher. - useEffect(() => { - const status: SessionRunStatus = error - ? "error" - : hitlPending - ? "awaiting" - : busy - ? "running" - : "idle" - setSessionStatus({id: sessionId, status}) - }, [error, hitlPending, busy, sessionId, setSessionStatus]) - useEffect( - () => () => setSessionStatus({id: sessionId, status: "idle"}), - [sessionId, setSessionStatus], - ) - - // Consume a pending "Run in playground" request (declared above) via the queue's `submit`, - // so it interleaves with HITL approval / queued messages exactly like a manual send. - useEffect(() => { - if (!pendingRun || activeSessionId !== sessionId) return - // A new-session run is handled at the panel level first (it creates + activates a fresh - // session and clears the flag); this per-session consumer ignores it until then. - if (pendingRun.newSession) return - if (consumedRunNonceRef.current === pendingRun.nonce) return - consumedRunNonceRef.current = pendingRun.nonce - stickRef.current = true - setShowJump(false) - submit({text: pendingRun.text}) - setPendingRun(null) - }, [pendingRun, activeSessionId, sessionId, submit, setPendingRun]) - - // Surface a stream failure inline: stamp the parsed error onto the failing assistant turn so - // it renders as a red error bubble with the real reason (and persists with the session via the - // effect below), instead of a transient top banner + a generic "no response". FE-only — it - // uses the error useChat already has; the backend doesn't need to attach it to the trace. - useEffect(() => { - if (!error) return - const parsed = parseAgentRunError(error) - setMessages((prev) => { - const last = prev.length > 0 ? prev[prev.length - 1] : undefined - const existing = (last?.metadata as {runError?: {message?: string}} | undefined) - ?.runError - if (last?.role === "assistant") { - if (existing?.message === parsed.message) return prev // already stamped - const next = [...prev] - next[next.length - 1] = { - ...last, - metadata: {...(last.metadata as object | undefined), runError: parsed}, - } - return next - } - // No trailing assistant turn (failed before one existed) — add a minimal carrier. - return [ - ...prev, - { - id: `run-error-${generateId()}`, - role: "assistant", - parts: [], - metadata: {runError: parsed}, - } as (typeof prev)[number], - ] - }) - }, [error, setMessages]) - - // Persist the conversation whenever its stream settles (skip mid-stream). - useEffect(() => { - if (status === "streaming") return - persistMessages({id: sessionId, messages}) - }, [messages, status, sessionId, persistMessages]) - - // Bound the in-message expand-state store: on settle, drop entries whose owning message is gone - // (rewound / evicted / closed). Live = every open session's persisted messages ∪ this active one. - // `store.get` reads without subscribing, so this never adds re-renders on the streaming hot path. - const pruneExpanded = useSetAtom(pruneExpandedAtom) - useEffect(() => { - if (status === "streaming") return - const persisted = store.get(sessionMessagesAtom) - const live = new Set() - for (const sid in persisted) - for (const key of expandedKeysForMessages(persisted[sid])) live.add(key) - for (const key of expandedKeysForMessages(messages)) live.add(key) - pruneExpanded(live) - }, [messages, status, store, pruneExpanded]) - - // Stamp a first-seen timestamp on any newly-appeared LIVE message (user + assistant). - // Restored rows are excluded: their first-seen is the reload moment, not the turn's time — - // stamping them made old turns read "just now" until (or forever if) the trace never loads. - // Unstamped, their timestamp slot shows a pending placeholder, then the trace's real time. - useEffect(() => { - stampMessagesCreatedAt( - messages.filter((m) => !restoredIdsRef.current.has(m.id)).map((m) => m.id), - ) - }, [messages, stampMessagesCreatedAt]) - - // ── #4920 Application 1: refresh the config on a committed revision ── - // When the agent commits a new revision of itself, the backend emits a one-way - // `data-committed-revision` part (same channel as `data-trace`), whether the tool asked first - // or ran directly. On receipt we invalidate the latest-revision and - // inspect caches so the config panel, section drawers, and build-kit view all re-read the new - // config. Deduped by revision id so a re-render (token stream) doesn't re-invalidate. - const committedRevisionsSeenRef = useRef>(new Set()) - const setAgentCommitSignal = useSetAtom(agentSelfCommitSignalAtom) - useEffect(() => { - for (const message of messages) { - for (const part of message.parts) { - if ((part as {type?: string}).type !== "data-committed-revision") continue - const data = (part as {data?: {revisionId?: string; version?: string}}).data - // A stable key per commit: prefer the revision id, fall back to the whole payload. - const key = data?.revisionId ?? JSON.stringify(data ?? {}) ?? "committed" - if (committedRevisionsSeenRef.current.has(key)) continue - committedRevisionsSeenRef.current.add(key) - invalidateAgentCommittedRevisionCache() - if (data?.revisionId && data.revisionId !== entityId) { - // Capture the OUTGOING revision's parameters before switching, so the config - // panel can show what the agent changed (per-section indicators + summary). - const prevParameters = store.get( - workflowMolecule.selectors.configuration(entityId), - ) - setAgentCommitSignal({ - revisionId: data.revisionId, - version: data.version, - prevParameters: prevParameters ?? null, - at: Date.now(), - }) - switchEntity({currentEntityId: entityId, newEntityId: data.revisionId}) - } - } - } - }, [messages, entityId, switchEntity, store, setAgentCommitSignal]) - - // ── DT3 cancelled state: wrap stop() to mark the in-flight assistant turn ── - const markStopped = useCallback(() => { - const last = messages[messages.length - 1] - if (last && last.role === "assistant") setStopped(true) - }, [messages]) - - const handleStop = useCallback(() => { - markStopped() - stop() - }, [markStopped, stop]) - - // ── D9 teardown: abort the in-flight stream on unmount (tab close / revision swap) ── - // Keyed on sessionId: closing a tab or swapping the revision unmounts this conversation - // and should tear down its stream. - useEffect(() => { - return () => { - stop() - } - }, [sessionId, stop]) - - // ── SC-3: anchor-based scroll preservation ── - // We do scroll-anchoring ourselves (Safari has no CSS overflow-anchor, and it would fight our - // programmatic pins). While NOT following, remember the topmost visible message; when content above - // it changes height (an image loads, markdown/code renders, a tool card expands), we compensate - // scrollTop so that message stays on the same line. Growth BELOW the anchor (the streaming answer) - // doesn't move it, so it's left alone. - const anchorRef = useRef<{id: string; top: number} | null>(null) - const recordAnchor = useCallback(() => { - const el = scrollRef.current - if (!el) return - const containerTop = el.getBoundingClientRect().top - for (const w of el.querySelectorAll("[data-mid]")) { - const r = w.getBoundingClientRect() - // First message whose bottom is still below the viewport top = the topmost visible one. - if (r.bottom > containerTop + 1) { - anchorRef.current = {id: w.dataset.mid ?? "", top: r.top - containerTop} - return - } - } - anchorRef.current = null - }, []) - - // ── DT4 autoscroll: stick to the bottom of the scrollable area while following ── - // The fill (min-h-full turn group) makes "question at top" the scroll bottom for a short answer - // and the answer's end the bottom for a long one, so scrollHeight is the right target (+ pb-6 gap). - // Only writes when not already pinned: the ResizeObserver (below) and the follow effect both pin on - // the same streamed growth, so the guard drops the redundant write (and the scroll event it fires). - const scrollToBottom = useCallback(() => { - const el = scrollRef.current - if (!el) return - const target = el.scrollHeight - el.clientHeight - if (el.scrollTop < target - 0.5) el.scrollTop = target - }, []) - - // Recompute jump-pill visibility, coalesced to one rAF per frame. The measurement (atLiveEdge → - // querySelectorAll + getBoundingClientRect) is display-only, so a one-frame lag is invisible; the - // correctness-critical follow decision (stickRef) and SC-3 anchor stay synchronous in onScroll. - const scheduleShowJump = useCallback(() => { - if (showJumpRafRef.current) return - showJumpRafRef.current = requestAnimationFrame(() => { - showJumpRafRef.current = 0 - const el = scrollRef.current - if (!el) return - setShowJump(!stickRef.current && !atLiveEdge(el)) - }) - }, []) - - // Smoothly scroll the log to `target` (the SC-1 pin / jump-to-latest). Uses the browser's NATIVE - // smooth scroll so it runs on the compositor — smooth even while React re-renders streamed tokens, - // and natively interruptible. The caller holds programmaticScrollRef across it so onScroll / the - // ResizeObserver ignore the in-between frames; `scrollend` (or a fallback timeout) settles it. A - // real user wheel/touch hands control straight back. Honors prefers-reduced-motion (instant). - const animatePinTo = useCallback((el: HTMLDivElement, target: number, onSettle: () => void) => { - pinCleanupRef.current?.() - const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches - if (reduce || Math.abs(target - el.scrollTop) < 2) { - el.scrollTop = target - onSettle() - return - } - let done = false - let timer = 0 - const cleanup = () => { - el.removeEventListener("scrollend", onEnd) - el.removeEventListener("wheel", onUser) - el.removeEventListener("touchstart", onUser) - if (timer) clearTimeout(timer) - pinCleanupRef.current = null - } - // Reached the target (scrollend, or the fallback timer) → settle: recordAnchor + release guard. - const onEnd = () => { - if (done) return - done = true - cleanup() - onSettle() - } - // User grabbed the scroll mid-glide → stop guarding so their scroll is honored; don't settle. - const onUser = () => { - if (done) return - done = true - cleanup() - programmaticScrollRef.current = false - } - el.addEventListener("scrollend", onEnd) - el.addEventListener("wheel", onUser, {passive: true}) - el.addEventListener("touchstart", onUser, {passive: true}) - timer = window.setTimeout(onEnd, 700) // fallback where scrollend is unsupported (older Safari) - // Cancel without settling (a newer pin supersedes this one, or we unmount). - pinCleanupRef.current = () => { - done = true - cleanup() - } - el.scrollTo({top: target, behavior: "smooth"}) - }, []) - - // Stop any in-flight pin animation on unmount (tab close / revision swap). - useEffect( - () => () => { - pinCleanupRef.current?.() - if (showJumpRafRef.current) cancelAnimationFrame(showJumpRafRef.current) - }, - [], - ) - - // After each commit, mark on-screen messages as seen so they don't re-animate on later renders - // (e.g. streaming tokens). Done in an effect, not during render, so StrictMode's double invoke - // doesn't mark a brand-new message before its first paint and rob it of the fade. - useEffect(() => { - for (const m of messages) seenIdsRef.current.add(m.id) - }, [messages]) - - useEffect(() => { - if (useVirtuoso) return - // Don't instant-jump while a programmatic glide (SC-1 submit / jump-to-latest) owns the - // scroll — that snap would override the animation. The glide's own settle re-pins to bottom. - if (stickRef.current && !programmaticScrollRef.current) scrollToBottom() - }, [messages, status, scrollToBottom, useVirtuoso]) - - const onScroll = useCallback(() => { - const el = scrollRef.current - if (!el) return - // Track scrollTop even for our own pins (recorded, then ignored) so the next real event has an - // accurate baseline to compare against. - const prevTop = lastScrollTopRef.current - lastScrollTopRef.current = el.scrollTop - // Ignore the scroll event our own pin produced — only a real user scroll changes follow state. - if (programmaticScrollRef.current) return - // Follow ONLY when at the very bottom of the scrollable area; a partial scroll must not enable - // it (that was the yank). Re-arm follow ONLY when the user actively scrolls DOWN to the edge (or - // is already following): a content shrink (tool gutter collapsing to "Used N tools", reasoning - // folding) clamps scrollTop to the new smaller bottom and fires a scroll event, but a clamp only - // ever DECREASES scrollTop, so `> prevTop` rejects it — otherwise the next token would snap the - // min-h-full active turn to the top (reported as the chat "jumping to the top" mid-stream). - const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24 - stickRef.current = atBottom && (stickRef.current || el.scrollTop > prevTop) - // Anchor is correctness-critical for SC-3 (the RO reads it next resize) → capture synchronously. - if (!stickRef.current) recordAnchor() - // Pill is display-only → coalesce its costly measurement to one rAF/frame. - scheduleShowJump() - }, [recordAnchor, scheduleShowJump]) - - const jumpToLatest = useCallback(() => { - const el = scrollRef.current - if (!el) return - setShowJump(false) - // Glide to the bottom like the SC-1 pin. Resume follow (stickRef) only ON SETTLE — flipping - // it true now would let the per-token follow effect jam to the bottom mid-glide. The final - // scrollToBottom catches any content that streamed in during the animation. - programmaticScrollRef.current = true - animatePinTo(el, el.scrollHeight, () => { - el.scrollTop = el.scrollHeight - stickRef.current = true - programmaticScrollRef.current = false - }) - }, [animatePinTo]) - - // SC-3: when any message resizes (image load, markdown/code render, tool-card expand), hold the - // reader's place. Following → keep pinned to the bottom; otherwise compensate scrollTop so the - // anchored (topmost visible) message stays on the same line. Guarded so it never fights our own - // pins. Re-subscribed when the message set changes (a part growing fires on the same wrapper). - useEffect(() => { - if (useVirtuoso) return - const el = scrollRef.current - if (!el) return - const onResize = (entries: ResizeObserverEntry[]) => { - // Pin each rendered row's REAL height as its own `content-visibility` placeholder, so it - // keeps the exact same box when it later scrolls off-screen. Only meaningful while - // content-visibility is enabled — otherwise `containIntrinsicSize` is inert, so skip the - // whole measurement to avoid a getBoundingClientRect + style write per row on every resize. - if (CONTENT_VISIBILITY_ENABLED) { - for (const e of entries) { - const node = e.target as HTMLElement - if (node === el) continue // the viewport itself is not a row — never pin it - const check = node.checkVisibility as - | ((o?: {contentVisibilityAuto?: boolean}) => boolean) - | undefined - if (check && !check.call(node, {contentVisibilityAuto: true})) continue - const h = Math.round(node.getBoundingClientRect().height) - if (h > 0) node.style.containIntrinsicSize = `auto ${h}px` - } - } - if (programmaticScrollRef.current) return - if (stickRef.current) { - scrollToBottom() // guarded: no-op if the follow effect already pinned this growth - return - } - const a = anchorRef.current - if (!a) return - let node: HTMLElement | null = null - try { - node = el.querySelector(`[data-mid="${a.id}"]`) - } catch { - node = null - } - if (!node) return - const delta = node.getBoundingClientRect().top - el.getBoundingClientRect().top - a.top - // A stale anchor (its node scrolled far off after a programmatic jump / follow) yields an - // implausible delta; applying it would slam the scroll to the top. Drop it and let the next - // scroll / pointer-down re-anchor. A real collapse/expand moves the anchor well under a viewport. - if (Math.abs(delta) > el.clientHeight) { - anchorRef.current = null - return - } - if (Math.abs(delta) > 0.5) { - programmaticScrollRef.current = true - el.scrollTop += delta - requestAnimationFrame(() => { - programmaticScrollRef.current = false - }) - } - } - const ro = new ResizeObserver(onResize) - // Observe the VIEWPORT too: the lazy composer/session-bar regions outside it hydrate a - // beat after mount and change this element's clientHeight — rows alone don't resize then, - // so without this the clamp shifts the view (following → re-pin; reading → hold anchor). - ro.observe(el) - el.querySelectorAll("[data-mid]").forEach((w) => ro.observe(w)) - return () => ro.disconnect() - }, [messages.length, scrollToBottom, useVirtuoso]) - - // SC-1 (submit) / SC-2 (restore): scroll the log to the bottom, once, when armed. With the active - // turn reserving a viewport (min-h-full + top padding to clear the fade), "bottom" shows the new - // question pinned at the top and the answer streaming into the space below — no per-element pin to - // compute, nothing to keep re-aligning as content arrives. A fresh submit glides; a restore jumps. - // Follow (stickRef) resumes only ON SETTLE so the per-token follow effect can't jam mid-glide. - useLayoutEffect(() => { - if (useVirtuoso) return - if (!armBottomRef.current) return - const el = scrollRef.current - if (!el) return - armBottomRef.current = false - programmaticScrollRef.current = true - const settle = () => { - el.scrollTop = el.scrollHeight // catch anything that streamed in during the glide - stickRef.current = true - programmaticScrollRef.current = false - } - if (animateBottomRef.current) { - animateBottomRef.current = false - animatePinTo(el, el.scrollHeight, settle) - } else { - el.scrollTop = el.scrollHeight - requestAnimationFrame(settle) - } - }, [messages, animatePinTo, useVirtuoso]) - - // Keep the jump pill honest as content streams/settles: show it when the real latest message is - // below the fold (e.g. a long answer growing past the viewport while parked at the top), and hide - // it once that message is visible or while we're following. Coalesced (not a sync layout read per - // streamed render) — the pill is display-only, so one frame of lag is imperceptible. - useEffect(() => { - if (useVirtuoso) return - scheduleShowJump() - }, [messages, status, scheduleShowJump, useVirtuoso]) - - // SC-4: interaction is intent, not just scrolling. While following, a real text selection inside - // the transcript — or opening a link in it — means the reader is engaging here, so release follow - // (exactly like a scroll). New content keeps arriving offscreen and the jump pill offers the way - // back. Keyboard / wheel / touch already release because they scroll (onScroll). The composer is - // exempt: its selections and links aren't inside the log, so `el.contains(...)` ignores them. - useEffect(() => { - const el = scrollRef.current - if (!el) return - const release = () => { - if (!stickRef.current) return - stickRef.current = false - setShowJump(!atLiveEdge(el)) - } - const onSelectionChange = () => { - if (!stickRef.current) return - const sel = window.getSelection() - if (!sel || sel.isCollapsed || sel.rangeCount === 0) return - if (sel.anchorNode && el.contains(sel.anchorNode)) release() - } - const onClick = (e: MouseEvent) => { - if ((e.target as HTMLElement | null)?.closest("a")) release() - } - document.addEventListener("selectionchange", onSelectionChange) - el.addEventListener("click", onClick) - return () => { - document.removeEventListener("selectionchange", onSelectionChange) - el.removeEventListener("click", onClick) - } - }, []) - - // ── SPIKE(virtuoso) scroll wiring (only active when the flag is on) ── - // Follow = stick to the bottom. Virtuoso's `followOutput` fires on item-count changes only, but the - // active turn streams inside the Footer (not an item), so drive stick manually on each update. - const virtFollowRef = useRef(true) - // While true (a short window after a submit), the follow tracks the bottom SMOOTHLY — so the sent - // question glides to the top and the streaming answer is tracked continuously (each token retargets - // the in-flight smooth scroll). Otherwise it snaps instantly to keep up with fast streaming. - const virtSmoothRef = useRef(false) - const virtSmoothTimerRef = useRef(0) - useEffect(() => { - if (!useVirtuoso || !virtFollowRef.current) return - const behavior: ScrollBehavior = virtSmoothRef.current ? "smooth" : "auto" - const id = requestAnimationFrame(() => virtuosoRef.current?.scrollTo({top: 1e9, behavior})) - return () => cancelAnimationFrame(id) - }, [messages, status, useVirtuoso]) - // SC-1 reserve for the virtuoso path: `min-h-full` doesn't work inside Virtuoso's Footer (100% - // resolves against its content-sized list, not the viewport), so measure the scroller's height and - // reserve it explicitly on the active-turn Footer — that's what lets a sent question pin to the top. - const virtRoRef = useRef(null) - const [virtViewportH, setVirtViewportH] = useState(0) - const setVirtScroller = useCallback((el: HTMLElement | Window | null) => { - virtRoRef.current?.disconnect() - const node = el instanceof HTMLElement ? el : null - if (!node) return - setVirtViewportH(node.clientHeight) - const ro = new ResizeObserver(() => setVirtViewportH(node.clientHeight)) - ro.observe(node) - virtRoRef.current = ro - }, []) - useEffect( - () => () => { - virtRoRef.current?.disconnect() - window.clearTimeout(virtSmoothTimerRef.current) - }, - [], - ) - // SC-1/2 equivalent: on submit/restore (armBottomRef), re-arm follow to the bottom once Virtuoso has - // mounted + measured (question-at-top emerges from the Footer's viewport reserve). A submit tracks - // smoothly for a short window; a restore snaps. The follow effect above does the per-token scrolling. - useLayoutEffect(() => { - if (!useVirtuoso || !armBottomRef.current) return - const animate = animateBottomRef.current - armBottomRef.current = false - animateBottomRef.current = false - virtFollowRef.current = true - setShowJump(false) - virtSmoothRef.current = animate - window.clearTimeout(virtSmoothTimerRef.current) - if (animate) { - virtSmoothTimerRef.current = window.setTimeout(() => { - virtSmoothRef.current = false - }, 600) - } - requestAnimationFrame(() => - requestAnimationFrame(() => - virtuosoRef.current?.scrollTo({top: 1e9, behavior: animate ? "smooth" : "auto"}), - ), - ) - }, [messages, useVirtuoso]) - const virtJumpToLatest = useCallback(() => { - virtFollowRef.current = true - setShowJump(false) - virtuosoRef.current?.scrollTo({top: 1e9, behavior: "smooth"}) - }, []) - // Stable component identities (Virtuoso remounts these if their identity changes). They read live - // content from `context`, which we pass fresh each render — so they re-render without remounting. - const virtComponents = useMemo>( - () => ({ - Header: ({context}) => <>{context?.header ?? null}, - Footer: ({context}) => <>{context?.footer ?? null}, - }), - [], - ) - - const toUploadFile = (file: File): UploadFile => ({ - uid: `${file.name}-${file.lastModified}-${file.size}`, - name: file.name, - status: "done", - originFileObj: file as UploadFile["originFileObj"], - }) - - /** Add files from paste / programmatic sources through the guardrails. */ - const addFiles = (incoming: File[]) => { - const {accepted, rejections: rej} = validateIncoming(incoming, files.length, limits) - if (accepted.length) { - setFiles((prev) => [...prev, ...accepted.map(toUploadFile)]) - setAttachmentsOpen(true) - } - setRejections(rej) - } - - const removeFile = (uid: string) => setFiles((prev) => prev.filter((f) => f.uid !== uid)) - - // Native drag-and-drop onto the whole panel. A depth counter ignores dragenter/leave from - // nested children so the overlay doesn't flicker; only file drags (not text) are handled. - const isFileDrag = (e: React.DragEvent) => Array.from(e.dataTransfer.types).includes("Files") - const onDragEnter = (e: React.DragEvent) => { - if (!isFileDrag(e)) return - dragDepthRef.current += 1 - setIsDragging(true) - } - const onDragOver = (e: React.DragEvent) => { - if (isFileDrag(e)) e.preventDefault() - } - const onDragLeave = (e: React.DragEvent) => { - if (!isFileDrag(e)) return - dragDepthRef.current -= 1 - if (dragDepthRef.current <= 0) { - dragDepthRef.current = 0 - setIsDragging(false) - } - } - const onDrop = (e: React.DragEvent) => { - if (!isFileDrag(e)) return - e.preventDefault() - dragDepthRef.current = 0 - setIsDragging(false) - const dropped = Array.from(e.dataTransfer.files) - if (dropped.length) { - addFiles(dropped) - setAttachmentsOpen(true) - } - } - - const handleSubmit = async (text: string) => { - const trimmed = text.trim() - const fileObjs = files - .map((f) => f.originFileObj as File | undefined) - .filter((f): f is File => Boolean(f)) - if (!trimmed && fileObjs.length === 0) return - const fileParts = fileObjs.length ? await filesToParts(fileObjs) : undefined - // Glide to the bottom; the min-h-full active turn makes that show the new question at the top - // with the answer streaming below. Park during the glide, follow again on settle. Clear any - // prior "stopped" marker — it's resolved by asking again. - stickRef.current = false - armBottomRef.current = true - animateBottomRef.current = true - setShowJump(false) - setStopped(false) - // One path: `submit` sends now or queues behind held messages via the shared release gate. - submit({text: trimmed, fileParts}) - // The message left the composer — drop its persisted draft (and any pending capture). - window.clearTimeout(draftTimerRef.current) - composerDraftBySession.delete(sessionId) - // Sending consumes the template provenance along with the composer text. - if (TEMPLATE_STRIP_MODE) stripProvenance.clear() - setFiles([]) - setRejections([]) - setAttachmentsOpen(false) - } - - // First-run auto-start: a freshly-created agent lands with a seeded prompt, but its model is often - // gated (no provider key yet). Connecting the key IS the go-ahead — so once the gate clears we send - // the seeded prompt automatically, rather than making them click Start a second time ("no explicit - // action twice"). Fires once, while the conversation is still empty, when EITHER: the model just - // unblocked (was gated), OR the seed is an explicit "go" (`firstRunAutoSend` — the onboarding - // Create-agent click) and the model is ready. A redirect-seed that merely arrived with a ready model - // still waits for Start. `handleSubmit` is read via a ref so the transition drives the send. - const handleSubmitRef = useRef(handleSubmit) - handleSubmitRef.current = handleSubmit - const autoStartedSeedRef = useRef(false) - const seedWasBlockedRef = useRef(false) - useEffect(() => { - if (!firstRunPrompt || autoStartedSeedRef.current) return - if (modelBlocked) { - seedWasBlockedRef.current = true - return - } - if ((!seedWasBlockedRef.current && !firstRunAutoSend) || messages.length > 0) return - autoStartedSeedRef.current = true - handleSubmitRef.current(firstRunPrompt) - }, [firstRunPrompt, firstRunAutoSend, modelBlocked, messages.length]) - - const handleRewind = useCallback( - (message: UIMessage) => { - const msgs = messagesRef.current - if (busyRef.current) return - const idx = msgs.findIndex((m) => m.id === message.id) - if (idx < 0) return - const isUser = message.role === "user" - const sideEffects = sideEffectingToolsInRange(msgs.slice(idx)) - - const run = () => { - if (isUser) { - setMessages(msgs.slice(0, idx)) - richInputRef.current?.setMarkdown(messageText(message)) - requestAnimationFrame(() => richInputRef.current?.focus()) - } else { - regenerate({messageId: message.id}).catch(ignoreStreamRejection) - } - } - - if (sideEffects.length > 0) { - modal.confirm({ - title: "Rewind past a tool that already ran?", - content: `${sideEffects.join(", ")} already executed. Rewinding re-runs the conversation from here but will NOT undo it.`, - okText: "Rewind anyway", - okButtonProps: {danger: true}, - cancelText: "Cancel", - centered: true, - style: {borderRadius: 16}, - onOk: run, - }) - } else { - run() - } - }, - [regenerate, setMessages, modal], - ) - - // Group the ACTIVE turn (the last user message + its response) into one wrapper that carries the - // fill. Keeping the fill on a STABLE element — not hopping it from the user bubble to the assistant - // bubble when the answer arrives — avoids the mid-stream layout jump. - const lastUserIndex = (() => { - for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "user") return i - return -1 - })() - const activeStart = lastUserIndex >= 0 ? lastUserIndex : messages.length - // The fill = min-h-full on the active turn whenever there's PRIOR conversation above it (so the - // question can sit at the top). Derived from layout, NOT from `busy` — so it persists when the turn - // settles instead of being yanked away (which clamped the scroll and jumped the view). - const reserveActive = activeStart > 0 - - const renderMessage = (message: UIMessage, index: number) => { - const isLast = index === messages.length - 1 - // New since mount → fade in once. Mark seen immediately so a re-render mid-stream (tokens - // arriving) doesn't re-arm the animation; the row keeps its mounted state by key anyway. - // Don't mutate seenIdsRef here — that runs during render (unsafe under StrictMode's double - // invoke). Marking happens in an effect after commit. - const enter = !seenIdsRef.current.has(message.id) - // A user turn has no trace of its own; borrow the paired (next) assistant turn's trace so its - // timestamp dates from the run, not this browser's first-seen stamp. - const turnTraceId = - message.role === "user" && messages[index + 1] - ? getMessageTraceId(messages[index + 1]) - : undefined - // While the inspector is open, an assistant turn tints when it's the target and is - // click-to-refocus otherwise (click any other turn to re-point the inspector at it). - const isAssistantTurn = message.role === "assistant" - const isInspected = - inspectorOpen && isAssistantTurn && message.id === inspectorTarget?.assistantMessageId - const onInspect = - inspectorOpen && isAssistantTurn - ? () => openTurnInspector({sessionId, assistantMessageId: message.id}) - : undefined - const showInspect = buildMode && isAssistantTurn - const showWorking = - isLast && busy && (!isAssistantTurn || message.parts.some(isVisiblePart)) - return ( - - 0 && isEmptyAssistantTurn(messages[index - 1]) - } - turnTraceId={turnTraceId} - /> - {/* Stopped tag + Resend belong only to the LAST assistant turn (the one you cancelled), - gated on position so it can never smear onto past turns. Cleared on resend / ask. */} - {stopped && isLast && message.role === "assistant" && ( -
- Stopped - -
- )} - {/* Meta row: "Inspect turn" + the working dots share ONE compact line under the - turn. The dots run for the WHOLE busy run — so gaps with no streaming output - (approval-resume cold-replay, between steps, server tool waits) never read as - frozen — and drop the moment the run settles. The affordance renders FIRST so - its left edge stays put (and aligned with older turns') when the trailing dots - unmount — no settle-time layout shift. An EMPTY streaming assistant turn - already renders its own loading bubble (AgentMessage), so the dots skip it — - exactly one indicator while busy. */} - {(showWorking || showInspect) && ( -
- {showInspect && ( - - )} - {showWorking && } -
- )} -
- ) - } - - // Strip era (TEMPLATE_STRIP_MODE): the bare "what do you want to build?" hero (no messages yet, - // nothing pending, not browsing the template gallery) is when the onboarding TemplateStrip docks - // directly above the composer, mirroring the agent-chat strip's bottom-anchored rhythm. - const showBareOnboardingHero = - TEMPLATE_STRIP_MODE && - onboardingActive && - messages.length === 0 && - !pendingFirstTurn && - !onboarding?.browseAll - - return ( -
- {/* Themed confirm dialogs (rewind-past-a-tool) mount through this holder. */} - {modalContextHolder} - {/* Chat column. The turn inspector is a flex sibling (below) so it pushes this column - aside rather than overlaying it. */} -
- {isDragging && ( -
- - - Drop files here - - - {limits.label} · up to {limits.maxCount},{" "} - {Math.round(limits.maxBytes / 1024 / 1024)} MB each - -
- )} - {/* Stream errors are surfaced inline on the failing turn (red error bubble with the - real reason), stamped in the effect above — no separate top-level banner. */} -
- {useVirtuoso && messages.length > 0 && ( - - ref={virtuosoRef} - scrollerRef={setVirtScroller} - data={messages.slice(0, activeStart)} - className="ag-canvas flex-1 [overflow-anchor:none]" - style={{maskImage: EDGE_FADE_MASK, WebkitMaskImage: EDGE_FADE_MASK}} - // Wide buffer so rows are rendered AND measured before they enter view — the - // height correction (85–1022px vs the estimate) then happens off-screen, so - // real content scrolls in without blanks or jitter. Tunable from settings. - increaseViewportBy={{ - top: virtOverscan, - bottom: Math.round(virtOverscan * 0.66), - }} - defaultItemHeight={virtItemEstimate} - // A prior mount's snapshot restores true row heights + scroll in the - // first frame; only a genuinely first visit anchors by index (the two - // props conflict, so exactly one is passed). - {...(virtRestoreState - ? {restoreStateFrom: virtRestoreState} - : { - initialTopMostItemIndex: { - index: Math.max(0, activeStart - 1), - align: "end" as const, - }, - })} - computeItemKey={(_i, m) => m.id} - itemContent={(index, m) => ( -
{renderMessage(m, index)}
- )} - atBottomStateChange={(atBottom) => { - virtFollowRef.current = atBottom - setShowJump(!atBottom) - }} - context={{ - header:
, - footer: - activeStart < messages.length ? ( -
- {messages - .slice(activeStart) - .map((m, i) => renderMessage(m, activeStart + i))} -
- ) : null, - }} - components={virtComponents} - /> - )} - {(!useVirtuoso || messages.length === 0) && ( -
{ - scrollRef.current = el - }} - onScroll={onScroll} - // Capture a fresh SC-3 anchor before a click acts (expand/collapse a tool step, - // reasoning fold): those resize the transcript without a scroll, so onScroll never - // refreshes the anchor and the ResizeObserver would compensate against a stale one. - onPointerDownCapture={recordAnchor} - role="log" - aria-live="polite" - aria-label="Agent conversation" - // `pt-8`/`pb-8` (32px) ≥ the 28px fades so the first message and the last turn's - // meta row (Inspect turn + streaming dots) clear them at rest; the bottom pad - // + `[overflow-anchor:none]` are the SC scroll-engineering essentials (browser - // anchoring off so our pin/anchor logic owns the scroll position). - className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overflow-x-hidden p-3 pt-8 pb-8 [overflow-anchor:none]" - // Fade content into the top edge (under the tab bar) and the bottom edge (into the - // composer) as it scrolls. A gradient mask on the scroll container: transparent at - // each edge → opaque across the middle. GPU-composited, no JS, theme-agnostic. - style={{ - maskImage: EDGE_FADE_MASK, - WebkitMaskImage: EDGE_FADE_MASK, - }} - > - {messages.length === 0 && - (pendingFirstTurn ? ( - // Optimistic first turn: the submitted description as a sent user bubble + - // an assistant loading placeholder (mirrors a real `status:"submitted"` - // turn), so the commit reads as one continuous chat, not an empty state. - - - - - ) : onboardingActive && onboarding?.browseAll ? ( - // "Browse all templates" swaps the hero for the full gallery IN PLACE. - - ) : ( - - richInputRef.current?.setMarkdown(text) - } - /> - ))} - {messages.slice(0, activeStart).map((m, i) => renderMessage(m, i))} - {activeStart < messages.length && ( - // The active turn reserves a viewport (min-h-full) when there's prior - // conversation, so sticking to the bottom shows the question at the top with the - // answer streaming into the space below — the "pin" is this layout, not JS. - // `pt-8` keeps the question clear of the top fade once it reaches the top. -
- {messages - .slice(activeStart) - .map((m, i) => renderMessage(m, activeStart + i))} -
- )} -
- )} - - {/* Always mounted so it can fade + slide in/out; hidden state is non-interactive and - keeps `-translate-x-1/2` (Tailwind composes x/y translate on one transform). */} - -
- - {/* Queue sits BETWEEN the messages and the composer, so showing it never shifts the - composer (and the editor) upward. Streaming itself is signalled by the composer's - send button (it becomes a spinning Stop button), so there's no "Streaming…" row. */} - 0} className={CHAT_COLUMN}> -
- -
-
- - {/* Rich markdown composer (Lexical). Enter sends; attachments via header/prefix slots. - Wrapper `px-3` keeps the session-bar gutter; the input centers on CHAT_COLUMN so it - aligns with the (also centered) message column when the panel is wide. The persistent - HITL approval dock lives in this same block (above the input) — always mounted so it - animates in/out, and inside the composer region so the paused gate can't scroll out - of reach and its collapse adds no gap to the surrounding column. */} - {/* The whole composer fades + rises in ONCE on mount (Reveal), so the input joins the - empty-state/hero entrance instead of popping. Mount-only: it never remounts across the - onboarding→chat transitions, so this never reintroduces layout shift on state changes. */} - - {/* Agent empty-chat strip (S6): docked above the composer, unmounts once a - message exists or a first-run prompt is pending. Build-mode + fresh-agent - only — never in maximized chat mode, and gone for good after any commit. */} - {TEMPLATE_STRIP_MODE && - !onboardingActive && - buildMode && - isFreshAgentRevision && - messages.length === 0 && - !firstRunPrompt && - !pendingFirstTurn ? ( -
- -
- ) : null} - {/* Always mounted so it animates in/out (RevealCollapse) instead of popping. Pre-commit - onboarding SUPPRESSES it — the provider-key check is deferred until the agent is - committed (Create-agent then runs the connect→unlock→auto-send flow on the real agent). */} -
- -
- - {/* Owner call: a template pick must not shift the composer, so no chip renders here - (unlike the home surface) — the strip card's own selected state is the - "which template" indicator; the composer text is the only other feedback. */} - {/* Onboarding strip: docked directly above the composer (mb-3 gap), mirroring the - agent-chat strip's rhythm — hero stays top-aligned above the flex space, and - the strip + composer read as one bottom-anchored cluster. */} - {showBareOnboardingHero ? ( -
- -
- ) : null} - {/* Composer region hydrates independently (Lexical chunk); the fallback is the - same skeleton the pane-level gates render for this slot, so the box never - changes shape — the editor just materializes inside it. */} - }> - handleCreateAgent() : handleSubmit} - disabled={onboardingActive ? ideHandoffActive : modelBlocked} - hideSendButton={onboardingActive} - submitOnEnter={!onboardingActive} - placeholder={ - onboardingActive - ? ideHandoffActive - ? "Continue in your IDE from the steps above — or start over." - : "e.g. Watch our #support channel, triage each thread by urgency, and route it to the right owner — ask me before closing anything." - : modelBlocked - ? "Connect a model to start chatting…" - : "Ask the agent… (Enter to send, ⌘/Ctrl+Enter for newline)" - } - initialMarkdown={initialDraft} - onChange={handleComposerChange} - onPasteFile={(pasted) => addFiles(Array.from(pasted))} - sendForceEnabled={files.length > 0} - streaming={busy} - onStop={handleStop} - prefix={ - // Attach button is gated until the agent service is ready for inline - // file parts (big-agents d4b119af26); paste / drag-to-add still work. - - - ) : ( -
- {TEMPLATE_STRIP_MODE ? ( - // Strip era: the IDE handoff is a one-click copy + toast, no modal/bubble. - - ) : ( - - )} - -
- ) - ) : undefined - } - /> -
-
-
- - {TEMPLATE_STRIP_MODE ? ( - setCopiedToastOpen(false)} - /> - ) : null} -
- ) -} - /** * AgentChatPanel — the agent-generation surface hosted INSIDE the playground (the third * generation arm beside chat and completion). @@ -2033,18 +51,8 @@ const AgentConversation = ({ * preserves a session's live stream / approval state. Each tab is its own `useChat` driven by * `buildAgentRequest` against the current `entityId` (so the run always uses the live draft config). */ -const AgentChatPanel = ({ - entityId, - onMounted, -}: { - entityId: string - /** Fired once after first commit — lets the crossfade host dissolve its skeleton overlay. */ - onMounted?: () => void -}) => { +const AgentChatPanel = ({entityId}: {entityId: string}) => { const scope = useChatScopeKey() - useEffect(() => { - onMounted?.() - }, [onMounted]) // Pre-commit onboarding: one ephemeral session, no multi-session UX — hide the whole session bar // (tabs / new / search / history). Stays hidden through the commit + first send, then eases in a beat // later (`chromeRevealed`) so the bar doesn't push the transcript down mid-send. @@ -2131,7 +139,12 @@ const AgentChatPanel = ({ {/* Rail pane is width-0 unless maximized, so no visible fallback is needed. */} {/* min-w matches RAIL_MIN_WIDTH (Tailwind needs the literal). */} - + + +
@@ -2151,28 +164,31 @@ const AgentChatPanel = ({ className="min-w-0 shrink-0 overflow-hidden motion-safe:transition-[height] motion-safe:duration-[240ms] motion-safe:ease-[cubic-bezier(0.4,0,0.2,1)]" style={{height: chromeHidden || chatMaximized ? 0 : 48}} > - {/* Region fallback = the same bar skeleton the pane-level gates render, - so the strip's lane holds its shape while this chunk loads. */} + {/* Region fallback = the same bar skeleton the pre-confirmation gate + renders, so the strip's lane holds its shape while this chunk loads; the + real bar eases in over it (MountFade) instead of popping. */} }> - renameSession({id, title})} - showSessions={!chatMaximized} - extra={ - chatMaximized ? undefined : ( - <> - - - - ) - } - /> + + renameSession({id, title})} + showSessions={!chatMaximized} + extra={ + chatMaximized ? undefined : ( + <> + + + + ) + } + /> +
)} @@ -2181,11 +197,17 @@ const AgentChatPanel = ({ // Bar is rendered by `renderTabBar` (SessionTagBar); the per-item label is unused. label: null, children: ( - + // The heavy conversation body hydrates behind its own transcript/composer + // skeleton (same shape the frame reserves) and eases in over it. + }> + + + + ), }))} /> diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanelHost.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanelHost.tsx deleted file mode 100644 index 5eafa4c208b..00000000000 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanelHost.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import {useCallback, useEffect, useState} from "react" - -import dynamic from "next/dynamic" - -import AgentChatSkeleton from "./components/AgentChatSkeleton" - -// No `loading` fallback here on purpose — the skeleton below is a persistent overlay, -// not a discarded placeholder, so the swap can be a crossfade instead of a replace. -// Once the module has loaded ONCE, later host mounts (navigate away and back) render the -// panel synchronously — the overlay must not re-arm, or every re-entry flashes a skeleton -// over content that is already available. -let agentChatPanelModuleLoaded = false -const AgentChatPanel = dynamic( - () => - import("./AgentChatPanel").then((m) => { - agentChatPanelModuleLoaded = true - return m - }), - {ssr: false}, -) - -/** - * Crossfade host for the lazy agent chat panel. On the FIRST load the skeleton stays - * mounted while the heavy chunk loads AND while the real panel commits beneath it at - * opacity 0; once the panel signals mounted, the skeleton dissolves and the panel fades - * in — the components materialize through the skeleton in place, instead of a - * discard → gap → sudden pop. On later mounts (module warm) the overlay is skipped - * entirely and the panel paints in the first frame. The overlay never intercepts pointer - * events, so the panel is interactive the moment it exists. - */ -const AgentChatPanelHost = ({entityId}: {entityId: string}) => { - const [ready, setReady] = useState(() => agentChatPanelModuleLoaded) - // Unmount the overlay only after the fade has played (timeout, not transitionend — - // reduced-motion environments may never fire the event). - const [overlayGone, setOverlayGone] = useState(() => agentChatPanelModuleLoaded) - const onMounted = useCallback(() => setReady(true), []) - useEffect(() => { - if (!ready || overlayGone) return - const t = window.setTimeout(() => setOverlayGone(true), 350) - return () => window.clearTimeout(t) - }, [ready, overlayGone]) - - return ( -
-
- -
- {overlayGone ? null : ( -
- -
- )} -
- ) -} - -export default AgentChatPanelHost diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx new file mode 100644 index 00000000000..d007f6b5545 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -0,0 +1,2005 @@ +import { + lazy, + Suspense, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type MutableRefObject, +} from "react" + +import {markTraceAsFresh} from "@agenta/entities/trace" +import {invalidateAgentCommittedRevisionCache, workflowMolecule} from "@agenta/entities/workflow" +import { + agentShouldResumeAfterApproval, + buildAgentRequest, + buildTurnCapture, + playgroundController, +} from "@agenta/playground" +import {agentSelfCommitSignalAtom, simulatedAgentRunAtomFamily} from "@agenta/shared/state" +import {generateId} from "@agenta/shared/utils" +import {HeightCollapse} from "@agenta/ui" +import {type RichChatInputHandle} from "@agenta/ui/rich-chat-input" +import {useChat} from "@ai-sdk/react" +import {Bubble} from "@ant-design/x" +import { + ArrowDown, + ArrowRight, + Code, + Paperclip, + Terminal, + TreeStructure, + UploadSimple, +} from "@phosphor-icons/react" +import {type UIMessage} from "ai" +import {App, Button, Modal, Tag, Tooltip} from "antd" +import type {UploadFile} from "antd" +import {useAtom, useAtomValue, useSetAtom, useStore} from "jotai" +import {useRouter} from "next/router" +import {Virtuoso, type Components, type StateSnapshot, type VirtuosoHandle} from "react-virtuoso" + +import { + IDE_INSTALL_COMMAND, + TEMPLATE_STRIP_MODE, +} from "@/oss/components/pages/agent-home/assets/constants" +import { + captureFirstAgentIntent, + classifyAgentIntent, + truncateForCapture, +} from "@/oss/components/pages/agent-home/assets/onboardingAnalytics" +import {type AgentTemplate} from "@/oss/components/pages/agent-home/assets/templates" +import OnboardingBrowseTemplates from "@/oss/components/pages/agent-home/PlaygroundOnboarding/OnboardingBrowseTemplates" +import {useOptionalOnboardingContext} from "@/oss/components/pages/agent-home/PlaygroundOnboarding/OnboardingContext" +import Reveal from "@/oss/components/pages/agent-home/PlaygroundOnboarding/Reveal" +import {openTraceDrawerAtom} from "@/oss/components/SharedDrawers/TraceDrawer/store/traceDrawerStore" +import TemplateStrip from "@/oss/components/TemplateStrip" +import {buildCodingAgentClipboard} from "@/oss/components/TemplateStrip/assets/codingAgentClipboard" +import {STRIP_COPY} from "@/oss/components/TemplateStrip/assets/constants" +import CopiedToast from "@/oss/components/TemplateStrip/components/CopiedToast" +import {useTemplateProvenance} from "@/oss/components/TemplateStrip/hooks/useTemplateProvenance" +import {usePostHogAg} from "@/oss/lib/helpers/analytics/hooks/usePostHogAg" + +import {AgentChatTransport} from "./assets/AgentChatTransport" +import { + type AttachmentRejection, + DEFAULT_ATTACHMENT_LIMITS, + validateIncoming, +} from "./assets/attachments" +import {filesToParts} from "./assets/files" +import {messageText, sideEffectingToolsInRange} from "./assets/rewind" +import {getMessageTraceId} from "./assets/trace" +import AgentChatEmptyState from "./components/AgentChatEmptyState" +import {ComposerSkeleton} from "./components/AgentChatSkeleton" +import AgentMessage from "./components/AgentMessage" +import ApprovalDock, {getPendingApprovals} from "./components/ApprovalDock" +import type {ClientToolOutputHandler} from "./components/clientTools" +import ComposerAttachments from "./components/ComposerAttachments" +import ConnectModelBanner from "./components/ConnectModelBanner" +import QueuedMessages from "./components/QueuedMessages" +import RevealCollapse from "./components/RevealCollapse" +import TurnInspector from "./components/TurnInspector/TurnInspector" +import {useAgentChatQueue, type QueuedMessage} from "./hooks/useAgentChatQueue" +import {useAgentModelKeyStatus} from "./hooks/useAgentModelKeyStatus" +import {expandedKeysForMessages, pruneExpandedAtom} from "./state/expandState" +import {agentFirstRunSeedAtom} from "./state/firstRunSeed" +import {chatPanelMaximizedAtom} from "./state/panelLayout" +import {useChatScopeKey} from "./state/scope" +import { + type SessionRunStatus, + activeSessionIdAtomFamily, + persistSessionMessagesAtom, + sessionMessagesAtom, + setSessionStatusAtom, + stampMessagesCreatedAtAtom, +} from "./state/sessions" +import {captureTurnRequestAtom} from "./state/turnCaptures" +import {turnInspectorAtom} from "./state/turnInspector" +import { + agentChatItemEstimateAtom, + agentChatOverscanAtom, + agentChatVirtualizeAtom, + isAgentChatVirtualizationAvailable, +} from "./state/virtualization" + +// The composer carries Lexical — the heaviest dependency of this chunk — out of the +// conversation's synchronous mount; React.lazy (not next/dynamic) so the imperative handle +// ref forwards. Its fallback is the same ComposerSkeleton the frame reserves for this slot. +const RichChatInput = lazy(() => + import("@agenta/ui/rich-chat-input").then((m) => ({default: m.RichChatInput})), +) + +/** A stream error/abort is already surfaced via `useChat`'s `onError` + the in-chat `error` + * alert; swallow the floating `sendMessage`/`regenerate` rejection so it doesn't bubble to the + * Next.js dev Runtime Error overlay (F-033). */ +const ignoreStreamRejection = () => {} + +// Virtuoso state (measured row heights + scrollTop) per session, captured before a route +// change unmounts the transcript. A fresh Virtuoso mount otherwise renders with height +// ESTIMATES, measures the real rows async, then corrects — a visible reshuffle on every +// re-entry (rows here span 85–1022px, so the correction is large). Restoring the snapshot +// paints the transcript at its true geometry and scroll position in the first frame. +const virtStateBySession = new Map() + +// Unsent composer drafts per session — survive pane remounts (route re-entry, tab +// close/reopen), so switching back to a session restores its in-progress message. +const composerDraftBySession = new Map() + +// Pending (not yet sent) attachments per session — same lifetime as the drafts. In-memory +// only: `UploadFile.originFileObj` holds live File blobs, which can't be serialized anyway. +const attachmentsBySession = new Map() + +/** Height of the top-edge fade, in px. Shared by the CSS mask and the SC-1 pin so a pinned turn + * lands BELOW the fade (otherwise the freshly-asked question renders partially faded). */ +const TOP_FADE_PX = 28 +/** Height of the bottom-edge fade, matching the top so content dissolves into the composer edge. */ +const BOTTOM_FADE_PX = 28 +/** Edge fades for the message scroll area: transparent at the very top, fully opaque by TOP_FADE_PX, + * then fading back to transparent over the last BOTTOM_FADE_PX. Applied as a CSS mask so the content + * itself fades (correct in any theme). */ +const EDGE_FADE_MASK = `linear-gradient(to bottom, transparent 0, #000 ${TOP_FADE_PX}px, #000 calc(100% - ${BOTTOM_FADE_PX}px), transparent 100%)` +/** Centered reading column for the chat body. Caps line length / bubble width so a wide (maximized) + * panel doesn't sprawl into oversized bubbles and over-spaced turns; freed side space is whitespace. */ +const CHAT_COLUMN = "mx-auto w-full max-w-[880px]" + +/** Single source of truth for the (currently DISABLED) content-visibility optimization. Disabled in + * 5f0fa73d06 — it caused a scrollbar-shrink on first scroll-through — but the mechanism is kept so it + * can be re-enabled with a fix. Gates BOTH the CSS class and the SC-3 intrinsic-size measurement, so + * while off neither the styling nor the measurement runs. Typed `boolean` so the guards aren't + * flagged as always-false. Under Virtuoso it must stay off regardless (it corrupts item measurement). */ +const CONTENT_VISIBILITY_ENABLED = false as boolean + +/** + * One agent conversation for a single session tab. A `useChat` whose transport is fed by the + * PLAYGROUND request builder (`buildAgentRequest`) — the entity supplies the config/auth/ + * references, the session id is the tab's id and travels to the backend as `session_id`. + * Messages persist to localStorage (seeded on mount, written when the stream settles) so the + * tab survives a reload / revision swap. + * + * Design decisions baked in (docs/design/agent-workflows/playground-agent-generation.md): + * - D9 teardown: abort the in-flight stream on unmount (tab close / revision swap). + * - DT3 cancelled state: a stopped stream tags its partial bubble "Stopped" + offers Resend. + * - DT4 autoscroll: stick to bottom while streaming; pause when scrolled up; "jump to latest". + * - DT5 a11y: the message log is an aria-live region; controls are keyboard-operable. + */ + +/** A part the transcript actually renders — non-empty text/reasoning, files, sources, tools. */ +const isVisiblePart = (p: UIMessage["parts"][number]): boolean => + (p.type === "text" && Boolean((p as {text?: string}).text?.trim())) || + (p.type === "reasoning" && Boolean((p as {text?: string}).text?.trim())) || + p.type === "file" || + p.type === "source-url" || + p.type.startsWith("tool-") || + p.type === "dynamic-tool" + +/** A settled assistant turn with no content at all — no answer, reasoning, tool, file, or + * source part. Mirrors AgentMessage's `!hasContent`; used to collapse a run of "no response" + * bubbles (e.g. repeated failed runs) down to the first one. */ +const isEmptyAssistantTurn = (m: UIMessage): boolean => + m.role === "assistant" && !m.parts.some(isVisiblePart) + +interface ParsedRunError { + message: string + code?: number +} + +/** + * Best-effort human reason from a useChat stream error. The server may hand us a clean string + * ("Agent run failed: …") or a JSON envelope (`{status:{code,message,…}}` / `{message}`) — pull + * the message out of either and drop the stacktrace / docs-url noise so it reads cleanly inline. + */ +const parseAgentRunError = (err: unknown): ParsedRunError => { + const raw = + err instanceof Error ? err.message : typeof err === "string" ? err : String(err ?? "") + const fallback = raw.trim() || "The agent run failed." + try { + const obj = JSON.parse(raw) as Record + const status = (obj?.status && typeof obj.status === "object" ? obj.status : obj) as Record< + string, + unknown + > + const message = + typeof status?.message === "string" + ? status.message + : typeof obj?.message === "string" + ? (obj.message as string) + : null + if (message) { + return {message, code: typeof status?.code === "number" ? status.code : undefined} + } + } catch { + // raw isn't JSON — it's already the human message. + } + return {message: fallback} +} + +/** The last real content element in the log (the last turn's last child). Used to measure the REAL + * content bottom and ignore the min-h-full reserve that pads a streaming turn — so the jump pill and + * stick-to-bottom track the latest message, not the bottom of the empty reserved space. */ +const lastContentEl = (el: HTMLElement): HTMLElement | null => { + const wrappers = el.querySelectorAll("[data-mid]") + const wrapper = wrappers[wrappers.length - 1] + if (!wrapper) return null + return (wrapper.lastElementChild as HTMLElement | null) ?? wrapper +} + +/** True when the latest message content sits at or above the viewport bottom (i.e. fully visible). */ +const atLiveEdge = (el: HTMLElement): boolean => { + const last = lastContentEl(el) + if (!last) return true + return last.getBoundingClientRect().bottom - el.getBoundingClientRect().bottom < 24 +} + +/** SPIKE(virtuoso): context passed to the Virtuoso Header/Footer slots (top padding + active turn). */ +interface VirtCtx { + header: React.ReactNode + footer: React.ReactNode +} + +/** + * One message row. Carries `data-mid` (load-bearing for the pin / anchor / ResizeObserver, which all + * query it). A message added after mount (`enter`) fades in — OPACITY ONLY, deliberately: opacity + * doesn't change geometry, so it can't move the scroll position or trip the SC-3 ResizeObserver. A + * restored thread's messages render with `enter=false` (no cascade). Honors reduced-motion: the + * initial transparency and the transition are both `motion-safe`, so it's instant-visible otherwise. + */ +const MessageRow = ({ + mid, + enter, + children, + inspected = false, + onInspect, + offscreenSkip = false, +}: { + mid: string + enter: boolean + children: React.ReactNode + /** This turn is the Turn Inspector's current target — tint it. */ + inspected?: boolean + /** Set (assistant turns, inspector open) → click the row to re-focus the inspector on it. */ + onInspect?: () => void + /** Settled row → `content-visibility:auto` so the browser skips its layout/paint while off-screen. + * `contain-intrinsic-size: auto` remembers the real height after first paint, so leaving the + * viewport causes no layout shift (heights here range ~85–1022px; no fixed estimate works). */ + offscreenSkip?: boolean +}) => { + const [shown, setShown] = useState(!enter) + // Reveal one frame after mount so the opacity transition plays. Deps are [] (NOT + // [enter]) on purpose: an `enter` flip when a sibling turn arrives must not cancel + // this rAF, or a just-sent message strands at opacity-0 for the whole agent run. + useEffect(() => { + const raf = requestAnimationFrame(() => setShown(true)) + return () => cancelAnimationFrame(raf) + }, []) + // Click-to-refocus: only while the inspector is open, and never over an interactive control or + // an active text selection (so buttons, links, and copy-select still work). + const handleClick = onInspect + ? (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest("button, a, input, textarea, [role='button']")) + return + if (!window.getSelection()?.isCollapsed) return + onInspect() + } + : undefined + // While the inspector is open, a turn is interactive: padded + rounded so the fill has breathing + // room. Inspected = a persistent, slightly stronger version of the hover fill (same visual + // language, just "held"). `box-border` is required (preflight off → content-box) so the padding + // doesn't overflow the 880px column. + const interactive = Boolean(onInspect) + // `shown || !enter` is a belt-and-suspenders: a settled row (id seen) is always visible. + return ( +
+ {children} +
+ ) +} + +/** Compact three-dot pulse for the meta row under the last turn — the run-in-progress signal. + * Deliberately NOT a Bubble: it shares one line with "Inspect turn" instead of adding a + * bubble-sized row of its own. */ +const WorkingDots = () => ( + + + + + +) + +const AgentConversation = ({ + entityId, + sessionId, + revealPlayedRef, +}: { + entityId: string + sessionId: string + /** Shared across the panel's session panes: the composer entrance plays only once. */ + revealPlayedRef: MutableRefObject +}) => { + const store = useStore() + const persistMessages = useSetAtom(persistSessionMessagesAtom) + const stampMessagesCreatedAt = useSetAtom(stampMessagesCreatedAtAtom) + const switchEntity = useSetAtom(playgroundController.actions.switchEntity) + const setSessionStatus = useSetAtom(setSessionStatusAtom) + const openTurnInspector = useSetAtom(turnInspectorAtom) + const inspectorTarget = useAtomValue(turnInspectorAtom) + const buildMode = !useAtomValue(chatPanelMaximizedAtom) + const inspectorOpen = inspectorTarget?.sessionId === sessionId + // Leaving Build for Chat dismisses the inspector — it's a Build-mode tool, and the panel would + // otherwise linger (and keep tinting a turn) in the maximized chat view. + useEffect(() => { + if (!buildMode && inspectorOpen) openTurnInspector(null) + }, [buildMode, inspectorOpen, openTurnInspector]) + + // Restored from the per-session store on remount (route re-entry, tab close/reopen) — + // pending attachments survive alongside the composer draft. Rejections stay transient. + const [files, setFiles] = useState( + () => attachmentsBySession.get(sessionId) ?? [], + ) + useEffect(() => { + if (files.length > 0) attachmentsBySession.set(sessionId, files) + else attachmentsBySession.delete(sessionId) + }, [files, sessionId]) + // Files turned away by the guardrails (too big, wrong type, over the count), shown inline. + const [rejections, setRejections] = useState([]) + const [attachmentsOpen, setAttachmentsOpen] = useState(false) + // Single limits object so it can later be swapped for capability-derived limits. + const limits = DEFAULT_ATTACHMENT_LIMITS + const atMax = files.length >= limits.maxCount + // Drag-over state for the whole-panel drop overlay (depth counter avoids child flicker). + const dragDepthRef = useRef(0) + const [isDragging, setIsDragging] = useState(false) + // Whether the LAST assistant turn was user-stopped. You can only cancel the in-flight (last) turn, + // so this is a single boolean gated on position at render time — independent of message ids (which + // can be missing/duplicated in restore/error paths and would otherwise smear the tag onto every + // turn). Cleared on the next send/resend. + const [stopped, setStopped] = useState(false) + // Seed once from the persisted store (read imperatively so our own writes don't feed back). + const [initialMessages] = useState(() => store.get(sessionMessagesAtom)[sessionId] ?? []) + // Ids already on screen — restored/settled turns don't re-animate; only turns added live fade in. + const seenIdsRef = useRef>(new Set(initialMessages.map((m) => m.id))) + // Immutable snapshot of the restored ids (seenIdsRef grows) — the first-seen stamping + // effect below skips these so a reload can't masquerade as the turns' send time. + const restoredIdsRef = useRef>(new Set(initialMessages.map((m) => m.id))) + // Themed confirm dialogs. The static `Modal.confirm` renders detached from the app's + // ConfigProvider, so it loses the theme (white box in dark mode). The hook form's + // `contextHolder` is rendered in-tree, so its dialogs inherit the theme — same look as the + // declarative EnhancedModal (centered, 16px radius). + const [modal, modalContextHolder] = Modal.useModal() + + const richInputRef = useRef(null) + + // Composer entrance plays once per PANEL mount — additional session panes mount the + // composer fully shown (the replayed fade read as a "composer reload" on session switch). + // Frozen at mount: recomputing per render would flip Reveal's `enabled` mid-entrance + // (the latch effect below runs before the fade completes). + const [playComposerEntrance] = useState(() => !revealPlayedRef.current) + useEffect(() => { + revealPlayedRef.current = true + }, [revealPlayedRef]) + + // Per-session unsent draft: restore once at mount (initialMarkdown is mount-only) and + // capture edits debounced — markdown is read from the handle at capture time, not per + // keystroke (serialization isn't free). + const [initialDraft] = useState(() => composerDraftBySession.get(sessionId)) + const draftTimerRef = useRef(0) + const handleComposerChange = useCallback( + (text: string) => { + window.clearTimeout(draftTimerRef.current) + draftTimerRef.current = window.setTimeout(() => { + const md = richInputRef.current?.getMarkdown() ?? text + if (md.trim()) composerDraftBySession.set(sessionId, md) + else composerDraftBySession.delete(sessionId) + }, 400) + }, + [sessionId], + ) + useEffect( + () => () => { + window.clearTimeout(draftTimerRef.current) + // Best-effort final capture on unmount (guarded — the editor may be detached). + const md = richInputRef.current?.getMarkdown() + if (md !== undefined) { + if (md.trim()) composerDraftBySession.set(sessionId, md) + else composerDraftBySession.delete(sessionId) + } + }, + [sessionId], + ) + const scrollRef = useRef(null) + // ── SPIKE(react-virtuoso): windowing variant, evaluated against content-visibility. ── + // Controlled live from the playground settings dropdown (Virtualization section). When on, the + // SC-1..4 scroll effects below are disabled (Virtuoso owns measurement/anchoring) and the + // transcript renders via ; overscan / row-estimate are tunable there too. + // Virtualize only when the env flag is present AND it's enabled in the settings — no other gates. + const virtEnabledInSettings = useAtomValue(agentChatVirtualizeAtom) + const useVirtuoso = isAgentChatVirtualizationAvailable() && virtEnabledInSettings + const virtOverscan = useAtomValue(agentChatOverscanAtom) + const virtItemEstimate = useAtomValue(agentChatItemEstimateAtom) + const virtuosoRef = useRef(null) + // Snapshot captured by a previous mount of this session (route re-entry). Read once at + // mount — `restoreStateFrom` is a mount-time-only Virtuoso prop. + const [virtRestoreState] = useState(() => + useVirtuoso ? virtStateBySession.get(sessionId) : undefined, + ) + const router = useRouter() + useEffect(() => { + if (!useVirtuoso) return + const capture = () => { + // getState is synchronous; guard the handle for the unmount-cleanup path. + virtuosoRef.current?.getState((snapshot) => { + virtStateBySession.set(sessionId, snapshot) + }) + } + // routeChangeStart fires while the transcript is still mounted and measured — the + // reliable capture point. The cleanup capture is best-effort (the handle may already + // be detached there), covering non-route unmounts like a revision-type swap. + router.events.on("routeChangeStart", capture) + return () => { + router.events.off("routeChangeStart", capture) + capture() + } + }, [useVirtuoso, sessionId, router]) + // Stick to the bottom of the scrollable area. This is the ONE source of truth for auto-scroll: + // the active turn reserves a viewport (min-h-full), so "bottom" puts the latest question at the + // top with the answer streaming into the space below — the pin is emergent, not computed. A real + // user scroll-up releases it (onScroll); jump-to-latest re-arms it. + const stickRef = useRef(true) + const [showJump, setShowJump] = useState(false) + // Arm a one-shot scroll to the bottom: on a fresh submit (glide) and on restoring a saved thread + // (instant). Combined with min-h-full this is the whole SC-1/SC-2 positioning — no per-element pin. + const armBottomRef = useRef(initialMessages.length > 0) + const animateBottomRef = useRef(false) + // Set while WE move the scroll (the bottom glide / SC-3 compensation). onScroll ignores the + // resulting event so our own scroll isn't mistaken for the user reaching/leaving the live edge. + const programmaticScrollRef = useRef(false) + // Teardown for the in-flight smooth scroll (removes its listeners + fallback timer). + const pinCleanupRef = useRef<(() => void) | null>(null) + // Last observed scrollTop. A content shrink (tool gutter collapsing, reasoning folding) clamps + // scrollTop to the new smaller bottom and fires a scroll event that isn't a user gesture; comparing + // against this lets onScroll tell a real scroll-DOWN-to-edge from that clamp (which only decreases). + const lastScrollTopRef = useRef(0) + // rAF handle coalescing the jump-pill measurement (querySelectorAll + getBoundingClientRect) to once + // per frame — a fast wheel/drag and every streamed render would otherwise re-measure a dirtied layout. + const showJumpRafRef = useRef(0) + + // `useChat` pins its `Chat` (and thus this transport) for the life of the session `id`; it is + // NOT recreated when `entityId` changes (only on an `id` change). So the request builder must + // read the CURRENT entity through a ref — capturing `entityId` by value would send every turn + // with the revision that was displayed when the session first mounted, even after a switch or a + // self-commit. Reading `entityIdRef.current` at send time keeps runs on the live revision. + const entityIdRef = useRef(entityId) + entityIdRef.current = entityId + + // Turn Inspector capture write, read via ref so the transport `useMemo` doesn't depend on it. + const captureTurnRequest = useSetAtom(captureTurnRequestAtom) + const captureRef = useRef(captureTurnRequest) + captureRef.current = captureTurnRequest + + // Transport feeds the v6 stream request from the playground pipeline. `api` here is a + // placeholder that `prepareSendMessagesRequest` overrides per request. + const transport = useMemo( + () => + new AgentChatTransport({ + api: "", + prepareSendMessagesRequest: async ({messages, id}) => { + const req = await buildAgentRequest(entityIdRef.current, messages, { + sessionId: id ?? sessionId, + }) + if (!req) { + throw new Error( + "This agent workflow has no invocation URL — it can’t be run yet.", + ) + } + captureRef.current(buildTurnCapture(req, generateId(), Date.now())) + return {api: req.invocationUrl, headers: req.headers, body: req.requestBody} + }, + }), + [sessionId], + ) + + const { + messages, + sendMessage, + status, + stop, + regenerate, + setMessages, + addToolApprovalResponse, + addToolOutput, + error, + } = useChat({ + id: sessionId, + messages: initialMessages, + transport, + // Coalesce stream deltas to ~1 UI commit / 50ms so a fast token stream doesn't drive a + // render per token; caps commit frequency independently of the per-commit memo win. + experimental_throttle: 50, + // Approve AND deny both resume — a deny-only decision must re-send so the runner + // gets the denial round-trip and the model continues (no `approval-responded` limbo). + sendAutomaticallyWhen: agentShouldResumeAfterApproval, + // The turn's trace may not be ingested yet when the row asks for its summary — + // marking it fresh lets the trace queries retry through the ingestion lag + // (historical traces get no such grace; a 404 there means the trace is gone). + onFinish: ({message}) => markTraceAsFresh(getMessageTraceId(message)), + onError: (err) => { + // Render the error in-chat (the `error` alert below); swallow it here so an + // aborted/errored stream doesn't bubble unhandled to the Next.js dev overlay (F-033). + console.warn("[AgentChatPanel] useChat error (rendered in-chat):", err) + }, + }) + + const busy = status === "submitted" || status === "streaming" + + // Settle a parked client tool (#4920). The dispatcher calls this from a widget (e.g. the connect + // widget) with the structured reference; `addToolOutput` matches the part by `toolCallId` on the + // last turn and the resume predicate auto-resends. `tool` is only the typed-tools key — matching + // is by id — so a cast onto the untyped UIMessage tool map is safe. + const handleClientToolOutput = useCallback( + ({toolName, toolCallId, output, errorText}) => { + if (errorText !== undefined) { + addToolOutput({ + state: "output-error", + tool: toolName as never, + toolCallId, + errorText, + }).catch(ignoreStreamRejection) + } else { + addToolOutput({ + tool: toolName as never, + toolCallId, + output: (output ?? {}) as never, + }).catch(ignoreStreamRejection) + } + }, + [addToolOutput], + ) + + // ── "Run in playground" seam (producer: a trigger drawer's Run-in-playground) ── + // A trigger fires server-side and never reaches the playground; this lets a user + // channel a trigger's resolved inputs into the active session. Only the ACTIVE + // session's conversation consumes the pending run (antd Tabs can keep inactive + // panes mounted), sends it as a user turn, and clears it. A monotonic nonce lets + // the same inputs run again; a ref guards double-firing. The consuming effect lives + // below `useAgentChatQueue` so the run goes through the same `submit` path as a manual + // send — respecting a pending HITL approval and any queued messages instead of jumping + // ahead with a raw `sendMessage`. + const scopeKey = useChatScopeKey() + const activeSessionId = useAtomValue(activeSessionIdAtomFamily(scopeKey)) + const pendingRun = useAtomValue(simulatedAgentRunAtomFamily(entityId)) + const setPendingRun = useSetAtom(simulatedAgentRunAtomFamily(entityId)) + + // Model connection: is the project vault empty (no key of any kind), the agent not self-managed, + // and the user never set up a key before? Drives the connect-a-model banner AND disables the + // composer until connected — see `gateActive` on `useAgentModelKeyStatus` for the full chain. + const modelKey = useAgentModelKeyStatus(entityId) + const modelBlocked = modelKey.gateActive + + // ── Playground-native onboarding ────────────────────────────────────────── + // This chat panel IS the onboarding surface while the agent is ephemeral: the empty state shows the + // "what do you want to build?" hero and the composer renders Create-agent / Continue-in-IDE controls + // (submit = commit the ephemeral in place, not send). Read from the OnboardingContext, present ONLY + // inside the onboarding playground — null everywhere else, so every other chat usage is unchanged. + const onboarding = useOptionalOnboardingContext() + const onboardingActive = !!onboarding && !onboarding.realEntityId + // Post-commit chrome (the connect-model banner) stays hidden through the commit + first send, then + // eases in a beat later (see `chromeRevealed`) so it doesn't move the composer during the send. + const chromeHidden = !!onboarding && !onboarding.chromeRevealed + const onboardingPosthog = usePostHogAg() + const {message: appMessage} = App.useApp() + + // ── Template strip (TEMPLATE_STRIP_MODE) ───────────────────────────────── + // One provenance instance per panel, shared by the onboarding hero strip (S5) and the + // agent empty-chat strip (S6): pick fills the composer + docks the chip above it. + const stripProvenance = useTemplateProvenance({ + composerApi: { + setText: (text) => richInputRef.current?.setMarkdown(text), + getText: () => richInputRef.current?.getMarkdown() ?? "", + }, + }) + // Provenance is scoped to ONE agent revision. `AgentConversation` survives an `entityId` + // change in place (see the self-commit `switchEntity` above and a revision swap) — without + // this, a template picked against the old entity would leak its name into the new one. + useEffect(() => { + stripProvenance.clear() + }, [entityId, stripProvenance.clear]) + // S6 gate: fresh agent only (`version` v0/v1 = creation, same seed-vs-history convention used + // elsewhere); unknown while loading counts as not-fresh so the strip never flashes in. + const revisionQuery = useAtomValue(workflowMolecule.selectors.query(entityId)) + const revisionVersion = revisionQuery.data?.version + const isFreshAgentRevision = + !revisionQuery.isPending && typeof revisionVersion === "number" && revisionVersion <= 1 + const [copiedToastOpen, setCopiedToastOpen] = useState(false) + const handleStripPick = useCallback( + (template: AgentTemplate) => { + stripProvenance.pick(template) + captureFirstAgentIntent(onboardingPosthog, { + source: "template", + properties: { + template: template.name, + templateId: template.key, + templateCategory: template.category, + mode: "strip", + surface: onboardingActive ? "onboarding" : "agent-chat", + }, + intentValue: template.category || template.name, + }) + }, + [stripProvenance.pick, onboardingPosthog, onboardingActive], + ) + const handleCodingAgentCopy = useCallback(async () => { + const text = richInputRef.current?.getMarkdown().trim() ?? "" + try { + await navigator.clipboard.writeText(buildCodingAgentClipboard(text)) + setCopiedToastOpen(true) + } catch { + appMessage.error("Couldn't copy — copy it manually") + return + } + captureFirstAgentIntent(onboardingPosthog, { + source: "composer", + properties: {action: "coding_agent_copy", message: truncateForCapture(text)}, + }) + }, [appMessage, onboardingPosthog]) + + // Optimistic first turn: the description the user submitted with "Create agent", shown as a sent + // user message + assistant loading placeholder DURING commit + until the real conversation takes + // over — so the onboarding hero never flashes back and the switch reads as one continuous chat. + const [pendingFirstTurn, setPendingFirstTurn] = useState(null) + + const handleCreateAgent = useCallback(() => { + if (!onboarding || onboarding.committing) return + const text = richInputRef.current?.getMarkdown().trim() ?? "" + // Resolve BEFORE clearing the composer below — `resolveTemplateName` compares against the + // live text, so reading it after the clear would always see "" and never match the seed. + const templateName = stripProvenance.resolveTemplateName(text) + setPendingFirstTurn(text || null) + // The text becomes the sent first turn — clear the composer so it doesn't linger into the chat. + richInputRef.current?.setMarkdown("") + // Free-text submit (never a template — those go straight through `onboarding.commit` from the + // template pickers below, source "template"), so no double-fire with those call sites. + if (text) { + captureFirstAgentIntent(onboardingPosthog, { + source: "composer", + properties: {message: truncateForCapture(text)}, + intentValue: classifyAgentIntent(text), + }) + } + onboarding.commit(text, templateName) + if (TEMPLATE_STRIP_MODE) stripProvenance.clear() + }, [onboarding, onboardingPosthog, stripProvenance.clear, stripProvenance.resolveTemplateName]) + + // Also cover the template-click commit path (which goes straight through `commit()`, not the + // Create button): whenever a commit is in flight, show its seed as the optimistic turn and clear + // any lingering composer text (e.g. a "Try" chip the user had prefilled). + useEffect(() => { + if (onboarding?.committing && onboarding.committingSeed) { + setPendingFirstTurn(onboarding.committingSeed) + richInputRef.current?.setMarkdown("") + } + }, [onboarding?.committing, onboarding?.committingSeed]) + + // Once the real conversation has a message (auto-send fired post-commit), the placeholder handed + // off — drop it so the real turn owns the view. + useEffect(() => { + if (messages.length > 0 && pendingFirstTurn) setPendingFirstTurn(null) + }, [messages.length, pendingFirstTurn]) + + // Commit failed (committing went true→false without producing a real agent): restore the hero so + // the user can retry, rather than stranding the placeholder with an eternal spinner. + const sawCommittingRef = useRef(false) + useEffect(() => { + if (onboarding?.committing) { + sawCommittingRef.current = true + } else if (sawCommittingRef.current && !onboarding?.realEntityId && messages.length === 0) { + sawCommittingRef.current = false + setPendingFirstTurn(null) + } + }, [onboarding?.committing, onboarding?.realEntityId, messages.length]) + + const pendingFirstMessage = useMemo( + () => ({ + id: "pending-first-turn", + role: "user", + parts: [{type: "text", text: pendingFirstTurn ?? ""}], + }), + [pendingFirstTurn], + ) + + // "Continue in IDE" — the user's prompt lands as a real user turn, and a streamed-looking assistant + // bubble hands off the install command + prompt (a pseudo response; there's no agent to run + // pre-commit). Two clear steps: install the skill, then give the coding agent the prompt — the prompt + // is NOT inside the shell block (it's not a command). Clears the composer so the text isn't duplicated. + // Holds the pending IDE-bubble typewriter timer so it can be cancelled on unmount (tab close, + // rewind, route change) — otherwise the recursive chain keeps calling setMessages on a stale closure. + const ideBubbleTimerRef = useRef(null) + const streamIdeBubble = useCallback(() => { + const prompt = richInputRef.current?.getMarkdown().trim() ?? "" + const promptQuote = prompt + .split("\n") + .map((line) => `> ${line}`) + .join("\n") + const full = prompt + ? `Prefer to build in your IDE? Install the Agenta skill for Claude Code, Cursor, or any coding agent:\n\n\`\`\`bash\n${IDE_INSTALL_COMMAND}\n\`\`\`\n\nThen hand it your prompt:\n\n${promptQuote}` + : `Prefer to build in your IDE? Install the Agenta skill for Claude Code, Cursor, or any coding agent:\n\n\`\`\`bash\n${IDE_INSTALL_COMMAND}\n\`\`\`\n\nThen describe the agent you want it to build.` + const id = `ide-${generateId()}` + const userId = `ide-user-${generateId()}` + stickRef.current = false + armBottomRef.current = true + animateBottomRef.current = true + setShowJump(false) + setStopped(false) + // Clear the composer — the prompt is now the sent user turn (and the editor is disabled after this). + richInputRef.current?.setMarkdown("") + setMessages( + (prev) => + [ + ...prev, + ...(prompt + ? [{id: userId, role: "user", parts: [{type: "text", text: prompt}]}] + : []), + {id, role: "assistant", parts: [{type: "text", text: ""}]}, + ] as typeof prev, + ) + let shown = 0 + const chunk = Math.max(3, Math.ceil(full.length / 36)) + const tick = () => { + shown = Math.min(full.length, shown + chunk) + const text = full.slice(0, shown) + setMessages( + (prev) => + prev.map((m) => + m.id === id ? {...m, parts: [{type: "text", text}]} : m, + ) as typeof prev, + ) + if (shown < full.length) ideBubbleTimerRef.current = window.setTimeout(tick, 28) + } + ideBubbleTimerRef.current = window.setTimeout(tick, 120) + }, [setMessages]) + + // Cancel any in-flight IDE-bubble animation on unmount so its timer chain can't fire post-unmount. + useEffect( + () => () => { + if (ideBubbleTimerRef.current) window.clearTimeout(ideBubbleTimerRef.current) + }, + [], + ) + + // After an IDE hand-off (onboarding + messages exist but nothing was committed), the chat is a + // dead-end — there's no agent to talk to. Disable the composer and offer a single "Start over". + const ideHandoffActive = onboardingActive && messages.length > 0 + const handleStartOver = useCallback(() => { + setMessages([]) + richInputRef.current?.setMarkdown("") + }, [setMessages]) + + // First-run seed: a freshly-created agent (from Home's composer/template) surfaces its starting + // prompt in the empty state (see AgentChatEmptyState) rather than pre-filling the composer, so it + // reads as "here's what we'll do" not stray user input. Consumed once by the active session on a + // fresh conversation, matching either the revision or app id, then cleared. + const [firstRunSeed, setFirstRunSeed] = useAtom(agentFirstRunSeedAtom) + const [firstRunPrompt, setFirstRunPrompt] = useState(null) + // An explicit-"go" seed (the onboarding Create-agent click) sends as soon as the model is ready. + const [firstRunAutoSend, setFirstRunAutoSend] = useState(false) + const seedConsumedRef = useRef(false) + useEffect(() => { + if (seedConsumedRef.current || !firstRunSeed) return + if (entityId !== firstRunSeed.revisionId && entityId !== firstRunSeed.appId) return + if (activeSessionId !== sessionId || messages.length > 0) return + seedConsumedRef.current = true + setFirstRunPrompt(firstRunSeed.seedMessage) + setFirstRunAutoSend(!!firstRunSeed.autoSend) + setFirstRunSeed(null) + }, [firstRunSeed, entityId, activeSessionId, sessionId, messages.length, setFirstRunSeed]) + const consumedRunNonceRef = useRef(null) + + // `handleRewind` is passed to every memo'd `AgentMessage`, so it must stay referentially + // stable — a streamed token must not recreate it and re-render the whole list. `messages`/ + // `busy` change every token, so read them through refs instead of capturing them. + const messagesRef = useRef(messages) + messagesRef.current = messages + const busyRef = useRef(busy) + busyRef.current = busy + + // Send one released queued message. Stable (only depends on `sendMessage`) so the queue's + // release effect doesn't churn on every token. + const sendQueued = useCallback( + (item: QueuedMessage) => { + stickRef.current = true + setShowJump(false) + // Any actual send supersedes a prior user-stop, so clear the marker here (covers the + // queue-release path; the manual path also clears it in handleSubmit) — otherwise the + // "Stopped" tag would smear onto the freshly-sent turn. + setStopped(false) + sendMessage( + item.fileParts && item.fileParts.length + ? item.text + ? {text: item.text, files: item.fileParts} + : {files: item.fileParts} + : {text: item.text}, + ).catch(ignoreStreamRejection) + }, + [sendMessage], + ) + + // Queue messages typed while a turn is streaming or paused on a HITL approval; released + // one-by-one once the turn truly settles (never mid-approval). A user stop is the exception — + // it voids the pending gate, so `stopped` lets a fresh send go immediately (not queue). + const {queued, submit, removeQueued, clearQueue, hitlPending} = useAgentChatQueue({ + status, + messages, + stopped, + sendQueued, + sessionId, + }) + // Latch the last non-empty queue so the row keeps its content while it animates closed on release. + const shownQueuedRef = useRef(queued) + if (queued.length > 0) shownQueuedRef.current = queued + + // Pending HITL gates for the paused turn, surfaced in the persistent ApprovalDock above the + // composer (not inline in the transcript, so a paused run can't scroll out of reach). Trace + // opens the paused turn's own trace drawer. + const openTraceDrawer = useSetAtom(openTraceDrawerAtom) + const pendingApprovals = useMemo(() => getPendingApprovals(messages), [messages]) + const openPausedTurnTrace = useMemo(() => { + const last = messages[messages.length - 1] + const traceId = last ? getMessageTraceId(last) : undefined + return traceId ? () => openTraceDrawer({traceId}) : undefined + }, [messages, openTraceDrawer]) + + // Publish this session's run state (single source of truth: drives the tab bar's status dot + // AND the Session inspector's live-watcher signal, which derives "streaming" from `running`). + // Precedence error > awaiting approval > running > idle. Reset to idle on unmount so a closed + // tab keeps no stale dot and stops claiming it's the live watcher. + useEffect(() => { + const status: SessionRunStatus = error + ? "error" + : hitlPending + ? "awaiting" + : busy + ? "running" + : "idle" + setSessionStatus({id: sessionId, status}) + }, [error, hitlPending, busy, sessionId, setSessionStatus]) + useEffect( + () => () => setSessionStatus({id: sessionId, status: "idle"}), + [sessionId, setSessionStatus], + ) + + // Consume a pending "Run in playground" request (declared above) via the queue's `submit`, + // so it interleaves with HITL approval / queued messages exactly like a manual send. + useEffect(() => { + if (!pendingRun || activeSessionId !== sessionId) return + // A new-session run is handled at the panel level first (it creates + activates a fresh + // session and clears the flag); this per-session consumer ignores it until then. + if (pendingRun.newSession) return + if (consumedRunNonceRef.current === pendingRun.nonce) return + consumedRunNonceRef.current = pendingRun.nonce + stickRef.current = true + setShowJump(false) + submit({text: pendingRun.text}) + setPendingRun(null) + }, [pendingRun, activeSessionId, sessionId, submit, setPendingRun]) + + // Surface a stream failure inline: stamp the parsed error onto the failing assistant turn so + // it renders as a red error bubble with the real reason (and persists with the session via the + // effect below), instead of a transient top banner + a generic "no response". FE-only — it + // uses the error useChat already has; the backend doesn't need to attach it to the trace. + useEffect(() => { + if (!error) return + const parsed = parseAgentRunError(error) + setMessages((prev) => { + const last = prev.length > 0 ? prev[prev.length - 1] : undefined + const existing = (last?.metadata as {runError?: {message?: string}} | undefined) + ?.runError + if (last?.role === "assistant") { + if (existing?.message === parsed.message) return prev // already stamped + const next = [...prev] + next[next.length - 1] = { + ...last, + metadata: {...(last.metadata as object | undefined), runError: parsed}, + } + return next + } + // No trailing assistant turn (failed before one existed) — add a minimal carrier. + return [ + ...prev, + { + id: `run-error-${generateId()}`, + role: "assistant", + parts: [], + metadata: {runError: parsed}, + } as (typeof prev)[number], + ] + }) + }, [error, setMessages]) + + // Persist the conversation whenever its stream settles (skip mid-stream). + useEffect(() => { + if (status === "streaming") return + persistMessages({id: sessionId, messages}) + }, [messages, status, sessionId, persistMessages]) + + // Bound the in-message expand-state store: on settle, drop entries whose owning message is gone + // (rewound / evicted / closed). Live = every open session's persisted messages ∪ this active one. + // `store.get` reads without subscribing, so this never adds re-renders on the streaming hot path. + const pruneExpanded = useSetAtom(pruneExpandedAtom) + useEffect(() => { + if (status === "streaming") return + const persisted = store.get(sessionMessagesAtom) + const live = new Set() + for (const sid in persisted) + for (const key of expandedKeysForMessages(persisted[sid])) live.add(key) + for (const key of expandedKeysForMessages(messages)) live.add(key) + pruneExpanded(live) + }, [messages, status, store, pruneExpanded]) + + // Stamp a first-seen timestamp on any newly-appeared LIVE message (user + assistant). + // Restored rows are excluded: their first-seen is the reload moment, not the turn's time — + // stamping them made old turns read "just now" until (or forever if) the trace never loads. + // Unstamped, their timestamp slot shows a pending placeholder, then the trace's real time. + useEffect(() => { + stampMessagesCreatedAt( + messages.filter((m) => !restoredIdsRef.current.has(m.id)).map((m) => m.id), + ) + }, [messages, stampMessagesCreatedAt]) + + // ── #4920 Application 1: refresh the config on a committed revision ── + // When the agent commits a new revision of itself, the backend emits a one-way + // `data-committed-revision` part (same channel as `data-trace`), whether the tool asked first + // or ran directly. On receipt we invalidate the latest-revision and + // inspect caches so the config panel, section drawers, and build-kit view all re-read the new + // config. Deduped by revision id so a re-render (token stream) doesn't re-invalidate. + const committedRevisionsSeenRef = useRef>(new Set()) + const setAgentCommitSignal = useSetAtom(agentSelfCommitSignalAtom) + useEffect(() => { + for (const message of messages) { + for (const part of message.parts) { + if ((part as {type?: string}).type !== "data-committed-revision") continue + const data = (part as {data?: {revisionId?: string; version?: string}}).data + // A stable key per commit: prefer the revision id, fall back to the whole payload. + const key = data?.revisionId ?? JSON.stringify(data ?? {}) ?? "committed" + if (committedRevisionsSeenRef.current.has(key)) continue + committedRevisionsSeenRef.current.add(key) + invalidateAgentCommittedRevisionCache() + if (data?.revisionId && data.revisionId !== entityId) { + // Capture the OUTGOING revision's parameters before switching, so the config + // panel can show what the agent changed (per-section indicators + summary). + const prevParameters = store.get( + workflowMolecule.selectors.configuration(entityId), + ) + setAgentCommitSignal({ + revisionId: data.revisionId, + version: data.version, + prevParameters: prevParameters ?? null, + at: Date.now(), + }) + switchEntity({currentEntityId: entityId, newEntityId: data.revisionId}) + } + } + } + }, [messages, entityId, switchEntity, store, setAgentCommitSignal]) + + // ── DT3 cancelled state: wrap stop() to mark the in-flight assistant turn ── + const markStopped = useCallback(() => { + const last = messages[messages.length - 1] + if (last && last.role === "assistant") setStopped(true) + }, [messages]) + + const handleStop = useCallback(() => { + markStopped() + stop() + }, [markStopped, stop]) + + // ── D9 teardown: abort the in-flight stream on unmount (tab close / revision swap) ── + // Keyed on sessionId: closing a tab or swapping the revision unmounts this conversation + // and should tear down its stream. + useEffect(() => { + return () => { + stop() + } + }, [sessionId, stop]) + + // ── SC-3: anchor-based scroll preservation ── + // We do scroll-anchoring ourselves (Safari has no CSS overflow-anchor, and it would fight our + // programmatic pins). While NOT following, remember the topmost visible message; when content above + // it changes height (an image loads, markdown/code renders, a tool card expands), we compensate + // scrollTop so that message stays on the same line. Growth BELOW the anchor (the streaming answer) + // doesn't move it, so it's left alone. + const anchorRef = useRef<{id: string; top: number} | null>(null) + const recordAnchor = useCallback(() => { + const el = scrollRef.current + if (!el) return + const containerTop = el.getBoundingClientRect().top + for (const w of el.querySelectorAll("[data-mid]")) { + const r = w.getBoundingClientRect() + // First message whose bottom is still below the viewport top = the topmost visible one. + if (r.bottom > containerTop + 1) { + anchorRef.current = {id: w.dataset.mid ?? "", top: r.top - containerTop} + return + } + } + anchorRef.current = null + }, []) + + // ── DT4 autoscroll: stick to the bottom of the scrollable area while following ── + // The fill (min-h-full turn group) makes "question at top" the scroll bottom for a short answer + // and the answer's end the bottom for a long one, so scrollHeight is the right target (+ pb-6 gap). + // Only writes when not already pinned: the ResizeObserver (below) and the follow effect both pin on + // the same streamed growth, so the guard drops the redundant write (and the scroll event it fires). + const scrollToBottom = useCallback(() => { + const el = scrollRef.current + if (!el) return + const target = el.scrollHeight - el.clientHeight + if (el.scrollTop < target - 0.5) el.scrollTop = target + }, []) + + // Recompute jump-pill visibility, coalesced to one rAF per frame. The measurement (atLiveEdge → + // querySelectorAll + getBoundingClientRect) is display-only, so a one-frame lag is invisible; the + // correctness-critical follow decision (stickRef) and SC-3 anchor stay synchronous in onScroll. + const scheduleShowJump = useCallback(() => { + if (showJumpRafRef.current) return + showJumpRafRef.current = requestAnimationFrame(() => { + showJumpRafRef.current = 0 + const el = scrollRef.current + if (!el) return + setShowJump(!stickRef.current && !atLiveEdge(el)) + }) + }, []) + + // Smoothly scroll the log to `target` (the SC-1 pin / jump-to-latest). Uses the browser's NATIVE + // smooth scroll so it runs on the compositor — smooth even while React re-renders streamed tokens, + // and natively interruptible. The caller holds programmaticScrollRef across it so onScroll / the + // ResizeObserver ignore the in-between frames; `scrollend` (or a fallback timeout) settles it. A + // real user wheel/touch hands control straight back. Honors prefers-reduced-motion (instant). + const animatePinTo = useCallback((el: HTMLDivElement, target: number, onSettle: () => void) => { + pinCleanupRef.current?.() + const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches + if (reduce || Math.abs(target - el.scrollTop) < 2) { + el.scrollTop = target + onSettle() + return + } + let done = false + let timer = 0 + const cleanup = () => { + el.removeEventListener("scrollend", onEnd) + el.removeEventListener("wheel", onUser) + el.removeEventListener("touchstart", onUser) + if (timer) clearTimeout(timer) + pinCleanupRef.current = null + } + // Reached the target (scrollend, or the fallback timer) → settle: recordAnchor + release guard. + const onEnd = () => { + if (done) return + done = true + cleanup() + onSettle() + } + // User grabbed the scroll mid-glide → stop guarding so their scroll is honored; don't settle. + const onUser = () => { + if (done) return + done = true + cleanup() + programmaticScrollRef.current = false + } + el.addEventListener("scrollend", onEnd) + el.addEventListener("wheel", onUser, {passive: true}) + el.addEventListener("touchstart", onUser, {passive: true}) + timer = window.setTimeout(onEnd, 700) // fallback where scrollend is unsupported (older Safari) + // Cancel without settling (a newer pin supersedes this one, or we unmount). + pinCleanupRef.current = () => { + done = true + cleanup() + } + el.scrollTo({top: target, behavior: "smooth"}) + }, []) + + // Stop any in-flight pin animation on unmount (tab close / revision swap). + useEffect( + () => () => { + pinCleanupRef.current?.() + if (showJumpRafRef.current) cancelAnimationFrame(showJumpRafRef.current) + }, + [], + ) + + // After each commit, mark on-screen messages as seen so they don't re-animate on later renders + // (e.g. streaming tokens). Done in an effect, not during render, so StrictMode's double invoke + // doesn't mark a brand-new message before its first paint and rob it of the fade. + useEffect(() => { + for (const m of messages) seenIdsRef.current.add(m.id) + }, [messages]) + + useEffect(() => { + if (useVirtuoso) return + // Don't instant-jump while a programmatic glide (SC-1 submit / jump-to-latest) owns the + // scroll — that snap would override the animation. The glide's own settle re-pins to bottom. + if (stickRef.current && !programmaticScrollRef.current) scrollToBottom() + }, [messages, status, scrollToBottom, useVirtuoso]) + + const onScroll = useCallback(() => { + const el = scrollRef.current + if (!el) return + // Track scrollTop even for our own pins (recorded, then ignored) so the next real event has an + // accurate baseline to compare against. + const prevTop = lastScrollTopRef.current + lastScrollTopRef.current = el.scrollTop + // Ignore the scroll event our own pin produced — only a real user scroll changes follow state. + if (programmaticScrollRef.current) return + // Follow ONLY when at the very bottom of the scrollable area; a partial scroll must not enable + // it (that was the yank). Re-arm follow ONLY when the user actively scrolls DOWN to the edge (or + // is already following): a content shrink (tool gutter collapsing to "Used N tools", reasoning + // folding) clamps scrollTop to the new smaller bottom and fires a scroll event, but a clamp only + // ever DECREASES scrollTop, so `> prevTop` rejects it — otherwise the next token would snap the + // min-h-full active turn to the top (reported as the chat "jumping to the top" mid-stream). + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24 + stickRef.current = atBottom && (stickRef.current || el.scrollTop > prevTop) + // Anchor is correctness-critical for SC-3 (the RO reads it next resize) → capture synchronously. + if (!stickRef.current) recordAnchor() + // Pill is display-only → coalesce its costly measurement to one rAF/frame. + scheduleShowJump() + }, [recordAnchor, scheduleShowJump]) + + const jumpToLatest = useCallback(() => { + const el = scrollRef.current + if (!el) return + setShowJump(false) + // Glide to the bottom like the SC-1 pin. Resume follow (stickRef) only ON SETTLE — flipping + // it true now would let the per-token follow effect jam to the bottom mid-glide. The final + // scrollToBottom catches any content that streamed in during the animation. + programmaticScrollRef.current = true + animatePinTo(el, el.scrollHeight, () => { + el.scrollTop = el.scrollHeight + stickRef.current = true + programmaticScrollRef.current = false + }) + }, [animatePinTo]) + + // SC-3: when any message resizes (image load, markdown/code render, tool-card expand), hold the + // reader's place. Following → keep pinned to the bottom; otherwise compensate scrollTop so the + // anchored (topmost visible) message stays on the same line. Guarded so it never fights our own + // pins. Re-subscribed when the message set changes (a part growing fires on the same wrapper). + useEffect(() => { + if (useVirtuoso) return + const el = scrollRef.current + if (!el) return + const onResize = (entries: ResizeObserverEntry[]) => { + // Pin each rendered row's REAL height as its own `content-visibility` placeholder, so it + // keeps the exact same box when it later scrolls off-screen. Only meaningful while + // content-visibility is enabled — otherwise `containIntrinsicSize` is inert, so skip the + // whole measurement to avoid a getBoundingClientRect + style write per row on every resize. + if (CONTENT_VISIBILITY_ENABLED) { + for (const e of entries) { + const node = e.target as HTMLElement + if (node === el) continue // the viewport itself is not a row — never pin it + const check = node.checkVisibility as + | ((o?: {contentVisibilityAuto?: boolean}) => boolean) + | undefined + if (check && !check.call(node, {contentVisibilityAuto: true})) continue + const h = Math.round(node.getBoundingClientRect().height) + if (h > 0) node.style.containIntrinsicSize = `auto ${h}px` + } + } + if (programmaticScrollRef.current) return + if (stickRef.current) { + scrollToBottom() // guarded: no-op if the follow effect already pinned this growth + return + } + const a = anchorRef.current + if (!a) return + let node: HTMLElement | null = null + try { + node = el.querySelector(`[data-mid="${a.id}"]`) + } catch { + node = null + } + if (!node) return + const delta = node.getBoundingClientRect().top - el.getBoundingClientRect().top - a.top + // A stale anchor (its node scrolled far off after a programmatic jump / follow) yields an + // implausible delta; applying it would slam the scroll to the top. Drop it and let the next + // scroll / pointer-down re-anchor. A real collapse/expand moves the anchor well under a viewport. + if (Math.abs(delta) > el.clientHeight) { + anchorRef.current = null + return + } + if (Math.abs(delta) > 0.5) { + programmaticScrollRef.current = true + el.scrollTop += delta + requestAnimationFrame(() => { + programmaticScrollRef.current = false + }) + } + } + const ro = new ResizeObserver(onResize) + // Observe the VIEWPORT too: the lazy composer/session-bar regions outside it hydrate a + // beat after mount and change this element's clientHeight — rows alone don't resize then, + // so without this the clamp shifts the view (following → re-pin; reading → hold anchor). + ro.observe(el) + el.querySelectorAll("[data-mid]").forEach((w) => ro.observe(w)) + return () => ro.disconnect() + }, [messages.length, scrollToBottom, useVirtuoso]) + + // SC-1 (submit) / SC-2 (restore): scroll the log to the bottom, once, when armed. With the active + // turn reserving a viewport (min-h-full + top padding to clear the fade), "bottom" shows the new + // question pinned at the top and the answer streaming into the space below — no per-element pin to + // compute, nothing to keep re-aligning as content arrives. A fresh submit glides; a restore jumps. + // Follow (stickRef) resumes only ON SETTLE so the per-token follow effect can't jam mid-glide. + useLayoutEffect(() => { + if (useVirtuoso) return + if (!armBottomRef.current) return + const el = scrollRef.current + if (!el) return + armBottomRef.current = false + programmaticScrollRef.current = true + const settle = () => { + el.scrollTop = el.scrollHeight // catch anything that streamed in during the glide + stickRef.current = true + programmaticScrollRef.current = false + } + if (animateBottomRef.current) { + animateBottomRef.current = false + animatePinTo(el, el.scrollHeight, settle) + } else { + el.scrollTop = el.scrollHeight + requestAnimationFrame(settle) + } + }, [messages, animatePinTo, useVirtuoso]) + + // Keep the jump pill honest as content streams/settles: show it when the real latest message is + // below the fold (e.g. a long answer growing past the viewport while parked at the top), and hide + // it once that message is visible or while we're following. Coalesced (not a sync layout read per + // streamed render) — the pill is display-only, so one frame of lag is imperceptible. + useEffect(() => { + if (useVirtuoso) return + scheduleShowJump() + }, [messages, status, scheduleShowJump, useVirtuoso]) + + // SC-4: interaction is intent, not just scrolling. While following, a real text selection inside + // the transcript — or opening a link in it — means the reader is engaging here, so release follow + // (exactly like a scroll). New content keeps arriving offscreen and the jump pill offers the way + // back. Keyboard / wheel / touch already release because they scroll (onScroll). The composer is + // exempt: its selections and links aren't inside the log, so `el.contains(...)` ignores them. + useEffect(() => { + const el = scrollRef.current + if (!el) return + const release = () => { + if (!stickRef.current) return + stickRef.current = false + setShowJump(!atLiveEdge(el)) + } + const onSelectionChange = () => { + if (!stickRef.current) return + const sel = window.getSelection() + if (!sel || sel.isCollapsed || sel.rangeCount === 0) return + if (sel.anchorNode && el.contains(sel.anchorNode)) release() + } + const onClick = (e: MouseEvent) => { + if ((e.target as HTMLElement | null)?.closest("a")) release() + } + document.addEventListener("selectionchange", onSelectionChange) + el.addEventListener("click", onClick) + return () => { + document.removeEventListener("selectionchange", onSelectionChange) + el.removeEventListener("click", onClick) + } + }, []) + + // ── SPIKE(virtuoso) scroll wiring (only active when the flag is on) ── + // Follow = stick to the bottom. Virtuoso's `followOutput` fires on item-count changes only, but the + // active turn streams inside the Footer (not an item), so drive stick manually on each update. + const virtFollowRef = useRef(true) + // While true (a short window after a submit), the follow tracks the bottom SMOOTHLY — so the sent + // question glides to the top and the streaming answer is tracked continuously (each token retargets + // the in-flight smooth scroll). Otherwise it snaps instantly to keep up with fast streaming. + const virtSmoothRef = useRef(false) + const virtSmoothTimerRef = useRef(0) + useEffect(() => { + if (!useVirtuoso || !virtFollowRef.current) return + const behavior: ScrollBehavior = virtSmoothRef.current ? "smooth" : "auto" + const id = requestAnimationFrame(() => virtuosoRef.current?.scrollTo({top: 1e9, behavior})) + return () => cancelAnimationFrame(id) + }, [messages, status, useVirtuoso]) + // SC-1 reserve for the virtuoso path: `min-h-full` doesn't work inside Virtuoso's Footer (100% + // resolves against its content-sized list, not the viewport), so measure the scroller's height and + // reserve it explicitly on the active-turn Footer — that's what lets a sent question pin to the top. + const virtRoRef = useRef(null) + const [virtViewportH, setVirtViewportH] = useState(0) + const setVirtScroller = useCallback((el: HTMLElement | Window | null) => { + virtRoRef.current?.disconnect() + const node = el instanceof HTMLElement ? el : null + if (!node) return + setVirtViewportH(node.clientHeight) + const ro = new ResizeObserver(() => setVirtViewportH(node.clientHeight)) + ro.observe(node) + virtRoRef.current = ro + }, []) + useEffect( + () => () => { + virtRoRef.current?.disconnect() + window.clearTimeout(virtSmoothTimerRef.current) + }, + [], + ) + // SC-1/2 equivalent: on submit/restore (armBottomRef), re-arm follow to the bottom once Virtuoso has + // mounted + measured (question-at-top emerges from the Footer's viewport reserve). A submit tracks + // smoothly for a short window; a restore snaps. The follow effect above does the per-token scrolling. + useLayoutEffect(() => { + if (!useVirtuoso || !armBottomRef.current) return + const animate = animateBottomRef.current + armBottomRef.current = false + animateBottomRef.current = false + virtFollowRef.current = true + setShowJump(false) + virtSmoothRef.current = animate + window.clearTimeout(virtSmoothTimerRef.current) + if (animate) { + virtSmoothTimerRef.current = window.setTimeout(() => { + virtSmoothRef.current = false + }, 600) + } + requestAnimationFrame(() => + requestAnimationFrame(() => + virtuosoRef.current?.scrollTo({top: 1e9, behavior: animate ? "smooth" : "auto"}), + ), + ) + }, [messages, useVirtuoso]) + const virtJumpToLatest = useCallback(() => { + virtFollowRef.current = true + setShowJump(false) + virtuosoRef.current?.scrollTo({top: 1e9, behavior: "smooth"}) + }, []) + // Stable component identities (Virtuoso remounts these if their identity changes). They read live + // content from `context`, which we pass fresh each render — so they re-render without remounting. + const virtComponents = useMemo>( + () => ({ + Header: ({context}) => <>{context?.header ?? null}, + Footer: ({context}) => <>{context?.footer ?? null}, + }), + [], + ) + + const toUploadFile = (file: File): UploadFile => ({ + uid: `${file.name}-${file.lastModified}-${file.size}`, + name: file.name, + status: "done", + originFileObj: file as UploadFile["originFileObj"], + }) + + /** Add files from paste / programmatic sources through the guardrails. */ + const addFiles = (incoming: File[]) => { + const {accepted, rejections: rej} = validateIncoming(incoming, files.length, limits) + if (accepted.length) { + setFiles((prev) => [...prev, ...accepted.map(toUploadFile)]) + setAttachmentsOpen(true) + } + setRejections(rej) + } + + const removeFile = (uid: string) => setFiles((prev) => prev.filter((f) => f.uid !== uid)) + + // Native drag-and-drop onto the whole panel. A depth counter ignores dragenter/leave from + // nested children so the overlay doesn't flicker; only file drags (not text) are handled. + const isFileDrag = (e: React.DragEvent) => Array.from(e.dataTransfer.types).includes("Files") + const onDragEnter = (e: React.DragEvent) => { + if (!isFileDrag(e)) return + dragDepthRef.current += 1 + setIsDragging(true) + } + const onDragOver = (e: React.DragEvent) => { + if (isFileDrag(e)) e.preventDefault() + } + const onDragLeave = (e: React.DragEvent) => { + if (!isFileDrag(e)) return + dragDepthRef.current -= 1 + if (dragDepthRef.current <= 0) { + dragDepthRef.current = 0 + setIsDragging(false) + } + } + const onDrop = (e: React.DragEvent) => { + if (!isFileDrag(e)) return + e.preventDefault() + dragDepthRef.current = 0 + setIsDragging(false) + const dropped = Array.from(e.dataTransfer.files) + if (dropped.length) { + addFiles(dropped) + setAttachmentsOpen(true) + } + } + + const handleSubmit = async (text: string) => { + const trimmed = text.trim() + const fileObjs = files + .map((f) => f.originFileObj as File | undefined) + .filter((f): f is File => Boolean(f)) + if (!trimmed && fileObjs.length === 0) return + const fileParts = fileObjs.length ? await filesToParts(fileObjs) : undefined + // Glide to the bottom; the min-h-full active turn makes that show the new question at the top + // with the answer streaming below. Park during the glide, follow again on settle. Clear any + // prior "stopped" marker — it's resolved by asking again. + stickRef.current = false + armBottomRef.current = true + animateBottomRef.current = true + setShowJump(false) + setStopped(false) + // One path: `submit` sends now or queues behind held messages via the shared release gate. + submit({text: trimmed, fileParts}) + // The message left the composer — drop its persisted draft (and any pending capture). + window.clearTimeout(draftTimerRef.current) + composerDraftBySession.delete(sessionId) + // Sending consumes the template provenance along with the composer text. + if (TEMPLATE_STRIP_MODE) stripProvenance.clear() + setFiles([]) + setRejections([]) + setAttachmentsOpen(false) + } + + // First-run auto-start: a freshly-created agent lands with a seeded prompt, but its model is often + // gated (no provider key yet). Connecting the key IS the go-ahead — so once the gate clears we send + // the seeded prompt automatically, rather than making them click Start a second time ("no explicit + // action twice"). Fires once, while the conversation is still empty, when EITHER: the model just + // unblocked (was gated), OR the seed is an explicit "go" (`firstRunAutoSend` — the onboarding + // Create-agent click) and the model is ready. A redirect-seed that merely arrived with a ready model + // still waits for Start. `handleSubmit` is read via a ref so the transition drives the send. + const handleSubmitRef = useRef(handleSubmit) + handleSubmitRef.current = handleSubmit + const autoStartedSeedRef = useRef(false) + const seedWasBlockedRef = useRef(false) + useEffect(() => { + if (!firstRunPrompt || autoStartedSeedRef.current) return + if (modelBlocked) { + seedWasBlockedRef.current = true + return + } + if ((!seedWasBlockedRef.current && !firstRunAutoSend) || messages.length > 0) return + autoStartedSeedRef.current = true + handleSubmitRef.current(firstRunPrompt) + }, [firstRunPrompt, firstRunAutoSend, modelBlocked, messages.length]) + + const handleRewind = useCallback( + (message: UIMessage) => { + const msgs = messagesRef.current + if (busyRef.current) return + const idx = msgs.findIndex((m) => m.id === message.id) + if (idx < 0) return + const isUser = message.role === "user" + const sideEffects = sideEffectingToolsInRange(msgs.slice(idx)) + + const run = () => { + if (isUser) { + setMessages(msgs.slice(0, idx)) + richInputRef.current?.setMarkdown(messageText(message)) + requestAnimationFrame(() => richInputRef.current?.focus()) + } else { + regenerate({messageId: message.id}).catch(ignoreStreamRejection) + } + } + + if (sideEffects.length > 0) { + modal.confirm({ + title: "Rewind past a tool that already ran?", + content: `${sideEffects.join(", ")} already executed. Rewinding re-runs the conversation from here but will NOT undo it.`, + okText: "Rewind anyway", + okButtonProps: {danger: true}, + cancelText: "Cancel", + centered: true, + style: {borderRadius: 16}, + onOk: run, + }) + } else { + run() + } + }, + [regenerate, setMessages, modal], + ) + + // Group the ACTIVE turn (the last user message + its response) into one wrapper that carries the + // fill. Keeping the fill on a STABLE element — not hopping it from the user bubble to the assistant + // bubble when the answer arrives — avoids the mid-stream layout jump. + const lastUserIndex = (() => { + for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "user") return i + return -1 + })() + const activeStart = lastUserIndex >= 0 ? lastUserIndex : messages.length + // The fill = min-h-full on the active turn whenever there's PRIOR conversation above it (so the + // question can sit at the top). Derived from layout, NOT from `busy` — so it persists when the turn + // settles instead of being yanked away (which clamped the scroll and jumped the view). + const reserveActive = activeStart > 0 + + const renderMessage = (message: UIMessage, index: number) => { + const isLast = index === messages.length - 1 + // New since mount → fade in once. Mark seen immediately so a re-render mid-stream (tokens + // arriving) doesn't re-arm the animation; the row keeps its mounted state by key anyway. + // Don't mutate seenIdsRef here — that runs during render (unsafe under StrictMode's double + // invoke). Marking happens in an effect after commit. + const enter = !seenIdsRef.current.has(message.id) + // A user turn has no trace of its own; borrow the paired (next) assistant turn's trace so its + // timestamp dates from the run, not this browser's first-seen stamp. + const turnTraceId = + message.role === "user" && messages[index + 1] + ? getMessageTraceId(messages[index + 1]) + : undefined + // While the inspector is open, an assistant turn tints when it's the target and is + // click-to-refocus otherwise (click any other turn to re-point the inspector at it). + const isAssistantTurn = message.role === "assistant" + const isInspected = + inspectorOpen && isAssistantTurn && message.id === inspectorTarget?.assistantMessageId + const onInspect = + inspectorOpen && isAssistantTurn + ? () => openTurnInspector({sessionId, assistantMessageId: message.id}) + : undefined + const showInspect = buildMode && isAssistantTurn + const showWorking = + isLast && busy && (!isAssistantTurn || message.parts.some(isVisiblePart)) + return ( + + 0 && isEmptyAssistantTurn(messages[index - 1]) + } + turnTraceId={turnTraceId} + /> + {/* Stopped tag + Resend belong only to the LAST assistant turn (the one you cancelled), + gated on position so it can never smear onto past turns. Cleared on resend / ask. */} + {stopped && isLast && message.role === "assistant" && ( +
+ Stopped + +
+ )} + {/* Meta row: "Inspect turn" + the working dots share ONE compact line under the + turn. The dots run for the WHOLE busy run — so gaps with no streaming output + (approval-resume cold-replay, between steps, server tool waits) never read as + frozen — and drop the moment the run settles. The affordance renders FIRST so + its left edge stays put (and aligned with older turns') when the trailing dots + unmount — no settle-time layout shift. An EMPTY streaming assistant turn + already renders its own loading bubble (AgentMessage), so the dots skip it — + exactly one indicator while busy. */} + {(showWorking || showInspect) && ( +
+ {showInspect && ( + + )} + {showWorking && } +
+ )} +
+ ) + } + + // Strip era (TEMPLATE_STRIP_MODE): the bare "what do you want to build?" hero (no messages yet, + // nothing pending, not browsing the template gallery) is when the onboarding TemplateStrip docks + // directly above the composer, mirroring the agent-chat strip's bottom-anchored rhythm. + const showBareOnboardingHero = + TEMPLATE_STRIP_MODE && + onboardingActive && + messages.length === 0 && + !pendingFirstTurn && + !onboarding?.browseAll + + return ( +
+ {/* Themed confirm dialogs (rewind-past-a-tool) mount through this holder. */} + {modalContextHolder} + {/* Chat column. The turn inspector is a flex sibling (below) so it pushes this column + aside rather than overlaying it. */} +
+ {isDragging && ( +
+ + + Drop files here + + + {limits.label} · up to {limits.maxCount},{" "} + {Math.round(limits.maxBytes / 1024 / 1024)} MB each + +
+ )} + {/* Stream errors are surfaced inline on the failing turn (red error bubble with the + real reason), stamped in the effect above — no separate top-level banner. */} +
+ {useVirtuoso && messages.length > 0 && ( + + ref={virtuosoRef} + scrollerRef={setVirtScroller} + data={messages.slice(0, activeStart)} + className="ag-canvas flex-1 [overflow-anchor:none]" + style={{maskImage: EDGE_FADE_MASK, WebkitMaskImage: EDGE_FADE_MASK}} + // Wide buffer so rows are rendered AND measured before they enter view — the + // height correction (85–1022px vs the estimate) then happens off-screen, so + // real content scrolls in without blanks or jitter. Tunable from settings. + increaseViewportBy={{ + top: virtOverscan, + bottom: Math.round(virtOverscan * 0.66), + }} + defaultItemHeight={virtItemEstimate} + // A prior mount's snapshot restores true row heights + scroll in the + // first frame; only a genuinely first visit anchors by index (the two + // props conflict, so exactly one is passed). + {...(virtRestoreState + ? {restoreStateFrom: virtRestoreState} + : { + initialTopMostItemIndex: { + index: Math.max(0, activeStart - 1), + align: "end" as const, + }, + })} + computeItemKey={(_i, m) => m.id} + itemContent={(index, m) => ( +
{renderMessage(m, index)}
+ )} + atBottomStateChange={(atBottom) => { + virtFollowRef.current = atBottom + setShowJump(!atBottom) + }} + context={{ + header:
, + footer: + activeStart < messages.length ? ( +
+ {messages + .slice(activeStart) + .map((m, i) => renderMessage(m, activeStart + i))} +
+ ) : null, + }} + components={virtComponents} + /> + )} + {(!useVirtuoso || messages.length === 0) && ( +
{ + scrollRef.current = el + }} + onScroll={onScroll} + // Capture a fresh SC-3 anchor before a click acts (expand/collapse a tool step, + // reasoning fold): those resize the transcript without a scroll, so onScroll never + // refreshes the anchor and the ResizeObserver would compensate against a stale one. + onPointerDownCapture={recordAnchor} + role="log" + aria-live="polite" + aria-label="Agent conversation" + // `pt-8`/`pb-8` (32px) ≥ the 28px fades so the first message and the last turn's + // meta row (Inspect turn + streaming dots) clear them at rest; the bottom pad + // + `[overflow-anchor:none]` are the SC scroll-engineering essentials (browser + // anchoring off so our pin/anchor logic owns the scroll position). + className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overflow-x-hidden p-3 pt-8 pb-8 [overflow-anchor:none]" + // Fade content into the top edge (under the tab bar) and the bottom edge (into the + // composer) as it scrolls. A gradient mask on the scroll container: transparent at + // each edge → opaque across the middle. GPU-composited, no JS, theme-agnostic. + style={{ + maskImage: EDGE_FADE_MASK, + WebkitMaskImage: EDGE_FADE_MASK, + }} + > + {messages.length === 0 && + (pendingFirstTurn ? ( + // Optimistic first turn: the submitted description as a sent user bubble + + // an assistant loading placeholder (mirrors a real `status:"submitted"` + // turn), so the commit reads as one continuous chat, not an empty state. + + + + + ) : onboardingActive && onboarding?.browseAll ? ( + // "Browse all templates" swaps the hero for the full gallery IN PLACE. + + ) : ( + + richInputRef.current?.setMarkdown(text) + } + /> + ))} + {messages.slice(0, activeStart).map((m, i) => renderMessage(m, i))} + {activeStart < messages.length && ( + // The active turn reserves a viewport (min-h-full) when there's prior + // conversation, so sticking to the bottom shows the question at the top with the + // answer streaming into the space below — the "pin" is this layout, not JS. + // `pt-8` keeps the question clear of the top fade once it reaches the top. +
+ {messages + .slice(activeStart) + .map((m, i) => renderMessage(m, activeStart + i))} +
+ )} +
+ )} + + {/* Always mounted so it can fade + slide in/out; hidden state is non-interactive and + keeps `-translate-x-1/2` (Tailwind composes x/y translate on one transform). */} + +
+ + {/* Queue sits BETWEEN the messages and the composer, so showing it never shifts the + composer (and the editor) upward. Streaming itself is signalled by the composer's + send button (it becomes a spinning Stop button), so there's no "Streaming…" row. */} + 0} className={CHAT_COLUMN}> +
+ +
+
+ + {/* Rich markdown composer (Lexical). Enter sends; attachments via header/prefix slots. + Wrapper `px-3` keeps the session-bar gutter; the input centers on CHAT_COLUMN so it + aligns with the (also centered) message column when the panel is wide. The persistent + HITL approval dock lives in this same block (above the input) — always mounted so it + animates in/out, and inside the composer region so the paused gate can't scroll out + of reach and its collapse adds no gap to the surrounding column. */} + {/* The whole composer fades + rises in ONCE on mount (Reveal), so the input joins the + empty-state/hero entrance instead of popping. Mount-only: it never remounts across the + onboarding→chat transitions, so this never reintroduces layout shift on state changes. */} + + {/* Agent empty-chat strip (S6): docked above the composer, unmounts once a + message exists or a first-run prompt is pending. Build-mode + fresh-agent + only — never in maximized chat mode, and gone for good after any commit. */} + {TEMPLATE_STRIP_MODE && + !onboardingActive && + buildMode && + isFreshAgentRevision && + messages.length === 0 && + !firstRunPrompt && + !pendingFirstTurn ? ( +
+ +
+ ) : null} + {/* Always mounted so it animates in/out (RevealCollapse) instead of popping. Pre-commit + onboarding SUPPRESSES it — the provider-key check is deferred until the agent is + committed (Create-agent then runs the connect→unlock→auto-send flow on the real agent). */} +
+ +
+ + {/* Owner call: a template pick must not shift the composer, so no chip renders here + (unlike the home surface) — the strip card's own selected state is the + "which template" indicator; the composer text is the only other feedback. */} + {/* Onboarding strip: docked directly above the composer (mb-3 gap), mirroring the + agent-chat strip's rhythm — hero stays top-aligned above the flex space, and + the strip + composer read as one bottom-anchored cluster. */} + {showBareOnboardingHero ? ( +
+ +
+ ) : null} + {/* Composer region hydrates independently (Lexical chunk); the fallback is the + same skeleton the pane-level gates render for this slot, so the box never + changes shape — the editor just materializes inside it. */} + }> + handleCreateAgent() : handleSubmit} + disabled={onboardingActive ? ideHandoffActive : modelBlocked} + hideSendButton={onboardingActive} + submitOnEnter={!onboardingActive} + placeholder={ + onboardingActive + ? ideHandoffActive + ? "Continue in your IDE from the steps above — or start over." + : "e.g. Watch our #support channel, triage each thread by urgency, and route it to the right owner — ask me before closing anything." + : modelBlocked + ? "Connect a model to start chatting…" + : "Ask the agent… (Enter to send, ⌘/Ctrl+Enter for newline)" + } + initialMarkdown={initialDraft} + onChange={handleComposerChange} + onPasteFile={(pasted) => addFiles(Array.from(pasted))} + sendForceEnabled={files.length > 0} + streaming={busy} + onStop={handleStop} + prefix={ + // Attach button is gated until the agent service is ready for inline + // file parts (big-agents d4b119af26); paste / drag-to-add still work. + + + ) : ( +
+ {TEMPLATE_STRIP_MODE ? ( + // Strip era: the IDE handoff is a one-click copy + toast, no modal/bubble. + + ) : ( + + )} + +
+ ) + ) : undefined + } + /> +
+
+
+ + {TEMPLATE_STRIP_MODE ? ( + setCopiedToastOpen(false)} + /> + ) : null} +
+ ) +} + +export default AgentConversation diff --git a/web/oss/src/components/AgentChatSlice/components/AgentChatSkeleton.tsx b/web/oss/src/components/AgentChatSlice/components/AgentChatSkeleton.tsx index 11b075e5823..ccd4d904af6 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentChatSkeleton.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentChatSkeleton.tsx @@ -64,9 +64,28 @@ export const ComposerSkeleton = ({className}: {className?: string}) => ( ) /** - * Whole-pane placeholder, shown before the panel itself can mount: (1) the workflow - * revision is still resolving the agent flag, (2) the lazy AgentChatPanel chunk is - * loading (the crossfade host keeps it as a dissolving overlay). + * Conversation-body placeholder: transcript + composer, WITHOUT the session bar (the frame owns + * that region separately). This is the Suspense fallback the synchronous panel frame reserves for + * each tab while the lazy `AgentConversation` chunk loads — so the frame's real structure paints + * immediately and only the body fills in behind this. + */ +export const ConversationSkeleton = () => ( +
+ +
+ +
+
+) + +/** + * Whole-pane placeholder (bar + transcript + composer), shown at ONE gate: the workflow revision + * is still resolving the agent flag, so it's not yet confirmed to be an agent and the live panel + * must not mount. Once confirmed, the frame renders directly (no crossfade overlay). */ const AgentChatSkeleton = () => (
diff --git a/web/oss/src/components/AgentChatSlice/components/MountFade.tsx b/web/oss/src/components/AgentChatSlice/components/MountFade.tsx new file mode 100644 index 00000000000..6ccebe922cb --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/MountFade.tsx @@ -0,0 +1,26 @@ +import {useEffect, useState} from "react" + +/** + * Fades its children in one frame after mount (OPACITY ONLY, so it can't shift layout) — used to + * ease a lazily-hydrated region in over its skeleton instead of a hard Suspense pop. Honors + * reduced motion: the initial transparency and the transition are both `motion-safe`, so it's + * instant-visible otherwise. + */ +const MountFade = ({className, children}: {className?: string; children: React.ReactNode}) => { + const [shown, setShown] = useState(false) + useEffect(() => { + const raf = requestAnimationFrame(() => setShown(true)) + return () => cancelAnimationFrame(raf) + }, []) + return ( +
+ {children} +
+ ) +} + +export default MountFade diff --git a/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx b/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx index 46f59fb5b5a..050de1b7805 100644 --- a/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx +++ b/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx @@ -53,6 +53,9 @@ interface SessionTagProps { index: number active: boolean closable: boolean + /** True when this session already existed at the bar's first mount (reload restore) — an + * activation here jumps instantly; a session added afterwards keeps the smooth scroll. */ + presentAtMount: boolean onSelect: () => void onClose: () => void onRename: (title: string) => void @@ -64,6 +67,7 @@ const SessionTag = ({ index, active, closable, + presentAtMount, onSelect, onClose, onRename, @@ -71,16 +75,17 @@ const SessionTag = ({ const text = useAtomValue(sessionFirstUserTextAtomFamily(session.id)) const label = session.title || text || `Chat ${index + 1}` const tabRef = useRef(null) - // Keep the active tab visible. On the tab's FIRST reveal (reload restoring a far-away active - // session) jump instantly — the strip's scroll-smooth would otherwise play a long scroll across - // the whole strip. Later activations (user switching) keep the CSS smooth nudge. + // Keep the active tab visible. Jump INSTANTLY only on the bar's initial reveal of a session that + // was already present at mount (reload restoring a far-away active tab) — the strip's scroll-smooth + // would otherwise play a long scroll across the whole strip. A session added later, or any user + // switch, keeps the CSS smooth nudge (so a freshly-created tab still glides into view). const mountedRef = useRef(false) useEffect(() => { if (active) { tabRef.current?.scrollIntoView({ block: "nearest", inline: "nearest", - behavior: mountedRef.current ? undefined : "instant", + behavior: presentAtMount && !mountedRef.current ? "instant" : undefined, }) } mountedRef.current = true @@ -160,6 +165,14 @@ const SessionTagBar = ({ showSessions = true, }: SessionTagBarProps) => { const closable = sessions.length > 1 + // Session ids present when the bar first mounted. Seeded once; NOT topped up, so an id that + // appears later reads as "added after mount" and scrolls smoothly (see SessionTag). + const presentAtMountRef = useRef>(new Set()) + const seededRef = useRef(false) + if (!seededRef.current) { + seededRef.current = true + sessions.forEach((s) => presentAtMountRef.current.add(s.id)) + } return (
{showSessions ? ( @@ -171,6 +184,7 @@ const SessionTagBar = ({ index={index} active={session.id === activeId} closable={closable} + presentAtMount={presentAtMountRef.current.has(session.id)} onSelect={() => onSelect(session.id)} onClose={() => onClose(session.id)} onRename={(title) => onRename(session.id, title)} 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 4c524cbce53..d77f49fed87 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx @@ -11,6 +11,7 @@ import {VariantDetailsWithStatus} from "@agenta/entity-ui/variant" import {isAgentModeAtomFamily, playgroundController} from "@agenta/playground" import {message} from "@agenta/ui/app-message" import {DraftTag} from "@agenta/ui/components" +import {MoreOutlined} from "@ant-design/icons" import {Trash} from "@phosphor-icons/react" import {Button, Tooltip} from "antd" import {useAtomValue, useSetAtom} from "jotai" @@ -27,7 +28,12 @@ import {PlaygroundVariantConfigHeaderProps} from "./types" const PlaygroundVariantHeaderMenu = dynamic( () => import("../../Menus/PlaygroundVariantHeaderMenu"), - {ssr: false}, + { + ssr: false, + // Reserve the kebab's 32px footprint while the chunk loads so it doesn't pop in and + // shift Commit/Deploy leftward on the (skeletonized) agent config header. + loading: () => + + + ) : null} + ) : ( <> {!embedded && !isLocalDraftVariant && ( diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx index 9460b532974..a55d29ae400 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx @@ -36,7 +36,11 @@ import { } from "@agenta/entities/workflow/commitDiff" import {agentSelfCommitSignalAtom, openAgentConfigSectionAtom} from "@agenta/shared/state" import {stripAgentaMetadataDeep} from "@agenta/shared/utils" -import {ConfigAccordionSection, sectionIndicatorColor} from "@agenta/ui/components/presentational" +import { + ConfigAccordionSection, + sectionIndicatorColor, + type SectionIndicatorTone, +} from "@agenta/ui/components/presentational" import {useDrillInUI} from "@agenta/ui/drill-in" import {cn} from "@agenta/ui/styles" import { @@ -289,7 +293,7 @@ export function AgentTemplateControl({ // NEW revision, diff the configs per section and mark the changed ones. The computed // set is FROZEN on first non-empty result so the user's own subsequent edits don't // drift into the "agent changed this" indication. Dismiss (or the next commit) clears. - const [commitSignal, setCommitSignal] = useAtom(agentSelfCommitSignalAtom) + const commitSignal = useAtomValue(agentSelfCommitSignalAtom) const frozenAgentDiffRef = useRef<{signalAt: number; keys: Set} | null>(null) const agentChangedKeys = useMemo(() => { if (!commitSignal || !revisionId || commitSignal.revisionId !== revisionId) return null @@ -317,13 +321,15 @@ export function AgentTemplateControl({ } }, [commitSignal, revisionId, value]) const agentChangeIndicator = useCallback( - (sectionKey: string) => - agentChangedKeys?.has(sectionKey) - ? { - tone: "draft" as const, - tooltip: `Updated by the agent${commitSignal?.version ? ` in ${commitSignal.version}` : ""}`, - } - : undefined, + (sectionKey: string) => { + if (!agentChangedKeys?.has(sectionKey)) return undefined + const raw = commitSignal?.version ? String(commitSignal.version) : null + const version = raw ? (raw.startsWith("v") ? raw : `v${raw}`) : null + return { + tone: "agent" as const, + tooltip: `Updated by the agent${version ? ` in ${version}` : ""}`, + } + }, [agentChangedKeys, commitSignal?.version], ) // Triggers bound to this agent (for the section count badge). The section body and the header @@ -741,7 +747,7 @@ export function AgentTemplateControl({ title: React.ReactNode summary?: React.ReactNode extra?: React.ReactNode - indicator?: {tone: "draft" | "invalid" | "incomplete"; tooltip?: string} + indicator?: {tone: SectionIndicatorTone; tooltip?: string} defaultOpen?: boolean onOpen?: () => void content: React.ReactNode @@ -766,26 +772,6 @@ export function AgentTemplateControl({ return (
- {agentChangedKeys ? ( -
- - Agent updated this configuration - {commitSignal?.version ? ` (${commitSignal.version})` : ""} — the marked - sections changed - - -
- ) : null} {sections.length === 0 ? ( No agent configuration fields are available for this schema. diff --git a/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx b/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx index bf7e3220b6c..dce94c5a5ef 100644 --- a/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx +++ b/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx @@ -36,18 +36,22 @@ import {HeightCollapse} from "../../HeightCollapse" const {Text} = Typography -export type SectionIndicatorTone = "draft" | "invalid" | "incomplete" +export type SectionIndicatorTone = "draft" | "invalid" | "incomplete" | "agent" /** * The accent token for a section/item change indicator. Single source of truth for the * tone→token mapping, shared by the section header and the config panel's item indicators. + * "agent" (the agent changed this in a self-commit) is deliberately DISTINCT from "draft" + * blue — it uses the agent teal so it can't be read as the user's own unsaved edits. */ export function sectionIndicatorColor(tone: SectionIndicatorTone): string { return tone === "invalid" ? "var(--ag-colorError)" : tone === "incomplete" ? "var(--ag-colorWarning)" - : "var(--ag-colorInfo)" + : tone === "agent" + ? "var(--ag-c-13C2C2, #13c2c2)" + : "var(--ag-colorInfo)" } export interface ConfigAccordionSectionProps { @@ -103,7 +107,7 @@ export interface ConfigAccordionSectionProps { * agent config panel to flag sections with unsaved edits (`"draft"`), a blocking problem * (`"invalid"`), or an optional gap (`"incomplete"`). */ - indicator?: {tone: "draft" | "invalid" | "incomplete"; tooltip?: ReactNode} + indicator?: {tone: SectionIndicatorTone; tooltip?: ReactNode} /** Only show `summary` while the section is collapsed. @default false (always). */ summaryCollapsedOnly?: boolean /** From f8c56d62a5216a0c3a14dabf7cb4b0800b8aebb8 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 10 Jul 2026 02:39:40 +0200 Subject: [PATCH 32/50] fix(frontend): keep agent config sections mounted across revision switches Latch the rendered revision ID (not a data snapshot) in PlaygroundConfigSection: the drill-in view resolves data/schema by id, so rendering the target id before its data landed dropped the tree to 'No items to display' and remounted every section. Now the previous revision keeps rendering until the target is renderable (or its query settles), then ids swap in one commit and sections update in place. Also align the loading-skeleton inset with the real section rows (single 16px inset from the field wrapper). --- .../agentTemplate/AgentConfigSkeleton.tsx | 3 +- .../components/PlaygroundConfigSection.tsx | 98 ++++++++++++------- 2 files changed, 65 insertions(+), 36 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/AgentConfigSkeleton.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/AgentConfigSkeleton.tsx index af4a84d06db..62c8426b042 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/AgentConfigSkeleton.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/AgentConfigSkeleton.tsx @@ -20,7 +20,8 @@ const ROWS: {title: number; value: number; withAdd?: boolean}[] = [ ] const AgentConfigSkeleton = () => ( -
+ // No padding of its own: the surrounding field/fallback wrapper provides the 16px inset. +
{ROWS.map((row, i) => (
record[group] !== undefined) } +// Best-available data for render/loading decisions: draft-merged data, else server data. +function pickActiveData}>( + useServerData: boolean, + data: T | null, + serverData: T | null, +): T | null { + if (useServerData) return serverData + if (hasParameters(data)) return data + if (hasParameters(serverData)) return serverData + return data ?? serverData +} + // Route tagged `__siblingData` payloads to `actions.update`; otherwise treat as parameter updates. const configUpdateRouterAtom = atom( null, @@ -626,7 +638,7 @@ export interface PlaygroundConfigSectionProps { } function PlaygroundConfigSection({ - revisionId, + revisionId: targetRevisionId, disabled = false, useServerData = false, className, @@ -638,11 +650,38 @@ function PlaygroundConfigSection({ }: PlaygroundConfigSectionProps) { const {llmProviderConfig} = useDrillInUI() + const mol = moleculeAdapter ?? defaultAdapter + const dispatchUpdate = useSetAtom(mol.reducers.update) + + // ── Revision-switch latch (id, not data) ── + // The playground keeps this component mounted and swaps `revisionId` in place (e.g. the + // agent committing itself). Everything below — including the drill-in view, which resolves + // data/schema BY ID — must stay on one consistent revision, so rendering the target id + // before its data lands drops the drill-in to an empty state and remounts every section. + // Keep rendering the PREVIOUS id until the target is renderable (or its query settles), + // then swap in one commit so the sections update their values in place. + const targetSchemaQuery = useAtomValue( + useMemo(() => mol.atoms.schemaQuery(targetRevisionId), [mol, targetRevisionId]), + ) + const targetActiveData = pickActiveData( + useServerData, + useAtomValue(useMemo(() => mol.atoms.data(targetRevisionId), [mol, targetRevisionId])), + useAtomValue( + useMemo(() => mol.atoms.serverData(targetRevisionId), [mol, targetRevisionId]), + ), + ) + const renderIdRef = useRef(targetRevisionId) + if ( + renderIdRef.current !== targetRevisionId && + (hasRenderableConfigSections(targetActiveData) || !targetSchemaQuery.isPending) + ) { + renderIdRef.current = targetRevisionId + } + const revisionId = renderIdRef.current + // Feedback config mode (shared with FeedbackConfigurationControl via atom) const feedbackModeAtom = useMemo(() => feedbackConfigModeAtomFamily(revisionId), [revisionId]) const [feedbackMode, setFeedbackMode] = useAtom(feedbackModeAtom) - const mol = moleculeAdapter ?? defaultAdapter - const dispatchUpdate = useSetAtom(mol.reducers.update) // ========== DATA ========== const dataAtom = useMemo(() => mol.atoms.data(revisionId), [mol, revisionId]) @@ -658,35 +697,10 @@ function PlaygroundConfigSection({ // Schema for model config popover const schemaAtom = useMemo(() => mol.atoms.agConfigSchema(revisionId), [mol, revisionId]) - const schemaLive = useAtomValue(schemaAtom) + const schema = useAtomValue(schemaAtom) // Choose the best available data for loading checks - const activeDataLive = useMemo(() => { - if (useServerData) return serverData - if (hasParameters(data)) return data - if (hasParameters(serverData)) return serverData - return data ?? serverData - }, [useServerData, data, serverData]) - - // Revision switches (e.g. the agent committing itself) keep this component mounted and - // swap `revisionId` in place. While the NEW revision's data/schema queries are pending, - // keep rendering the LAST renderable snapshot instead of dropping to the loading skeleton - // — the sections then update their values in place when the data lands (no teardown, no - // collapsed→open replay). The snapshot ref resets with the component, so keyed usages - // (per-variant configs in prompt playgrounds) are unaffected. - const renderSnapshotRef = useRef<{data: typeof activeDataLive; schema: typeof schemaLive}>({ - data: null, - schema: null, - }) - if (hasRenderableConfigSections(activeDataLive)) { - renderSnapshotRef.current = {data: activeDataLive, schema: schemaLive} - } - const holdPrevious = - schemaQuery.isPending && - !hasRenderableConfigSections(activeDataLive) && - hasRenderableConfigSections(renderSnapshotRef.current.data) - const activeData = holdPrevious ? renderSnapshotRef.current.data : activeDataLive - const schema = holdPrevious ? renderSnapshotRef.current.schema : schemaLive + const activeData = pickActiveData(useServerData, data, serverData) const parameters = (activeData?.parameters ?? {}) as Record @@ -777,15 +791,26 @@ function PlaygroundConfigSection({ // Derive a stable flag so the effect fires when draft is discarded (becomes null) const isDraftEmpty = draft === null || draft === undefined - // Track discard events to force re-mount of Form/YAML editors whose internal + // Track DISCARD events to force re-mount of Form/YAML editors whose internal // state (Lexical editor, local control state) may not fully reset via prop // changes alone. Computed during render to avoid useEffect/setState loops. + // + // Revision-scoped on purpose: this component can now survive a revision SWITCH + // (the agent playground's stable config host swaps `revisionId` in place), and a + // switch from a drafted revision to a clean one flips `isDraftEmpty` false→true + // exactly like a discard — bumping the version there remounted the whole form + // (replaying the sections' entrance) for a plain switch. Only a draft emptying + // on the SAME revision is a discard; a revision change just re-seeds the tracker. const discardVersionRef = useRef(0) - const prevIsDraftEmptyRef = useRef(isDraftEmpty) - if (isDraftEmpty && !prevIsDraftEmptyRef.current) { + const draftTrackerRef = useRef({revisionId, isDraftEmpty}) + if ( + draftTrackerRef.current.revisionId === revisionId && + isDraftEmpty && + !draftTrackerRef.current.isDraftEmpty + ) { discardVersionRef.current += 1 } - prevIsDraftEmptyRef.current = isDraftEmpty + draftTrackerRef.current = {revisionId, isDraftEmpty} // Eagerly sync rawEditorValue during render when entering a raw mode. // Without this, switching Form → YAML/JSON after a revision change renders @@ -1833,7 +1858,10 @@ function PlaygroundConfigSection({ if (isConfigLoading) { if (loadingFallback) { - return
{loadingFallback}
+ // px-4 py-3 mirrors the field-content wrapper the real sections (and the lazy + // control's Suspense fallback) render inside — without it the fallback skeleton + // sits 16px wider / 12px higher and visibly shifts when the schema lands. + return
{loadingFallback}
} return (
From 55b72430bfc24a3374c6bc3e4a17525ccbaf1497 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 10 Jul 2026 02:39:54 +0200 Subject: [PATCH 33/50] feat(frontend): agent self-commit notice as a bottom-pinned banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the header chip with AgentCommitNotice rendered as the last row of the config pane's flex column, outside the scroller — pinned to the pane's bottom edge with no layout shift on the sections. Shows the commit version and the commit message (two-line clamp + tooltip), enters/exits with an opacity + rise transition, and Dismiss clears the shared signal (and the teal section dots). --- .../Components/AgentCommitNotice.tsx | 121 +++++++++++++++++ .../Components/MainLayout/index.tsx | 124 ++++++++++-------- .../assets/PlaygroundVariantConfigHeader.tsx | 48 +------ 3 files changed, 194 insertions(+), 99 deletions(-) create mode 100644 web/oss/src/components/Playground/Components/AgentCommitNotice.tsx diff --git a/web/oss/src/components/Playground/Components/AgentCommitNotice.tsx b/web/oss/src/components/Playground/Components/AgentCommitNotice.tsx new file mode 100644 index 00000000000..dabdf569a2f --- /dev/null +++ b/web/oss/src/components/Playground/Components/AgentCommitNotice.tsx @@ -0,0 +1,121 @@ +import {useEffect, useLayoutEffect, useRef, useState} from "react" + +import {workflowMolecule} from "@agenta/entities/workflow" +import {agentSelfCommitSignalAtom} from "@agenta/shared/state" +import {Robot} from "@phosphor-icons/react" +import {Button} from "antd" +import {useAtom, useAtomValue} from "jotai" + +/** + * Agent self-commit notice, rendered by MainLayout as the LAST row of the config pane's + * flex column — BELOW the scrolling sections, so it is pinned to the pane's bottom edge + * regardless of content height or scroll position, and can never shift the sections. + * Shown while the shared signal targets the displayed revision; Dismiss clears it (and + * the teal section dots with it). Enters/exits with an opacity + rise transition. + */ +const AgentCommitNotice = ({revisionId}: {revisionId: string}) => { + const [signal, setSignal] = useAtom(agentSelfCommitSignalAtom) + const active = Boolean(signal && revisionId && signal.revisionId === revisionId) + + // Latch the last matching signal so the content stays rendered through the exit fade. + const lastSignalRef = useRef(signal) + if (signal && active) lastSignalRef.current = signal + const shownSignal = lastSignalRef.current + + // Enter/exit: `render` keeps the node mounted through the exit transition; `shown` + // drives the opacity/translate classes. Double rAF so the hidden state paints first. + const [render, setRender] = useState(active) + const [shown, setShown] = useState(false) + useEffect(() => { + if (active) { + setRender(true) + const raf = requestAnimationFrame(() => requestAnimationFrame(() => setShown(true))) + return () => cancelAnimationFrame(raf) + } + setShown(false) + const t = window.setTimeout(() => setRender(false), 240) + return () => window.clearTimeout(t) + }, [active]) + + // Commit message comes from the committed revision entity (the stream part carries only + // id/version) — also covers the notice surviving a reload while the signal is set. + const revisionData = useAtomValue( + workflowMolecule.selectors.data(shownSignal?.revisionId ?? ""), + ) as {message?: string | null} | null + + // Long commit messages get a collapse/expand toggle instead of a tooltip: collapsed + // clamps to two lines, expanded caps at a scrollable box so an essay can't grow the pane. + const messageRef = useRef(null) + const [expanded, setExpanded] = useState(false) + const [overflowing, setOverflowing] = useState(false) + const commitMessage = revisionData?.message?.trim() || null + + useLayoutEffect(() => { + setExpanded(false) + }, [shownSignal?.revisionId, commitMessage]) + + useLayoutEffect(() => { + const el = messageRef.current + if (!el || expanded) return + setOverflowing(el.scrollHeight - el.clientHeight > 1) + }, [commitMessage, expanded, render]) + + if (!render || !shownSignal) return null + + const rawVersion = shownSignal.version ? String(shownSignal.version) : null + const version = rawVersion ? (rawVersion.startsWith("v") ? rawVersion : `v${rawVersion}`) : null + + return ( +
+
+
+ + + +
+ + Agent updated this configuration{version ? ` in ${version}` : ""} — + changed sections are marked + + {commitMessage ? ( +
+

+ “{commitMessage}” +

+ {overflowing || expanded ? ( + + ) : null} +
+ ) : null} +
+
+ +
+
+ ) +} + +export default AgentCommitNotice diff --git a/web/oss/src/components/Playground/Components/MainLayout/index.tsx b/web/oss/src/components/Playground/Components/MainLayout/index.tsx index de0f4bd2b67..975f355ccd7 100644 --- a/web/oss/src/components/Playground/Components/MainLayout/index.tsx +++ b/web/oss/src/components/Playground/Components/MainLayout/index.tsx @@ -26,6 +26,7 @@ import {routerAppIdAtom} from "@/oss/state/app/selectors/app" import {playgroundEarlyAgentStateAtom} from "@/oss/state/workflow" import {usePlaygroundScrollSync} from "../../hooks/usePlaygroundScrollSync" +import AgentCommitNotice from "../AgentCommitNotice" import PlaygroundVariantConfig from "../PlaygroundVariantConfig" import type {BaseContainerProps} from "../types" const PlaygroundFocusDrawer = dynamic(() => import("../PlaygroundFocusDrawerAdapter"), { @@ -354,64 +355,75 @@ const PlaygroundMainView = ({ collapsible={splitCollapsible} key={`${splitterKey}-splitter-panel-config`} > -
- <> - {isComparisonView && hasDisplayedEntities && ( - - )} - {renderConfigOverride && !isComparisonView ? ( - renderConfigOverride - ) : configEntityIds.length > 0 ? ( - configEntityIds.map((variantId, index) => ( -
{ - variantRefs.current[index] = el - }} - > - +
+ <> + {isComparisonView && hasDisplayedEntities && ( + + )} + {renderConfigOverride && !isComparisonView ? ( + renderConfigOverride + ) : configEntityIds.length > 0 ? ( + configEntityIds.map((variantId, index) => ( +
{ + variantRefs.current[index] = el + }} + > + +
+ )) + ) : ( +
+
- )) - ) : ( -
-
-
- )} - -
+ )} + +
+ {!isComparisonView && isAgentConfig && primaryConfigId ? ( + + ) : null} +
- - Configuration - - {agentCommitTag ? ( - - - - - Updated by agent - {agentCommitVersion ? ` · ${agentCommitVersion}` : ""} - - - - - ) : null} - + + Configuration + ) : ( <> {!embedded && !isLocalDraftVariant && ( From ad608189c319f08cecdfa4c7b8fba8af078750b5 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 10 Jul 2026 02:40:05 +0200 Subject: [PATCH 34/50] fix(frontend): persist agent type at every revision learn point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persisted agent-type map (read by playgroundEarlyAgentStateAtom for the cold-load geometry) had a single writer inside the dedicated latest-revision query, which the revisions-list priming disables — so the map starved and every cold load mounted the prompt 50% split before snapping to the agent layout. Write it from the revisions-list queryFn and from all three return paths of the by-id revision queryFn. --- .../src/workflow/state/store.ts | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index 3d60f2152b4..9b04f5817ac 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -619,6 +619,14 @@ export const workflowRevisionsByWorkflowQueryAtomFamily = atomFamily((workflowId ["workflows", "latestRevision", workflowId, projectId], latestByRecency, ) + // Persist agent-ness for the next cold load (playgroundEarlyAgentStateAtom). + // This priming DISABLES the dedicated latest-revision query (its only other + // writer), so without writing here the map starves and every cold load + // mounts the prompt 50% split before snapping to the agent geometry. + writePersistedAgentType( + workflowId, + deriveWorkflowTypeFromRevision(latestByRecency), + ) } // Return thin references only — full data is in the detail cache @@ -1119,8 +1127,21 @@ export const workflowQueryAtomFamily = atomFamily((revisionId: string) => queryKey: ["workflows", "revision", revisionId, projectId], queryFn: async (): Promise => { if (!projectId || !revisionId) return null + // Persist agent-ness wherever a revision is learned, keyed by its WORKFLOW id + // (playgroundEarlyAgentStateAtom reads this on the next cold load). Covers cold + // loads that resolve a `?revisions=` id directly, without the list query. + const persistType = (revision: Workflow | null): Workflow | null => { + const workflowId = (revision as {workflow_id?: string} | null)?.workflow_id + if (revision && workflowId) { + writePersistedAgentType( + String(workflowId), + deriveWorkflowTypeFromRevision(revision), + ) + } + return revision + } const cached = findWorkflowRevisionInCache(queryClient, projectId, revisionId) - if (cached) return cached + if (cached) return persistType(cached) // Dedup vs the revisions-by-workflow list: that query primes this revision's detail // cache under the SAME key (primeWorkflowRevisionDetailCache), so on a cold first // paint the current app's displayed revision would otherwise be fetched twice — once @@ -1140,12 +1161,12 @@ export const workflowQueryAtomFamily = atomFamily((revisionId: string) => projectId, revisionId, ) - if (primed) return primed + if (primed) return persistType(primed) } } catch { // fall through to the direct fetch below } - return workflowRevisionBatchFetcher({projectId, revisionId}) + return persistType(await workflowRevisionBatchFetcher({projectId, revisionId})) }, initialData: detailCached ?? undefined, enabled: From 1f67ce2865aeb84e21a09b2bf48127c399909f4e Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 10 Jul 2026 02:40:05 +0200 Subject: [PATCH 35/50] fix(frontend): tolerate non-numeric revision versions in workflowSchema z.coerce.number() failed the whole revision parse on a bad version tag, which blanked the config panel for that revision (the by-ids batch drops items that fail validation). Degrade non-numeric versions to null instead. --- .../agenta-entities/src/workflow/core/schema.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/web/packages/agenta-entities/src/workflow/core/schema.ts b/web/packages/agenta-entities/src/workflow/core/schema.ts index cefcf2e1a25..00d31e83163 100644 --- a/web/packages/agenta-entities/src/workflow/core/schema.ts +++ b/web/packages/agenta-entities/src/workflow/core/schema.ts @@ -276,8 +276,14 @@ export const workflowSchema = z // Slug slug: z.string().nullable().optional(), - // Version (present on revisions — backend returns as string, coerce to number) - version: z.coerce.number().nullable().optional(), + // Version (present on revisions — backend returns as string). Coerce to a + // number, but degrade a non-numeric value to null instead of failing the + // whole revision parse (a bad version tag must not blank the config). + version: z.preprocess((v) => { + if (v === null || v === undefined) return v + const n = Number(v) + return Number.isFinite(n) ? n : null + }, z.number().nullable().optional()), // Header name: z.string().nullable().optional(), From 73022804e2084f26051f3f52340670f030cead72 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 10 Jul 2026 03:52:35 +0200 Subject: [PATCH 36/50] feat(frontend): transition section-header indicator states The indicator dot on ConfigAccordionSection headers (draft/invalid/agent tones) was conditionally rendered, so state changes popped in and out instantly. Keep the dot always mounted and drive it with scale/opacity transitions, transition background-color across tone changes (latching the last tone's color through the scale-out so it never flashes colorless), and transition the leading icon's tint instead of jumping it. --- .../section/ConfigAccordionSection.tsx | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx b/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx index dce94c5a5ef..e70f9855c7f 100644 --- a/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx +++ b/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx @@ -26,7 +26,7 @@ * * ``` */ -import {type ReactNode, useCallback, useEffect, useState} from "react" +import {type ReactNode, useCallback, useEffect, useRef, useState} from "react" import {CaretDown, CaretRight, Lock} from "@phosphor-icons/react" import {Tooltip, Typography} from "antd" @@ -180,6 +180,10 @@ export function ConfigAccordionSection({ }, [revealOnMount, revealDelayMs]) // Indicator (unsaved edits / validation) takes precedence over the completion `status`. const indicatorColor = indicator ? sectionIndicatorColor(indicator.tone) : null + // Keep the last tone's color while the dot scales out, so it doesn't flash colorless. + const lastIndicatorColorRef = useRef(null) + if (indicatorColor) lastIndicatorColorRef.current = indicatorColor + const dotColor = indicatorColor ?? lastIndicatorColorRef.current // The glyph gets a soft, desaturated tint; the full accent lives on the dot so it still reads. const iconColor = indicatorColor ? `color-mix(in srgb, ${indicatorColor} 45%, var(--ag-colorTextTertiary))` @@ -248,16 +252,20 @@ export function ConfigAccordionSection({ {icon ? ( {icon} - {indicator ? ( - - ) : null} + {/* Always mounted: state changes play as scale/opacity/color + transitions instead of the dot popping in and out. */} + ) : null} From d3d336c481cbef9e6ca61a1b78e3d657db0fc2b2 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 10 Jul 2026 11:50:31 +0200 Subject: [PATCH 37/50] fix(api,frontend): scope OAuth completion to its connection so multi-connect requests settle correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent can request several connections in one step; each renders its own ConnectToolWidget. The OAuth callback posted a bare tools:oauth:complete with no identity, so any completion settled every live connect widget as connected — a connection the user never authorized was reported ready and the run failed. Thread the connection identity (slug/integration) through the signed OAuth state so the callback tags every card, and post it in tools:oauth:complete. The widget now settles only on its own connection's completion; an identity-less message keeps the prior single-flow behavior. --- api/oss/src/apis/fastapi/tools/router.py | 71 ++++++++++++++----- .../src/core/gateway/connections/service.py | 4 ++ api/oss/src/core/gateway/connections/utils.py | 13 +++- .../unit/tools/test_oauth_state_identity.py | 60 ++++++++++++++++ .../clientTools/ConnectToolWidget.tsx | 22 +++++- 5 files changed, 150 insertions(+), 20 deletions(-) create mode 100644 api/oss/tests/pytest/unit/tools/test_oauth_state_identity.py diff --git a/api/oss/src/apis/fastapi/tools/router.py b/api/oss/src/apis/fastapi/tools/router.py index de21f95e6c3..fdc50128c8f 100644 --- a/api/oss/src/apis/fastapi/tools/router.py +++ b/api/oss/src/apis/fastapi/tools/router.py @@ -4,7 +4,7 @@ import re from datetime import datetime, timezone from functools import wraps -from typing import List, Optional +from typing import Dict, List, Optional from urllib.parse import urlsplit from uuid import UUID, uuid4 @@ -894,6 +894,23 @@ async def callback_connection( state: Optional[str] = Query(default=None), ) -> HTMLResponse: """Handle OAuth callback from Composio.""" + # Decode the HMAC-signed state up front to recover BOTH the project scope and the + # connection identity. The identity tags every card (success or failure) so the + # opener can tell WHICH connect flow finished — the playground can have several + # live at once (see ConnectToolWidget), and an untagged completion would settle + # all of them. + state_payload = ( + decode_oauth_state(state, secret_key=env.agenta.crypt_key) + if state + else None + ) + if not state: + log.warning("OAuth callback received without state token") + elif state_payload is None: + log.warning("OAuth callback: invalid or expired state token") + state_slug = state_payload.get("slug") if state_payload else None + state_integration = state_payload.get("integration") if state_payload else None + if error_message or status == "failed": log.error("OAuth callback failed: status=%s", status) return HTMLResponse( @@ -901,6 +918,8 @@ async def callback_connection( content=_oauth_card( success=False, error=error_message or "Authorization failed. Please try again.", + slug=state_slug, + integration_key=state_integration, ), ) @@ -910,24 +929,19 @@ async def callback_connection( content=_oauth_card( success=False, error="Missing connection identifier. Please try again.", + slug=state_slug, + integration_key=state_integration, ), ) - # Decode HMAC-signed state to recover project scope. Activation is - # project-scoped, so a missing/invalid state is fatal — we never activate - # without a resolved project_id. + # Activation is project-scoped, so a missing/invalid state is fatal — we never + # activate without a resolved project_id. project_id: Optional[UUID] = None - if state: - payload = decode_oauth_state(state, secret_key=env.agenta.crypt_key) - if payload is None: - log.warning("OAuth callback: invalid or expired state token") - else: - try: - project_id = UUID(payload["project_id"]) - except (KeyError, ValueError): - log.warning("OAuth callback state missing or invalid project_id") - else: - log.warning("OAuth callback received without state token") + if state_payload is not None: + try: + project_id = UUID(state_payload["project_id"]) + except (KeyError, ValueError): + log.warning("OAuth callback state missing or invalid project_id") if project_id is None: return HTMLResponse( @@ -935,6 +949,8 @@ async def callback_connection( content=_oauth_card( success=False, error="Connection could not be activated. Please try again.", + slug=state_slug, + integration_key=state_integration, ), ) @@ -954,6 +970,8 @@ async def callback_connection( content=_oauth_card( success=False, error="Connection could not be activated. Please try again.", + slug=state_slug, + integration_key=state_integration, ), ) except Exception: @@ -963,6 +981,8 @@ async def callback_connection( content=_oauth_card( success=False, error="An internal error occurred. Please try again.", + slug=state_slug, + integration_key=state_integration, ), ) @@ -992,6 +1012,8 @@ async def callback_connection( integration_logo=integration_logo, integration_url=integration_url, agenta_url=env.agenta.web_url, + slug=conn.slug, + integration_key=conn.integration_key, ), ) @@ -1436,6 +1458,8 @@ def _oauth_card( integration_url: Optional[str] = None, agenta_url: Optional[str] = None, error: Optional[str] = None, + slug: Optional[str] = None, + integration_key: Optional[str] = None, ) -> str: # HTML-escape all provider-supplied strings before interpolation. safe_label = html_lib.escape(integration_label) if integration_label else None @@ -1450,6 +1474,18 @@ def _oauth_card( agenta_origin = f"{parsed_agenta_url.scheme}://{parsed_agenta_url.netloc}" agenta_post_message_origin_js = json.dumps(agenta_origin) + # Tag the completion message with the connection's identity so the opener can tell + # WHICH connection finished. The playground can have several connect flows live at + # once (an agent may request multiple connections in one turn); without this, every + # open connect widget would settle on the first completion. Absent keys keep older + # openers working (they ignore the extra fields). + oauth_complete_payload: Dict[str, str] = {"type": "tools:oauth:complete"} + if slug: + oauth_complete_payload["slug"] = slug + if integration_key: + oauth_complete_payload["integration"] = integration_key + oauth_complete_message_js = json.dumps(oauth_complete_payload) + accent = "#16a34a" if success else "#dc2626" agenta_favicon = ( f"{safe_agenta_url}/assets/favicon.ico" if safe_agenta_url else None @@ -1630,6 +1666,7 @@ def _oauth_card(
' could terminate the completion page's script block (slug is regex-validated URL-safe; integration_key is not). Escape '<' in every JSON value embedded in the inline script. --- api/oss/src/apis/fastapi/tools/router.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/api/oss/src/apis/fastapi/tools/router.py b/api/oss/src/apis/fastapi/tools/router.py index fdc50128c8f..14438465873 100644 --- a/api/oss/src/apis/fastapi/tools/router.py +++ b/api/oss/src/apis/fastapi/tools/router.py @@ -4,7 +4,7 @@ import re from datetime import datetime, timezone from functools import wraps -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from urllib.parse import urlsplit from uuid import UUID, uuid4 @@ -1450,6 +1450,13 @@ async def _emit_data_event( # --------------------------------------------------------------------------- +def _json_for_inline_script(value: Any) -> str: + # `json.dumps` leaves `<` intact, so a value containing `` would terminate + # the inline