From 5d2e1edc731df034954ee0726f259f0f54ddc721 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Sun, 12 Jul 2026 20:50:41 +0600 Subject: [PATCH 1/3] feat: Implement InteractionDock for handling client-tool interactions - Added InteractionDock component to manage parked client-tool interactions, mirroring ApprovalDock's functionality. - Introduced WaitingForInput component to indicate when the agent is waiting for user input. - Updated AgentConversation to integrate InteractionDock and display waiting states appropriately. - Enhanced QueuedMessages to reflect held messages when the interaction is pending. - Refactored ConnectToolWidget to utilize useConnectFlow for managing connection states and actions. - Added useConnectFlow hook to centralize connection logic for both InteractionDock and inline components. - Updated state management to include isPendingClientToolInteraction for better handling of client-tool interactions. --- .../AgentChatSlice/AgentConversation.tsx | 42 ++- .../components/InteractionDock.tsx | 174 ++++++++++ .../components/QueuedMessages.tsx | 16 +- .../clientTools/ConnectToolWidget.tsx | 282 ++-------------- .../components/clientTools/useConnectFlow.ts | 304 ++++++++++++++++++ web/packages/agenta-playground/src/index.ts | 2 +- .../src/state/execution/index.ts | 2 +- .../agenta-playground/src/state/index.ts | 2 +- 8 files changed, 554 insertions(+), 270 deletions(-) create mode 100644 web/oss/src/components/AgentChatSlice/components/InteractionDock.tsx create mode 100644 web/oss/src/components/AgentChatSlice/components/clientTools/useConnectFlow.ts diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index dfd7c0d6bb..132e78f95e 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -32,6 +32,7 @@ import { ArrowDown, ArrowRight, Code, + Hourglass, Paperclip, TreeStructure, UploadSimple, @@ -81,6 +82,7 @@ import ApprovalDock, {getPendingApprovals} from "./components/ApprovalDock" import type {ClientToolOutputHandler} from "./components/clientTools" import ComposerAttachments from "./components/ComposerAttachments" import ConnectModelBanner from "./components/ConnectModelBanner" +import InteractionDock, {getPendingConnectInteraction} from "./components/InteractionDock" import QueuedMessages from "./components/QueuedMessages" import RevealCollapse from "./components/RevealCollapse" import TurnInspector from "./components/TurnInspector/TurnInspector" @@ -318,6 +320,20 @@ const WorkingDots = () => ( ) +/** The WorkingDots slot while the run is PARKED on the user (approval / connect / elicitation). + * The stream reads "ready" there, so without this the turn looks finished while the queue silently + * holds new sends. Deliberately static: motion says "the agent is working" — here it's your move. */ +const WaitingForInput = () => ( + + + Waiting for your input + +) + const AgentConversation = ({ entityId, sessionId, @@ -885,6 +901,13 @@ const AgentConversation = ({ // opens the paused turn's own trace drawer. const openTraceDrawer = useSetAtom(openTraceDrawerAtom) const pendingApprovals = useMemo(() => getPendingApprovals(messages), [messages]) + // Parked connect interaction on the paused turn → the InteractionDock owns its actions (the + // inline row is a passive marker). Gated off while busy (`input-streaming` isn't parked yet) + // and after a user stop (the run is dead, nothing to settle — matches the queue's stop void). + const pendingInteraction = useMemo( + () => (busy || stopped ? null : getPendingConnectInteraction(messages)), + [messages, busy, stopped], + ) const openPausedTurnTrace = useMemo(() => { const last = messages[messages.length - 1] const traceId = last ? getMessageTraceId(last) : undefined @@ -1616,6 +1639,9 @@ const AgentConversation = ({ const showInspect = buildMode && isAssistantTurn const showWorking = isLast && busy && (!isAssistantTurn || message.parts.some(isVisiblePart)) + // Paused on the user (never concurrently with showWorking — hitlPending implies not busy): + // the "waiting" chip keeps the turn from reading as finished while the queue holds sends. + const showWaiting = isLast && isAssistantTurn && !busy && hitlPending return ( {showInspect && ( + ) : ( + <> + + + + )} + + + ) +} + +interface InteractionDockProps { + /** The parked connect interaction the run is blocked on (from `getPendingConnectInteraction`). */ + pending: ClientToolMeta | null + /** Settle channel — the panel maps this onto `addToolOutput` (marks the resume as live). */ + onOutput: ClientToolOutputHandler + className?: string +} + +/** + * Always mounted; enter + leave animate via the grid-rows 0fr↔1fr height collapse (+ opacity), the + * same idiom as ApprovalDock. `inert` while closed drops the (clipped, latched) card from tab order + * + a11y so a keyboard user can't reach hidden buttons. + */ +const InteractionDock = ({pending, onOutput, className}: InteractionDockProps) => { + const open = !!pending + // Latch the last pending interaction so the card persists through the height collapse. + const shownRef = useRef(pending) + if (pending) shownRef.current = pending + const shown = shownRef.current + const shownIsActive = !!pending && shown?.toolCallId === pending.toolCallId + + return ( +
+
+ {shown ? ( + // Keyed by call id so flow state (phase/popup) resets per parked call. + + ) : null} +
+
+ ) +} + +export default memo(InteractionDock) diff --git a/web/oss/src/components/AgentChatSlice/components/QueuedMessages.tsx b/web/oss/src/components/AgentChatSlice/components/QueuedMessages.tsx index 71f8813d87..b0e1dc2392 100644 --- a/web/oss/src/components/AgentChatSlice/components/QueuedMessages.tsx +++ b/web/oss/src/components/AgentChatSlice/components/QueuedMessages.tsx @@ -47,17 +47,19 @@ const Attachment = ({part}: {part: FileUIPart}) => { const QueuedList = ({ queued, + held, onRemove, onClear, }: { queued: QueuedMessage[] + held: boolean onRemove: (id: string) => void onClear: () => void }) => (
- Queued — sent one by one + {held ? "Held until you answer the agent" : "Queued — sent one by one"} diff --git a/web/oss/src/components/AgentChatSlice/components/clientTools/ConnectToolWidget.tsx b/web/oss/src/components/AgentChatSlice/components/clientTools/ConnectToolWidget.tsx index 630a428e76..c8286025b0 100644 --- a/web/oss/src/components/AgentChatSlice/components/clientTools/ConnectToolWidget.tsx +++ b/web/oss/src/components/AgentChatSlice/components/clientTools/ConnectToolWidget.tsx @@ -1,27 +1,16 @@ /** * Connect widget — the `request_connection` / `render.kind: "connect"` client tool (#4920). * - * The agent asked for a connection it lacks (e.g. GitHub). This widget runs the Agenta OAuth flow in - * the playground and settles the parked call with a **reference, never a secret**: the runner - * re-resolves the credential from the project vault on resume. It reuses the existing connection - * machinery (`useToolsConnections` → `POST /tools/connections/`, then a popup on the returned - * `redirect_url`) rather than reinventing the OAuth call. + * The agent asked for a connection it lacks (e.g. GitHub). While the call is PARKED, this inline + * row is a passive marker only — the actions (Connect / Not now / Cancel) live in the + * InteractionDock in the composer region, mirroring ApprovalDock's "dock acts, inline marks" + * contract, so the paused run can never scroll out of reach and always has an escape hatch. * - * Security (hard requirement, design §"Security"): the popup posts back a `tools:oauth:complete` - * message; we trust it ONLY when `event.origin` equals the Agenta API origin (the callback page's - * origin) and the payload shape matches. Everything else is dropped. The callback also tags the - * message with the connection's `slug`/`integration`, so when several connect widgets are live at - * once each settles only on its OWN completion (never on a sibling's). - * - * Settle on every terminal path (design §"Settle on every path"), so the run never hangs: - * success → {connected:true, integration, slug} · cancel/abandon → {connected:false, - * reason:"cancelled"} · timeout → {connected:false, reason:"timeout"} · failure → errorText. - * - * Result UX is U1 — an inline status chip in the same visual language as approve/deny: "Connect - * GitHub" → "Connecting GitHub…" → "GitHub connected" ✓, or "Connection not completed" + Retry. + * After the call settles this row owns the result UX (U1) — an inline status chip in the same + * visual language as approve/deny: "GitHub connected" ✓, or "Connection not completed" + Retry + * (which re-runs the OAuth via the shared `useConnectFlow`, priming the vault for the agent's + * re-ask — the settled part itself can't be re-resolved). */ -import {useCallback, useEffect, useRef, useState} from "react" - import { ArrowClockwise, CheckCircle, @@ -32,23 +21,11 @@ import { } from "@phosphor-icons/react" import {Button, Typography} from "antd" -import {getAgentaApiUrl} from "@/oss/lib/helpers/api" - -import {useToolsConnections} from "../../../pages/settings/Tools/hooks/useToolsConnections" - import type {ClientToolHandlerProps} from "./types" +import {useConnectFlow, type ConnectOutput} from "./useConnectFlow" const {Text} = Typography -/** - * No terminal signal within this bound settles the call as a timeout so the run can't wait forever. - * Armed only once the popup is open (the user is mid-flow). 3 minutes covers a real OAuth consent. - * NOTE for Mahmoud: confirm the bound — open question §"abandon timeout". - */ -const CONNECT_TIMEOUT_MS = 180_000 -/** Popup-closed poll cadence, matching the existing ConnectModal. */ -const POPUP_POLL_MS = 1000 - /** * The runner parks only ONE interaction per turn; a second `request_connection` in the same step is * force-settled with this sentinel and RE-REQUESTED next turn (services/runner otel.ts @@ -57,45 +34,9 @@ const POPUP_POLL_MS = 1000 */ const DEFERRED_SENTINEL = "DEFERRED_NOT_EXECUTED" -/** The settled call's reference shape (what the runner re-resolves against). */ -interface ConnectOutput { - connected?: boolean - integration?: string - slug?: string - reason?: string -} - -/** `github` → `GitHub`-ish: a readable label without a provider catalog lookup. */ -const prettyIntegration = (key: string): string => - key ? key.charAt(0).toUpperCase() + key.slice(1) : "the service" - -/** Read the API origin the OAuth callback page posts from; null if it can't be resolved. */ -const agentaApiOrigin = (): string | null => { - try { - const url = getAgentaApiUrl() - if (!url) return null - return new URL(url, typeof window !== "undefined" ? window.location.href : undefined).origin - } catch { - return null - } -} - -type Phase = "idle" | "connecting" | "error" - const ConnectToolWidget = ({meta, settle}: ClientToolHandlerProps) => { - const input = (meta.input ?? {}) as Record - const integration = typeof input.integration === "string" ? input.integration : "" - // Connection slug: the call may pin one; default to the integration key. The output carries it - // back as the reference the runner re-resolves. - const slug = - typeof input.slug === "string" && input.slug ? input.slug : integration || "default" - const mode = input.mode === "api_key" ? "api_key" : "oauth" - const label = prettyIntegration(integration) - // A window name UNIQUE to this widget. Several connect widgets can be live at once; a shared - // name makes the second `window.open` reuse the first's popup, so the second flow's - // `tools:oauth:complete` message never reaches this widget and its popup-closed poll settles it - // as cancelled — "connected but shows failed". The tool-call id is unique per parked call. - const oauthWindowName = `tools_oauth_${meta.toolCallId}` + const {label, phase, errorText, outcome, manuallyConnected, runConnect, cancel} = + useConnectFlow(meta, settle) // A runner-deferred sibling settles as an error carrying the deferral sentinel (not a real // connection failure); see DEFERRED_SENTINEL. @@ -105,190 +46,7 @@ const ConnectToolWidget = ({meta, settle}: ClientToolHandlerProps) => { typeof partErrorText === "string" && partErrorText.startsWith(DEFERRED_SENTINEL) - const {handleCreate, invalidate} = useToolsConnections(integration) - - const [phase, setPhase] = useState("idle") - const [errorText, setErrorText] = useState(null) - // A retry started AFTER the parked call already settled (as a failure) succeeded. The settled - // part can't be re-resolved, but the connection now exists in the vault, so we flip the chip to - // "connected" — the agent's re-ask resolves cleanly on its next turn. - const [manuallyConnected, setManuallyConnected] = useState(false) - // The live flow's terminal result, held locally so the chip paints the instant we settle — - // `meta.settled` only flips a render later (after `addToolOutput` propagates), and without this - // the widget would stay on "Connecting…" until then. - const [outcome, setOutcome] = useState<{connected: boolean} | null>(null) - - // One-shot guard so the parked call settles exactly once, plus shared cleanup for the running - // popup's listener/poll/timeout. - const settledRef = useRef(false) - const popupRef = useRef(null) - const cleanupRef = useRef<(() => void) | null>(null) - - const teardown = useCallback(() => { - cleanupRef.current?.() - cleanupRef.current = null - popupRef.current = null - }, []) - - // Settle the parked part exactly once (success/cancel/timeout/failure all route through here). - const finish = useCallback( - (result: ConnectOutput | {errorText: string}) => { - if (settledRef.current) return - settledRef.current = true - teardown() - // Leave "connecting" and record the terminal result so the chip paints now. - setPhase("idle") - if ("errorText" in result) { - setOutcome({connected: false}) - settle({errorText: result.errorText}) - } else { - setOutcome({connected: result.connected === true}) - settle({output: result as Record}) - } - }, - [settle, teardown], - ) - - useEffect(() => () => teardown(), [teardown]) - - /** - * Run the Agenta OAuth flow: create the connection, open the popup, and watch its three terminal - * signals (origin-validated success message, popup closed without success, or timeout backstop). - * - * `settleParkedCall` distinguishes the two callers: - * - the live parked interaction (`true`): each terminal signal settles the parked tool call so - * the run resumes; - * - a manual retry after the call already settled (`false`): nothing to settle, so success just - * flips the local "connected" chip and primes the vault for the agent's re-ask. - */ - const runConnect = useCallback( - async (settleParkedCall: boolean) => { - if (phase === "connecting") return - if (settleParkedCall && settledRef.current) return - setErrorText(null) - setPhase("connecting") - try { - const result = await handleCreate({slug, name: slug, mode}) - const redirectUrl = - typeof result.connection?.data?.redirect_url === "string" - ? result.connection.data.redirect_url - : undefined - - const onSuccess = () => { - invalidate() - if (settleParkedCall) finish({connected: true, integration, slug}) - else { - setManuallyConnected(true) - setPhase("idle") - } - } - - if (!redirectUrl) { - // No OAuth step (e.g. api_key created inline): the connection already exists. - onSuccess() - return - } - - const popup = window.open( - redirectUrl, - oauthWindowName, - "width=600,height=700,popup=yes", - ) - if (!popup) { - setPhase("error") - setErrorText("Couldn’t open the connection window. Allow popups and retry.") - return - } - popupRef.current = popup - - const apiOrigin = agentaApiOrigin() - let succeeded = false - - const onMessage = (event: MessageEvent) => { - // HARD requirement: only trust the callback from the Agenta API origin. - if (apiOrigin && event.origin !== apiOrigin) return - const data = event.data as { - type?: unknown - slug?: unknown - integration?: unknown - } | null - if (!data || data.type !== "tools:oauth:complete") return - // Several connect widgets can be live at once (an agent may ask for - // multiple connections in one turn). The callback tags the completion with - // its connection identity, so a widget settles ONLY on its own completion — - // otherwise the first finished flow would mark every open widget connected. - // A legacy callback without identity keeps the prior single-flow behavior. - if (typeof data.slug === "string" && data.slug !== slug) return - else if ( - typeof data.slug !== "string" && - typeof data.integration === "string" && - data.integration !== integration - ) - return - succeeded = true - teardown() - onSuccess() - } - window.addEventListener("message", onMessage) - - const poll = window.setInterval(() => { - if (!popupRef.current?.closed || succeeded) return - // Abandon: closed without a success message. - teardown() - if (settleParkedCall) - finish({connected: false, integration, slug, reason: "cancelled"}) - else setPhase("idle") - }, POPUP_POLL_MS) - - const timeout = window.setTimeout(() => { - if (succeeded) return - teardown() - if (settleParkedCall) - finish({connected: false, integration, slug, reason: "timeout"}) - else setPhase("idle") - }, CONNECT_TIMEOUT_MS) - - cleanupRef.current = () => { - window.removeEventListener("message", onMessage) - window.clearInterval(poll) - window.clearTimeout(timeout) - try { - popupRef.current?.close() - } catch { - // best effort - } - } - } catch (err) { - const message = err instanceof Error ? err.message : "Connection failed." - // A create failure is terminal for the parked call: settle so the run resumes; for a - // manual retry just surface the reason with another Retry. - setPhase("error") - setErrorText(message) - if (settleParkedCall) finish({connected: false, integration, slug, reason: message}) - } - }, - [ - phase, - handleCreate, - slug, - mode, - invalidate, - finish, - teardown, - integration, - oauthWindowName, - ], - ) - - // Explicit cancel while the popup is open: settle the parked call as cancelled (or, for a manual - // retry, just stop). - const cancel = useCallback(() => { - teardown() - if (!settledRef.current) finish({connected: false, integration, slug, reason: "cancelled"}) - else setPhase("idle") - }, [finish, teardown, integration, slug]) - - // ── Connecting: popup open (either the live flow or a manual retry) ────────────────────────── + // ── Connecting: a post-settle manual retry's popup is open ─────────────────────────────────── if (phase === "connecting") { return ( }> @@ -325,8 +83,8 @@ const ConnectToolWidget = ({meta, settle}: ClientToolHandlerProps) => { ) } - // Cancelled / timeout / failed: a Retry re-runs the OAuth fresh (the parked call already - // resolved, so this primes the vault and flips the chip on success). + // Declined / cancelled / timeout / failed: a Retry re-runs the OAuth fresh (the parked call + // already resolved, so this primes the vault and flips the chip on success). return ( }> @@ -337,25 +95,25 @@ const ConnectToolWidget = ({meta, settle}: ClientToolHandlerProps) => { ) } - // ── Error (create failed, popup blocked) on the live flow: show reason + Retry ─────────────── + // ── Error on a manual retry (create failed, popup blocked): show reason + Retry ────────────── if (phase === "error") { return ( }> {errorText ?? "Connection failed."} - runConnect(true)} /> + runConnect(false)} /> ) } - // ── Idle: the initial prompt ──────────────────────────────────────────────────────────────── + // ── Pending: passive marker — the InteractionDock (above the composer) owns the actions ────── return ( }> Connect {label} - + + waiting for your response below + ) } diff --git a/web/oss/src/components/AgentChatSlice/components/clientTools/useConnectFlow.ts b/web/oss/src/components/AgentChatSlice/components/clientTools/useConnectFlow.ts new file mode 100644 index 0000000000..a265ee4728 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/clientTools/useConnectFlow.ts @@ -0,0 +1,304 @@ +/** + * The Agenta OAuth connect flow for a `request_connection` client tool (#4920), extracted from + * ConnectToolWidget so two surfaces share ONE implementation without double-settling: + * - the InteractionDock card (composer region) owns the LIVE parked call's actions — Connect, + * "Not now" (decline), Cancel — mirroring ApprovalDock's "dock acts, inline marks" contract; + * - the inline transcript chip keeps the post-settle states (result chip + Retry, which re-runs + * the OAuth with `settleParkedCall=false` to prime the vault for the agent's re-ask). + * + * It runs the existing connection machinery (`useToolsConnections` → `POST /tools/connections/`, + * then a popup on the returned `redirect_url`) and settles the parked call with a **reference, + * never a secret**: the runner re-resolves the credential from the project vault on resume. + * + * Security (hard requirement, design §"Security"): the popup posts back a `tools:oauth:complete` + * message; we trust it ONLY when `event.origin` equals the Agenta API origin (the callback page's + * origin) and the payload shape matches. Everything else is dropped. The callback also tags the + * message with the connection's `slug`/`integration`, so when several connect flows are live at + * once each settles only on its OWN completion (never on a sibling's). + * + * Settle on every terminal path (design §"Settle on every path"), so the run never hangs: + * success → {connected:true, integration, slug} · decline → {connected:false, reason:"declined"} + * · cancel/abandon → {connected:false, reason:"cancelled"} · timeout → {connected:false, + * reason:"timeout"} · failure → errorText. + * + * Two instances can be mounted for the SAME parked call (dock + inline marker). `meta.settled` + * guards every live-settle path in addition to the per-instance `settledRef`, so an instance that + * didn't perform the settle can never fire a second `addToolOutput` for it. + */ +import {useCallback, useEffect, useRef, useState} from "react" + +import {getAgentaApiUrl} from "@/oss/lib/helpers/api" + +import {useToolsConnections} from "../../../pages/settings/Tools/hooks/useToolsConnections" + +import type {ClientToolMeta, SettleClientTool} from "./types" + +/** + * No terminal signal within this bound settles the call as a timeout so the run can't wait forever. + * Armed only once the popup is open (the user is mid-flow). 3 minutes covers a real OAuth consent. + * NOTE for Mahmoud: confirm the bound — open question §"abandon timeout". + */ +const CONNECT_TIMEOUT_MS = 180_000 +/** Popup-closed poll cadence, matching the existing ConnectModal. */ +const POPUP_POLL_MS = 1000 + +/** The settled call's reference shape (what the runner re-resolves against). */ +export interface ConnectOutput { + connected?: boolean + integration?: string + slug?: string + reason?: string +} + +export type ConnectPhase = "idle" | "connecting" | "error" + +/** `github` → `GitHub`-ish: a readable label without a provider catalog lookup. */ +const prettyIntegration = (key: string): string => + key ? key.charAt(0).toUpperCase() + key.slice(1) : "the service" + +/** Read the API origin the OAuth callback page posts from; null if it can't be resolved. */ +const agentaApiOrigin = (): string | null => { + try { + const url = getAgentaApiUrl() + if (!url) return null + return new URL(url, typeof window !== "undefined" ? window.location.href : undefined).origin + } catch { + return null + } +} + +export const useConnectFlow = (meta: ClientToolMeta, settle: SettleClientTool, active = true) => { + const input = (meta.input ?? {}) as Record + const integration = typeof input.integration === "string" ? input.integration : "" + // Connection slug: the call may pin one; default to the integration key. The output carries it + // back as the reference the runner re-resolves. + const slug = + typeof input.slug === "string" && input.slug ? input.slug : integration || "default" + const mode = input.mode === "api_key" ? "api_key" : "oauth" + const label = prettyIntegration(integration) + // A window name UNIQUE to this parked call. Several connect flows can be live at once; a shared + // name makes the second `window.open` reuse the first's popup, so the second flow's + // `tools:oauth:complete` message never reaches this flow and its popup-closed poll settles it + // as cancelled — "connected but shows failed". The tool-call id is unique per parked call. + const oauthWindowName = `tools_oauth_${meta.toolCallId}` + + const {handleCreate, invalidate} = useToolsConnections(integration) + + const [phase, setPhase] = useState("idle") + const [errorText, setErrorText] = useState(null) + // A retry started AFTER the parked call already settled (as a failure) succeeded. The settled + // part can't be re-resolved, but the connection now exists in the vault, so we flip the chip to + // "connected" — the agent's re-ask resolves cleanly on its next turn. + const [manuallyConnected, setManuallyConnected] = useState(false) + // The live flow's terminal result, held locally so the chip paints the instant we settle — + // `meta.settled` only flips a render later (after `addToolOutput` propagates), and without this + // the surface would stay on "Connecting…" until then. + const [outcome, setOutcome] = useState<{connected: boolean} | null>(null) + + // One-shot guard so THIS instance settles the parked call at most once, plus shared cleanup for + // the running popup's listener/poll/timeout. `meta.settled` covers the OTHER instance's settle. + const settledRef = useRef(false) + const activeRef = useRef(active) + activeRef.current = active + const popupRef = useRef(null) + const cleanupRef = useRef<(() => void) | null>(null) + + const teardown = useCallback(() => { + cleanupRef.current?.() + cleanupRef.current = null + popupRef.current = null + }, []) + + // Settle the parked part exactly once (success/decline/cancel/timeout/failure route through here). + const finish = useCallback( + (result: ConnectOutput | {errorText: string}) => { + if (settledRef.current) return + settledRef.current = true + teardown() + // Leave "connecting" and record the terminal result so the chip paints now. + setPhase("idle") + if ("errorText" in result) { + setOutcome({connected: false}) + settle({errorText: result.errorText}) + } else { + setOutcome({connected: result.connected === true}) + settle({output: result as Record}) + } + }, + [settle, teardown], + ) + + useEffect(() => { + if (!active) { + teardown() + setPhase("idle") + } + return () => teardown() + }, [active, teardown]) + + /** + * Run the Agenta OAuth flow: create the connection, open the popup, and watch its three terminal + * signals (origin-validated success message, popup closed without success, or timeout backstop). + * + * `settleParkedCall` distinguishes the two callers: + * - the live parked interaction (`true`): each terminal signal settles the parked tool call so + * the run resumes; + * - a manual retry after the call already settled (`false`): nothing to settle, so success just + * flips the local "connected" chip and primes the vault for the agent's re-ask. + */ + const runConnect = useCallback( + async (settleParkedCall: boolean) => { + if (phase === "connecting") return + if (settleParkedCall && (!activeRef.current || settledRef.current || meta.settled)) + return + setErrorText(null) + setPhase("connecting") + try { + const result = await handleCreate({slug, name: slug, mode}) + if (settleParkedCall && !activeRef.current) return + const redirectUrl = + typeof result.connection?.data?.redirect_url === "string" + ? result.connection.data.redirect_url + : undefined + + const onSuccess = () => { + if (settleParkedCall && !activeRef.current) return + invalidate() + if (settleParkedCall) finish({connected: true, integration, slug}) + else { + setManuallyConnected(true) + setPhase("idle") + } + } + + if (!redirectUrl) { + // No OAuth step (e.g. api_key created inline): the connection already exists. + onSuccess() + return + } + + const popup = window.open( + redirectUrl, + oauthWindowName, + "width=600,height=700,popup=yes", + ) + if (!popup) { + setPhase("error") + setErrorText("Couldn’t open the connection window. Allow popups and retry.") + return + } + popupRef.current = popup + + const apiOrigin = agentaApiOrigin() + let succeeded = false + + const onMessage = (event: MessageEvent) => { + // HARD requirement: only trust the callback from the Agenta API origin. + if (!apiOrigin || event.origin !== apiOrigin) return + const data = event.data as { + type?: unknown + slug?: unknown + integration?: unknown + } | null + if (!data || data.type !== "tools:oauth:complete") return + // Several connect flows can be live at once (an agent may ask for + // multiple connections in one turn). The callback tags the completion with + // its connection identity, so a flow settles ONLY on its own completion — + // otherwise the first finished flow would mark every open one connected. + // A legacy callback without identity keeps the prior single-flow behavior. + if (typeof data.slug === "string" && data.slug !== slug) return + else if ( + typeof data.slug !== "string" && + typeof data.integration === "string" && + data.integration !== integration + ) + return + succeeded = true + teardown() + onSuccess() + } + window.addEventListener("message", onMessage) + + const poll = window.setInterval(() => { + if (!popupRef.current?.closed || succeeded) return + // Abandon: closed without a success message. + teardown() + if (settleParkedCall && !activeRef.current) return + if (settleParkedCall) + finish({connected: false, integration, slug, reason: "cancelled"}) + else setPhase("idle") + }, POPUP_POLL_MS) + + const timeout = window.setTimeout(() => { + if (succeeded) return + teardown() + if (settleParkedCall && !activeRef.current) return + if (settleParkedCall) + finish({connected: false, integration, slug, reason: "timeout"}) + else setPhase("idle") + }, CONNECT_TIMEOUT_MS) + + cleanupRef.current = () => { + window.removeEventListener("message", onMessage) + window.clearInterval(poll) + window.clearTimeout(timeout) + try { + popupRef.current?.close() + } catch { + // best effort + } + } + } catch (err) { + if (settleParkedCall && !activeRef.current) return + const message = err instanceof Error ? err.message : "Connection failed." + // A create failure is terminal for the parked call: settle so the run resumes; for a + // manual retry just surface the reason with another Retry. + setPhase("error") + setErrorText(message) + if (settleParkedCall) finish({connected: false, integration, slug, reason: message}) + } + }, + [ + phase, + meta.settled, + handleCreate, + slug, + mode, + invalidate, + finish, + teardown, + integration, + oauthWindowName, + ], + ) + + // Explicit cancel while the popup is open: settle the parked call as cancelled (or, when the + // call is already settled — a manual retry — just stop). + const cancel = useCallback(() => { + teardown() + if (!settledRef.current && !meta.settled) + finish({connected: false, integration, slug, reason: "cancelled"}) + else setPhase("idle") + }, [finish, teardown, integration, slug, meta.settled]) + + // The user's "Not now": a structured refusal (NOT an error), so the run resumes and the agent + // can respond gracefully / offer an alternative. Distinct from "cancelled" (abandoned popup) so + // the agent can tell an explicit decline from a mishap. + const decline = useCallback(() => { + if (settledRef.current || meta.settled) return + finish({connected: false, integration, slug, reason: "declined"}) + }, [finish, integration, slug, meta.settled]) + + return { + integration, + slug, + label, + phase, + errorText, + outcome, + manuallyConnected, + runConnect, + cancel, + decline, + } +} diff --git a/web/packages/agenta-playground/src/index.ts b/web/packages/agenta-playground/src/index.ts index 0a6332ef9a..550d0894ea 100644 --- a/web/packages/agenta-playground/src/index.ts +++ b/web/packages/agenta-playground/src/index.ts @@ -79,7 +79,7 @@ export { } from "./state" export type {AgentRequest, AgentChannelMode, NegotiatingFetch} from "./state" // HITL resume predicate for `useChat`'s `sendAutomaticallyWhen` (approve AND deny resume). -export {agentShouldResumeAfterApproval} from "./state" +export {agentShouldResumeAfterApproval, isPendingClientToolInteraction} from "./state" // Render-hint map for interaction kinds (sibling `data-render` parts → toolCallId lookup). export {buildRenderMap, renderKindFor, type RenderHintLike} from "./state" // Queued-message release gate for the agent chat composer (HITL-safe, one-by-one). diff --git a/web/packages/agenta-playground/src/state/execution/index.ts b/web/packages/agenta-playground/src/state/execution/index.ts index c59d582628..2b2239d615 100644 --- a/web/packages/agenta-playground/src/state/execution/index.ts +++ b/web/packages/agenta-playground/src/state/execution/index.ts @@ -363,7 +363,7 @@ export {agentChannelModeAtom, type AgentChannelMode} from "./channelMode" // Transport negotiation: try stream, fall back to batch on 406, error gracefully otherwise. export {createNegotiatingFetch, type NegotiatingFetch} from "./agentNegotiation" // Agent-lane HITL resume predicate (approve AND deny both resume the conversation). -export {agentShouldResumeAfterApproval} from "./agentApprovalResume" +export {agentShouldResumeAfterApproval, isPendingClientToolInteraction} from "./agentApprovalResume" // Render-hint map: sibling `data-render` parts → toolCallId lookup (interaction kinds). export {buildRenderMap, renderKindFor, type RenderHintLike} from "./renderMap" // Agent-lane queued-message release gate (never releases mid-HITL or pre-resume). diff --git a/web/packages/agenta-playground/src/state/index.ts b/web/packages/agenta-playground/src/state/index.ts index af6690e74b..d49d8ca442 100644 --- a/web/packages/agenta-playground/src/state/index.ts +++ b/web/packages/agenta-playground/src/state/index.ts @@ -183,7 +183,7 @@ export { } from "./execution" export {agentChannelModeAtom, type AgentChannelMode} from "./execution" export {createNegotiatingFetch, type NegotiatingFetch} from "./execution" -export {agentShouldResumeAfterApproval} from "./execution" +export {agentShouldResumeAfterApproval, isPendingClientToolInteraction} from "./execution" export {buildRenderMap, renderKindFor, type RenderHintLike} from "./execution" export {canReleaseQueuedMessage, isHitlPending} from "./execution" export { From c5fafd2bfa9fef27fce8075117b6bf921cd88584 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Mon, 13 Jul 2026 14:34:51 +0600 Subject: [PATCH 2/3] fixed the copy of the dock --- .../components/AgentChatSlice/components/InteractionDock.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/components/InteractionDock.tsx b/web/oss/src/components/AgentChatSlice/components/InteractionDock.tsx index 7b75b294f4..a07caec628 100644 --- a/web/oss/src/components/AgentChatSlice/components/InteractionDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/InteractionDock.tsx @@ -107,8 +107,8 @@ const ConnectCard = ({ ) : ( - It needs a {label}{" "} - connection to keep going. Skipping tells the agent to continue without it. + Connect {label} to let the + agent continue, or continue without the connection. )} From 4cd83233c187b8d2f21447793abfa301bdafe60a Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Mon, 13 Jul 2026 15:01:09 +0600 Subject: [PATCH 3/3] feat(workflow): removed varinat creation and hide deployment - reframe default variant as history, nothing else changed - hide the deployment option for the agent --- .../AgentRevisionSelector/index.tsx | 1 + .../components/RevisionChildTitle.tsx | 4 ++- .../Components/Menus/SelectVariant/index.tsx | 35 ++++++++++++++----- .../Components/Menus/SelectVariant/types.d.ts | 5 +++ .../CommitVariantChangesModal/index.tsx | 21 +++++++---- .../assets/PlaygroundVariantConfigHeader.tsx | 12 ++----- .../Sidebar/hooks/useSidebarConfig/index.tsx | 2 +- web/oss/src/state/workflow/hooks.ts | 1 + .../variants/TreeSelectPopupContent.tsx | 30 +++++++++++----- 9 files changed, 74 insertions(+), 37 deletions(-) diff --git a/web/oss/src/components/Playground/Components/AgentRevisionSelector/index.tsx b/web/oss/src/components/Playground/Components/AgentRevisionSelector/index.tsx index 074d242e33..782020dad6 100644 --- a/web/oss/src/components/Playground/Components/AgentRevisionSelector/index.tsx +++ b/web/oss/src/components/Playground/Components/AgentRevisionSelector/index.tsx @@ -63,6 +63,7 @@ const AgentRevisionSelector = ({variantId}: {variantId: string}) => { onChange={(value) => handleSwitchVariant(value)} value={_variantId ?? undefined} borderlessTrigger + versioning="linear" /> {variantRevision !== null && variantRevision !== undefined && ( void latestRevisionId?: string | null } @@ -22,6 +23,7 @@ const RevisionChildTitle = ({ isDisabled, showLatestTag, showAsCompare, + showBadges = true, onCreateLocalCopy, latestRevisionId, }: RevisionChildTitleProps) => { @@ -37,7 +39,7 @@ const RevisionChildTitle = ({ revision={version} variant={variant as any} hideName - showBadges + showBadges={showBadges} showLatestTag={showLatestTag} isLatest={isLatest} /> 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 8c5f634978..a9aec384bf 100644 --- a/web/oss/src/components/Playground/Components/Menus/SelectVariant/index.tsx +++ b/web/oss/src/components/Playground/Components/Menus/SelectVariant/index.tsx @@ -57,8 +57,10 @@ const SelectVariant = ({ customBrowseAdapter, style, borderlessTrigger = false, + versioning = "branching", ...props }: SelectVariantProps) => { + const isLinearVersioning = versioning === "linear" const selectedVariants = useAtomValue(playgroundController.selectors.entityIds()) const setSelectedVariants = useSetAtom(playgroundController.actions.setEntityIds) const selectedAppId = useAtomValue(selectedAppIdAtom) @@ -122,13 +124,14 @@ const SelectVariant = ({ selectedExistsInAppList, ]) - const selectPlaceholder = - !showAsCompare && - !!singleSelectedValue && - selectedRevisionQuery.isPending && - !hasSelectedDisplayName - ? "Loading variant..." - : "Select variant" + const selectPlaceholder = isLinearVersioning + ? "History" + : !showAsCompare && + !!singleSelectedValue && + selectedRevisionQuery.isPending && + !hasSelectedDisplayName + ? "Loading variant..." + : "Select variant" // Scoped adapter: 2-level (Variant → Revision), scoped to the current app const scopedAdapter = useMemo( @@ -260,12 +263,20 @@ const SelectVariant = ({ isDisabled={disabledIds.has(c.id)} showLatestTag={showLatestTag} showAsCompare={showAsCompare} + showBadges={!isLinearVersioning} onCreateLocalCopy={handleCreateLocalCopy} latestRevisionId={latestRevisionId} /> ) }, - [showLatestTag, showAsCompare, handleCreateLocalCopy, disabledIds, latestRevisionId], + [ + showLatestTag, + showAsCompare, + handleCreateLocalCopy, + disabledIds, + latestRevisionId, + isLinearVersioning, + ], ) const renderSelectedLabel = useCallback( @@ -363,6 +374,7 @@ const SelectVariant = ({ // Uses singleSelectedValue directly (not selectedValueForControl which // may be undefined while the existence check resolves). const triggerLabel = useMemo(() => { + if (isLinearVersioning && mode === "scoped") return "History" if (!singleSelectedValue) return selectPlaceholder if (isLocalDraftId(singleSelectedValue)) { return selectedVariantName ?? selectedRevisionData?.name ?? "Draft" @@ -388,6 +400,7 @@ const SelectVariant = ({ selectPlaceholder, mode, workflowName, + isLinearVersioning, ]) // Initial expanded keys for browse mode — expand the parent workflow of the selected revision @@ -503,11 +516,15 @@ const SelectVariant = ({ onSelect={handleSingleSelect} selectedValue={selectedValueForControl} disabledChildIds={disabledIds} - renderParentTitle={renderParentTitle} + renderParentTitle={ + isLinearVersioning ? undefined : renderParentTitle + } renderChildTitle={renderChildTitle} renderSelectedLabel={renderSelectedLabel} popupMinWidth={280} maxHeight={400} + showSearch={!isLinearVersioning} + flattenSingleParent={isLinearVersioning} /> )}
diff --git a/web/oss/src/components/Playground/Components/Menus/SelectVariant/types.d.ts b/web/oss/src/components/Playground/Components/Menus/SelectVariant/types.d.ts index 58d9bfcc61..beba367bb2 100644 --- a/web/oss/src/components/Playground/Components/Menus/SelectVariant/types.d.ts +++ b/web/oss/src/components/Playground/Components/Menus/SelectVariant/types.d.ts @@ -24,6 +24,11 @@ export interface SelectVariantProps extends TreeSelectProps { * header so the revision picker reads as an identity, not a form switch. */ borderlessTrigger?: boolean + /** + * Controls how revisions are presented. Linear versioning hides the persisted + * variant level and deployment badges and labels the control as History. + */ + versioning?: "linear" | "branching" } export interface TreeSelectItemRendererProps { diff --git a/web/oss/src/components/Playground/Components/Modals/CommitVariantChangesModal/index.tsx b/web/oss/src/components/Playground/Components/Modals/CommitVariantChangesModal/index.tsx index 5e0e2b245a..4e4311612e 100644 --- a/web/oss/src/components/Playground/Components/Modals/CommitVariantChangesModal/index.tsx +++ b/web/oss/src/components/Playground/Components/Modals/CommitVariantChangesModal/index.tsx @@ -22,6 +22,7 @@ import { clearRegistryVariantNameCache, } from "@/oss/components/VariantsComponents/store/registryStore" import {selectedAppIdAtom} from "@/oss/state/app" +import {currentWorkflowAtom} from "@/oss/state/workflow" import {CommitVariantChangesModalProps} from "./assets/types" @@ -43,6 +44,8 @@ const CommitVariantChangesModal: React.FC = ({ const isEphemeral = useAtomValue(workflowMolecule.selectors.isEphemeral(variantId || "")) const isEvaluator = useAtomValue(workflowMolecule.selectors.isEvaluator(variantId || "")) const isApplication = useAtomValue(workflowMolecule.selectors.isApplication(variantId || "")) + const isAgent = useAtomValue(workflowMolecule.selectors.isAgent(variantId || "")) + const currentWorkflow = useAtomValue(currentWorkflowAtom) const appId = useAtomValue(selectedAppIdAtom) const commitRevision = useSetAtom(playgroundController.actions.commitRevision) @@ -51,6 +54,7 @@ const CommitVariantChangesModal: React.FC = ({ const {mutateAsync: publish} = useAtomValue(publishMutationAtom) const variantName = runnableData?.name || "Variant" + const commitTargetName = isAgent ? currentWorkflow?.name || "Agent" : variantName const variantSlug = runnableData?.slug // Environments offered in the footer's "Commit & deploy to …" split-button dropdown. @@ -73,7 +77,7 @@ const CommitVariantChangesModal: React.FC = ({ deployEnvironments: string[] | undefined, deployMessage?: string, ) => { - if (!deployEnvironments || deployEnvironments.length === 0) return + if (isAgent || !deployEnvironments || deployEnvironments.length === 0) return const newRevisionData = workflowMolecule.get.data(newRevisionId) const refs = { revisionId: newRevisionId, @@ -105,7 +109,7 @@ const CommitVariantChangesModal: React.FC = ({ message.error(`Couldn't publish ${label} to ${failed.join(", ")}`) } }, - [publish, runnableData, appId], + [publish, runnableData, appId, isAgent], ) const handleSubmit = useCallback( @@ -145,7 +149,9 @@ const CommitVariantChangesModal: React.FC = ({ return {success: true, newRevisionId: result.newRevisionId} } - const selectedMode = mode === "variant" ? "variant" : "version" + // Agents always append to their one persisted timeline. Ignore a stale + // or externally supplied branching mode as a mechanical guardrail. + const selectedMode = !isAgent && mode === "variant" ? "variant" : "version" const note = commitMessage ?? undefined if (selectedMode === "variant") { @@ -221,6 +227,7 @@ const CommitVariantChangesModal: React.FC = ({ }, [ isEphemeral, + isAgent, createFromEphemeral, createVariant, variantId, @@ -234,13 +241,13 @@ const CommitVariantChangesModal: React.FC = ({ const commitModes = useMemo( () => - isEvaluator + isEvaluator || isAgent ? [{id: "version", label: "As a new version"}] : [ {id: "version", label: `Update ${variantName}`}, {id: "variant", label: "Save as a new variant"}, ], - [isEvaluator, variantName], + [isEvaluator, isAgent, variantName], ) // For ephemeral entities, render a simplified "Create" modal with editable name. @@ -284,11 +291,11 @@ const CommitVariantChangesModal: React.FC = ({ entity={{ type: "variant", id: variantId, - name: variantName, + name: commitTargetName, }} commitModes={commitModes} defaultCommitMode="version" - commitDeployOptions={isEvaluator ? undefined : commitDeployOptions} + commitDeployOptions={isEvaluator || isAgent ? undefined : commitDeployOptions} canSubmit={({mode, entityName}) => { if (mode === "variant") { if (!entityName?.trim()) return false 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 d77f49fed8..2d4128131c 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx @@ -296,16 +296,8 @@ const PlaygroundVariantConfigHeader = ({ ) : ( <> - {!embedded && !isEvaluatorEntity && ( - // Agents get a labeled secondary "Deploy" so the action row reads as a - // hierarchy (primary Commit, secondary Deploy, ghost kebab); other - // surfaces keep the icon-only deploy. - + {!embedded && !isEvaluatorEntity && !isAgentEffective && ( + )} { icon: , disabled: !hasProjectURL, dataTour: "registry-nav", - workflowCategories: ["app", "agent"], + workflowCategories: ["app"], }, { key: "app-evaluations-link", diff --git a/web/oss/src/state/workflow/hooks.ts b/web/oss/src/state/workflow/hooks.ts index 3779742638..5ad21a5238 100644 --- a/web/oss/src/state/workflow/hooks.ts +++ b/web/oss/src/state/workflow/hooks.ts @@ -82,6 +82,7 @@ export const useWorkflowsData = () => { currentWorkflow: currentWorkflow ?? null, workflowKind: ctx.workflowKind, isApp: ctx.workflowKind === "app", + isAgent: ctx.workflowKind === "app" && currentWorkflow?.flags?.is_agent === true, isEvaluator: ctx.workflowKind === "evaluator", isSnippet: ctx.workflowKind === "snippet", // Terminal states (mutually exclusive). diff --git a/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/variants/TreeSelectPopupContent.tsx b/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/variants/TreeSelectPopupContent.tsx index aa3959cf2d..e30cf1aea5 100644 --- a/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/variants/TreeSelectPopupContent.tsx +++ b/web/packages/agenta-entity-ui/src/selection/components/UnifiedEntityPicker/variants/TreeSelectPopupContent.tsx @@ -44,6 +44,8 @@ export type TreeSelectPopupContentProps = Om * Children for these parents will be fetched on mount. */ initialExpandedKeys?: string[] + /** Hide a sole structural parent and render its children as a flat list. */ + flattenSingleParent?: boolean } // ============================================================================ @@ -77,6 +79,7 @@ export function TreeSelectPopupContent({ width = 280, showDate = false, initialExpandedKeys, + flattenSingleParent = false, }: TreeSelectPopupContentProps) { const generatedId = useId() const instanceId = providedInstanceId ?? generatedId @@ -150,6 +153,8 @@ export function TreeSelectPopupContent({ // Get display messages const displayEmptyMessage = emptyMessage ?? resolvedAdapter.emptyMessage ?? "No items found" const displayLoadingMessage = loadingMessage ?? resolvedAdapter.loadingMessage ?? "Loading..." + const displayTreeData = + flattenSingleParent && treeData.length === 1 ? (treeData[0].children ?? []) : treeData // Handle tree node selection const handleTreeSelect = useCallback( @@ -195,7 +200,7 @@ export function TreeSelectPopupContent({ {popupHeader} {/* Loading state — only shown when no tree data is available yet */} - {(isLoadingParents || isLoadingChildren) && treeData.length === 0 && ( + {(isLoadingParents || isLoadingChildren) && displayTreeData.length === 0 && (
{displayLoadingMessage} @@ -208,22 +213,29 @@ export function TreeSelectPopupContent({ )} {/* Empty state */} - {!isLoadingParents && !parentsError && treeData.length === 0 && ( -
- {displayEmptyMessage} -
- )} + {!isLoadingParents && + !isLoadingChildren && + !parentsError && + displayTreeData.length === 0 && ( +
+ {displayEmptyMessage} +
+ )} {/* Tree list */} - {!isLoadingParents && !parentsError && treeData.length > 0 && ( + {!isLoadingParents && !parentsError && displayTreeData.length > 0 && (