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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/choices-card-web.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions examples/web-chat/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -128,6 +129,27 @@ export default function App() {
</div>
))}

{/* 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] && (
<div className="smooth-choices-host mx-6 mb-3">
{(() => {
const Card = interactionCards[op.interaction.kind as keyof typeof interactionCards];
return (
<Card
spec={op.interaction.spec}
reason={op.interaction.reason}
errors={op.interaction.errors}
busy={op.interaction.busy}
onSubmit={op.submitInteraction}
onDecline={op.declineInteraction}
/>
);
})()}
</div>
)}

{/* Composer */}
<div className="border-t border-slate-800 px-6 py-4">
<div className="flex items-end gap-2">
Expand Down
3 changes: 3 additions & 0 deletions examples/web-chat/src/main.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
83 changes: 77 additions & 6 deletions examples/web-chat/src/operator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -117,6 +135,7 @@ function renderHistory(raw: any[]): ChatMessage[] {
export function useOperator(): OperatorApi {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [approvals, setApprovals] = useState<Approval[]>([]);
const [interaction, setInteraction] = useState<Interaction | null>(null);
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
Expand All @@ -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();
}
Expand All @@ -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);
Expand Down Expand Up @@ -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<string, unknown> });
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));
Expand All @@ -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();
Expand All @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions examples/web-chat/src/styles.css
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading