@smooai/chat-widget 0.10.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smooai/chat-widget",
3
- "version": "0.10.2",
3
+ "version": "0.12.0",
4
4
  "description": "Embeddable AI chat as a framework-light web component — the Aurora Glass design, streaming replies, grounded sources, and per-brand theming. Speaks the smooth-operator WebSocket protocol.",
5
5
  "license": "MIT",
6
6
  "author": "SmooAI",
@@ -58,7 +58,7 @@
58
58
  "access": "public"
59
59
  },
60
60
  "dependencies": {
61
- "@smooai/smooth-operator": "^1.8.0",
61
+ "@smooai/smooth-operator": "^1.21.1",
62
62
  "libphonenumber-js": "^1.13.7",
63
63
  "zustand": "^5.0.14"
64
64
  },
package/src/config.ts CHANGED
@@ -106,6 +106,12 @@ export interface ChatWidgetConfig {
106
106
  * Clicking one sends it. Capped at 5 for layout.
107
107
  */
108
108
  examplePrompts?: string[];
109
+ /**
110
+ * Show mid-conversation suggested-reply chips ("quick replies") under the
111
+ * latest assistant message when the agent returns follow-up suggestions.
112
+ * Clicking one sends it. Defaults to `true` (shown unless explicitly `false`).
113
+ */
114
+ showSuggestedReplies?: boolean;
109
115
  /** Require the visitor's name before chatting. */
110
116
  requireName?: boolean;
111
117
  /** Require the visitor's email before chatting. */
@@ -180,6 +186,7 @@ export function resolveConfig(config: ChatWidgetConfig): ResolvedConfig {
180
186
  startOpen: config.startOpen ?? false,
181
187
  hideBranding: config.hideBranding ?? false,
182
188
  examplePrompts: (config.examplePrompts ?? []).filter((p) => p.trim().length > 0).slice(0, 5),
189
+ showSuggestedReplies: config.showSuggestedReplies ?? true,
183
190
  requireName: config.requireName ?? false,
184
191
  requireEmail: config.requireEmail ?? false,
185
192
  requirePhone: config.requirePhone ?? false,
@@ -75,6 +75,13 @@ export interface ChatMessage {
75
75
  * defensively off the terminal event — see {@link extractCitations}.
76
76
  */
77
77
  citations?: Citation[];
78
+ /**
79
+ * Suggested follow-up replies ("quick replies") the agent offered on the
80
+ * terminal `eventual_response`. Set ONLY on the finalized assistant message —
81
+ * never mid-stream. Read defensively (see {@link extractSuggestions}); capped
82
+ * at 4 for layout. Absent when the turn offered none.
83
+ */
84
+ suggestions?: string[];
78
85
  }
79
86
 
80
87
  export type ConnectionStatus = 'idle' | 'connecting' | 'ready' | 'error' | 'closed';
@@ -86,6 +93,9 @@ export type ConnectionStatus = 'idle' | 'connecting' | 'ready' | 'error' | 'clos
86
93
  * Resume with {@link ConversationController.verifyOtp}.
87
94
  * - `confirm` — the agent wants to run a state-mutating tool and needs approval.
88
95
  * Resume with {@link ConversationController.confirmTool}.
96
+ * - `interaction` — the agent raised a Rich Interaction (structured card, e.g.
97
+ * identity intake). Resume with {@link ConversationController.submitInteraction}
98
+ * or {@link ConversationController.declineInteraction}.
89
99
  */
90
100
  export type Interrupt =
91
101
  | {
@@ -99,7 +109,28 @@ export type Interrupt =
99
109
  error?: string;
100
110
  attemptsRemaining?: number;
101
111
  }
102
- | { kind: 'confirm'; toolId?: string; actionDescription?: string };
112
+ | { kind: 'confirm'; toolId?: string; actionDescription?: string }
113
+ | {
114
+ kind: 'interaction';
115
+ /** Server-generated interaction instance id (echoed on submit). */
116
+ interactionId: string;
117
+ /** The Rich Interaction kind (e.g. `identity_intake`) — selects the card. */
118
+ interactionKind: string;
119
+ /** Kind-specific render spec (identity_intake: `{ fields: [...] }`). */
120
+ spec: Record<string, unknown>;
121
+ /** Why the agent raised it (card header copy). */
122
+ reason?: string;
123
+ /** Per-field server-side validation errors (from `interaction_invalid`). */
124
+ errors?: { field: string; message: string }[];
125
+ };
126
+
127
+ /**
128
+ * The Rich-Interaction render capabilities this widget declares at session
129
+ * create (`supports`). Must stay aligned with the card registry in
130
+ * `element.ts` (`INTERACTION_CARDS`) — registering a card IS declaring its
131
+ * capability; a test asserts the two match.
132
+ */
133
+ export const SUPPORTED_INTERACTION_CAPABILITIES: readonly string[] = ['identity_form'];
103
134
 
104
135
  export interface UserInfo {
105
136
  name?: string;
@@ -183,6 +214,20 @@ function extractCitations(inner: unknown): Citation[] {
183
214
  return out;
184
215
  }
185
216
 
217
+ /**
218
+ * Pull the suggested follow-up replies out of a terminal `eventual_response`'s
219
+ * `response` object (`response.suggestedNextActions`). Optional + back-compatible
220
+ * like citations — read defensively (tolerating absence, non-array shapes, and
221
+ * non-string / blank entries) and capped at 4 so a chatty agent can't overflow
222
+ * the composer chip row.
223
+ */
224
+ function extractSuggestions(response: unknown): string[] {
225
+ if (!response || typeof response !== 'object') return [];
226
+ const raw = (response as { suggestedNextActions?: unknown }).suggestedNextActions;
227
+ if (!Array.isArray(raw)) return [];
228
+ return raw.filter((s): s is string => typeof s === 'string' && s.trim().length > 0).slice(0, 4);
229
+ }
230
+
186
231
  /** A `get_conversation_messages` row, narrowed defensively off the wire. */
187
232
  interface WireMessage {
188
233
  id?: string;
@@ -211,6 +256,9 @@ export class ConversationController {
211
256
  /** requestId of the in-flight turn — used to resume OTP / tool confirmations. */
212
257
  private activeRequestId: string | null = null;
213
258
  private interrupt: Interrupt | null = null;
259
+ /** Values from the last interaction submit, merged into the persisted
260
+ * identity (identity_intake only) once the server acks them. */
261
+ private pendingInteractionValues: { kind: string; values: Record<string, unknown> } | null = null;
214
262
  private identityRestore: IdentityRestore = { phase: 'idle' };
215
263
  /**
216
264
  * True once the resume probe (persisted-pointer get_session OR the
@@ -316,6 +364,41 @@ export class ConversationController {
316
364
  this.setInterrupt(null);
317
365
  }
318
366
 
367
+ /**
368
+ * Submit a Rich Interaction card to resume the parked turn. The server
369
+ * routes to the kind's validator: invalid values come back as an
370
+ * `interaction_invalid` event (the card re-renders with per-field errors —
371
+ * the turn stays parked); a valid submit is acked and the turn resumes.
372
+ * No-op if not awaiting an interaction.
373
+ */
374
+ submitInteraction(values: Record<string, unknown>): void {
375
+ if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== 'interaction') return;
376
+ // Stash the values so the ack (immediate_response) can merge accepted
377
+ // identity values into the persisted store.
378
+ this.pendingInteractionValues = { kind: this.interrupt.interactionKind, values };
379
+ this.client.submitInteraction({
380
+ sessionId: this.sessionId,
381
+ requestId: this.activeRequestId,
382
+ interactionId: this.interrupt.interactionId,
383
+ kind: this.interrupt.interactionKind,
384
+ values,
385
+ });
386
+ }
387
+
388
+ /** Decline the pending Rich Interaction; the agent continues without it. */
389
+ declineInteraction(): void {
390
+ if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== 'interaction') return;
391
+ this.client.submitInteraction({
392
+ sessionId: this.sessionId,
393
+ requestId: this.activeRequestId,
394
+ interactionId: this.interrupt.interactionId,
395
+ kind: this.interrupt.interactionKind,
396
+ declined: true,
397
+ });
398
+ this.pendingInteractionValues = null;
399
+ this.setInterrupt(null);
400
+ }
401
+
319
402
  private nextId(prefix: string): string {
320
403
  this.seq += 1;
321
404
  return `${prefix}-${this.seq}-${Date.now().toString(36)}`;
@@ -522,6 +605,10 @@ export class ConversationController {
522
605
  userName: state.identity.name,
523
606
  userEmail: state.identity.email,
524
607
  browserFingerprint: this.fingerprint(),
608
+ // Declare the Rich-Interaction cards this widget can render (derived
609
+ // from the card registry), so the server emits `interaction_required`
610
+ // for those kinds instead of the conversational fallback.
611
+ supports: [...SUPPORTED_INTERACTION_CAPABILITIES],
525
612
  ...(metadata ? { metadata } : {}),
526
613
  });
527
614
  this.sessionId = session.sessionId;
@@ -623,6 +710,11 @@ export class ConversationController {
623
710
  if (citations.length > 0) {
624
711
  assistant.citations = citations;
625
712
  }
713
+ // Suggested follow-up replies from the terminal event, when present.
714
+ const suggestions = extractSuggestions(inner?.response);
715
+ if (suggestions.length > 0) {
716
+ assistant.suggestions = suggestions;
717
+ }
626
718
  assistant.streaming = false;
627
719
  this.emitMessages();
628
720
  } catch (err) {
@@ -674,7 +766,51 @@ export class ConversationController {
674
766
  case 'write_confirmation_required':
675
767
  this.setInterrupt({ kind: 'confirm', toolId: str(inner.toolId), actionDescription: str(inner.actionDescription) });
676
768
  break;
677
- default:
769
+ case 'interaction_required': {
770
+ const interactionId = str(inner.interactionId);
771
+ const kind = str(inner.kind);
772
+ const spec = inner.spec && typeof inner.spec === 'object' ? (inner.spec as Record<string, unknown>) : {};
773
+ if (!interactionId || !kind) break; // not renderable — ignore
774
+ this.pendingInteractionValues = null;
775
+ this.setInterrupt({
776
+ kind: 'interaction',
777
+ interactionId,
778
+ interactionKind: kind,
779
+ spec,
780
+ reason: str(inner.reason),
781
+ });
782
+ break;
783
+ }
784
+ case 'interaction_invalid':
785
+ if (this.interrupt?.kind === 'interaction' && this.interrupt.interactionId === str(inner.interactionId)) {
786
+ const errors: { field: string; message: string }[] = [];
787
+ if (Array.isArray(inner.errors)) {
788
+ for (const e of inner.errors) {
789
+ if (!e || typeof e !== 'object') continue;
790
+ const o = e as Record<string, unknown>;
791
+ const field = str(o.field);
792
+ if (field) errors.push({ field, message: str(o.message) ?? 'Invalid value' });
793
+ }
794
+ }
795
+ this.pendingInteractionValues = null;
796
+ this.setInterrupt({ ...this.interrupt, errors });
797
+ }
798
+ break;
799
+ case 'immediate_response':
800
+ // Mid-turn immediate_response while an interaction card is showing
801
+ // is the submit/decline ack: the park resolved — clear the card
802
+ // and, for accepted identity values, persist them.
803
+ if (this.interrupt?.kind === 'interaction') {
804
+ const pending = this.pendingInteractionValues;
805
+ if (pending && pending.kind === 'identity_intake') {
806
+ const v = pending.values as { name?: string; email?: string; phone?: string };
807
+ this.store.getState().mergeIdentity({ name: v.name, email: v.email, phone: v.phone });
808
+ }
809
+ this.pendingInteractionValues = null;
810
+ this.setInterrupt(null);
811
+ }
812
+ break;
813
+ default:
678
814
  break;
679
815
  }
680
816
  }
package/src/element.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  import { AsYouType, isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js/min';
18
18
  import type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config.js';
19
19
  import { needsUserInfo, resolveConfig } from './config.js';
20
- import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type IdentityRestore, type Interrupt } from './conversation.js';
20
+ import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type IdentityRestore, type Interrupt, SUPPORTED_INTERACTION_CAPABILITIES } from './conversation.js';
21
21
  import { SMOOTH_ICON_SVG } from './logo.js';
22
22
  import { cleanCitationSnippet, escapeHtml, renderMarkdown, safeHttpUrl } from './markdown.js';
23
23
  import { buildStyles } from './styles.js';
@@ -71,10 +71,166 @@ const ICON = {
71
71
  chev: `<svg width="11" height="11" viewBox="0 0 24 24" fill="none"><path d="m9 6 6 6-6 6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
72
72
  /** OTP interrupt — a padlock. */
73
73
  lock: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="5" y="10.5" width="14" height="9.5" rx="2.2" stroke="currentColor" stroke-width="1.7"/><path d="M8 10.5V8a4 4 0 0 1 8 0v2.5" stroke="currentColor" stroke-width="1.7"/></svg>`,
74
+ /** Identity-intake interrupt — a person. */
75
+ user: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="8.2" r="3.4" stroke="currentColor" stroke-width="1.7"/><path d="M5.5 19.5c.8-3.1 3.4-4.8 6.5-4.8s5.7 1.7 6.5 4.8" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>`,
74
76
  /** Tool-confirmation interrupt — a shield. */
75
77
  shield: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 3 5 6v5c0 4.4 3 7.2 7 8.5 4-1.3 7-4.1 7-8.5V6l-7-3Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="m9 11.5 2 2 4-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
76
78
  } as const;
77
79
 
80
+ /**
81
+ * The Rich Interactions **card registry**: interaction kind → overlay card.
82
+ * `interaction_required { kind }` looks its card up here; registering a card IS
83
+ * declaring the widget's render capability for that kind (see
84
+ * `SUPPORTED_INTERACTION_CAPABILITIES` in conversation.ts — a test keeps the
85
+ * two aligned). Adding a kind = one card builder + one entry.
86
+ *
87
+ * The existing OTP and tool-approval overlays are prior instances of this same
88
+ * shape and should retrofit onto this registry later (their wire events predate
89
+ * the pattern).
90
+ */
91
+ export interface InteractionCardContext {
92
+ /** Submit kind-shaped values (resumes the parked turn; server validates). */
93
+ submit: (values: Record<string, unknown>) => void;
94
+ /** Decline the interaction (the agent continues without it). */
95
+ decline: () => void;
96
+ /** Persisted visitor identity, for pre-filling known fields. */
97
+ prefill: { name?: string; email?: string; phone?: string };
98
+ /** Wire live phone formatting + validity hint onto a form's phone input. */
99
+ wirePhoneField: (form: HTMLFormElement) => void;
100
+ }
101
+
102
+ export interface InteractionCard {
103
+ /** The render capability this card provides (goes into `supports`). */
104
+ capability: string;
105
+ /** Card header title. */
106
+ title: string;
107
+ /** Static, trusted header icon SVG. */
108
+ icon: string;
109
+ /** Build the card body for an `interaction` interrupt. */
110
+ build: (it: Extract<Interrupt, { kind: 'interaction' }>, ctx: InteractionCardContext) => HTMLElement;
111
+ }
112
+
113
+ /**
114
+ * The identity_intake card: the fields the agent requested (pre-chat form field
115
+ * pattern — same classes, same phone formatting), per-field server errors, a
116
+ * submit and a decline affordance. Server-supplied text (reason, labels, error
117
+ * messages) is set via `textContent` — never innerHTML.
118
+ */
119
+ function buildIdentityIntakeCard(it: Extract<Interrupt, { kind: 'interaction' }>, ctx: InteractionCardContext): HTMLElement {
120
+ const form = document.createElement('form');
121
+ form.className = 'int-form';
122
+ form.noValidate = true;
123
+
124
+ if (it.reason) {
125
+ const desc = document.createElement('div');
126
+ desc.className = 'int-desc';
127
+ desc.textContent = it.reason;
128
+ form.appendChild(desc);
129
+ }
130
+
131
+ const DEFAULTS: Record<string, { label: string; type: string; autocomplete: string }> = {
132
+ name: { label: 'Name', type: 'text', autocomplete: 'name' },
133
+ email: { label: 'Email', type: 'email', autocomplete: 'email' },
134
+ phone: { label: 'Phone', type: 'tel', autocomplete: 'tel' },
135
+ };
136
+
137
+ // Parse the kind's spec defensively: `{ fields: [{ key, required, label? }] }`.
138
+ const rawFields = Array.isArray(it.spec.fields) ? it.spec.fields : [];
139
+ const fields: { key: 'name' | 'email' | 'phone'; required: boolean; label?: string }[] = [];
140
+ for (const f of rawFields) {
141
+ if (!f || typeof f !== 'object') continue;
142
+ const o = f as Record<string, unknown>;
143
+ const key = typeof o.key === 'string' ? o.key : '';
144
+ if (key !== 'name' && key !== 'email' && key !== 'phone') continue;
145
+ fields.push({ key, required: o.required === true, label: typeof o.label === 'string' ? o.label : undefined });
146
+ }
147
+ if (fields.length === 0) fields.push({ key: 'email', required: true });
148
+
149
+ for (const f of fields) {
150
+ const d = DEFAULTS[f.key]!;
151
+ const label = document.createElement('label');
152
+ label.className = 'pc-field';
153
+ const caption = document.createElement('span');
154
+ caption.textContent = f.label ?? d.label;
155
+ const input = document.createElement('input');
156
+ input.name = f.key;
157
+ input.type = d.type;
158
+ input.setAttribute('autocomplete', d.autocomplete);
159
+ input.required = f.required;
160
+ const prefill = ctx.prefill[f.key];
161
+ if (prefill) input.value = prefill;
162
+ label.append(caption, input);
163
+ if (f.key === 'phone') {
164
+ const hint = document.createElement('span');
165
+ hint.className = 'pc-hint';
166
+ hint.setAttribute('aria-live', 'polite');
167
+ label.appendChild(hint);
168
+ }
169
+ // Per-field server-side validation error (interaction_invalid).
170
+ const serverError = it.errors?.find((e) => e.field === f.key);
171
+ if (serverError) {
172
+ label.classList.add('invalid');
173
+ const err = document.createElement('span');
174
+ err.className = 'int-error';
175
+ err.textContent = serverError.message;
176
+ label.appendChild(err);
177
+ }
178
+ form.appendChild(label);
179
+ }
180
+
181
+ const row = document.createElement('div');
182
+ row.className = 'int-row';
183
+ const decline = document.createElement('button');
184
+ decline.className = 'int-btn';
185
+ decline.type = 'button';
186
+ decline.textContent = 'No thanks';
187
+ decline.addEventListener('click', () => ctx.decline());
188
+ const share = document.createElement('button');
189
+ share.className = 'int-btn primary';
190
+ share.type = 'submit';
191
+ share.textContent = 'Share details';
192
+ row.append(decline, share);
193
+ form.appendChild(row);
194
+
195
+ form.addEventListener('submit', (ev) => {
196
+ ev.preventDefault();
197
+ if (!form.reportValidity()) return;
198
+ const data = new FormData(form);
199
+ const val = (k: string) => ((data.get(k) as string | null)?.trim() || undefined);
200
+
201
+ // Phone: mirror the pre-chat rules — block a required-but-invalid number,
202
+ // prefer canonical E.164 when it parses (the server re-validates anyway).
203
+ const rawPhone = val('phone');
204
+ const phoneInput = form.querySelector('input[name="phone"]') as HTMLInputElement | null;
205
+ if (rawPhone && phoneInput && !isValidPhoneNumber(rawPhone, PHONE_DEFAULT_REGION)) {
206
+ const field = phoneInput.closest('.pc-field');
207
+ field?.classList.add('invalid');
208
+ const hint = field?.querySelector('.pc-hint');
209
+ if (hint) hint.textContent = 'Enter a valid phone number';
210
+ if (phoneInput.hasAttribute('required')) {
211
+ phoneInput.focus();
212
+ return;
213
+ }
214
+ }
215
+ const phone = rawPhone ? (phoneToE164(rawPhone) ?? rawPhone) : undefined;
216
+ ctx.submit({ name: val('name'), email: val('email'), phone });
217
+ });
218
+ // Same live phone formatting + validity hint as the pre-chat form.
219
+ ctx.wirePhoneField(form);
220
+ queueMicrotask(() => (form.querySelector('input') as HTMLInputElement | null)?.focus());
221
+ return form;
222
+ }
223
+
224
+ /** Kind → card. See the registry doc above. */
225
+ export const INTERACTION_CARDS: Record<string, InteractionCard> = {
226
+ identity_intake: {
227
+ capability: 'identity_form',
228
+ title: 'Share your details',
229
+ icon: ICON.user,
230
+ build: buildIdentityIntakeCard,
231
+ },
232
+ };
233
+
78
234
  // `safeHttpUrl` / `escapeHtml` live in `./markdown.js` (the markdown renderer
79
235
  // needs them too); re-exported here for back-compat with existing importers.
80
236
  export { escapeHtml, safeHttpUrl } from './markdown.js';
@@ -97,6 +253,8 @@ export class SmoothAgentChatElement extends HTMLElement {
97
253
  private hasSent = false;
98
254
  /** Starter prompts shown as chips in the empty state. */
99
255
  private examplePrompts: string[] = [];
256
+ /** Whether mid-conversation suggested-reply chips are shown (config). */
257
+ private showSuggestedReplies = true;
100
258
  /** Resolved greeting text, cached so async (rAF) renders can reuse it. */
101
259
  private greeting = '';
102
260
  /** Current mid-turn interrupt (OTP / tool-confirmation), or null. */
@@ -117,6 +275,7 @@ export class SmoothAgentChatElement extends HTMLElement {
117
275
  private dotEl: HTMLElement | null = null;
118
276
  private inputEl: HTMLTextAreaElement | null = null;
119
277
  private sendBtn: HTMLButtonElement | null = null;
278
+ private suggestionsEl: HTMLElement | null = null;
120
279
 
121
280
  // ── Smooth streaming reveal ──
122
281
  // Tokens arrive in variable-size bursts at uneven rates, so revealing text in
@@ -209,6 +368,7 @@ export class SmoothAgentChatElement extends HTMLElement {
209
368
  startOpen: this.overrides.startOpen ?? this.hasAttribute('start-open'),
210
369
  hideBranding: this.overrides.hideBranding ?? this.hasAttribute('hide-branding'),
211
370
  examplePrompts: this.overrides.examplePrompts,
371
+ showSuggestedReplies: this.overrides.showSuggestedReplies,
212
372
  requireName: this.overrides.requireName,
213
373
  requireEmail: this.overrides.requireEmail,
214
374
  requirePhone: this.overrides.requirePhone,
@@ -247,10 +407,14 @@ export class SmoothAgentChatElement extends HTMLElement {
247
407
  onInterrupt: (interrupt) => {
248
408
  this.interrupt = interrupt;
249
409
  this.renderInterrupt();
410
+ // Suggestion chips are hidden while an interrupt overlay is up.
411
+ this.renderSuggestions();
250
412
  },
251
413
  onIdentityRestore: (state) => {
252
414
  this.identityRestore = state;
253
415
  this.renderInterrupt();
416
+ // The restore overlay reuses the interrupt slot — hide chips too.
417
+ this.renderSuggestions();
254
418
  },
255
419
  });
256
420
  if (resolved.startOpen) this.open = true;
@@ -303,6 +467,7 @@ export class SmoothAgentChatElement extends HTMLElement {
303
467
 
304
468
  // Remember starter prompts + greeting for the empty-state chips / async renders.
305
469
  this.examplePrompts = resolved.examplePrompts;
470
+ this.showSuggestedReplies = resolved.showSuggestedReplies;
306
471
  this.greeting = resolved.greeting;
307
472
 
308
473
  // Gate the conversation behind a pre-chat identity form when required.
@@ -348,6 +513,7 @@ export class SmoothAgentChatElement extends HTMLElement {
348
513
  const footerHtml = footerInner ? `<div class="footer">${footerInner}</div>` : '';
349
514
  const chatHtml = `
350
515
  <div class="messages"></div>
516
+ <div class="reply-suggestions"></div>
351
517
  <div class="interrupt hidden"></div>
352
518
  <div class="composer-wrap">
353
519
  <div class="composer">
@@ -385,6 +551,7 @@ export class SmoothAgentChatElement extends HTMLElement {
385
551
  this.inputEl = container.querySelector('textarea');
386
552
  this.sendBtn = container.querySelector('.send');
387
553
  this.interruptEl = container.querySelector('.interrupt');
554
+ this.suggestionsEl = container.querySelector('.reply-suggestions');
388
555
 
389
556
  this.launcherEl?.addEventListener('click', () => this.openChat());
390
557
  container.querySelector('.close')?.addEventListener('click', () => this.closeChat());
@@ -469,21 +636,38 @@ export class SmoothAgentChatElement extends HTMLElement {
469
636
  head.className = 'int-head';
470
637
  const ico = document.createElement('span');
471
638
  ico.className = 'int-ico';
472
- ico.innerHTML = it.kind === 'otp' ? ICON.lock : ICON.shield; // static, trusted
639
+ const card_meta = it.kind === 'interaction' ? INTERACTION_CARDS[it.interactionKind] : undefined;
640
+ if (it.kind === 'interaction' && !card_meta) {
641
+ // A kind we have no card for (shouldn't happen — we only declare
642
+ // capabilities for registered cards). Decline so the turn never hangs.
643
+ this.controller?.declineInteraction();
644
+ el.classList.add('hidden');
645
+ return;
646
+ }
647
+ ico.innerHTML = it.kind === 'otp' ? ICON.lock : it.kind === 'interaction' ? (card_meta?.icon ?? ICON.user) : ICON.shield; // static, trusted
473
648
  const title = document.createElement('span');
474
649
  title.className = 'int-title';
475
- title.textContent = it.kind === 'otp' ? 'Verification required' : 'Confirm this action';
650
+ title.textContent = it.kind === 'otp' ? 'Verification required' : it.kind === 'interaction' ? (card_meta?.title ?? 'One more thing') : 'Confirm this action';
476
651
  head.append(ico, title);
477
652
  card.appendChild(head);
478
653
 
479
- if (it.actionDescription) {
654
+ if (it.kind !== 'interaction' && it.actionDescription) {
480
655
  const desc = document.createElement('div');
481
656
  desc.className = 'int-desc';
482
657
  desc.textContent = it.actionDescription;
483
658
  card.appendChild(desc);
484
659
  }
485
660
 
486
- if (it.kind === 'otp') {
661
+ if (it.kind === 'interaction') {
662
+ card.appendChild(
663
+ card_meta!.build(it, {
664
+ submit: (values) => this.controller?.submitInteraction(values),
665
+ decline: () => this.controller?.declineInteraction(),
666
+ prefill: this.controller?.getStore().getState().identity ?? {},
667
+ wirePhoneField: (form) => this.wirePhoneField(form),
668
+ }),
669
+ );
670
+ } else if (it.kind === 'otp') {
487
671
  if (it.sent?.maskedDestination) {
488
672
  const sent = document.createElement('div');
489
673
  sent.className = 'int-sent';
@@ -936,6 +1120,39 @@ export class SmoothAgentChatElement extends HTMLElement {
936
1120
  }
937
1121
  }
938
1122
  this.scrollToBottom(true);
1123
+ this.renderSuggestions();
1124
+ }
1125
+
1126
+ /**
1127
+ * Render (or clear) the mid-conversation suggested-reply chips under the
1128
+ * composer. Shown ONLY when: the feature is enabled, no interrupt / restore
1129
+ * overlay is active (those reuse the slot above the composer), and the LATEST
1130
+ * message is a finalized assistant turn that carried suggestions. A new turn
1131
+ * (agent or the visitor typing) appends messages, so the latest is no longer
1132
+ * that assistant message and the chips clear on the next render. Chips reuse
1133
+ * the empty-state `.prompts`/`.chip` styling and send via the same path.
1134
+ */
1135
+ private renderSuggestions(): void {
1136
+ const el = this.suggestionsEl;
1137
+ if (!el) return;
1138
+ el.replaceChildren();
1139
+ if (!this.showSuggestedReplies) return;
1140
+ // Hidden while an OTP / tool-confirmation / restore overlay is active.
1141
+ if (this.interrupt || this.identityRestore.phase !== 'idle') return;
1142
+ const last = this.messages[this.messages.length - 1];
1143
+ if (!last || last.role !== 'assistant' || last.streaming || !last.suggestions || last.suggestions.length === 0) return;
1144
+
1145
+ const chips = document.createElement('div');
1146
+ chips.className = 'prompts';
1147
+ for (const suggestion of last.suggestions) {
1148
+ const chip = document.createElement('button');
1149
+ chip.type = 'button';
1150
+ chip.className = 'chip';
1151
+ chip.textContent = suggestion;
1152
+ chip.addEventListener('click', () => this.submitPrompt(suggestion));
1153
+ chips.appendChild(chip);
1154
+ }
1155
+ el.appendChild(chips);
939
1156
  }
940
1157
 
941
1158
  // ─────────────────────── Smooth streaming reveal ───────────────────────────
package/src/styles.ts CHANGED
@@ -605,6 +605,10 @@ ${
605
605
  transform: translateY(-1px);
606
606
  }
607
607
 
608
+ /* ───────────── Mid-conversation suggested-reply chips ─────────────── */
609
+ .reply-suggestions { padding: 0 14px 4px; }
610
+ .reply-suggestions:empty { display: none; }
611
+
608
612
  /* ─────────────── OTP / tool-confirmation interrupt ────────────────── */
609
613
  .interrupt { padding: 0 14px; }
610
614
  .int-card {
@@ -639,6 +643,9 @@ ${
639
643
  border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent);
640
644
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent);
641
645
  }
646
+ .int-form { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
647
+ .int-form .pc-field input { width: 100%; box-sizing: border-box; }
648
+ .int-form .int-error { margin-top: 2px; }
642
649
  .int-btn {
643
650
  border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
644
651
  background: var(--sac-surface-2);
@@ -658,8 +665,14 @@ ${
658
665
  color: var(--sac-primary-text);
659
666
  box-shadow: 0 6px 14px -6px color-mix(in srgb, var(--sac-primary) 65%, transparent);
660
667
  }
661
- .int-row .int-btn { flex: 1; }
662
- .int-row .int-input + .int-btn { flex: 0 0 auto; }
668
+ .int-row .int-form { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
669
+ .int-form .pc-field input { width: 100%; box-sizing: border-box; }
670
+ .int-form .int-error { margin-top: 2px; }
671
+ .int-btn { flex: 1; }
672
+ .int-row .int-input + .int-form { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
673
+ .int-form .pc-field input { width: 100%; box-sizing: border-box; }
674
+ .int-form .int-error { margin-top: 2px; }
675
+ .int-btn { flex: 0 0 auto; }
663
676
  .int-error { margin-top: 8px; font-size: 12px; color: #f87171; }
664
677
  .int-card { position: relative; }
665
678
  .int-close {