diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx index ded82aa561..78a76565b4 100644 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx @@ -228,6 +228,14 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: // Teardown for the in-flight smooth scroll (removes its listeners + fallback timer). const pinCleanupRef = useRef<(() => void) | null>(null) + // `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 + // Transport feeds the v6 stream request from the playground pipeline. `api` here is a // placeholder that `prepareSendMessagesRequest` overrides per request. const transport = useMemo( @@ -235,7 +243,7 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: new AgentChatTransport({ api: "", prepareSendMessagesRequest: async ({messages, id}) => { - const req = await buildAgentRequest(entityId, messages, { + const req = await buildAgentRequest(entityIdRef.current, messages, { sessionId: id ?? sessionId, }) if (!req) { @@ -246,7 +254,7 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: return {api: req.invocationUrl, headers: req.headers, body: req.requestBody} }, }), - [entityId, sessionId], + [sessionId], ) const { diff --git a/web/oss/src/components/Playground/Components/MainLayout/index.tsx b/web/oss/src/components/Playground/Components/MainLayout/index.tsx index 62dc3104e9..70a9194436 100644 --- a/web/oss/src/components/Playground/Components/MainLayout/index.tsx +++ b/web/oss/src/components/Playground/Components/MainLayout/index.tsx @@ -1,5 +1,6 @@ -import {memo, useCallback, useRef, type ReactNode} from "react" +import {memo, useCallback, useMemo, useRef, type ReactNode} from "react" +import {workflowMolecule} from "@agenta/entities/workflow" import type {ConfigViewMode} from "@agenta/entity-ui" import { executionController, @@ -128,6 +129,33 @@ const PlaygroundMainView = ({ // Which entity IDs to render config panels for const configEntityIds = configEntityIdsOverride ?? layoutEntityIds + // ── Agent generation host: keep a stable key across revision switches ── + // The agent chat surface lives inside the generation panel below. Keying that panel by the + // revision id (`variantId`) tears the whole conversation down on every revision switch — + // whether the agent self-commits a new revision or the user picks one in the config header — + // which aborts the live stream ("connection lost") and drops the still-streaming turn + // (persistence is skipped mid-stream). Agents are single-entity (excluded from comparison), + // so we give the single-agent generation panel a stable key; a switch then flows through as an + // `entityId` prop update, not a remount, and the conversation (app/session-scoped) survives. + // A freshly committed revision's flags load a beat after the swap (workflowType falls back to + // "completion" until then), so we LATCH the agent host across that gap and only drop it once + // the single entity has loaded as a definitively non-agent workflow. + const singleEntityId = + !isComparisonView && layoutEntityIds.length === 1 ? layoutEntityIds[0]! : "" + const isSingleAgentEntity = useAtomValue(isAgentModeAtomFamily(singleEntityId)) + const singleEntityQuery = useAtomValue( + useMemo(() => workflowMolecule.selectors.query(singleEntityId), [singleEntityId]), + ) + const agentHostRef = useRef(false) + if (!singleEntityId) { + agentHostRef.current = false + } else if (isSingleAgentEntity) { + agentHostRef.current = true + } else if (!singleEntityQuery.isPending) { + agentHostRef.current = false + } + const renderAgentGenerationHost = agentHostRef.current + // The agent config panel is a compact read-only summary (editing happens in section drawers), so // it stays narrow (550px) instead of the prompt config's 50/50 split. The default is a fixed px // width rather than a percentage on purpose: antd applies `defaultSize` verbatim at mount and only @@ -336,8 +364,22 @@ const PlaygroundMainView = ({ ) : isComparisonView && hasDisplayedEntities ? ( ) : ( - layoutEntityIds.map((variantId) => - displayedEntities.includes(variantId) || isEvaluatorMode ? ( + layoutEntityIds.map((variantId) => { + // Single-agent view: a stable key so a revision switch updates + // the entityId prop instead of remounting the live conversation. + // Rendered unconditionally (not gated on `displayedEntities`) so + // it can't blink to the placeholder during the atomic id swap. + if (renderAgentGenerationHost) { + return ( + + ) + } + return displayedEntities.includes(variantId) || + isEvaluatorMode ? ( - ), - ) + ) + }) )} 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 148282984a..b19205c1a8 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} from "react" +import {useMemo, useRef} from "react" import {workflowMolecule} from "@agenta/entities/workflow" import {executionController} from "@agenta/playground" @@ -52,6 +52,33 @@ const PlaygroundGenerations: React.FC = ({ const AgentGenerationPanel = usePlaygroundUIOptional()?.AgentGenerationPanel const isExecutionLoading = runnableQuery.isPending || isChat === undefined + // Latch the agent surface so a revision switch never unmounts the live chat conversation. + // A switch (self-commit or the config-header picker) points `entityId` at a new revision whose + // flags load a beat later — `isAgent` flips false and the query goes pending, which would drop + // this panel to the skeleton and tear down the agent chat (aborting the live stream, losing the + // in-progress turn). Once an agent, we keep rendering the agent panel across that load gap so + // the switch is just an `entityId` prop update; we only release the latch when the entity has + // loaded as a definitively non-agent workflow. (Paired with the stable key in MainLayout, which + // keeps THIS component mounted so the latch survives the switch.) + const agentSurfaceRef = useRef(false) + if (isAgent) { + agentSurfaceRef.current = true + } else if (!runnableQuery.isPending) { + agentSurfaceRef.current = false + } + if (agentSurfaceRef.current && AgentGenerationPanel) { + // No ExecutionHeader: it self-nulls for agents (`if (isAgent) return null`), but during the + // switch load gap `isAgent` is momentarily false, so rendering it would flash the non-agent + // header + register the run-all shortcut over the agent chat. Agents own their composer. + return ( +
+
+ +
+
+ ) + } + if (isExecutionLoading) { return (