@smooai/chat-widget 0.3.0 → 0.4.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/src/element.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config.js';
18
18
  import { needsUserInfo, resolveConfig } from './config.js';
19
- import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController } from './conversation.js';
19
+ import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type Interrupt } from './conversation.js';
20
20
  import { SMOOTH_LOGO_SVG } from './logo.js';
21
21
  import { buildStyles } from './styles.js';
22
22
 
@@ -39,6 +39,10 @@ const ICON = {
39
39
  send: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 19V6M12 6l-5.5 5.5M12 6l5.5 5.5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
40
40
  /** Sources disclosure caret. */
41
41
  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>`,
42
+ /** OTP interrupt — a padlock. */
43
+ 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>`,
44
+ /** Tool-confirmation interrupt — a shield. */
45
+ 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>`,
42
46
  } as const;
43
47
 
44
48
  /**
@@ -78,6 +82,9 @@ export class SmoothAgentChatElement extends HTMLElement {
78
82
  private hasSent = false;
79
83
  /** Starter prompts shown as chips in the empty state. */
80
84
  private examplePrompts: string[] = [];
85
+ /** Current mid-turn interrupt (OTP / tool-confirmation), or null. */
86
+ private interrupt: Interrupt | null = null;
87
+ private interruptEl: HTMLElement | null = null;
81
88
 
82
89
  // Cached DOM refs (populated in render()).
83
90
  private panelEl: HTMLElement | null = null;
@@ -187,6 +194,10 @@ export class SmoothAgentChatElement extends HTMLElement {
187
194
  this.renderStatus();
188
195
  this.renderComposerState();
189
196
  },
197
+ onInterrupt: (interrupt) => {
198
+ this.interrupt = interrupt;
199
+ this.renderInterrupt();
200
+ },
190
201
  });
191
202
  if (resolved.startOpen) this.open = true;
192
203
  }
@@ -243,6 +254,7 @@ export class SmoothAgentChatElement extends HTMLElement {
243
254
  </div>`;
244
255
  const chatHtml = `
245
256
  <div class="messages"></div>
257
+ <div class="interrupt hidden"></div>
246
258
  <div class="composer-wrap">
247
259
  <div class="composer">
248
260
  <textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
@@ -274,6 +286,7 @@ export class SmoothAgentChatElement extends HTMLElement {
274
286
  this.dotEl = container.querySelector('.dot');
275
287
  this.inputEl = container.querySelector('textarea');
276
288
  this.sendBtn = container.querySelector('.send');
289
+ this.interruptEl = container.querySelector('.interrupt');
277
290
 
278
291
  this.launcherEl?.addEventListener('click', () => this.openChat());
279
292
  container.querySelector('.close')?.addEventListener('click', () => this.closeChat());
@@ -300,6 +313,103 @@ export class SmoothAgentChatElement extends HTMLElement {
300
313
  if (!gating) this.renderMessages(resolved.greeting);
301
314
  this.renderStatus();
302
315
  this.renderComposerState();
316
+ this.renderInterrupt();
317
+ }
318
+
319
+ /**
320
+ * Render (or clear) the mid-turn interrupt overlay above the composer:
321
+ * an OTP code prompt or a tool-write confirmation. Server-supplied text is
322
+ * set via `textContent` (never innerHTML); only static icons use innerHTML.
323
+ */
324
+ private renderInterrupt(): void {
325
+ const el = this.interruptEl;
326
+ if (!el) return;
327
+ el.replaceChildren();
328
+ const it = this.interrupt;
329
+ if (!it) {
330
+ el.classList.add('hidden');
331
+ return;
332
+ }
333
+ el.classList.remove('hidden');
334
+
335
+ const card = document.createElement('div');
336
+ card.className = 'int-card';
337
+
338
+ const head = document.createElement('div');
339
+ head.className = 'int-head';
340
+ const ico = document.createElement('span');
341
+ ico.className = 'int-ico';
342
+ ico.innerHTML = it.kind === 'otp' ? ICON.lock : ICON.shield; // static, trusted
343
+ const title = document.createElement('span');
344
+ title.className = 'int-title';
345
+ title.textContent = it.kind === 'otp' ? 'Verification required' : 'Confirm this action';
346
+ head.append(ico, title);
347
+ card.appendChild(head);
348
+
349
+ if (it.actionDescription) {
350
+ const desc = document.createElement('div');
351
+ desc.className = 'int-desc';
352
+ desc.textContent = it.actionDescription;
353
+ card.appendChild(desc);
354
+ }
355
+
356
+ if (it.kind === 'otp') {
357
+ if (it.sent?.maskedDestination) {
358
+ const sent = document.createElement('div');
359
+ sent.className = 'int-sent';
360
+ sent.textContent = `Code sent to ${it.sent.maskedDestination}${it.sent.channel ? ` via ${it.sent.channel}` : ''}.`;
361
+ card.appendChild(sent);
362
+ }
363
+ const row = document.createElement('div');
364
+ row.className = 'int-row';
365
+ const input = document.createElement('input');
366
+ input.className = 'int-input';
367
+ input.type = 'text';
368
+ input.inputMode = 'numeric';
369
+ input.autocomplete = 'one-time-code';
370
+ input.placeholder = 'Enter code';
371
+ const submit = () => {
372
+ const code = input.value.trim();
373
+ if (code) this.controller?.verifyOtp(code);
374
+ };
375
+ input.addEventListener('keydown', (ev) => {
376
+ if (ev.key === 'Enter') {
377
+ ev.preventDefault();
378
+ submit();
379
+ }
380
+ });
381
+ const verify = document.createElement('button');
382
+ verify.className = 'int-btn primary';
383
+ verify.type = 'button';
384
+ verify.textContent = 'Verify';
385
+ verify.addEventListener('click', submit);
386
+ row.append(input, verify);
387
+ card.appendChild(row);
388
+ if (it.error) {
389
+ const err = document.createElement('div');
390
+ err.className = 'int-error';
391
+ err.textContent = it.attemptsRemaining != null ? `${it.error} (${it.attemptsRemaining} left)` : it.error;
392
+ card.appendChild(err);
393
+ }
394
+ queueMicrotask(() => input.focus());
395
+ } else {
396
+ const row = document.createElement('div');
397
+ row.className = 'int-row';
398
+ const decline = document.createElement('button');
399
+ decline.className = 'int-btn';
400
+ decline.type = 'button';
401
+ decline.textContent = 'Decline';
402
+ decline.addEventListener('click', () => this.controller?.confirmTool(false));
403
+ const approve = document.createElement('button');
404
+ approve.className = 'int-btn primary';
405
+ approve.type = 'button';
406
+ approve.textContent = 'Approve';
407
+ approve.addEventListener('click', () => this.controller?.confirmTool(true));
408
+ row.append(decline, approve);
409
+ card.appendChild(row);
410
+ }
411
+
412
+ el.appendChild(card);
303
413
  }
304
414
 
305
415
  /** Collect identity from the pre-chat form, then drop into the chat view. */
package/src/styles.ts CHANGED
@@ -508,6 +508,63 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove
508
508
  transform: translateY(-1px);
509
509
  }
510
510
 
511
+ /* ─────────────── OTP / tool-confirmation interrupt ────────────────── */
512
+ .interrupt { padding: 0 14px; }
513
+ .int-card {
514
+ border: 1px solid color-mix(in srgb, var(--sac-primary) 35%, var(--sac-border));
515
+ background: color-mix(in srgb, var(--sac-primary) 8%, var(--sac-surface-2));
516
+ border-radius: 14px;
517
+ padding: 12px 13px;
518
+ animation: sac-msg-in .3s var(--sac-ease) both;
519
+ }
520
+ .int-head { display: flex; align-items: center; gap: 8px; }
521
+ .int-ico { display: flex; color: var(--sac-primary); }
522
+ .int-ico svg { width: 17px; height: 17px; }
523
+ .int-title { font-size: 13.5px; font-weight: 650; }
524
+ .int-desc { margin-top: 5px; font-size: 12.5px; line-height: 1.45; color: color-mix(in srgb, var(--sac-text) 80%, transparent); }
525
+ .int-sent { margin-top: 6px; font-size: 11.5px; color: color-mix(in srgb, var(--sac-text) 60%, transparent); }
526
+ .int-row { display: flex; gap: 8px; margin-top: 10px; }
527
+ .int-input {
528
+ flex: 1;
529
+ min-width: 0;
530
+ border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
531
+ background: var(--sac-bg);
532
+ color: var(--sac-text);
533
+ border-radius: 10px;
534
+ padding: 9px 11px;
535
+ font-family: inherit;
536
+ font-size: 14px;
537
+ letter-spacing: .14em;
538
+ outline: none;
539
+ transition: border-color .2s ease, box-shadow .2s ease;
540
+ }
541
+ .int-input:focus {
542
+ border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent);
543
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent);
544
+ }
545
+ .int-btn {
546
+ border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
547
+ background: var(--sac-surface-2);
548
+ color: var(--sac-text);
549
+ border-radius: 10px;
550
+ padding: 9px 14px;
551
+ font-family: inherit;
552
+ font-size: 13px;
553
+ font-weight: 600;
554
+ cursor: pointer;
555
+ transition: transform .2s var(--sac-ease), background .2s ease, border-color .2s ease;
556
+ }
557
+ .int-btn:hover { transform: translateY(-1px); }
558
+ .int-btn.primary {
559
+ border: none;
560
+ background: linear-gradient(150deg, var(--sac-primary), var(--sac-primary-2));
561
+ color: var(--sac-primary-text);
562
+ box-shadow: 0 6px 14px -6px color-mix(in srgb, var(--sac-primary) 65%, transparent);
563
+ }
564
+ .int-row .int-btn { flex: 1; }
565
+ .int-row .int-input + .int-btn { flex: 0 0 auto; }
566
+ .int-error { margin-top: 8px; font-size: 12px; color: #f87171; }
567
+
511
568
  .hidden { display: none !important; }
512
569
 
513
570
  @media (prefers-reduced-motion: reduce) {