diff --git a/.changeset/choices-card-web.md b/.changeset/choices-card-web.md new file mode 100644 index 00000000..ee06a195 --- /dev/null +++ b/.changeset/choices-card-web.md @@ -0,0 +1,25 @@ +--- +"@smooai/smooth-operator": minor +--- + +feat(web): `choices` Rich Interaction card (AskUserQuestion) for the React SDK + web-chat example + +The `choices` interaction kind (structured multiple-choice ask, modeled on Claude +Code's AskUserQuestion) now has a web renderer. `ChoicesCard` (exported from +`@smooai/smooth-operator/react`) renders each question's `header`, prompt, and +option chips — radios when `multiSelect` is false, checkboxes when true — plus a +free-text **"Other"** escape hatch per question that is always available. Submit +builds the canonical `{ answers: [{ header, options?, other? }] }` values and +resumes the parked turn via the existing `submitInteraction()` verb; a Decline +path sends `declined: true`. Server-side `interaction_invalid` errors re-render +per question with the turn still parked. + +A minimal `interactionCards` registry (`kind` → card) is exported so a client +looks the card up by kind; `choices` is registered there. The web-chat example +declares the `choice_chips` capability in `create_conversation_session` and +renders the card in its overlay slot above the composer. + +Also regenerates `src/generated/types.ts` from `spec/` (adds `ChoicesSpec` / +`ChoicesValues` / `ChoicesPayload`; picks up the already-merged optional-`agentId` +and `choice_chips` spec descriptions). No protocol/client change — the generic +`submit_interaction` verb already speaks every kind. diff --git a/examples/web-chat/src/App.tsx b/examples/web-chat/src/App.tsx index 906792a0..91fbad3a 100644 --- a/examples/web-chat/src/App.tsx +++ b/examples/web-chat/src/App.tsx @@ -3,6 +3,7 @@ // A deliberately minimal, dependency-light port of the daemon PWA so the // interesting part — driving the protocol — stays legible. +import { interactionCards } from '@smooai/smooth-operator/react'; import { useEffect, useRef, useState } from 'react'; import { useOperator, type ChatMessage, type ToolCall } from './operator'; @@ -128,6 +129,27 @@ export default function App() { ))} + {/* Rich Interaction — a parked `choices` card (AskUserQuestion). The + kind → card lookup mirrors the widget's registry; the card brings + its own `--smooth-*`-themed styling (dark tokens set in styles.css). */} + {op.interaction && interactionCards[op.interaction.kind as keyof typeof interactionCards] && ( +
+ {(() => { + const Card = interactionCards[op.interaction.kind as keyof typeof interactionCards]; + return ( + + ); + })()} +
+ )} + {/* Composer */}
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 ( +
+ {reason ?

{reason}

: null} + + {questions.map((q, qi) => { + const s = state[q.header] ?? { selected: [], other: '' }; + const otherActive = s.selected.includes(OTHER); + const err = errorFor(q.header); + const errId = err ? `${groupId}-${qi}-err` : undefined; + return ( +
+ + {q.header} + {q.question} + + + {q.options.map((opt, oi) => { + const id = `${groupId}-${qi}-${oi}`; + const checked = q.multiSelect ? s.selected.includes(opt.label) : s.selected[0] === opt.label; + return ( + + ); + })} + + {/* Free-text "Other" — always available (the AskUserQuestion escape hatch). */} + + + {err ? ( + + ) : null} +
+ ); + })} + +
+ + +
+
+ ); +} + +/** + * 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); + }); +});