diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index fb8da785c0..295fb307e3 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -41,6 +41,7 @@ import { ArrowDown, ArrowRight, Code, + Hourglass, Paperclip, TreeStructure, UploadSimple, @@ -111,6 +112,7 @@ import { inspectorTargetAtom, openInspectorTurnAtom, } from "./components/Inspector/state" +import InteractionDock, {getPendingConnectInteraction} from "./components/InteractionDock" import QueuedMessages from "./components/QueuedMessages" import RevealCollapse from "./components/RevealCollapse" import RightPanelSplit from "./components/RightPanel/RightPanelSplit" @@ -354,6 +356,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, @@ -1127,6 +1143,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 @@ -1885,6 +1908,9 @@ const AgentConversation = ({ const showInspect = inspectorEnabled && 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 23faac7879..2f1c9ad745 100644 --- a/web/packages/agenta-playground/src/index.ts +++ b/web/packages/agenta-playground/src/index.ts @@ -79,7 +79,11 @@ export { } from "./state" export type {AgentRequest, AgentChannelMode, NegotiatingFetch} from "./state" // HITL resume predicate for `useChat`'s `sendAutomaticallyWhen` (approve AND deny resume). -export {agentShouldResumeAfterApproval, type LiveAgentInteraction} from "./state" +export { + agentShouldResumeAfterApproval, + isPendingClientToolInteraction, + type LiveAgentInteraction, +} 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 8afdad87f6..8707f8e06a 100644 --- a/web/packages/agenta-playground/src/state/execution/index.ts +++ b/web/packages/agenta-playground/src/state/execution/index.ts @@ -363,7 +363,11 @@ export {agentChannelModeAtomFamily, 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, type LiveAgentInteraction} from "./agentApprovalResume" +export { + agentShouldResumeAfterApproval, + isPendingClientToolInteraction, + type LiveAgentInteraction, +} 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 d41a18f761..803675ea06 100644 --- a/web/packages/agenta-playground/src/state/index.ts +++ b/web/packages/agenta-playground/src/state/index.ts @@ -183,7 +183,11 @@ export { } from "./execution" export {agentChannelModeAtomFamily, type AgentChannelMode} from "./execution" export {createNegotiatingFetch, type NegotiatingFetch} from "./execution" -export {agentShouldResumeAfterApproval, type LiveAgentInteraction} from "./execution" +export { + agentShouldResumeAfterApproval, + isPendingClientToolInteraction, + type LiveAgentInteraction, +} from "./execution" export {buildRenderMap, renderKindFor, type RenderHintLike} from "./execution" export {canReleaseQueuedMessage, isHitlPending} from "./execution" export {