Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions web/oss/src/components/AgentChatSlice/AgentConversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
ArrowDown,
ArrowRight,
Code,
Hourglass,
Paperclip,
TreeStructure,
UploadSimple,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -318,6 +320,20 @@ const WorkingDots = () => (
</span>
)

/** 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 = () => (
<span
role="status"
aria-label="Agent is waiting for your input"
className="flex items-center gap-1.5 px-1 py-0.5 text-xs text-colorTextTertiary"
>
<Hourglass size={12} />
Waiting for your input
</span>
)

const AgentConversation = ({
entityId,
sessionId,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<MessageRow
key={message.id}
Expand Down Expand Up @@ -1665,7 +1691,7 @@ const AgentConversation = ({
unmount — no settle-time layout shift. An EMPTY streaming assistant turn
already renders its own loading bubble (AgentMessage), so the dots skip it —
exactly one indicator while busy. */}
{(showWorking || showInspect) && (
{(showWorking || showInspect || showWaiting) && (
<div className="flex items-center gap-2 self-start">
{showInspect && (
<button
Expand All @@ -1680,6 +1706,7 @@ const AgentConversation = ({
</button>
)}
{showWorking && <WorkingDots />}
{showWaiting && <WaitingForInput />}
</div>
)}
</MessageRow>
Expand Down Expand Up @@ -1890,6 +1917,7 @@ const AgentConversation = ({
queued={queued}
onRemove={removeQueued}
onClear={clearQueue}
held={hitlPending}
/>
</div>
</RevealCollapse>
Expand Down Expand Up @@ -1936,6 +1964,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. */}
<InteractionDock
className={CHAT_COLUMN}
pending={pendingInteraction}
onOutput={handleClientToolOutput}
/>
{/* 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. */}
Expand Down Expand Up @@ -1972,7 +2008,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}
Expand Down
174 changes: 174 additions & 0 deletions web/oss/src/components/AgentChatSlice/components/InteractionDock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* Persistent "the agent is waiting for you" band for parked CLIENT-TOOL interactions — the sibling
* of ApprovalDock with the same placement contract: it lives in the composer region (between the
* transcript and the input) so a paused run can't scroll out of reach, and it OWNS the actions
* while the inline transcript row is just a marker.
*
* Why it exists (UX): when the runner parks a `request_connection`, the stream genuinely ends —
* `useChat` reads "ready", so nothing busy-derived (working dots, stop button) signals the pause,
* while the message queue silently holds every send (`isHitlPending`). This dock makes the paused
* state visible where the user is typing AND provides the escape hatch a parked connection
* previously lacked: "Not now" settles the call as a structured decline, so the run resumes and
* the conversation unfreezes.
*
* v1 covers the connect interaction (`request_connection` / render.kind "connect"). Elicitation
* stays inline — it's a form the user fills in the transcript, and it carries its own
* Decline/Dismiss actions; the composer's waiting state covers its visibility.
*/
import {memo, useCallback, useRef} from "react"

import {buildRenderMap, isPendingClientToolInteraction} from "@agenta/playground"
import {Plugs, Spinner} from "@phosphor-icons/react"
import type {ToolUIPart, UIMessage} from "ai"
import {Button, Typography} from "antd"

import {clientToolMeta, type ClientToolMeta, type ClientToolOutputHandler} from "./clientTools"
import type {SettleClientTool} from "./clientTools/types"
import {useConnectFlow} from "./clientTools/useConnectFlow"

const {Text} = Typography

/** Whether this client-tool meta is the connect interaction (registry's two dispatch axes). */
const isConnectInteraction = (meta: ClientToolMeta): boolean =>
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<SettleClientTool>(
(args: {output: Record<string, unknown>} | {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 (
<div className="ag-surface-chat mb-2 flex flex-col gap-2.5 rounded-lg p-3">
{/* Header: a quiet primary cue, same visual language as the approval dock's header. */}
<div className="flex items-center gap-2">
<Plugs size={15} weight="fill" className="shrink-0 text-colorPrimary" />
<Text className="!text-xs !font-medium">The agent is waiting for you</Text>
</div>

{phase === "connecting" ? (
<div className="flex items-center gap-2">
<Spinner size={13} className="shrink-0 animate-spin text-colorPrimary" />
<Text type="secondary" className="!text-xs">
Connecting {label}… finish signing in from the popup window.
</Text>
</div>
) : phase === "error" ? (
<Text type="danger" className="!text-xs" title={errorText ?? undefined}>
{errorText ?? "Connection failed."}
</Text>
) : (
<Text type="secondary" className="!text-xs">
Connect <span className="font-medium text-colorText">{label}</span> to let the
agent continue, or continue without the connection.
</Text>
)}

<div className="flex items-center justify-end gap-1.5">
{phase === "connecting" ? (
<Button onClick={cancel}>Cancel</Button>
) : (
<>
<Button onClick={decline}>Not now</Button>
<Button type="primary" onClick={() => runConnect(true)}>
{phase === "error" ? "Retry" : `Connect ${label}`}
</Button>
</>
)}
</div>
</div>
)
}

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 (
<div
className={`grid transition-[grid-template-rows,opacity] duration-200 ease-out ${
open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
} ${className ?? ""}`}
inert={!open}
>
<div className="min-h-0 overflow-hidden">
{shown ? (
// Keyed by call id so flow state (phase/popup) resets per parked call.
<ConnectCard
key={shown.toolCallId}
meta={shown}
onOutput={onOutput}
active={shownIsActive}
/>
) : null}
</div>
</div>
)
}

export default memo(InteractionDock)
Original file line number Diff line number Diff line change
Expand Up @@ -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
}) => (
<div className="w-[300px] max-w-[80vw]">
<div className="flex items-center justify-between gap-2 border-0 border-b border-solid border-colorBorderSecondary px-2.5 py-1.5">
<Text type="secondary" className="!text-[11px] uppercase tracking-wide">
Queued — sent one by one
{held ? "Held until you answer the agent" : "Queued — sent one by one"}
</Text>
<button
type="button"
Expand Down Expand Up @@ -120,10 +122,13 @@ const QueuedList = ({
*/
const QueuedMessages = ({
queued,
held = false,
onRemove,
onClear,
}: {
queued: QueuedMessage[]
/** The run is paused on the user (HITL / parked interaction) — say WHY the queue is held. */
held?: boolean
onRemove: (id: string) => void
onClear: () => void
}) => {
Expand All @@ -137,15 +142,20 @@ const QueuedMessages = ({
placement="topLeft"
arrow={false}
styles={{content: {padding: 0}}}
content={<QueuedList queued={queued} onRemove={onRemove} onClear={onClear} />}
content={
<QueuedList queued={queued} held={held} onRemove={onRemove} onClear={onClear} />
}
>
<button
type="button"
aria-label={`${queued.length} queued message${queued.length > 1 ? "s" : ""}`}
aria-label={`${queued.length} queued message${queued.length > 1 ? "s" : ""}${
held ? ", held until you respond to the agent" : ""
}`}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-solid border-colorBorder bg-colorBgContainer px-2.5 py-0.5 text-xs text-colorTextSecondary transition-colors hover:text-colorText"
>
<Stack size={13} />
{queued.length} queued
{held ? <span className="text-colorTextTertiary">· waiting on you</span> : null}
{open ? <CaretDown size={10} /> : <CaretUp size={10} />}
</button>
</Popover>
Expand Down
Loading
Loading