From 91616a62259021959a4d8a453febf72a3b92c02c Mon Sep 17 00:00:00 2001 From: Raghavan G V Date: Thu, 20 Aug 2026 13:24:36 +0530 Subject: [PATCH 1/8] feat(widget): render the concurrency wait-queue status Handle the queue_status server events (waiting/admitted/timed_out) in the embeddable widget, mirroring elevenlabs/xi#46137 for the agent Preview: show a waiting status while all agents are busy, hide the typing indicator and block text/attachment sending while queued, and render a friendly non-error message when the queue wait times out. New queue_waiting_status and queue_timed_out text-content keys are customizable like all other copy. --- .changeset/convai-widget-queue-status.md | 6 + .../src/contexts/conversation.tsx | 105 +++++++++++---- packages/convai-widget-core/src/index.test.ts | 122 ++++++++++++++++++ .../convai-widget-core/src/markdown.dev.tsx | 2 + .../convai-widget-core/src/mocks/browser.ts | 57 ++++++++ .../convai-widget-core/src/types/config.ts | 3 + .../src/utils/display-transcript.ts | 4 + .../convai-widget-core/src/widget/Sheet.tsx | 6 +- .../src/widget/SheetActions.tsx | 12 +- .../src/widget/StatusLabel.tsx | 35 +++-- .../src/widget/TranscriptMessage.tsx | 24 ++++ 11 files changed, 339 insertions(+), 37 deletions(-) create mode 100644 .changeset/convai-widget-queue-status.md diff --git a/.changeset/convai-widget-queue-status.md b/.changeset/convai-widget-queue-status.md new file mode 100644 index 00000000..5683c43f --- /dev/null +++ b/.changeset/convai-widget-queue-status.md @@ -0,0 +1,6 @@ +--- +"@elevenlabs/convai-widget-core": minor +"@elevenlabs/convai-widget-embed": patch +--- + +Support concurrency wait-queue (`queue_status`) server events: show a waiting status while all agents are busy, hide the typing indicator and block text/attachment sending while queued, and render a friendly non-error message when the queue wait times out. Adds `queue_waiting_status` and `queue_timed_out` text-content keys for customization and localization. diff --git a/packages/convai-widget-core/src/contexts/conversation.tsx b/packages/convai-widget-core/src/contexts/conversation.tsx index dd5e3227..557296c0 100644 --- a/packages/convai-widget-core/src/contexts/conversation.tsx +++ b/packages/convai-widget-core/src/contexts/conversation.tsx @@ -88,6 +88,10 @@ export type TranscriptEntry = type: "mode_toggle"; mode: ConversationMode; conversationIndex: number; + } + | { + type: "queue_timeout"; + conversationIndex: number; }; export function ConversationProvider({ children }: ConversationProviderProps) { @@ -189,6 +193,21 @@ function useConversationSetup() { const conversationTextOnly = signal(null); const isAgentTyping = signal(false); const isExternalAgentMode = signal(false); + const queueStatus = signal(null); + // The caller reached the agent when it never entered the concurrency wait + // queue (null) or was admitted from it. A status added on the backend but + // unknown here counts as reached, degrading gracefully to the regular UI. + const reachedAgent = computed(() => { + const s = queueStatus.value; + return s === null || (s !== "waiting" && s !== "timed_out"); + }); + // While queued the transport is connected but the orchestrator discards + // client messages. "timed_out" arrives as a heads-up just before the + // server closes the connection, so it still counts as waiting until the + // disconnect actually lands. + const isWaitingForAgent = computed( + () => !reachedAgent.value && !isDisconnected.value + ); const setAgentTyping = (typing: boolean, durationMs?: number | null) => { clearTypingTimer(); @@ -214,6 +233,8 @@ function useConversationSetup() { transcript, isAgentTyping, isExternalAgentMode, + queueStatus, + isWaitingForAgent, startSession: async ( element: HTMLElement, initialMessage?: string, @@ -259,6 +280,7 @@ function useConversationSetup() { } conversationTextOnly.value = processedConfig.textOnly ?? false; + queueStatus.value = null; transcript.value = [ ...firstMessageEntries(), ...(initialMessage @@ -432,7 +454,27 @@ function useConversationSetup() { setAgentTyping(false); isExternalAgentMode.value = false; }, + // The SDK forwards server events it does not handle (such as + // queue_status) to onDebug, alongside other debug payloads, so + // the event shape has to be narrowed here. + onDebug: (props: unknown) => { + const event = props as { + type?: string; + queue_status_event?: { status?: unknown }; + }; + if ( + event?.type === "queue_status" && + typeof event.queue_status_event?.status === "string" + ) { + queueStatus.value = event.queue_status_event.status; + } + }, onDisconnect: details => { + // The server closes with an error after a queue timeout; show + // friendly copy instead of the raw close reason in that case. + const queueTimedOut = + details.reason === "error" && + queueStatus.peek() === "timed_out"; receivedFirstMessageRef.current = false; conversationTextOnly.value = null; streamingMessageIndexRef.current = null; @@ -442,20 +484,25 @@ function useConversationSetup() { isExternalAgentMode.value = false; transcript.value = [ ...transcript.peek(), - details.reason === "error" + queueTimedOut ? { - type: "error", - message: details.message, + type: "queue_timeout", conversationIndex: conversationIndex.peek(), } - : { - type: "disconnection", - role: details.reason === "user" ? "user" : "agent", - conversationIndex: conversationIndex.peek(), - }, + : details.reason === "error" + ? { + type: "error", + message: details.message, + conversationIndex: conversationIndex.peek(), + } + : { + type: "disconnection", + role: details.reason === "user" ? "user" : "agent", + conversationIndex: conversationIndex.peek(), + }, ]; conversationIndex.value++; - if (details.reason === "error") { + if (details.reason === "error" && !queueTimedOut) { error.value = details.message; console.error( "[ConversationalAI] Disconnected due to an error:", @@ -481,21 +528,33 @@ function useConversationSetup() { error.value = null; return id; } catch (e) { - let message = "Could not start a conversation."; - if (e instanceof CloseEvent) { - message = e.reason || message; - } else if (e instanceof Error) { - message = e.message || message; + // A queue timeout can close the connection before startSession + // resolves; it gets the same friendly treatment as in onDisconnect. + if (queueStatus.peek() === "timed_out") { + transcript.value = [ + ...transcript.value, + { + type: "queue_timeout", + conversationIndex: conversationIndex.peek(), + }, + ]; + } else { + let message = "Could not start a conversation."; + if (e instanceof CloseEvent) { + message = e.reason || message; + } else if (e instanceof Error) { + message = e.message || message; + } + error.value = message; + transcript.value = [ + ...transcript.value, + { + type: "error", + message, + conversationIndex: conversationIndex.peek(), + }, + ]; } - error.value = message; - transcript.value = [ - ...transcript.value, - { - type: "error", - message, - conversationIndex: conversationIndex.peek(), - }, - ]; } finally { lockRef.current = null; } diff --git a/packages/convai-widget-core/src/index.test.ts b/packages/convai-widget-core/src/index.test.ts index e5325993..422b2d69 100644 --- a/packages/convai-widget-core/src/index.test.ts +++ b/packages/convai-widget-core/src/index.test.ts @@ -296,6 +296,128 @@ describe("elevenlabs-convai", () => { } ); + describe("concurrency wait queue", () => { + it("shows the waiting status and blocks sending while queued", async () => { + setupWebComponent({ + "agent-id": "queued", + transcript: "true", + "text-input": "true", + }); + + const startButton = page.getByRole("button", { name: "Start a call" }); + await startButton.click(); + const acceptButton = page.getByRole("button", { name: "Accept" }); + await acceptButton.click(); + + await expect + .element(page.getByText("All agents are busy right now")) + .toBeInTheDocument(); + + // The typing indicator stays hidden while queued. + await expect + .element(page.getByText("Agent is typing ...")) + .not.toBeInTheDocument(); + + // Sending is blocked: the orchestrator discards client messages while + // held in the queue, so they would only pollute the local transcript. + const textInput = page.getByRole("textbox", { + name: "Text message input", + }); + await textInput.fill("Queued message"); + await expect + .element(page.getByRole("button", { name: "Send", exact: true })) + .toBeDisabled(); + await userEvent.keyboard("{Enter}"); + await expect + .element(page.getByText("Queued message")) + .not.toBeInTheDocument(); + + // Hanging up while queued goes through the regular disconnect flow. + const endButton = page.getByRole("button", { name: "End", exact: true }); + await endButton.click(); + await expect + .element(page.getByText("You ended the conversation")) + .toBeInTheDocument(); + await expect + .element(page.getByText("All agents are busy right now")) + .not.toBeInTheDocument(); + + // No residual gating: a new conversation can start from the textarea. + await textInput.fill("New text message"); + await userEvent.keyboard("{Enter}"); + await expect + .element(page.getByText("New text message")) + .toBeInTheDocument(); + }); + + it("returns to the regular flow when admitted from the queue", async () => { + setupWebComponent({ + "agent-id": "queue_admit", + transcript: "true", + "text-input": "true", + }); + + const startButton = page.getByRole("button", { name: "Start a call" }); + await startButton.click(); + const acceptButton = page.getByRole("button", { name: "Accept" }); + await acceptButton.click(); + + await expect + .element(page.getByText("All agents are busy right now")) + .toBeInTheDocument(); + + // Admitted: the waiting status clears and the agent responds. + await expect + .element(page.getByText("Queue cleared response")) + .toBeInTheDocument(); + await expect + .element(page.getByText("All agents are busy right now")) + .not.toBeInTheDocument(); + + // Sending works again. + const textInput = page.getByRole("textbox", { + name: "Text message input", + }); + await textInput.fill("Hello after queue"); + await userEvent.keyboard("{Enter}"); + await expect + .element(page.getByText("Hello after queue")) + .toBeInTheDocument(); + }); + + it("shows a friendly message when the queue wait times out", async () => { + setupWebComponent({ + "agent-id": "queue_timeout", + transcript: "true", + "text-input": "true", + }); + + const startButton = page.getByRole("button", { name: "Start a call" }); + await startButton.click(); + const acceptButton = page.getByRole("button", { name: "Accept" }); + await acceptButton.click(); + + await expect + .element(page.getByText("All agents are busy right now")) + .toBeInTheDocument(); + + // The server closes after the timeout; the caller gets friendly copy + // instead of the raw close reason or error styling. + await expect + .element( + page.getByText("All agents are still busy. Please try again later.") + ) + .toBeInTheDocument(); + await expect + .element(page.getByText("An error occurred")) + .not.toBeInTheDocument(); + await expect + .element(page.getByText("too many concurrent connections")) + .not.toBeInTheDocument(); + await expect.element(startButton).toBeInTheDocument(); + }); + }); + describe("expansion events", () => { it.each(["document", "widget"])( "should expand and collapse widget when elevenlabs-agent:expand event is dispatched (%s)", diff --git a/packages/convai-widget-core/src/markdown.dev.tsx b/packages/convai-widget-core/src/markdown.dev.tsx index edbfa831..4324f75b 100644 --- a/packages/convai-widget-core/src/markdown.dev.tsx +++ b/packages/convai-widget-core/src/markdown.dev.tsx @@ -258,6 +258,8 @@ function MockConversationProvider({ transcript: mockTranscript, isAgentTyping: signal(false), isExternalAgentMode: signal(false), + queueStatus: signal(null), + isWaitingForAgent: signal(false), startSession: async () => "", endSession: async () => {}, getInputVolume: () => 0, diff --git a/packages/convai-widget-core/src/mocks/browser.ts b/packages/convai-widget-core/src/mocks/browser.ts index 89032f89..5f4c7a2a 100644 --- a/packages/convai-widget-core/src/mocks/browser.ts +++ b/packages/convai-widget-core/src/mocks/browser.ts @@ -42,6 +42,12 @@ export const AGENTS = { use_rtc: true, }, fail: BASIC_CONFIG, + // Held in the concurrency wait queue and never admitted. + queued: BASIC_CONFIG, + // Admitted from the wait queue after a delay. + queue_admit: BASIC_CONFIG, + // The wait queue times out and the server closes with an error. + queue_timeout: BASIC_CONFIG, end_call_test: { ...BASIC_CONFIG, text_only: true, @@ -375,6 +381,57 @@ export const Worker = setupWorker( client.close(3000, "Test reason"); }); } + if ( + agentId === "queued" || + agentId === "queue_admit" || + agentId === "queue_timeout" + ) { + client.send( + JSON.stringify({ + type: "queue_status", + queue_status_event: { status: "waiting" }, + }) + ); + } + if (agentId === "queued") { + // A typing indicator arriving while queued must stay hidden. + client.send(JSON.stringify({ type: "external_agent_connected" })); + client.send( + JSON.stringify({ + type: "agent_typing", + agent_typing_event: { is_typing: true }, + }) + ); + } + if (agentId === "queue_admit") { + await new Promise(resolve => setTimeout(resolve, 1500)); + client.send( + JSON.stringify({ + type: "queue_status", + queue_status_event: { status: "admitted" }, + }) + ); + client.send( + JSON.stringify({ + type: "agent_response", + agent_response_event: { + agent_response: "Queue cleared response", + event_id: 4, + }, + }) + ); + } + if (agentId === "queue_timeout") { + await new Promise(resolve => setTimeout(resolve, 1000)); + client.send( + JSON.stringify({ + type: "queue_status", + queue_status_event: { status: "timed_out" }, + }) + ); + await new Promise(resolve => setTimeout(resolve, 400)); + client.close(3008, "too many concurrent connections"); + } if (agentId === "end_call_test") { client.addEventListener("message", async () => { client.send( diff --git a/packages/convai-widget-core/src/types/config.ts b/packages/convai-widget-core/src/types/config.ts index 0d51c5b4..557c3ffb 100644 --- a/packages/convai-widget-core/src/types/config.ts +++ b/packages/convai-widget-core/src/types/config.ts @@ -129,6 +129,8 @@ export const DefaultTextContents = { speaking_status: "Talk to interrupt", connecting_status: "Connecting", chatting_status: "Chatting with AI Agent", + queue_waiting_status: + "All agents are busy right now. We'll connect you automatically as soon as one is available.", input_label: "Text message input", input_placeholder: "Send a message...", @@ -139,6 +141,7 @@ export const DefaultTextContents = { agent_ended_conversation: "The agent ended the conversation", conversation_id: "ID", error_occurred: "An error occurred", + queue_timed_out: "All agents are still busy. Please try again later.", copy_id: "Copy ID", initiate_feedback: "How was this conversation?", request_follow_up_feedback: "Tell us more", diff --git a/packages/convai-widget-core/src/utils/display-transcript.ts b/packages/convai-widget-core/src/utils/display-transcript.ts index 409abbc7..ef6442ac 100644 --- a/packages/convai-widget-core/src/utils/display-transcript.ts +++ b/packages/convai-widget-core/src/utils/display-transcript.ts @@ -45,6 +45,10 @@ export type DisplayTranscriptEntry = type: "typing_indicator"; conversationIndex: number; } + | { + type: "queue_timeout"; + conversationIndex: number; + } | { type: "rich_content"; component: string; diff --git a/packages/convai-widget-core/src/widget/Sheet.tsx b/packages/convai-widget-core/src/widget/Sheet.tsx index 5fc82f6c..c6852546 100644 --- a/packages/convai-widget-core/src/widget/Sheet.tsx +++ b/packages/convai-widget-core/src/widget/Sheet.tsx @@ -48,6 +48,7 @@ export function Sheet({ open }: SheetProps) { conversationIndex, isAgentTyping, isExternalAgentMode, + isWaitingForAgent, } = useConversation(); const firstMessage = useFirstMessage(); const { currentContent, currentConfig } = useSheetContent(); @@ -67,7 +68,10 @@ export function Sheet({ open }: SheetProps) { ? firstMessage.value : undefined, firstMessageConversationIndex: conversationIndex.peek(), - showTypingIndicator: isExternalAgentMode.value && isAgentTyping.value, + showTypingIndicator: + isExternalAgentMode.value && + isAgentTyping.value && + !isWaitingForAgent.value, }); }); const showTranscript = useComputed( diff --git a/packages/convai-widget-core/src/widget/SheetActions.tsx b/packages/convai-widget-core/src/widget/SheetActions.tsx index fc2814cb..def42006 100644 --- a/packages/convai-widget-core/src/widget/SheetActions.tsx +++ b/packages/convai-widget-core/src/widget/SheetActions.tsx @@ -53,6 +53,7 @@ export function SheetActions({ startSession, sendUserMessage, sendMultimodalMessage, + isWaitingForAgent, } = useConversation(); const fileError = useSignal(null); @@ -89,7 +90,8 @@ export function SheetActions({ () => !pendingFile.value && !hasReachedLimit.value && - status.value === "connected" + status.value === "connected" && + !isWaitingForAgent.value ); const handleFileSelect = useCallback( @@ -112,7 +114,13 @@ export function SheetActions({ const canSend = useComputed(() => { const hasText = !!userMessage.value.trim(); const hasReadyFile = pendingFile.value?.status === "ready"; - return (hasText || hasReadyFile) && !isUploading.value; + // While held in the concurrency wait queue the orchestrator discards + // client messages, so sending would only pollute the local transcript. + return ( + (hasText || hasReadyFile) && + !isUploading.value && + !isWaitingForAgent.value + ); }); const handleSendMessage = useCallback( diff --git a/packages/convai-widget-core/src/widget/StatusLabel.tsx b/packages/convai-widget-core/src/widget/StatusLabel.tsx index e0b9c913..82900230 100644 --- a/packages/convai-widget-core/src/widget/StatusLabel.tsx +++ b/packages/convai-widget-core/src/widget/StatusLabel.tsx @@ -9,19 +9,24 @@ import { useConversationMode } from "../contexts/conversation-mode"; function userCurrentLabel() { - const { status, isSpeaking } = useConversation(); + const { status, isSpeaking, isWaitingForAgent } = useConversation(); const textOnly = useIsConversationTextOnly(); const { isTextMode } = useConversationMode(); const text = useTextContents(); const compute = () => { - if (status.value !== "connected") return {label: text.connecting_status.value, updateImmediately: true}; + // The waiting copy wins over the connected states: while held in the + // concurrency wait queue the transport is connected but no agent is + // listening or speaking yet. + if (isWaitingForAgent.value) return {label: text.queue_waiting_status.value, updateImmediately: true, wrap: true}; - if (textOnly.value || isTextMode.value) return {label: text.chatting_status.value, updateImmediately: isSpeaking.value}; + if (status.value !== "connected") return {label: text.connecting_status.value, updateImmediately: true, wrap: false}; - if (isSpeaking.value) return {label: text.speaking_status.value, updateImmediately: isSpeaking.value}; + if (textOnly.value || isTextMode.value) return {label: text.chatting_status.value, updateImmediately: isSpeaking.value, wrap: false}; - return {label: text.listening_status.value, updateImmediately: isSpeaking.value}; + if (isSpeaking.value) return {label: text.speaking_status.value, updateImmediately: isSpeaking.value, wrap: false}; + + return {label: text.listening_status.value, updateImmediately: isSpeaking.value, wrap: false}; } return useComputed(compute) } @@ -31,14 +36,17 @@ export function StatusLabel({ ...props }: HTMLAttributes) { const currentLabel = userCurrentLabel(); - const [label, setLabel] = useState(currentLabel.peek().label); + const [{ label, wrap }, setLabel] = useState(() => { + const { label, wrap } = currentLabel.peek(); + return { label, wrap }; + }); useSignalEffect(() => { - const label = currentLabel.value; - if (label.updateImmediately) { - setLabel(label.label); + const next = currentLabel.value; + if (next.updateImmediately) { + setLabel({ label: next.label, wrap: next.wrap }); } else { const timeout = setTimeout(() => { - setLabel(label.label); + setLabel({ label: next.label, wrap: next.wrap }); }, 500); return () => clearTimeout(timeout); } @@ -53,7 +61,12 @@ export function StatusLabel({ {...props} > -
+
{label}
diff --git a/packages/convai-widget-core/src/widget/TranscriptMessage.tsx b/packages/convai-widget-core/src/widget/TranscriptMessage.tsx index c8cc55d2..50aab323 100644 --- a/packages/convai-widget-core/src/widget/TranscriptMessage.tsx +++ b/packages/convai-widget-core/src/widget/TranscriptMessage.tsx @@ -215,6 +215,27 @@ function ErrorMessage({ ); } +// Rendered when the session ended because the concurrency wait-queue hold +// timed out. Deliberately not styled as an error: the caller did nothing +// wrong, all agents were simply busy. +function QueueTimeoutMessage() { + const text = useTextContents(); + const { lastId } = useConversation(); + const config = useWidgetConfig(); + + return ( +
+ {text.queue_timed_out} +
+ {lastId.value && config.value.show_conversation_id && ( + + {text.conversation_id}: {lastId.value} + + )} +
+ ); +} + interface ModeToggleMessageProps { entry: Extract; } @@ -302,6 +323,9 @@ function getMessageComponent(entry: DisplayTranscriptEntry) { if (entry.type === "error") { return ; } + if (entry.type === "queue_timeout") { + return ; + } if (entry.type === "typing_indicator") { return ; } From ec31bbf75ea9fd66ace570d3951d558f4f366676 Mon Sep 17 00:00:00 2001 From: Raghavan G V Date: Thu, 20 Aug 2026 14:25:32 +0530 Subject: [PATCH 2/8] refactor(widget): match the isHeldInQueue naming from the Preview change --- .../src/contexts/conversation.tsx | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/convai-widget-core/src/contexts/conversation.tsx b/packages/convai-widget-core/src/contexts/conversation.tsx index 557296c0..8efa4ee0 100644 --- a/packages/convai-widget-core/src/contexts/conversation.tsx +++ b/packages/convai-widget-core/src/contexts/conversation.tsx @@ -194,19 +194,20 @@ function useConversationSetup() { const isAgentTyping = signal(false); const isExternalAgentMode = signal(false); const queueStatus = signal(null); - // The caller reached the agent when it never entered the concurrency wait - // queue (null) or was admitted from it. A status added on the backend but - // unknown here counts as reached, degrading gracefully to the regular UI. - const reachedAgent = computed(() => { - const s = queueStatus.value; - return s === null || (s !== "waiting" && s !== "timed_out"); - }); + // Only the statuses that hold a caller back are listed, so no queue, + // admission, and any status this build does not know about all mean "not + // held" — a status added on the backend renders like the pre-queue flow + // instead of locking the UI. + const isHeldInQueue = computed( + () => + queueStatus.value === "waiting" || queueStatus.value === "timed_out" + ); // While queued the transport is connected but the orchestrator discards - // client messages. "timed_out" arrives as a heads-up just before the - // server closes the connection, so it still counts as waiting until the - // disconnect actually lands. + // client messages. "timed_out" counts as held: the server sends it as a + // heads-up just before closing the connection, so it still renders as + // waiting until the disconnect actually lands. const isWaitingForAgent = computed( - () => !reachedAgent.value && !isDisconnected.value + () => isHeldInQueue.value && !isDisconnected.value ); const setAgentTyping = (typing: boolean, durationMs?: number | null) => { From 8165f6718a94fec0743324f99ea75dd8cc9e7481 Mon Sep 17 00:00:00 2001 From: Raghavan G V Date: Thu, 20 Aug 2026 16:02:25 +0530 Subject: [PATCH 3/8] fix(widget): give the waiting status room on every surface Live-testing the embed against a real backend showed the full waiting sentence collapsing to min-content width in the avatar overlay pill and overflowing into the composer, and it cannot fit the single-line pills in the sheet header and full trigger at all. The avatar overlay wrapper now sizes to w-max so the copy wraps at its intended cap, and the cramped surfaces show a new short queue_waiting_status_short key ("Waiting for an available agent") instead. --- .changeset/convai-widget-queue-status.md | 2 +- packages/convai-widget-core/src/index.test.ts | 26 +++++++++++++++---- .../convai-widget-core/src/mocks/browser.ts | 8 ++++++ .../convai-widget-core/src/types/config.ts | 1 + .../src/widget/AvatarOverlay.tsx | 5 +++- .../src/widget/FullTrigger.tsx | 5 +++- .../src/widget/SheetHeader.tsx | 5 +++- .../src/widget/StatusLabel.tsx | 20 ++++++++++---- 8 files changed, 58 insertions(+), 14 deletions(-) diff --git a/.changeset/convai-widget-queue-status.md b/.changeset/convai-widget-queue-status.md index 5683c43f..c49844b2 100644 --- a/.changeset/convai-widget-queue-status.md +++ b/.changeset/convai-widget-queue-status.md @@ -3,4 +3,4 @@ "@elevenlabs/convai-widget-embed": patch --- -Support concurrency wait-queue (`queue_status`) server events: show a waiting status while all agents are busy, hide the typing indicator and block text/attachment sending while queued, and render a friendly non-error message when the queue wait times out. Adds `queue_waiting_status` and `queue_timed_out` text-content keys for customization and localization. +Support concurrency wait-queue (`queue_status`) server events: show a waiting status while all agents are busy, hide the typing indicator and block text/attachment sending while queued, and render a friendly non-error message when the queue wait times out. Adds `queue_waiting_status`, `queue_waiting_status_short`, and `queue_timed_out` text-content keys for customization and localization. diff --git a/packages/convai-widget-core/src/index.test.ts b/packages/convai-widget-core/src/index.test.ts index 422b2d69..b638b656 100644 --- a/packages/convai-widget-core/src/index.test.ts +++ b/packages/convai-widget-core/src/index.test.ts @@ -310,7 +310,7 @@ describe("elevenlabs-convai", () => { await acceptButton.click(); await expect - .element(page.getByText("All agents are busy right now")) + .element(page.getByText("Waiting for an available agent")) .toBeInTheDocument(); // The typing indicator stays hidden while queued. @@ -339,7 +339,7 @@ describe("elevenlabs-convai", () => { .element(page.getByText("You ended the conversation")) .toBeInTheDocument(); await expect - .element(page.getByText("All agents are busy right now")) + .element(page.getByText("Waiting for an available agent")) .not.toBeInTheDocument(); // No residual gating: a new conversation can start from the textarea. @@ -350,6 +350,22 @@ describe("elevenlabs-convai", () => { .toBeInTheDocument(); }); + it("shows the full waiting message on the avatar overlay", async () => { + // An expanded sheet without a transcript shows the avatar overlay, + // which has room for the full reassurance copy instead of the short + // pill form. + setupWebComponent({ "agent-id": "queued_overlay" }); + + const startButton = page.getByRole("button", { name: "Start a call" }); + await startButton.click(); + const acceptButton = page.getByRole("button", { name: "Accept" }); + await acceptButton.click(); + + await expect + .element(page.getByText("All agents are busy right now")) + .toBeInTheDocument(); + }); + it("returns to the regular flow when admitted from the queue", async () => { setupWebComponent({ "agent-id": "queue_admit", @@ -363,7 +379,7 @@ describe("elevenlabs-convai", () => { await acceptButton.click(); await expect - .element(page.getByText("All agents are busy right now")) + .element(page.getByText("Waiting for an available agent")) .toBeInTheDocument(); // Admitted: the waiting status clears and the agent responds. @@ -371,7 +387,7 @@ describe("elevenlabs-convai", () => { .element(page.getByText("Queue cleared response")) .toBeInTheDocument(); await expect - .element(page.getByText("All agents are busy right now")) + .element(page.getByText("Waiting for an available agent")) .not.toBeInTheDocument(); // Sending works again. @@ -398,7 +414,7 @@ describe("elevenlabs-convai", () => { await acceptButton.click(); await expect - .element(page.getByText("All agents are busy right now")) + .element(page.getByText("Waiting for an available agent")) .toBeInTheDocument(); // The server closes after the timeout; the caller gets friendly copy diff --git a/packages/convai-widget-core/src/mocks/browser.ts b/packages/convai-widget-core/src/mocks/browser.ts index 5f4c7a2a..b0f89eba 100644 --- a/packages/convai-widget-core/src/mocks/browser.ts +++ b/packages/convai-widget-core/src/mocks/browser.ts @@ -44,6 +44,13 @@ export const AGENTS = { fail: BASIC_CONFIG, // Held in the concurrency wait queue and never admitted. queued: BASIC_CONFIG, + // Same, but with the sheet expanded and no transcript, so the avatar + // overlay (the spacious waiting-copy surface) is what renders. + queued_overlay: { + ...BASIC_CONFIG, + text_input_enabled: true, + default_expanded: true, + }, // Admitted from the wait queue after a delay. queue_admit: BASIC_CONFIG, // The wait queue times out and the server closes with an error. @@ -383,6 +390,7 @@ export const Worker = setupWorker( } if ( agentId === "queued" || + agentId === "queued_overlay" || agentId === "queue_admit" || agentId === "queue_timeout" ) { diff --git a/packages/convai-widget-core/src/types/config.ts b/packages/convai-widget-core/src/types/config.ts index 557c3ffb..040dfa3b 100644 --- a/packages/convai-widget-core/src/types/config.ts +++ b/packages/convai-widget-core/src/types/config.ts @@ -131,6 +131,7 @@ export const DefaultTextContents = { chatting_status: "Chatting with AI Agent", queue_waiting_status: "All agents are busy right now. We'll connect you automatically as soon as one is available.", + queue_waiting_status_short: "Waiting for an available agent", input_label: "Text message input", input_placeholder: "Send a message...", diff --git a/packages/convai-widget-core/src/widget/AvatarOverlay.tsx b/packages/convai-widget-core/src/widget/AvatarOverlay.tsx index dbd86c9b..6908e6e6 100644 --- a/packages/convai-widget-core/src/widget/AvatarOverlay.tsx +++ b/packages/convai-widget-core/src/widget/AvatarOverlay.tsx @@ -57,7 +57,10 @@ export function AvatarOverlay({
-
+ {/* w-max: an absolutely positioned box at left-1/2 otherwise + shrink-to-fits against half the avatar container, collapsing + wrapped copy (the queue waiting status) to min-content width. */} +
diff --git a/packages/convai-widget-core/src/widget/FullTrigger.tsx b/packages/convai-widget-core/src/widget/FullTrigger.tsx index ab8c2818..d7a0eaf0 100644 --- a/packages/convai-widget-core/src/widget/FullTrigger.tsx +++ b/packages/convai-widget-core/src/widget/FullTrigger.tsx @@ -36,7 +36,10 @@ export function FullTrigger({ {text.main_label} - +
diff --git a/packages/convai-widget-core/src/widget/SheetHeader.tsx b/packages/convai-widget-core/src/widget/SheetHeader.tsx index 71ac60c7..073b5f9e 100644 --- a/packages/convai-widget-core/src/widget/SheetHeader.tsx +++ b/packages/convai-widget-core/src/widget/SheetHeader.tsx @@ -51,7 +51,10 @@ export function SheetHeader({
)} - +
diff --git a/packages/convai-widget-core/src/widget/StatusLabel.tsx b/packages/convai-widget-core/src/widget/StatusLabel.tsx index 82900230..7935d6c7 100644 --- a/packages/convai-widget-core/src/widget/StatusLabel.tsx +++ b/packages/convai-widget-core/src/widget/StatusLabel.tsx @@ -8,7 +8,7 @@ import { useIsConversationTextOnly } from "../contexts/widget-config"; import { useConversationMode } from "../contexts/conversation-mode"; -function userCurrentLabel() { +function userCurrentLabel(compact: boolean) { const { status, isSpeaking, isWaitingForAgent } = useConversation(); const textOnly = useIsConversationTextOnly(); const { isTextMode } = useConversationMode(); @@ -17,8 +17,12 @@ function userCurrentLabel() { const compute = () => { // The waiting copy wins over the connected states: while held in the // concurrency wait queue the transport is connected but no agent is - // listening or speaking yet. - if (isWaitingForAgent.value) return {label: text.queue_waiting_status.value, updateImmediately: true, wrap: true}; + // listening or speaking yet. Compact surfaces (single-line pills) get the + // short form; spacious ones the full reassurance, wrapped. + if (isWaitingForAgent.value) + return compact + ? {label: text.queue_waiting_status_short.value, updateImmediately: true, wrap: false} + : {label: text.queue_waiting_status.value, updateImmediately: true, wrap: true}; if (status.value !== "connected") return {label: text.connecting_status.value, updateImmediately: true, wrap: false}; @@ -31,11 +35,17 @@ function userCurrentLabel() { return useComputed(compute) } +interface StatusLabelProps extends HTMLAttributes { + /** Render single-line copy for cramped surfaces (header pill, trigger). */ + compact?: boolean; +} + export function StatusLabel({ className, + compact = false, ...props -}: HTMLAttributes) { - const currentLabel = userCurrentLabel(); +}: StatusLabelProps) { + const currentLabel = userCurrentLabel(compact); const [{ label, wrap }, setLabel] = useState(() => { const { label, wrap } = currentLabel.peek(); return { label, wrap }; From c3c7efe7925605752c36075d3eb4d73b1b1b8913 Mon Sep 17 00:00:00 2001 From: Raghavan G V Date: Thu, 20 Aug 2026 21:30:15 +0530 Subject: [PATCH 4/8] chore: shorten the queue-status changeset summary --- .changeset/convai-widget-queue-status.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/convai-widget-queue-status.md b/.changeset/convai-widget-queue-status.md index c49844b2..0e57681a 100644 --- a/.changeset/convai-widget-queue-status.md +++ b/.changeset/convai-widget-queue-status.md @@ -3,4 +3,4 @@ "@elevenlabs/convai-widget-embed": patch --- -Support concurrency wait-queue (`queue_status`) server events: show a waiting status while all agents are busy, hide the typing indicator and block text/attachment sending while queued, and render a friendly non-error message when the queue wait times out. Adds `queue_waiting_status`, `queue_waiting_status_short`, and `queue_timed_out` text-content keys for customization and localization. +Support concurrency wait-queue (`queue_status`) server events: show a waiting status while all agents are busy, hide the typing indicator and block text/attachment sending while queued, and render a friendly non-error message when the queue wait times out. From f0ffd98d5fce60d92bece4850b690e1b09ab5f22 Mon Sep 17 00:00:00 2001 From: Raghavan G V Date: Fri, 21 Aug 2026 12:36:38 +0530 Subject: [PATCH 5/8] fix(widget): size the full trigger to fit the waiting status The status label was absolutely positioned over the main label, so it never contributed to the trigger's width and the long queue-waiting copy got clipped at the card edge. Stack both labels in one grid cell so the wider one sizes the card, and truncate single-line statuses that exceed the cap instead of spilling past it. --- packages/convai-widget-core/src/index.test.ts | 27 ++++++++++ .../src/widget/FullTrigger.tsx | 10 ++-- .../src/widget/StatusLabel.tsx | 49 +++++++++++++++---- 3 files changed, 73 insertions(+), 13 deletions(-) diff --git a/packages/convai-widget-core/src/index.test.ts b/packages/convai-widget-core/src/index.test.ts index b638b656..0c025b64 100644 --- a/packages/convai-widget-core/src/index.test.ts +++ b/packages/convai-widget-core/src/index.test.ts @@ -366,6 +366,33 @@ describe("elevenlabs-convai", () => { .toBeInTheDocument(); }); + it("keeps the waiting status inside the full trigger", async () => { + // The trigger sizes itself from the labels' shared grid cell; with an + // absolutely positioned status label the long waiting copy would not + // affect layout and would get clipped at the card edge mid-word. + setupWebComponent({ "agent-id": "queued", variant: "full" }); + + const startButton = page.getByRole("button", { name: "Start a call" }); + await startButton.click(); + const acceptButton = page.getByRole("button", { name: "Accept" }); + await acceptButton.click(); + + const label = page.getByText("Waiting for an available agent"); + await expect.element(label).toBeInTheDocument(); + + const labelElement = label.element() as HTMLElement; + // The full copy is visible, not ellipsized... + expect(labelElement.scrollWidth).toBeLessThanOrEqual( + labelElement.clientWidth + 1 + ); + // ...and the label stays within the trigger card. + const card = labelElement.closest(".rounded-sheet")!; + const labelRect = labelElement.getBoundingClientRect(); + const cardRect = card.getBoundingClientRect(); + expect(labelRect.right).toBeLessThanOrEqual(cardRect.right); + expect(labelRect.left).toBeGreaterThanOrEqual(cardRect.left); + }); + it("returns to the regular flow when admitted from the queue", async () => { setupWebComponent({ "agent-id": "queue_admit", diff --git a/packages/convai-widget-core/src/widget/FullTrigger.tsx b/packages/convai-widget-core/src/widget/FullTrigger.tsx index d7a0eaf0..0baa503e 100644 --- a/packages/convai-widget-core/src/widget/FullTrigger.tsx +++ b/packages/convai-widget-core/src/widget/FullTrigger.tsx @@ -26,10 +26,14 @@ export function FullTrigger({ >
-
+ {/* Both labels share one grid cell so the wider of the two sizes the + trigger — an absolutely positioned status label would not affect + layout and long copy (the queue waiting status) would get clipped + at the card edge. */} +
@@ -38,7 +42,7 @@ export function FullTrigger({
diff --git a/packages/convai-widget-core/src/widget/StatusLabel.tsx b/packages/convai-widget-core/src/widget/StatusLabel.tsx index 7935d6c7..4e3d7cb1 100644 --- a/packages/convai-widget-core/src/widget/StatusLabel.tsx +++ b/packages/convai-widget-core/src/widget/StatusLabel.tsx @@ -7,7 +7,6 @@ import { useTextContents } from "../contexts/text-contents"; import { useIsConversationTextOnly } from "../contexts/widget-config"; import { useConversationMode } from "../contexts/conversation-mode"; - function userCurrentLabel(compact: boolean) { const { status, isSpeaking, isWaitingForAgent } = useConversation(); const textOnly = useIsConversationTextOnly(); @@ -21,18 +20,45 @@ function userCurrentLabel(compact: boolean) { // short form; spacious ones the full reassurance, wrapped. if (isWaitingForAgent.value) return compact - ? {label: text.queue_waiting_status_short.value, updateImmediately: true, wrap: false} - : {label: text.queue_waiting_status.value, updateImmediately: true, wrap: true}; + ? { + label: text.queue_waiting_status_short.value, + updateImmediately: true, + wrap: false, + } + : { + label: text.queue_waiting_status.value, + updateImmediately: true, + wrap: true, + }; - if (status.value !== "connected") return {label: text.connecting_status.value, updateImmediately: true, wrap: false}; + if (status.value !== "connected") + return { + label: text.connecting_status.value, + updateImmediately: true, + wrap: false, + }; - if (textOnly.value || isTextMode.value) return {label: text.chatting_status.value, updateImmediately: isSpeaking.value, wrap: false}; + if (textOnly.value || isTextMode.value) + return { + label: text.chatting_status.value, + updateImmediately: isSpeaking.value, + wrap: false, + }; - if (isSpeaking.value) return {label: text.speaking_status.value, updateImmediately: isSpeaking.value, wrap: false}; + if (isSpeaking.value) + return { + label: text.speaking_status.value, + updateImmediately: isSpeaking.value, + wrap: false, + }; - return {label: text.listening_status.value, updateImmediately: isSpeaking.value, wrap: false}; - } - return useComputed(compute) + return { + label: text.listening_status.value, + updateImmediately: isSpeaking.value, + wrap: false, + }; + }; + return useComputed(compute); } interface StatusLabelProps extends HTMLAttributes { @@ -74,7 +100,10 @@ export function StatusLabel({
{label} From dbb6dadb62222bfc482995684acf0ffd29e2d22b Mon Sep 17 00:00:00 2001 From: Raghavan G V Date: Fri, 21 Aug 2026 15:45:31 +0530 Subject: [PATCH 6/8] refactor(widget): address review feedback on the status label Derive wrapping from the compact prop instead of hardcoding it per label type so long custom copy wraps on spacious surfaces too, take the suggested useState simplification, trim the verbose comments, and mark the onDebug narrowing as interim until the SDK handles queue_status explicitly. --- .../src/contexts/conversation.tsx | 26 +++++------- packages/convai-widget-core/src/index.test.ts | 16 +++----- .../convai-widget-core/src/mocks/browser.ts | 3 +- .../src/widget/AvatarOverlay.tsx | 4 +- .../src/widget/FullTrigger.tsx | 6 +-- .../src/widget/SheetActions.tsx | 3 +- .../src/widget/StatusLabel.tsx | 41 ++++++------------- .../src/widget/TranscriptMessage.tsx | 4 +- 8 files changed, 34 insertions(+), 69 deletions(-) diff --git a/packages/convai-widget-core/src/contexts/conversation.tsx b/packages/convai-widget-core/src/contexts/conversation.tsx index 8efa4ee0..f5dddcfc 100644 --- a/packages/convai-widget-core/src/contexts/conversation.tsx +++ b/packages/convai-widget-core/src/contexts/conversation.tsx @@ -194,18 +194,12 @@ function useConversationSetup() { const isAgentTyping = signal(false); const isExternalAgentMode = signal(false); const queueStatus = signal(null); - // Only the statuses that hold a caller back are listed, so no queue, - // admission, and any status this build does not know about all mean "not - // held" — a status added on the backend renders like the pre-queue flow - // instead of locking the UI. + // Unknown statuses mean "not held" so a new backend status cannot lock the UI. const isHeldInQueue = computed( - () => - queueStatus.value === "waiting" || queueStatus.value === "timed_out" + () => queueStatus.value === "waiting" || queueStatus.value === "timed_out" ); - // While queued the transport is connected but the orchestrator discards - // client messages. "timed_out" counts as held: the server sends it as a - // heads-up just before closing the connection, so it still renders as - // waiting until the disconnect actually lands. + // "timed_out" still counts as held: the server sends it just before + // closing, so the UI keeps showing waiting until the disconnect lands. const isWaitingForAgent = computed( () => isHeldInQueue.value && !isDisconnected.value ); @@ -455,9 +449,9 @@ function useConversationSetup() { setAgentTyping(false); isExternalAgentMode.value = false; }, - // The SDK forwards server events it does not handle (such as - // queue_status) to onDebug, alongside other debug payloads, so - // the event shape has to be narrowed here. + // The SDK forwards unhandled server events to onDebug. + // TODO: drop this narrowing once the SDK handles queue_status + // explicitly (planned after the queue protocol is finalized). onDebug: (props: unknown) => { const event = props as { type?: string; @@ -471,8 +465,8 @@ function useConversationSetup() { } }, onDisconnect: details => { - // The server closes with an error after a queue timeout; show - // friendly copy instead of the raw close reason in that case. + // A queue timeout closes with an error; show friendly copy + // instead of the raw close reason. const queueTimedOut = details.reason === "error" && queueStatus.peek() === "timed_out"; @@ -530,7 +524,7 @@ function useConversationSetup() { return id; } catch (e) { // A queue timeout can close the connection before startSession - // resolves; it gets the same friendly treatment as in onDisconnect. + // resolves. if (queueStatus.peek() === "timed_out") { transcript.value = [ ...transcript.value, diff --git a/packages/convai-widget-core/src/index.test.ts b/packages/convai-widget-core/src/index.test.ts index 0c025b64..54ee9aed 100644 --- a/packages/convai-widget-core/src/index.test.ts +++ b/packages/convai-widget-core/src/index.test.ts @@ -318,8 +318,7 @@ describe("elevenlabs-convai", () => { .element(page.getByText("Agent is typing ...")) .not.toBeInTheDocument(); - // Sending is blocked: the orchestrator discards client messages while - // held in the queue, so they would only pollute the local transcript. + // Sending is blocked while held in the queue. const textInput = page.getByRole("textbox", { name: "Text message input", }); @@ -351,9 +350,8 @@ describe("elevenlabs-convai", () => { }); it("shows the full waiting message on the avatar overlay", async () => { - // An expanded sheet without a transcript shows the avatar overlay, - // which has room for the full reassurance copy instead of the short - // pill form. + // The expanded sheet without a transcript shows the avatar overlay, + // which renders the full waiting copy. setupWebComponent({ "agent-id": "queued_overlay" }); const startButton = page.getByRole("button", { name: "Start a call" }); @@ -367,9 +365,8 @@ describe("elevenlabs-convai", () => { }); it("keeps the waiting status inside the full trigger", async () => { - // The trigger sizes itself from the labels' shared grid cell; with an - // absolutely positioned status label the long waiting copy would not - // affect layout and would get clipped at the card edge mid-word. + // Regression: the long waiting copy must size the trigger, not get + // clipped at the card edge. setupWebComponent({ "agent-id": "queued", variant: "full" }); const startButton = page.getByRole("button", { name: "Start a call" }); @@ -444,8 +441,7 @@ describe("elevenlabs-convai", () => { .element(page.getByText("Waiting for an available agent")) .toBeInTheDocument(); - // The server closes after the timeout; the caller gets friendly copy - // instead of the raw close reason or error styling. + // Friendly copy instead of the raw close reason or error styling. await expect .element( page.getByText("All agents are still busy. Please try again later.") diff --git a/packages/convai-widget-core/src/mocks/browser.ts b/packages/convai-widget-core/src/mocks/browser.ts index b0f89eba..3f4294ef 100644 --- a/packages/convai-widget-core/src/mocks/browser.ts +++ b/packages/convai-widget-core/src/mocks/browser.ts @@ -44,8 +44,7 @@ export const AGENTS = { fail: BASIC_CONFIG, // Held in the concurrency wait queue and never admitted. queued: BASIC_CONFIG, - // Same, but with the sheet expanded and no transcript, so the avatar - // overlay (the spacious waiting-copy surface) is what renders. + // Expanded with no transcript so the avatar overlay renders the waiting copy. queued_overlay: { ...BASIC_CONFIG, text_input_enabled: true, diff --git a/packages/convai-widget-core/src/widget/AvatarOverlay.tsx b/packages/convai-widget-core/src/widget/AvatarOverlay.tsx index 6908e6e6..59bd8308 100644 --- a/packages/convai-widget-core/src/widget/AvatarOverlay.tsx +++ b/packages/convai-widget-core/src/widget/AvatarOverlay.tsx @@ -57,9 +57,7 @@ export function AvatarOverlay({
- {/* w-max: an absolutely positioned box at left-1/2 otherwise - shrink-to-fits against half the avatar container, collapsing - wrapped copy (the queue waiting status) to min-content width. */} + {/* w-max keeps the wrapped status label from collapsing to min-content width */}
diff --git a/packages/convai-widget-core/src/widget/FullTrigger.tsx b/packages/convai-widget-core/src/widget/FullTrigger.tsx index 0baa503e..03e24189 100644 --- a/packages/convai-widget-core/src/widget/FullTrigger.tsx +++ b/packages/convai-widget-core/src/widget/FullTrigger.tsx @@ -26,10 +26,8 @@ export function FullTrigger({ >
- {/* Both labels share one grid cell so the wider of the two sizes the - trigger — an absolutely positioned status label would not affect - layout and long copy (the queue waiting status) would get clipped - at the card edge. */} + {/* Both labels share one grid cell so the wider one sizes the trigger + and long status copy is not clipped */}
{ const hasText = !!userMessage.value.trim(); const hasReadyFile = pendingFile.value?.status === "ready"; - // While held in the concurrency wait queue the orchestrator discards - // client messages, so sending would only pollute the local transcript. + // The orchestrator discards client messages while the caller is queued. return ( (hasText || hasReadyFile) && !isUploading.value && diff --git a/packages/convai-widget-core/src/widget/StatusLabel.tsx b/packages/convai-widget-core/src/widget/StatusLabel.tsx index 4e3d7cb1..ebff224c 100644 --- a/packages/convai-widget-core/src/widget/StatusLabel.tsx +++ b/packages/convai-widget-core/src/widget/StatusLabel.tsx @@ -14,48 +14,37 @@ function userCurrentLabel(compact: boolean) { const text = useTextContents(); const compute = () => { - // The waiting copy wins over the connected states: while held in the - // concurrency wait queue the transport is connected but no agent is - // listening or speaking yet. Compact surfaces (single-line pills) get the - // short form; spacious ones the full reassurance, wrapped. + // While queued the transport is connected but no agent is present yet, + // so the waiting copy wins over the connected statuses. if (isWaitingForAgent.value) - return compact - ? { - label: text.queue_waiting_status_short.value, - updateImmediately: true, - wrap: false, - } - : { - label: text.queue_waiting_status.value, - updateImmediately: true, - wrap: true, - }; + return { + label: compact + ? text.queue_waiting_status_short.value + : text.queue_waiting_status.value, + updateImmediately: true, + }; if (status.value !== "connected") return { label: text.connecting_status.value, updateImmediately: true, - wrap: false, }; if (textOnly.value || isTextMode.value) return { label: text.chatting_status.value, updateImmediately: isSpeaking.value, - wrap: false, }; if (isSpeaking.value) return { label: text.speaking_status.value, updateImmediately: isSpeaking.value, - wrap: false, }; return { label: text.listening_status.value, updateImmediately: isSpeaking.value, - wrap: false, }; }; return useComputed(compute); @@ -72,17 +61,14 @@ export function StatusLabel({ ...props }: StatusLabelProps) { const currentLabel = userCurrentLabel(compact); - const [{ label, wrap }, setLabel] = useState(() => { - const { label, wrap } = currentLabel.peek(); - return { label, wrap }; - }); + const [{ label }, setLabel] = useState(() => currentLabel.peek()); useSignalEffect(() => { const next = currentLabel.value; if (next.updateImmediately) { - setLabel({ label: next.label, wrap: next.wrap }); + setLabel(next); } else { const timeout = setTimeout(() => { - setLabel({ label: next.label, wrap: next.wrap }); + setLabel(next); }, 500); return () => clearTimeout(timeout); } @@ -100,10 +86,7 @@ export function StatusLabel({
{label} diff --git a/packages/convai-widget-core/src/widget/TranscriptMessage.tsx b/packages/convai-widget-core/src/widget/TranscriptMessage.tsx index 50aab323..76d68905 100644 --- a/packages/convai-widget-core/src/widget/TranscriptMessage.tsx +++ b/packages/convai-widget-core/src/widget/TranscriptMessage.tsx @@ -215,9 +215,7 @@ function ErrorMessage({ ); } -// Rendered when the session ended because the concurrency wait-queue hold -// timed out. Deliberately not styled as an error: the caller did nothing -// wrong, all agents were simply busy. +// Not styled as an error: the caller did nothing wrong, all agents were busy. function QueueTimeoutMessage() { const text = useTextContents(); const { lastId } = useConversation(); From 15cb2ed2b340e1cef7c3d508df8fad6cbc801fae Mon Sep 17 00:00:00 2001 From: Raghavan G V Date: Fri, 21 Aug 2026 16:43:30 +0530 Subject: [PATCH 7/8] chore(widget): drop remaining explanatory comments --- packages/convai-widget-core/src/widget/AvatarOverlay.tsx | 1 - packages/convai-widget-core/src/widget/FullTrigger.tsx | 2 -- packages/convai-widget-core/src/widget/SheetActions.tsx | 1 - packages/convai-widget-core/src/widget/TranscriptMessage.tsx | 1 - 4 files changed, 5 deletions(-) diff --git a/packages/convai-widget-core/src/widget/AvatarOverlay.tsx b/packages/convai-widget-core/src/widget/AvatarOverlay.tsx index 59bd8308..8cca74b6 100644 --- a/packages/convai-widget-core/src/widget/AvatarOverlay.tsx +++ b/packages/convai-widget-core/src/widget/AvatarOverlay.tsx @@ -57,7 +57,6 @@ export function AvatarOverlay({
- {/* w-max keeps the wrapped status label from collapsing to min-content width */}
diff --git a/packages/convai-widget-core/src/widget/FullTrigger.tsx b/packages/convai-widget-core/src/widget/FullTrigger.tsx index 03e24189..ac58e998 100644 --- a/packages/convai-widget-core/src/widget/FullTrigger.tsx +++ b/packages/convai-widget-core/src/widget/FullTrigger.tsx @@ -26,8 +26,6 @@ export function FullTrigger({ >
- {/* Both labels share one grid cell so the wider one sizes the trigger - and long status copy is not clipped */}
{ const hasText = !!userMessage.value.trim(); const hasReadyFile = pendingFile.value?.status === "ready"; - // The orchestrator discards client messages while the caller is queued. return ( (hasText || hasReadyFile) && !isUploading.value && diff --git a/packages/convai-widget-core/src/widget/TranscriptMessage.tsx b/packages/convai-widget-core/src/widget/TranscriptMessage.tsx index 76d68905..4e202451 100644 --- a/packages/convai-widget-core/src/widget/TranscriptMessage.tsx +++ b/packages/convai-widget-core/src/widget/TranscriptMessage.tsx @@ -215,7 +215,6 @@ function ErrorMessage({ ); } -// Not styled as an error: the caller did nothing wrong, all agents were busy. function QueueTimeoutMessage() { const text = useTextContents(); const { lastId } = useConversation(); From 377906c984a575d1d9e8cb326893336eeb6b41e3 Mon Sep 17 00:00:00 2001 From: Raghavan G V Date: Fri, 21 Aug 2026 17:21:59 +0530 Subject: [PATCH 8/8] fix(widget): block every send path while held in the queue The queue gate lived only in SheetActions' canSend, so rich-content message buttons and elevenlabs-agent:user-message events could still send (and append transcript entries) while the orchestrator was discarding client messages. Guard sendUserMessage and sendMultimodalMessage at the source and render rich-content buttons disabled while waiting. --- .../src/contexts/conversation.tsx | 3 ++ packages/convai-widget-core/src/index.test.ts | 39 +++++++++++++++++++ .../convai-widget-core/src/mocks/browser.ts | 20 ++++++++++ .../src/rich-content/ButtonGroup.tsx | 13 +++++-- 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/convai-widget-core/src/contexts/conversation.tsx b/packages/convai-widget-core/src/contexts/conversation.tsx index f5dddcfc..34a52b27 100644 --- a/packages/convai-widget-core/src/contexts/conversation.tsx +++ b/packages/convai-widget-core/src/contexts/conversation.tsx @@ -575,6 +575,8 @@ function useConversationSetup() { conversationRef.current?.sendFeedback(like); }, sendUserMessage: (text: string, options?: SendUserMessageOptions) => { + // The orchestrator discards messages sent while held in the queue. + if (isWaitingForAgent.peek()) return; conversationRef.current?.sendUserMessage(text, options); transcript.value = [ ...transcript.value, @@ -591,6 +593,7 @@ function useConversationSetup() { text?: string; file: TranscriptFileInput & { fileId: string }; }) => { + if (isWaitingForAgent.peek()) return; const trimmed = input.text?.trim() ?? ""; const { fileId, ...fileInput } = input.file; conversationRef.current?.sendMultimodalMessage({ diff --git a/packages/convai-widget-core/src/index.test.ts b/packages/convai-widget-core/src/index.test.ts index 54ee9aed..a37ee5b5 100644 --- a/packages/convai-widget-core/src/index.test.ts +++ b/packages/convai-widget-core/src/index.test.ts @@ -349,6 +349,45 @@ describe("elevenlabs-convai", () => { .toBeInTheDocument(); }); + it("disables rich-content buttons and drops bridged sends while queued", async () => { + const widget = setupWebComponent({ + "agent-id": "queued", + transcript: "true", + "text-input": "true", + "allow-events": "true", + "default-expanded": "true", + }); + + // Start via the textarea so the conversation is text-only and rich + // content renders. + const textInput = page.getByRole("textbox", { + name: "Text message input", + }); + await textInput.fill("Hello"); + await userEvent.keyboard("{Enter}"); + const acceptButton = page.getByRole("button", { name: "Accept" }); + await acceptButton.click(); + + await expect + .element(page.getByText("Waiting for an available agent")) + .toBeInTheDocument(); + + // Rich-content buttons received while queued render disabled. + await expect + .element(page.getByRole("button", { name: "Quick reply" })) + .toBeDisabled(); + + // Programmatic sends via the event bridge are dropped while queued. + widget.dispatchEvent( + new CustomEvent("elevenlabs-agent:user-message", { + detail: { message: "Bridged message" }, + }) + ); + await expect + .element(page.getByText("Bridged message")) + .not.toBeInTheDocument(); + }); + it("shows the full waiting message on the avatar overlay", async () => { // The expanded sheet without a transcript shows the avatar overlay, // which renders the full waiting copy. diff --git a/packages/convai-widget-core/src/mocks/browser.ts b/packages/convai-widget-core/src/mocks/browser.ts index 3f4294ef..edabc533 100644 --- a/packages/convai-widget-core/src/mocks/browser.ts +++ b/packages/convai-widget-core/src/mocks/browser.ts @@ -409,6 +409,26 @@ export const Worker = setupWorker( agent_typing_event: { is_typing: true }, }) ); + // Rich-content buttons arriving while queued must render disabled. + client.send( + JSON.stringify({ + type: "rich_content", + rich_content: { + rich_content_id: "rc_queued", + component: "buttons", + props: { + buttons: [ + { + type: "message", + label: "Quick reply", + message: "Quick reply", + }, + ], + }, + event_id: 2, + }, + }) + ); } if (agentId === "queue_admit") { await new Promise(resolve => setTimeout(resolve, 1500)); diff --git a/packages/convai-widget-core/src/rich-content/ButtonGroup.tsx b/packages/convai-widget-core/src/rich-content/ButtonGroup.tsx index a29a4bd1..f3a06f5c 100644 --- a/packages/convai-widget-core/src/rich-content/ButtonGroup.tsx +++ b/packages/convai-widget-core/src/rich-content/ButtonGroup.tsx @@ -21,9 +21,16 @@ export interface ButtonGroupProps { } export function ButtonGroup({ buttons, richContentId }: ButtonGroupProps) { - const { sendUserMessage, startSession, isDisconnected, status } = - useConversation(); - const disabled = status.value !== "connected" && !isDisconnected.value; + const { + sendUserMessage, + startSession, + isDisconnected, + status, + isWaitingForAgent, + } = useConversation(); + const disabled = + (status.value !== "connected" && !isDisconnected.value) || + isWaitingForAgent.value; if (buttons.length === 0) return null;