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 && (
@@ -2204,6 +2231,7 @@ const AgentConversation = ({
queued={queued}
onRemove={removeQueued}
onClear={clearQueue}
+ held={hitlPending}
/>
@@ -2252,6 +2280,14 @@ const AgentConversation = ({
onViewTrace={openPausedTurnTrace}
entityId={entityId}
/>
+ {/* Parked client-tool interactions (connect): same placement contract as the
+ approval dock — the paused gate can't scroll out of reach, and "Not now"
+ is the escape hatch that resumes the run without connecting. */}
+
{/* Owner call: a template pick must not shift the composer, so no chip renders here
(unlike the home surface) — the strip card's own selected state is the
"which template" indicator; the composer text is the only other feedback. */}
@@ -2301,7 +2337,9 @@ const AgentConversation = ({
: STRIP_COPY.describeAgentPlaceholder
: modelBlocked
? "Connect a model to start chatting…"
- : "Ask the agent… (Enter to send, ⌘/Ctrl+Enter for newline)"
+ : hitlPending
+ ? "The agent is waiting for your response — new messages will be queued"
+ : "Ask the agent… (Enter to send, ⌘/Ctrl+Enter for newline)"
}
initialMarkdown={initialDraft}
onChange={handleComposerChange}
@@ -2416,6 +2454,7 @@ const AgentConversation = ({
/>
+
{TEMPLATE_STRIP_MODE ? (
+ meta.renderKind === "connect" || meta.toolName === "request_connection"
+
+/**
+ * The parked connect interaction the run is currently blocked on, or null. Like
+ * `getPendingApprovals`, HITL only ever pauses the LAST assistant turn (see `isHitlPending`), so
+ * only that turn is read. The runner parks one interaction per turn — first match wins.
+ */
+export const getPendingConnectInteraction = (messages: UIMessage[]): ClientToolMeta | null => {
+ const last = messages[messages.length - 1]
+ if (!last || last.role !== "assistant") return null
+ const parts = last.parts ?? []
+ const renderMap = buildRenderMap(parts as {type?: string; data?: unknown}[])
+ for (const part of parts) {
+ if (!isPendingClientToolInteraction(part as {type?: string; state?: string}, renderMap))
+ continue
+ const meta = clientToolMeta(part as ToolUIPart, renderMap)
+ if (isConnectInteraction(meta)) return meta
+ }
+ return null
+}
+
+/** The dock's connect card: header + per-phase body + actions, driven by the shared OAuth flow. */
+const ConnectCard = ({
+ meta,
+ onOutput,
+ active,
+}: {
+ meta: ClientToolMeta
+ onOutput: ClientToolOutputHandler
+ active: boolean
+}) => {
+ // Same settle mapping as ClientToolPart — the dock is a second dispatch site for this part.
+ const settle = useCallback(
+ (args: {output: Record} | {errorText: string}) => {
+ if ("errorText" in args) {
+ onOutput({
+ toolName: meta.toolName,
+ toolCallId: meta.toolCallId,
+ errorText: args.errorText,
+ })
+ } else {
+ onOutput({
+ toolName: meta.toolName,
+ toolCallId: meta.toolCallId,
+ output: args.output,
+ })
+ }
+ },
+ [onOutput, meta.toolName, meta.toolCallId],
+ )
+ const {label, phase, errorText, runConnect, cancel, decline} = useConnectFlow(
+ meta,
+ settle,
+ active,
+ )
+
+ return (
+
+ {/* Header: a quiet primary cue, same visual language as the approval dock's header. */}
+
+
+ The agent is waiting for you
+
+
+ {phase === "connecting" ? (
+
+
+
+ Connecting {label}… finish signing in from the popup window.
+
+
+ ) : phase === "error" ? (
+
+ {errorText ?? "Connection failed."}
+
+ ) : (
+
+ Connect {label} to let the
+ agent continue, or continue without the connection.
+
+ )}
+
+