diff --git a/examples/web-chat/src/main.tsx b/examples/web-chat/src/main.tsx
index 2d1b7414..eb2297e9 100644
--- a/examples/web-chat/src/main.tsx
+++ b/examples/web-chat/src/main.tsx
@@ -1,4 +1,7 @@
import App from './App';
+// The SDK's card stylesheet (`.smooth-chat__interaction*`), themed to this app's
+// dark palette by the `--smooth-*` token overrides in styles.css.
+import '@smooai/smooth-operator/react/styles.css';
import './styles.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
diff --git a/examples/web-chat/src/operator.ts b/examples/web-chat/src/operator.ts
index 0a1068f9..4809361b 100644
--- a/examples/web-chat/src/operator.ts
+++ b/examples/web-chat/src/operator.ts
@@ -8,7 +8,7 @@
// Everything here drives a *real* running server; there is no mock. See the
// README for how to start `smooth-operator-server` and point this at it.
-import { SmoothAgentClient, type ConversationSummary } from '@smooai/smooth-operator';
+import { SmoothAgentClient, type ChoicesSpec, type ChoicesValues, type ConversationSummary } from '@smooai/smooth-operator';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
/** The agent's live presence — what the header reflects. */
@@ -49,6 +49,21 @@ export interface Approval {
description: string;
}
+/** A parked Rich Interaction the agent raised mid-turn (a `choices` ask, etc.).
+ * The turn stays parked until we `submitInteraction` / `declineInteraction`,
+ * echoing `interactionId` so a stale card can never resolve a newer park. */
+export interface Interaction {
+ turnRequestId: string;
+ interactionId: string;
+ kind: string;
+ spec: ChoicesSpec;
+ reason: string;
+ /** Per-question server errors from an `interaction_invalid` reply (still parked). */
+ errors?: { field: string; message: string }[];
+ /** True after a submit, until the turn resumes or comes back invalid. */
+ busy?: boolean;
+}
+
export interface Status {
connected: boolean;
error?: string;
@@ -58,9 +73,12 @@ interface OperatorApi {
state: AgentState;
messages: ChatMessage[];
approvals: Approval[];
+ interaction: Interaction | null;
status: Status;
sendMessage: (text: string) => void;
respond: (turnRequestId: string, approved: boolean) => void;
+ submitInteraction: (values: ChoicesValues) => void;
+ declineInteraction: () => void;
conversations: ConversationSummary[];
activeConversationId: string | null;
resumeConversation: (conversationId: string) => void;
@@ -117,6 +135,7 @@ function renderHistory(raw: any[]): ChatMessage[] {
export function useOperator(): OperatorApi {
const [messages, setMessages] = useState
([]);
const [approvals, setApprovals] = useState([]);
+ const [interaction, setInteraction] = useState(null);
const [conversations, setConversations] = useState([]);
const [activeConversationId, setActiveConversationId] = useState(null);
const [connected, setConnected] = useState(false);
@@ -163,6 +182,8 @@ export function useOperator(): OperatorApi {
switch (v.type) {
case 'stream_token': {
setStreaming(true);
+ // A valid submit resumed the turn — retire the parked card.
+ setInteraction((prev) => (prev?.busy ? null : prev));
const tok = v.token ?? v.data?.token ?? '';
patchStreaming((m) => {
const blocks = m.blocks.slice();
@@ -217,6 +238,26 @@ export function useOperator(): OperatorApi {
]);
break;
}
+ // A Rich Interaction was raised mid-turn (`choices`, …): park a
+ // card. The generic envelope nests the payload at data.data.
+ case 'interaction_required': {
+ const d = v.data?.data ?? {};
+ setInteraction({
+ turnRequestId: turn.requestId,
+ interactionId: d.interactionId,
+ kind: d.kind,
+ spec: d.spec,
+ reason: d.reason ?? '',
+ });
+ break;
+ }
+ // Server rejected the submitted values — stay parked, re-render
+ // the card with per-question errors (never a terminal error).
+ case 'interaction_invalid': {
+ const d = v.data?.data ?? {};
+ setInteraction((prev) => (prev ? { ...prev, errors: d.errors ?? [], busy: false } : prev));
+ break;
+ }
default:
break;
}
@@ -230,6 +271,7 @@ export function useOperator(): OperatorApi {
setTurnActive(false);
setStreaming(false);
setApprovals((prev) => prev.filter((a) => a.turnRequestId !== turn.requestId));
+ setInteraction((prev) => (prev?.turnRequestId === turn.requestId ? null : prev));
patchStreaming((m) => ({ ...m, streaming: false }));
void refreshConversations();
}
@@ -247,7 +289,7 @@ export function useOperator(): OperatorApi {
(async () => {
try {
await client.connect();
- const session = await client.createConversationSession({ agentId, userName: 'web-chat-example' });
+ const session = await client.createConversationSession({ agentId, userName: 'web-chat-example', supports: ['choice_chips'] });
if (cancelled) return;
sessionRef.current = session.sessionId;
setActiveConversationId(session.conversationId);
@@ -290,15 +332,40 @@ export function useOperator(): OperatorApi {
client.confirmToolAction({ sessionId: sessionRef.current, requestId: turnRequestId, approved });
}, []);
+ // Resume a parked Rich Interaction. The ONE `submitInteraction` verb serves
+ // every kind; the server validates and either resumes the turn (valid) or
+ // replies `interaction_invalid` (still parked). We mark the card busy and
+ // echo `interactionId` so a stale submit can't resolve a newer park.
+ const submitInteraction = useCallback((values: ChoicesValues) => {
+ const client = clientRef.current;
+ if (!client || !sessionRef.current) return;
+ setInteraction((prev) => {
+ if (!prev) return prev;
+ client.submitInteraction({ sessionId: sessionRef.current!, requestId: prev.turnRequestId, interactionId: prev.interactionId, kind: prev.kind, values: values as unknown as Record });
+ return { ...prev, busy: true, errors: undefined };
+ });
+ }, []);
+
+ const declineInteraction = useCallback(() => {
+ const client = clientRef.current;
+ if (!client || !sessionRef.current) return;
+ setInteraction((prev) => {
+ if (!prev) return prev;
+ client.submitInteraction({ sessionId: sessionRef.current!, requestId: prev.turnRequestId, interactionId: prev.interactionId, kind: prev.kind, declined: true });
+ return null;
+ });
+ }, []);
+
const resumeConversation = useCallback(async (conversationId: string) => {
const client = clientRef.current;
if (!client || !conversationId) return;
setActiveConversationId(conversationId);
setMessages([]);
setApprovals([]);
+ setInteraction(null);
sessionRef.current = null;
const { agentId } = targetRef.current;
- const session = await client.createConversationSession({ agentId, conversationId, userName: 'web-chat-example' });
+ const session = await client.createConversationSession({ agentId, conversationId, userName: 'web-chat-example', supports: ['choice_chips'] });
sessionRef.current = session.sessionId;
const { messages } = await client.getMessages({ sessionId: session.sessionId });
setMessages(renderHistory(messages));
@@ -309,9 +376,10 @@ export function useOperator(): OperatorApi {
if (!client) return;
setMessages([]);
setApprovals([]);
+ setInteraction(null);
sessionRef.current = null;
const { agentId } = targetRef.current;
- const session = await client.createConversationSession({ agentId, userName: 'web-chat-example' });
+ const session = await client.createConversationSession({ agentId, userName: 'web-chat-example', supports: ['choice_chips'] });
sessionRef.current = session.sessionId;
setActiveConversationId(session.conversationId);
void refreshConversations();
@@ -320,19 +388,22 @@ export function useOperator(): OperatorApi {
const state: AgentState = useMemo(() => {
if (error && !connected) return 'offline';
if (!connected) return 'connecting';
- if (approvals.length) return 'awaiting';
+ if (approvals.length || interaction) return 'awaiting';
if (streaming) return 'speaking';
if (turnActive) return 'thinking';
return 'awake';
- }, [error, connected, approvals.length, streaming, turnActive]);
+ }, [error, connected, approvals.length, interaction, streaming, turnActive]);
return {
state,
messages,
approvals,
+ interaction,
status: { connected, error },
sendMessage,
respond,
+ submitInteraction,
+ declineInteraction,
conversations,
activeConversationId,
resumeConversation,
diff --git a/examples/web-chat/src/styles.css b/examples/web-chat/src/styles.css
index d4b50785..1c99040c 100644
--- a/examples/web-chat/src/styles.css
+++ b/examples/web-chat/src/styles.css
@@ -1 +1,14 @@
@import 'tailwindcss';
+
+/* Dark `--smooth-*` tokens for the Rich Interaction card, matching this example's
+ slate palette (the SDK card CSS reads these from the nearest ancestor). */
+.smooth-choices-host {
+ --smooth-color-text: #f1f5f9;
+ --smooth-color-bg: #0f172a;
+ --smooth-color-surface: #1e293b;
+ --smooth-color-primary: #0d9488;
+ --smooth-color-primary-text: #ffffff;
+ --smooth-color-border: #334155;
+ --smooth-color-muted: #94a3b8;
+ --smooth-radius: 12px;
+}
diff --git a/typescript/src/generated/types.ts b/typescript/src/generated/types.ts
index f467d46e..1f840e74 100644
--- a/typescript/src/generated/types.ts
+++ b/typescript/src/generated/types.ts
@@ -86,7 +86,7 @@ export interface CreateConversationSessionRequest {
*/
browserFingerprint?: string;
/**
- * Client render capabilities for this session — a per-kind list gating the Rich Interactions the server may emit mid-turn (`interaction_required`). Each interaction kind declares the capability that gates it (e.g. kind `identity_intake` → capability `identity_form`); future kinds add their own values (`date_picker`, `file_upload`, …). Text-only channels (SMS, voice) omit this and the server degrades each kind to its conversational fallback. Unknown values are ignored (forward-compatible).
+ * Client render capabilities for this session — a per-kind list gating the Rich Interactions the server may emit mid-turn (`interaction_required`). Each interaction kind declares the capability that gates it (e.g. kind `identity_intake` → capability `identity_form`, kind `choices` → capability `choice_chips`); future kinds add their own values (`date_picker`, `file_upload`, …). Text-only channels (SMS, voice) omit this and the server degrades each kind to its conversational fallback. Unknown values are ignored (forward-compatible).
*/
supports?: string[];
/**
@@ -130,7 +130,7 @@ export interface CreateConversationSessionResponse {
/**
* ID of the agent handling this session.
*/
- agentId: string;
+ agentId?: string;
/**
* Display name of the agent.
*/
@@ -839,9 +839,9 @@ export interface Session {
*/
organizationId: string;
/**
- * The agent handling this session.
+ * The agent handling this session. OPTIONAL in storage: create_conversation_session REJECTS an absent or blank agentId, so a session created through the protocol always has one. It stays optional here for rows that predate that validation — it used to be filled with a fresh UUID, pointing every agentless session at an agent that had never existed (th-68897a). Absence is represented by omitting the field, never by a fabricated id.
*/
- agentId: string;
+ agentId?: string;
/**
* Human-readable display name of the agent.
*/
@@ -1730,6 +1730,1247 @@ export interface WriteConfirmationRequired {
timestamp?: number;
}
+// ── from interactions/choices.schema.json ──
+/**
+ * The `spec` carried on `interaction_required` for kind `choices`: the questions to ask.
+ */
+export interface ChoicesSpec {
+ /**
+ * The questions to ask, in display order (1–4).
+ *
+ * @minItems 1
+ * @maxItems 4
+ */
+ questions:
+ | [
+ {
+ /**
+ * The question prompt shown to the visitor.
+ */
+ question: string;
+ /**
+ * A short label (≤12 chars), unique within the raise. The answer key and the chip/tab caption.
+ */
+ header: string;
+ /**
+ * The enumerated options. A free-text `other` answer is always available in addition to these.
+ *
+ * @minItems 2
+ * @maxItems 4
+ */
+ options:
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ];
+ /**
+ * Whether the visitor may select more than one option (default false).
+ */
+ multiSelect?: boolean;
+ },
+ ]
+ | [
+ {
+ /**
+ * The question prompt shown to the visitor.
+ */
+ question: string;
+ /**
+ * A short label (≤12 chars), unique within the raise. The answer key and the chip/tab caption.
+ */
+ header: string;
+ /**
+ * The enumerated options. A free-text `other` answer is always available in addition to these.
+ *
+ * @minItems 2
+ * @maxItems 4
+ */
+ options:
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ];
+ /**
+ * Whether the visitor may select more than one option (default false).
+ */
+ multiSelect?: boolean;
+ },
+ {
+ /**
+ * The question prompt shown to the visitor.
+ */
+ question: string;
+ /**
+ * A short label (≤12 chars), unique within the raise. The answer key and the chip/tab caption.
+ */
+ header: string;
+ /**
+ * The enumerated options. A free-text `other` answer is always available in addition to these.
+ *
+ * @minItems 2
+ * @maxItems 4
+ */
+ options:
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ];
+ /**
+ * Whether the visitor may select more than one option (default false).
+ */
+ multiSelect?: boolean;
+ },
+ ]
+ | [
+ {
+ /**
+ * The question prompt shown to the visitor.
+ */
+ question: string;
+ /**
+ * A short label (≤12 chars), unique within the raise. The answer key and the chip/tab caption.
+ */
+ header: string;
+ /**
+ * The enumerated options. A free-text `other` answer is always available in addition to these.
+ *
+ * @minItems 2
+ * @maxItems 4
+ */
+ options:
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ];
+ /**
+ * Whether the visitor may select more than one option (default false).
+ */
+ multiSelect?: boolean;
+ },
+ {
+ /**
+ * The question prompt shown to the visitor.
+ */
+ question: string;
+ /**
+ * A short label (≤12 chars), unique within the raise. The answer key and the chip/tab caption.
+ */
+ header: string;
+ /**
+ * The enumerated options. A free-text `other` answer is always available in addition to these.
+ *
+ * @minItems 2
+ * @maxItems 4
+ */
+ options:
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ];
+ /**
+ * Whether the visitor may select more than one option (default false).
+ */
+ multiSelect?: boolean;
+ },
+ {
+ /**
+ * The question prompt shown to the visitor.
+ */
+ question: string;
+ /**
+ * A short label (≤12 chars), unique within the raise. The answer key and the chip/tab caption.
+ */
+ header: string;
+ /**
+ * The enumerated options. A free-text `other` answer is always available in addition to these.
+ *
+ * @minItems 2
+ * @maxItems 4
+ */
+ options:
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ];
+ /**
+ * Whether the visitor may select more than one option (default false).
+ */
+ multiSelect?: boolean;
+ },
+ ]
+ | [
+ {
+ /**
+ * The question prompt shown to the visitor.
+ */
+ question: string;
+ /**
+ * A short label (≤12 chars), unique within the raise. The answer key and the chip/tab caption.
+ */
+ header: string;
+ /**
+ * The enumerated options. A free-text `other` answer is always available in addition to these.
+ *
+ * @minItems 2
+ * @maxItems 4
+ */
+ options:
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ];
+ /**
+ * Whether the visitor may select more than one option (default false).
+ */
+ multiSelect?: boolean;
+ },
+ {
+ /**
+ * The question prompt shown to the visitor.
+ */
+ question: string;
+ /**
+ * A short label (≤12 chars), unique within the raise. The answer key and the chip/tab caption.
+ */
+ header: string;
+ /**
+ * The enumerated options. A free-text `other` answer is always available in addition to these.
+ *
+ * @minItems 2
+ * @maxItems 4
+ */
+ options:
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ];
+ /**
+ * Whether the visitor may select more than one option (default false).
+ */
+ multiSelect?: boolean;
+ },
+ {
+ /**
+ * The question prompt shown to the visitor.
+ */
+ question: string;
+ /**
+ * A short label (≤12 chars), unique within the raise. The answer key and the chip/tab caption.
+ */
+ header: string;
+ /**
+ * The enumerated options. A free-text `other` answer is always available in addition to these.
+ *
+ * @minItems 2
+ * @maxItems 4
+ */
+ options:
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ];
+ /**
+ * Whether the visitor may select more than one option (default false).
+ */
+ multiSelect?: boolean;
+ },
+ {
+ /**
+ * The question prompt shown to the visitor.
+ */
+ question: string;
+ /**
+ * A short label (≤12 chars), unique within the raise. The answer key and the chip/tab caption.
+ */
+ header: string;
+ /**
+ * The enumerated options. A free-text `other` answer is always available in addition to these.
+ *
+ * @minItems 2
+ * @maxItems 4
+ */
+ options:
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ]
+ | [
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ {
+ /**
+ * The option label — the value the visitor submits.
+ */
+ label: string;
+ /**
+ * A short human-readable gloss for the option.
+ */
+ description?: string;
+ },
+ ];
+ /**
+ * Whether the visitor may select more than one option (default false).
+ */
+ multiSelect?: boolean;
+ },
+ ];
+}
+
+// ── from interactions/choices.schema.json ──
+/**
+ * The `values` a client submits via `submit_interaction` for kind `choices`. Validated server-side: every question answered, each selected label is one of that question's options, single-select takes exactly one pick. The free-text `other` is always accepted (the AskUserQuestion 'Other' escape hatch).
+ */
+export interface ChoicesValues {
+ /**
+ * One entry per question, keyed by the question's `header`.
+ */
+ answers: {
+ /**
+ * Which question this answers — matches the spec question's `header`.
+ */
+ header: string;
+ /**
+ * The selected option label(s). One for single-select; empty when the visitor only used `other`.
+ */
+ options?: string[];
+ /**
+ * A free-text answer outside the enumerated options (the 'Other' escape hatch). Blank ⇒ omitted.
+ */
+ other?: string;
+ }[];
+}
+
+// ── from interactions/choices.schema.json ──
+/**
+ * The canonical validated payload the parked turn resumes with (identical on the chip and conversational paths).
+ */
+export interface ChoicesPayload {
+ /**
+ * How the interaction resolved.
+ */
+ status: 'submitted' | 'declined' | 'no_response';
+ /**
+ * Present when `status` is `submitted`: the validated, normalized answers.
+ */
+ values?: {
+ answers: {
+ header: string;
+ options?: string[];
+ other?: string;
+ }[];
+ };
+ /**
+ * Guidance for the agent when `status` is `declined` / `no_response`.
+ */
+ message?: string;
+}
+
// ── from interactions/identity-intake.schema.json ──
/**
* The `spec` carried on `interaction_required` for kind `identity_intake`: which fields to collect.
diff --git a/typescript/src/react/components/ChoicesCard.tsx b/typescript/src/react/components/ChoicesCard.tsx
new file mode 100644
index 00000000..148ac3e8
--- /dev/null
+++ b/typescript/src/react/components/ChoicesCard.tsx
@@ -0,0 +1,238 @@
+/**
+ * ChoicesCard — the web SDK card renderer for the `choices` Rich Interaction
+ * (modeled on Claude Code's AskUserQuestion).
+ *
+ * On a `choices` `interaction_required` event the client looks the card up by
+ * `kind` in {@link interactionCards} and renders it in the overlay slot above the
+ * composer. Each question shows its `header`, prompt, and option chips (label +
+ * gloss): radios when `multiSelect` is false, checkboxes when true — PLUS a
+ * free-text **"Other"** escape hatch per question that is ALWAYS available (the
+ * ever-present AskUserQuestion "Other"). Submit builds the canonical
+ * {@link ChoicesValues} (`{ answers: [{ header, options?, other? }] }`) and hands
+ * it to `onSubmit`; Decline calls `onDecline` (the caller sends `declined: true`).
+ *
+ * Styling: semantic markup with `smooth-chat__*` class names driven by the
+ * `--smooth-*` CSS variables in `react/styles.css` (same convention as `parts`);
+ * `className` is forwarded so you can layer utilities on top. Server-side
+ * validation failures (`interaction_invalid`) can be surfaced per question via
+ * `errors` (keyed by the question `header`) — the turn stays parked, resubmit.
+ */
+import { useEffect, useId, useMemo, useRef, useState, type FormEvent } from 'react';
+import type { ChoicesSpec, ChoicesValues } from '../../generated/types.js';
+
+/** One enumerated option within a question. */
+export interface ChoiceOption {
+ label: string;
+ description?: string;
+}
+
+/** A single question in a `choices` spec (the ergonomic, array-friendly view of
+ * the generated tuple type). */
+export interface ChoiceQuestion {
+ question: string;
+ header: string;
+ options: ChoiceOption[];
+ multiSelect?: boolean;
+}
+
+/** A per-question validation error surfaced from an `interaction_invalid` event.
+ * `field` is the question's `header`. */
+export interface ChoiceError {
+ field: string;
+ message: string;
+}
+
+export interface ChoicesCardProps {
+ /** The `choices` spec carried on the `interaction_required` event. */
+ spec: ChoicesSpec;
+ /** Human-readable reason the agent raised the ask (card header). */
+ reason?: string;
+ /** Called with the canonical values when the visitor submits a complete answer. */
+ onSubmit: (values: ChoicesValues) => void;
+ /** Called when the visitor declines the interaction. */
+ onDecline: () => void;
+ /** Per-question server validation errors (keyed by question `header`). */
+ errors?: ChoiceError[];
+ /** Disable all controls (e.g. while a submit is in flight). */
+ busy?: boolean;
+ className?: string;
+}
+
+/** Sentinel selection marking the single-select "Other" radio as chosen — kept
+ * out of the submitted `options` (it maps to the free-text `other` instead). */
+const OTHER = '__other__';
+
+/** Per-question working state: chosen enumerated labels + the free-text `other`. */
+interface QState {
+ selected: string[];
+ other: string;
+}
+
+function cx(...parts: (string | false | undefined)[]): string {
+ return parts.filter(Boolean).join(' ');
+}
+
+/** Coerce the generated tuple `spec.questions` into a plain array. */
+function questionsOf(spec: ChoicesSpec): ChoiceQuestion[] {
+ return (spec.questions as unknown as ChoiceQuestion[]) ?? [];
+}
+
+/**
+ * Build the canonical {@link ChoicesValues} from the card's working state.
+ *
+ * Pure + exported so the payload shape is unit-testable without a DOM. Per the
+ * schema Values: `other` is trimmed and dropped when blank; the single-select
+ * OTHER sentinel is stripped so a free-text single answer submits as `other`
+ * alone (options omitted); a question with no selection and no `other` still
+ * emits an entry (the server validator rejects the empty answer, keeping the
+ * turn parked — the UI blocks submit before it gets that far).
+ */
+export function buildChoicesValues(questions: ChoiceQuestion[], state: Record): ChoicesValues {
+ return {
+ answers: questions.map((q) => {
+ const s = state[q.header] ?? { selected: [], other: '' };
+ const enumerated = s.selected.filter((l) => l !== OTHER);
+ const other = s.other.trim();
+ const answer: ChoicesValues['answers'][number] = { header: q.header };
+ if (enumerated.length) answer.options = enumerated;
+ if (other) answer.other = other;
+ return answer;
+ }),
+ };
+}
+
+/** Whether every question has an answer (a selected label or non-blank `other`). */
+function isComplete(questions: ChoiceQuestion[], state: Record): boolean {
+ return questions.every((q) => {
+ const s = state[q.header] ?? { selected: [], other: '' };
+ return s.selected.some((l) => l !== OTHER) || s.other.trim().length > 0;
+ });
+}
+
+export function ChoicesCard({ spec, reason, onSubmit, onDecline, errors, busy, className }: ChoicesCardProps) {
+ const questions = useMemo(() => questionsOf(spec), [spec]);
+ const groupId = useId();
+ const firstControlRef = useRef(null);
+
+ const [state, setState] = useState>(() => Object.fromEntries(questions.map((q) => [q.header, { selected: [], other: '' }])));
+
+ // Move focus into the card when it appears so a keyboard visitor lands on the
+ // first option without a manual tab into the overlay.
+ useEffect(() => {
+ firstControlRef.current?.focus();
+ }, []);
+
+ const errorFor = (header: string) => errors?.find((e) => e.field === header)?.message;
+
+ const setQ = (header: string, next: Partial) => setState((prev) => ({ ...prev, [header]: { ...(prev[header] ?? { selected: [], other: '' }), ...next } }));
+
+ const pickSingle = (header: string, label: string) => setQ(header, { selected: [label] });
+ const toggleMulti = (header: string, label: string) =>
+ setState((prev) => {
+ const cur = prev[header] ?? { selected: [], other: '' };
+ const has = cur.selected.includes(label);
+ return { ...prev, [header]: { ...cur, selected: has ? cur.selected.filter((l) => l !== label) : [...cur.selected, label] } };
+ });
+
+ const complete = isComplete(questions, state);
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ if (!complete || busy) return;
+ onSubmit(buildChoicesValues(questions, state));
+ };
+
+ return (
+
+ );
+}
+
+/**
+ * The widget's card registry — `kind` → card component. `interaction_required`
+ * looks the card up by `kind` and renders it in the overlay slot. Registering a
+ * card here IS declaring the kind's render capability; adding a kind is one card
+ * component + one entry. (`identity_intake`'s card lives in the chat-widget repo;
+ * `choices` is registered here.)
+ */
+export const interactionCards = {
+ choices: ChoicesCard,
+} as const;
diff --git a/typescript/src/react/index.ts b/typescript/src/react/index.ts
index 8ef4b4f3..aa08266c 100644
--- a/typescript/src/react/index.ts
+++ b/typescript/src/react/index.ts
@@ -27,4 +27,15 @@ export { safeHttpUrl, extractCitations, extractFinalText } from './response.js';
export { SmoothChat, type SmoothChatProps } from './components/SmoothChat.js';
export { MessageList, MessageBubble, Citations, Composer, ConnectionStatusLabel } from './components/parts.js';
+// Rich Interaction cards — kind → card registry (see `interactionCards`).
+export {
+ ChoicesCard,
+ interactionCards,
+ buildChoicesValues,
+ type ChoicesCardProps,
+ type ChoiceOption,
+ type ChoiceQuestion,
+ type ChoiceError,
+} from './components/ChoicesCard.js';
+
export type { ChatMessage, ConnectionStatus, Role, Citation } from './types.js';
diff --git a/typescript/src/react/styles.css b/typescript/src/react/styles.css
index d6141304..7f44aff8 100644
--- a/typescript/src/react/styles.css
+++ b/typescript/src/react/styles.css
@@ -229,3 +229,122 @@ span.smooth-chat__source-title {
opacity: 0.5;
cursor: default;
}
+
+/* ---- Rich Interaction cards (choices / AskUserQuestion) ---- */
+.smooth-chat__interaction {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 14px;
+ border: 1px solid var(--smooth-color-border);
+ border-radius: var(--smooth-radius);
+ background: var(--smooth-color-surface);
+ color: var(--smooth-color-text);
+ font-family: var(--smooth-font);
+}
+.smooth-chat__interaction-reason {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+}
+.smooth-chat__interaction-question {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin: 0;
+ padding: 0;
+ border: none;
+}
+.smooth-chat__interaction-legend {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 0;
+ margin-bottom: 2px;
+}
+.smooth-chat__interaction-header {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--smooth-color-muted);
+}
+.smooth-chat__interaction-prompt {
+ font-size: 14px;
+}
+.smooth-chat__interaction-option {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 8px 10px;
+ border: 1px solid var(--smooth-color-border);
+ border-radius: calc(var(--smooth-radius) - 4px);
+ cursor: pointer;
+ background: var(--smooth-color-bg);
+}
+.smooth-chat__interaction-option--checked {
+ border-color: var(--smooth-color-primary);
+ outline: 1px solid var(--smooth-color-primary);
+}
+.smooth-chat__interaction-option input {
+ margin-top: 2px;
+ accent-color: var(--smooth-color-primary);
+}
+.smooth-chat__interaction-option:focus-within {
+ outline: 2px solid var(--smooth-color-primary);
+ outline-offset: 1px;
+}
+.smooth-chat__interaction-option-body {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+.smooth-chat__interaction-option-label {
+ font-size: 14px;
+ font-weight: 500;
+}
+.smooth-chat__interaction-option-desc {
+ font-size: 12px;
+ color: var(--smooth-color-muted);
+}
+.smooth-chat__interaction-other-input {
+ flex: 1;
+ min-width: 0;
+ border: none;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ font-size: 14px;
+ outline: none;
+}
+.smooth-chat__interaction-error {
+ margin: 0;
+ font-size: 12px;
+ color: #dc2626;
+}
+.smooth-chat__interaction-actions {
+ display: flex;
+ gap: 8px;
+}
+.smooth-chat__interaction-submit,
+.smooth-chat__interaction-decline {
+ border-radius: calc(var(--smooth-radius) - 4px);
+ padding: 8px 16px;
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 14px;
+}
+.smooth-chat__interaction-submit {
+ border: none;
+ background: var(--smooth-color-primary);
+ color: var(--smooth-color-primary-text);
+}
+.smooth-chat__interaction-decline {
+ border: 1px solid var(--smooth-color-border);
+ background: transparent;
+ color: var(--smooth-color-text);
+}
+.smooth-chat__interaction-submit:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
diff --git a/typescript/test/react/choices-card.test.tsx b/typescript/test/react/choices-card.test.tsx
new file mode 100644
index 00000000..065decde
--- /dev/null
+++ b/typescript/test/react/choices-card.test.tsx
@@ -0,0 +1,131 @@
+/**
+ * ChoicesCard tests — the web SDK card renderer for the `choices` Rich
+ * Interaction. We assert the two things that matter: the payload the card builds
+ * matches the schema `Values` shape (`spec/interactions/choices.schema.json`),
+ * and the card is usable (options render, Submit gates on completeness, Decline
+ * fires). No DOM-less pure test AND a rendered flow, since both paths ship.
+ */
+import { ChoicesCard, buildChoicesValues, type ChoiceQuestion } from '../../src/react/components/ChoicesCard.js';
+import type { ChoicesSpec } from '../../src/generated/types.js';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+
+// No globals in this vitest config, so @testing-library's auto-cleanup never
+// registers — unmount between tests ourselves or rendered cards accumulate.
+afterEach(cleanup);
+
+const SPEC: ChoicesSpec = {
+ questions: [
+ {
+ question: 'Which plan fits you?',
+ header: 'Plan',
+ options: [
+ { label: 'Starter', description: 'For individuals' },
+ { label: 'Team', description: 'For small teams' },
+ ],
+ },
+ {
+ question: 'Which features matter?',
+ header: 'Features',
+ multiSelect: true,
+ options: [
+ { label: 'Analytics' },
+ { label: 'SSO' },
+ { label: 'API access' },
+ ],
+ },
+ ],
+} as unknown as ChoicesSpec;
+
+const QUESTIONS = SPEC.questions as unknown as ChoiceQuestion[];
+
+describe('buildChoicesValues', () => {
+ it('emits one answer per question keyed by header', () => {
+ const values = buildChoicesValues(QUESTIONS, {
+ Plan: { selected: ['Starter'], other: '' },
+ Features: { selected: ['Analytics', 'SSO'], other: '' },
+ });
+ expect(values).toEqual({
+ answers: [
+ { header: 'Plan', options: ['Starter'] },
+ { header: 'Features', options: ['Analytics', 'SSO'] },
+ ],
+ });
+ });
+
+ it('drops blank `other` and trims non-blank', () => {
+ const values = buildChoicesValues(QUESTIONS, {
+ Plan: { selected: ['Team'], other: ' ' },
+ Features: { selected: [], other: ' Webhooks ' },
+ });
+ expect(values.answers[0]).toEqual({ header: 'Plan', options: ['Team'] });
+ expect(values.answers[1]).toEqual({ header: 'Features', other: 'Webhooks' });
+ });
+
+ it('single-select "other" submits as `other` alone (sentinel stripped, options omitted)', () => {
+ const values = buildChoicesValues(QUESTIONS, {
+ Plan: { selected: ['__other__'], other: 'Enterprise' },
+ Features: { selected: ['API access'], other: '' },
+ });
+ expect(values.answers[0]).toEqual({ header: 'Plan', other: 'Enterprise' });
+ expect(values.answers[1]).toEqual({ header: 'Features', options: ['API access'] });
+ });
+});
+
+describe('', () => {
+ it('renders every question header, prompt, and option', () => {
+ render();
+ expect(screen.getByText('to route your request')).toBeTruthy();
+ expect(screen.getByText('Plan')).toBeTruthy();
+ expect(screen.getByText('Which plan fits you?')).toBeTruthy();
+ expect(screen.getByText('For individuals')).toBeTruthy();
+ expect(screen.getByLabelText(/Starter/)).toBeTruthy();
+ });
+
+ it('gates Submit until every question is answered, then emits the schema Values', () => {
+ const onSubmit = vi.fn();
+ render();
+
+ const submit = screen.getByRole('button', { name: 'Submit' }) as HTMLButtonElement;
+ expect(submit.disabled).toBe(true); // nothing chosen yet
+
+ fireEvent.click(screen.getByLabelText(/Starter/)); // single-select radio
+ expect(submit.disabled).toBe(true); // second question still unanswered
+
+ fireEvent.click(screen.getByLabelText('Analytics')); // multi-select checkbox
+ fireEvent.click(screen.getByLabelText('SSO'));
+ expect(submit.disabled).toBe(false);
+
+ fireEvent.click(submit);
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ expect(onSubmit).toHaveBeenCalledWith({
+ answers: [
+ { header: 'Plan', options: ['Starter'] },
+ { header: 'Features', options: ['Analytics', 'SSO'] },
+ ],
+ });
+ });
+
+ it('accepts a free-text Other answer for a multi-select question', () => {
+ const onSubmit = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByLabelText(/Team/));
+ fireEvent.change(screen.getByLabelText('Other answer for Features'), { target: { value: 'On-prem' } });
+
+ fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
+ expect(onSubmit).toHaveBeenCalledWith({
+ answers: [
+ { header: 'Plan', options: ['Team'] },
+ { header: 'Features', other: 'On-prem' },
+ ],
+ });
+ });
+
+ it('fires onDecline from the "Not now" button', () => {
+ const onDecline = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'Not now' }));
+ expect(onDecline).toHaveBeenCalledTimes(1);
+ });
+});