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 (