@smooai/chat-widget 0.11.0 → 0.13.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
@@ -17,7 +17,16 @@
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 {
21
+ type ChatMessage,
22
+ type Citation,
23
+ type ConnectionStatus,
24
+ ConversationController,
25
+ type IdentityRestore,
26
+ type Interrupt,
27
+ SUPPORTED_INTERACTION_CAPABILITIES,
28
+ type ToolCall,
29
+ } from './conversation.js';
21
30
  import { SMOOTH_ICON_SVG } from './logo.js';
22
31
  import { cleanCitationSnippet, escapeHtml, renderMarkdown, safeHttpUrl } from './markdown.js';
23
32
  import { buildStyles } from './styles.js';
@@ -52,7 +61,7 @@ function phoneToE164(value: string): string | null {
52
61
  /** Public smooth-operator repo — the "powered by" header tag + footer link here. */
53
62
  const SMOOTH_OPERATOR_URL = 'https://github.com/SmooAI/smooth-operator';
54
63
 
55
- const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'logo-url', 'placeholder', 'greeting', 'start-open', 'mode', 'hide-branding'] as const;
64
+ const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'logo-url', 'placeholder', 'greeting', 'start-open', 'mode', 'hide-branding', 'show-tool-activity'] as const;
56
65
 
57
66
  /**
58
67
  * Inline SVG icons (static, trusted strings — never interpolated with user data).
@@ -71,10 +80,168 @@ const ICON = {
71
80
  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
81
  /** OTP interrupt — a padlock. */
73
82
  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>`,
83
+ /** Identity-intake interrupt — a person. */
84
+ 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
85
  /** Tool-confirmation interrupt — a shield. */
75
86
  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>`,
87
+ /** Tool-activity chip — a wrench. */
88
+ tool: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14.7 6.3a3.5 3.5 0 0 0-4.6 4.3l-5 5a1.6 1.6 0 0 0 2.3 2.3l5-5a3.5 3.5 0 0 0 4.3-4.6l-2 2-1.7-.3-.3-1.7 2-2Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>`,
76
89
  } as const;
77
90
 
91
+ /**
92
+ * The Rich Interactions **card registry**: interaction kind → overlay card.
93
+ * `interaction_required { kind }` looks its card up here; registering a card IS
94
+ * declaring the widget's render capability for that kind (see
95
+ * `SUPPORTED_INTERACTION_CAPABILITIES` in conversation.ts — a test keeps the
96
+ * two aligned). Adding a kind = one card builder + one entry.
97
+ *
98
+ * The existing OTP and tool-approval overlays are prior instances of this same
99
+ * shape and should retrofit onto this registry later (their wire events predate
100
+ * the pattern).
101
+ */
102
+ export interface InteractionCardContext {
103
+ /** Submit kind-shaped values (resumes the parked turn; server validates). */
104
+ submit: (values: Record<string, unknown>) => void;
105
+ /** Decline the interaction (the agent continues without it). */
106
+ decline: () => void;
107
+ /** Persisted visitor identity, for pre-filling known fields. */
108
+ prefill: { name?: string; email?: string; phone?: string };
109
+ /** Wire live phone formatting + validity hint onto a form's phone input. */
110
+ wirePhoneField: (form: HTMLFormElement) => void;
111
+ }
112
+
113
+ export interface InteractionCard {
114
+ /** The render capability this card provides (goes into `supports`). */
115
+ capability: string;
116
+ /** Card header title. */
117
+ title: string;
118
+ /** Static, trusted header icon SVG. */
119
+ icon: string;
120
+ /** Build the card body for an `interaction` interrupt. */
121
+ build: (it: Extract<Interrupt, { kind: 'interaction' }>, ctx: InteractionCardContext) => HTMLElement;
122
+ }
123
+
124
+ /**
125
+ * The identity_intake card: the fields the agent requested (pre-chat form field
126
+ * pattern — same classes, same phone formatting), per-field server errors, a
127
+ * submit and a decline affordance. Server-supplied text (reason, labels, error
128
+ * messages) is set via `textContent` — never innerHTML.
129
+ */
130
+ function buildIdentityIntakeCard(it: Extract<Interrupt, { kind: 'interaction' }>, ctx: InteractionCardContext): HTMLElement {
131
+ const form = document.createElement('form');
132
+ form.className = 'int-form';
133
+ form.noValidate = true;
134
+
135
+ if (it.reason) {
136
+ const desc = document.createElement('div');
137
+ desc.className = 'int-desc';
138
+ desc.textContent = it.reason;
139
+ form.appendChild(desc);
140
+ }
141
+
142
+ const DEFAULTS: Record<string, { label: string; type: string; autocomplete: string }> = {
143
+ name: { label: 'Name', type: 'text', autocomplete: 'name' },
144
+ email: { label: 'Email', type: 'email', autocomplete: 'email' },
145
+ phone: { label: 'Phone', type: 'tel', autocomplete: 'tel' },
146
+ };
147
+
148
+ // Parse the kind's spec defensively: `{ fields: [{ key, required, label? }] }`.
149
+ const rawFields = Array.isArray(it.spec.fields) ? it.spec.fields : [];
150
+ const fields: { key: 'name' | 'email' | 'phone'; required: boolean; label?: string }[] = [];
151
+ for (const f of rawFields) {
152
+ if (!f || typeof f !== 'object') continue;
153
+ const o = f as Record<string, unknown>;
154
+ const key = typeof o.key === 'string' ? o.key : '';
155
+ if (key !== 'name' && key !== 'email' && key !== 'phone') continue;
156
+ fields.push({ key, required: o.required === true, label: typeof o.label === 'string' ? o.label : undefined });
157
+ }
158
+ if (fields.length === 0) fields.push({ key: 'email', required: true });
159
+
160
+ for (const f of fields) {
161
+ const d = DEFAULTS[f.key]!;
162
+ const label = document.createElement('label');
163
+ label.className = 'pc-field';
164
+ const caption = document.createElement('span');
165
+ caption.textContent = f.label ?? d.label;
166
+ const input = document.createElement('input');
167
+ input.name = f.key;
168
+ input.type = d.type;
169
+ input.setAttribute('autocomplete', d.autocomplete);
170
+ input.required = f.required;
171
+ const prefill = ctx.prefill[f.key];
172
+ if (prefill) input.value = prefill;
173
+ label.append(caption, input);
174
+ if (f.key === 'phone') {
175
+ const hint = document.createElement('span');
176
+ hint.className = 'pc-hint';
177
+ hint.setAttribute('aria-live', 'polite');
178
+ label.appendChild(hint);
179
+ }
180
+ // Per-field server-side validation error (interaction_invalid).
181
+ const serverError = it.errors?.find((e) => e.field === f.key);
182
+ if (serverError) {
183
+ label.classList.add('invalid');
184
+ const err = document.createElement('span');
185
+ err.className = 'int-error';
186
+ err.textContent = serverError.message;
187
+ label.appendChild(err);
188
+ }
189
+ form.appendChild(label);
190
+ }
191
+
192
+ const row = document.createElement('div');
193
+ row.className = 'int-row';
194
+ const decline = document.createElement('button');
195
+ decline.className = 'int-btn';
196
+ decline.type = 'button';
197
+ decline.textContent = 'No thanks';
198
+ decline.addEventListener('click', () => ctx.decline());
199
+ const share = document.createElement('button');
200
+ share.className = 'int-btn primary';
201
+ share.type = 'submit';
202
+ share.textContent = 'Share details';
203
+ row.append(decline, share);
204
+ form.appendChild(row);
205
+
206
+ form.addEventListener('submit', (ev) => {
207
+ ev.preventDefault();
208
+ if (!form.reportValidity()) return;
209
+ const data = new FormData(form);
210
+ const val = (k: string) => ((data.get(k) as string | null)?.trim() || undefined);
211
+
212
+ // Phone: mirror the pre-chat rules — block a required-but-invalid number,
213
+ // prefer canonical E.164 when it parses (the server re-validates anyway).
214
+ const rawPhone = val('phone');
215
+ const phoneInput = form.querySelector('input[name="phone"]') as HTMLInputElement | null;
216
+ if (rawPhone && phoneInput && !isValidPhoneNumber(rawPhone, PHONE_DEFAULT_REGION)) {
217
+ const field = phoneInput.closest('.pc-field');
218
+ field?.classList.add('invalid');
219
+ const hint = field?.querySelector('.pc-hint');
220
+ if (hint) hint.textContent = 'Enter a valid phone number';
221
+ if (phoneInput.hasAttribute('required')) {
222
+ phoneInput.focus();
223
+ return;
224
+ }
225
+ }
226
+ const phone = rawPhone ? (phoneToE164(rawPhone) ?? rawPhone) : undefined;
227
+ ctx.submit({ name: val('name'), email: val('email'), phone });
228
+ });
229
+ // Same live phone formatting + validity hint as the pre-chat form.
230
+ ctx.wirePhoneField(form);
231
+ queueMicrotask(() => (form.querySelector('input') as HTMLInputElement | null)?.focus());
232
+ return form;
233
+ }
234
+
235
+ /** Kind → card. See the registry doc above. */
236
+ export const INTERACTION_CARDS: Record<string, InteractionCard> = {
237
+ identity_intake: {
238
+ capability: 'identity_form',
239
+ title: 'Share your details',
240
+ icon: ICON.user,
241
+ build: buildIdentityIntakeCard,
242
+ },
243
+ };
244
+
78
245
  // `safeHttpUrl` / `escapeHtml` live in `./markdown.js` (the markdown renderer
79
246
  // needs them too); re-exported here for back-compat with existing importers.
80
247
  export { escapeHtml, safeHttpUrl } from './markdown.js';
@@ -136,6 +303,10 @@ export class SmoothAgentChatElement extends HTMLElement {
136
303
  /** How many chars of {@link streamTarget} are currently shown. */
137
304
  private displayedLength = 0;
138
305
  private rafId = 0;
306
+ /** Block structure signature of the last-rendered streaming message (tool chips
307
+ * only — text growth doesn't change it), so a chip add/resolve forces a rebuild
308
+ * while plain trailing-text growth stays on the fast reveal path. */
309
+ private prevBlockSig = '';
139
310
 
140
311
  constructor() {
141
312
  super();
@@ -220,6 +391,7 @@ export class SmoothAgentChatElement extends HTMLElement {
220
391
  collectConsent: this.overrides.collectConsent,
221
392
  allowChatRestore: this.overrides.allowChatRestore,
222
393
  allowAnonymous: this.overrides.allowAnonymous,
394
+ showToolActivity: this.overrides.showToolActivity ?? this.hasAttribute('show-tool-activity'),
223
395
  theme,
224
396
  };
225
397
  }
@@ -480,21 +652,38 @@ export class SmoothAgentChatElement extends HTMLElement {
480
652
  head.className = 'int-head';
481
653
  const ico = document.createElement('span');
482
654
  ico.className = 'int-ico';
483
- ico.innerHTML = it.kind === 'otp' ? ICON.lock : ICON.shield; // static, trusted
655
+ const card_meta = it.kind === 'interaction' ? INTERACTION_CARDS[it.interactionKind] : undefined;
656
+ if (it.kind === 'interaction' && !card_meta) {
657
+ // A kind we have no card for (shouldn't happen — we only declare
658
+ // capabilities for registered cards). Decline so the turn never hangs.
659
+ this.controller?.declineInteraction();
660
+ el.classList.add('hidden');
661
+ return;
662
+ }
663
+ ico.innerHTML = it.kind === 'otp' ? ICON.lock : it.kind === 'interaction' ? (card_meta?.icon ?? ICON.user) : ICON.shield; // static, trusted
484
664
  const title = document.createElement('span');
485
665
  title.className = 'int-title';
486
- title.textContent = it.kind === 'otp' ? 'Verification required' : 'Confirm this action';
666
+ title.textContent = it.kind === 'otp' ? 'Verification required' : it.kind === 'interaction' ? (card_meta?.title ?? 'One more thing') : 'Confirm this action';
487
667
  head.append(ico, title);
488
668
  card.appendChild(head);
489
669
 
490
- if (it.actionDescription) {
670
+ if (it.kind !== 'interaction' && it.actionDescription) {
491
671
  const desc = document.createElement('div');
492
672
  desc.className = 'int-desc';
493
673
  desc.textContent = it.actionDescription;
494
674
  card.appendChild(desc);
495
675
  }
496
676
 
497
- if (it.kind === 'otp') {
677
+ if (it.kind === 'interaction') {
678
+ card.appendChild(
679
+ card_meta!.build(it, {
680
+ submit: (values) => this.controller?.submitInteraction(values),
681
+ decline: () => this.controller?.declineInteraction(),
682
+ prefill: this.controller?.getStore().getState().identity ?? {},
683
+ wirePhoneField: (form) => this.wirePhoneField(form),
684
+ }),
685
+ );
686
+ } else if (it.kind === 'otp') {
498
687
  if (it.sent?.maskedDestination) {
499
688
  const sent = document.createElement('div');
500
689
  sent.className = 'int-sent';
@@ -873,14 +1062,19 @@ export class SmoothAgentChatElement extends HTMLElement {
873
1062
  // finalize transition (streaming → done) needs the markdown render + sources
874
1063
  last.streaming !== prevLast.streaming ||
875
1064
  // first token after the typing indicator needs the bubble swapped in
876
- (!!last.streaming && !prevLast.text && !!last.text);
1065
+ (!!last.streaming && !prevLast.text && !!last.text) ||
1066
+ // a tool chip was added or resolved (text growth alone doesn't change this)
1067
+ this.blockSig(last) !== this.prevBlockSig;
877
1068
 
878
1069
  this.messages = messages;
879
-
880
- if (!structural && last && last.streaming && last.id === this.streamMsgId) {
881
- // Fast path: the streaming tail just grew. Bump the reveal target and
882
- // let the rAF loop catch up no DOM rebuild, no per-token reflow.
883
- this.streamTarget = last.text;
1070
+ this.prevBlockSig = this.blockSig(last);
1071
+
1072
+ if (!structural && last && last.streaming && this.tailKey(last) === this.streamMsgId) {
1073
+ // Fast path: the streaming (trailing) text just grew. Bump the reveal
1074
+ // target and let the rAF loop catch up — no DOM rebuild, no per-token
1075
+ // reflow. `tailText` is the live trailing text block for a tool turn, or
1076
+ // the whole message for a plain-prose turn.
1077
+ this.streamTarget = this.tailText(last);
884
1078
  this.ensureRevealLoop();
885
1079
  return;
886
1080
  }
@@ -888,6 +1082,32 @@ export class SmoothAgentChatElement extends HTMLElement {
888
1082
  this.renderMessages();
889
1083
  }
890
1084
 
1085
+ /** True when an assistant message's turn invoked at least one tool. */
1086
+ private hasToolBlocks(m?: ChatMessage): boolean {
1087
+ return !!m?.blocks?.some((b) => b.kind === 'tool');
1088
+ }
1089
+
1090
+ /** Signature that changes only when a tool chip is added or resolved (text growth is 'x'). */
1091
+ private blockSig(m?: ChatMessage): string {
1092
+ if (!m?.blocks) return '';
1093
+ return m.blocks.map((b) => (b.kind === 'tool' ? `t:${b.tool.id}:${b.tool.done ? 1 : 0}` : 'x')).join('|');
1094
+ }
1095
+
1096
+ /** The live (last) text block for a tool turn, else the whole message text. */
1097
+ private tailText(m: ChatMessage): string {
1098
+ if (this.hasToolBlocks(m) && m.blocks) {
1099
+ const last = m.blocks[m.blocks.length - 1];
1100
+ return last?.kind === 'text' ? last.text : '';
1101
+ }
1102
+ return m.text;
1103
+ }
1104
+
1105
+ /** Reveal-binding key — composite for a tool-turn tail (so a new trailing block rebinds), else msg id. */
1106
+ private tailKey(m: ChatMessage): string {
1107
+ if (this.hasToolBlocks(m) && m.blocks) return `${m.id}#${m.blocks.length - 1}`;
1108
+ return m.id;
1109
+ }
1110
+
891
1111
  private renderMessages(): void {
892
1112
  if (!this.messagesEl) return;
893
1113
  this.resetReveal();
@@ -914,6 +1134,16 @@ export class SmoothAgentChatElement extends HTMLElement {
914
1134
  }
915
1135
 
916
1136
  for (const msg of this.messages) {
1137
+ // Tool-activity turns (gated by `showToolActivity`) render as an ordered
1138
+ // strip of prose bubbles + inline tool chips instead of one bubble.
1139
+ if (msg.role === 'assistant' && this.hasToolBlocks(msg)) {
1140
+ this.messagesEl.appendChild(this.buildRow('assistant', this.renderAssistantBlocks(msg)));
1141
+ if (!msg.streaming && msg.citations && msg.citations.length > 0) {
1142
+ this.messagesEl.appendChild(this.renderSources(msg.citations));
1143
+ }
1144
+ continue;
1145
+ }
1146
+
917
1147
  const bubble = document.createElement('div');
918
1148
  bubble.className = `bubble ${msg.role}`;
919
1149
  if (msg.role === 'assistant' && msg.streaming && !msg.text) {
@@ -995,22 +1225,89 @@ export class SmoothAgentChatElement extends HTMLElement {
995
1225
  * doesn't restart the reveal from zero), then resumes the loop.
996
1226
  */
997
1227
  private bindReveal(msg: ChatMessage, bubble: HTMLElement): void {
998
- const carryOver = msg.id === this.streamMsgId ? Math.min(this.displayedLength, msg.text.length) : 0;
1228
+ // `tailKey`/`tailText` collapse to the message id + full text for a plain
1229
+ // prose turn, and to the live trailing text block for a tool turn — so both
1230
+ // the single streaming bubble and a tool turn's trailing prose share this path.
1231
+ const key = this.tailKey(msg);
1232
+ const target = this.tailText(msg);
1233
+ const carryOver = key === this.streamMsgId ? Math.min(this.displayedLength, target.length) : 0;
999
1234
  this.streamBubbleEl = bubble;
1000
- this.streamMsgId = msg.id;
1001
- this.streamTarget = msg.text;
1235
+ this.streamMsgId = key;
1236
+ this.streamTarget = target;
1002
1237
  this.displayedLength = carryOver;
1003
1238
 
1004
1239
  if (this.prefersReducedMotion()) {
1005
1240
  // No animation: show everything immediately.
1006
- this.displayedLength = msg.text.length;
1007
- bubble.textContent = msg.text;
1241
+ this.displayedLength = target.length;
1242
+ bubble.textContent = target;
1008
1243
  return;
1009
1244
  }
1010
- bubble.textContent = msg.text.slice(0, this.displayedLength);
1245
+ bubble.textContent = target.slice(0, this.displayedLength);
1011
1246
  this.ensureRevealLoop();
1012
1247
  }
1013
1248
 
1249
+ /**
1250
+ * Render a tool-activity assistant turn as an ordered strip: prose bubbles and
1251
+ * inline tool chips in the order the model produced them (mirrors the daemon
1252
+ * SPA's `blocks`). The live trailing text block (while streaming) binds to the
1253
+ * rAF reveal; earlier/finalized text blocks render as sanitized markdown.
1254
+ */
1255
+ private renderAssistantBlocks(msg: ChatMessage): HTMLElement {
1256
+ const wrap = document.createElement('div');
1257
+ wrap.className = 'blocks';
1258
+ const blocks = msg.blocks ?? [];
1259
+ const lastIdx = blocks.length - 1;
1260
+ blocks.forEach((block, i) => {
1261
+ if (block.kind === 'tool') {
1262
+ wrap.appendChild(this.buildToolChip(block.tool));
1263
+ return;
1264
+ }
1265
+ const bubble = document.createElement('div');
1266
+ bubble.className = 'bubble assistant';
1267
+ if (msg.streaming && i === lastIdx) {
1268
+ // Live trailing prose → plain text + cursor, driven by the reveal loop.
1269
+ bubble.classList.add('cursor');
1270
+ this.bindReveal(msg, bubble);
1271
+ } else {
1272
+ // Settled prose → sanitized markdown (same allowlisted renderer as bubbles).
1273
+ bubble.classList.add('md');
1274
+ bubble.innerHTML = renderMarkdown(block.text);
1275
+ }
1276
+ wrap.appendChild(bubble);
1277
+ });
1278
+ return wrap;
1279
+ }
1280
+
1281
+ /**
1282
+ * A single tool-activity chip: icon + tool name + status (running… / done / error),
1283
+ * with a truncated args preview. Tool name/args are set via `textContent` so a
1284
+ * tool payload can never inject markup.
1285
+ */
1286
+ private buildToolChip(tool: ToolCall): HTMLElement {
1287
+ const chip = document.createElement('div');
1288
+ chip.className = `toolchip ${tool.done ? (tool.isError ? 'error' : 'done') : 'running'}`;
1289
+ chip.setAttribute('part', 'tool-chip');
1290
+
1291
+ const icon = document.createElement('span');
1292
+ icon.className = 'ti';
1293
+ icon.innerHTML = ICON.tool; // static, trusted
1294
+ const name = document.createElement('span');
1295
+ name.className = 'tn';
1296
+ name.textContent = tool.name || 'tool';
1297
+ const status = document.createElement('span');
1298
+ status.className = 'ts';
1299
+ status.textContent = tool.done ? (tool.isError ? 'error' : 'done') : 'running…';
1300
+ chip.append(icon, name, status);
1301
+
1302
+ if (tool.args && tool.args !== '{}' && tool.args !== '""') {
1303
+ const args = document.createElement('span');
1304
+ args.className = 'ta';
1305
+ args.textContent = tool.args.length > 80 ? `${tool.args.slice(0, 80)}…` : tool.args;
1306
+ chip.appendChild(args);
1307
+ }
1308
+ return chip;
1309
+ }
1310
+
1014
1311
  /** Start the rAF loop if it isn't already running. */
1015
1312
  private ensureRevealLoop(): void {
1016
1313
  if (this.prefersReducedMotion() || typeof requestAnimationFrame !== 'function') {
package/src/index.ts CHANGED
@@ -39,8 +39,10 @@ export {
39
39
  type ConnectionStatus,
40
40
  type ConversationEvents,
41
41
  type IdentityRestore,
42
+ type MessageBlock,
42
43
  type RestorableConversation,
43
44
  type Role,
45
+ type ToolCall,
44
46
  type UserInfo,
45
47
  } from './conversation.js';
46
48
  export {
package/src/styles.ts CHANGED
@@ -29,6 +29,7 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove
29
29
  --sac-bg: ${theme.background};
30
30
  --sac-primary: ${theme.primary};
31
31
  --sac-primary-text: ${theme.primaryText};
32
+ --sac-secondary: ${theme.secondary};
32
33
  --sac-assistant-bubble: ${theme.assistantBubble};
33
34
  --sac-assistant-bubble-text: ${theme.assistantBubbleText};
34
35
  --sac-user-bubble: ${theme.userBubble};
@@ -349,6 +350,47 @@ ${
349
350
  }
350
351
  @keyframes sac-blink { to { opacity: 0 } }
351
352
 
353
+ /* Interleaved tool-activity strip (gated by showToolActivity): prose bubbles
354
+ and inline tool chips stacked in the order the model produced them. */
355
+ .blocks { display: flex; flex-direction: column; align-items: flex-start; gap: 7px; }
356
+ .blocks .bubble { align-self: flex-start; }
357
+ .toolchip {
358
+ display: inline-flex;
359
+ align-items: center;
360
+ gap: 6px;
361
+ max-width: 100%;
362
+ padding: 5px 10px;
363
+ border-radius: 99px;
364
+ font-size: 12px;
365
+ line-height: 1.3;
366
+ color: color-mix(in srgb, var(--sac-text) 78%, transparent);
367
+ background: color-mix(in srgb, var(--sac-text) 6%, transparent);
368
+ border: 1px solid color-mix(in srgb, var(--sac-text) 12%, transparent);
369
+ animation: sac-msg-in .3s var(--sac-ease) both;
370
+ }
371
+ .toolchip .ti { display: inline-flex; flex: none; opacity: .8; }
372
+ .toolchip .ti svg { width: 13px; height: 13px; }
373
+ .toolchip .tn { font-weight: 600; letter-spacing: .01em; }
374
+ .toolchip .ts { opacity: .7; }
375
+ .toolchip .ta {
376
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
377
+ font-size: 11px;
378
+ opacity: .6;
379
+ overflow: hidden;
380
+ text-overflow: ellipsis;
381
+ white-space: nowrap;
382
+ min-width: 0;
383
+ }
384
+ .toolchip.running { border-color: color-mix(in srgb, var(--sac-primary) 45%, transparent); }
385
+ .toolchip.running .ts { color: var(--sac-primary); opacity: 1; animation: sac-typing 1.3s ease-in-out infinite; }
386
+ .toolchip.done .ts::before { content: '✓ '; }
387
+ .toolchip.error {
388
+ color: var(--sac-secondary);
389
+ border-color: color-mix(in srgb, var(--sac-secondary) 50%, transparent);
390
+ background: color-mix(in srgb, var(--sac-secondary) 10%, transparent);
391
+ }
392
+ .toolchip.error .ts::before { content: '! '; }
393
+
352
394
  /* ─────────────── Rendered markdown (assistant bubbles / snippets) ─────────── */
353
395
  /* The renderer (markdown.ts) emits a small allowlisted set of tags; these rules
354
396
  keep them legible inside the tight Aurora-Glass bubble + citation card. */
@@ -643,6 +685,9 @@ ${
643
685
  border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent);
644
686
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent);
645
687
  }
688
+ .int-form { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
689
+ .int-form .pc-field input { width: 100%; box-sizing: border-box; }
690
+ .int-form .int-error { margin-top: 2px; }
646
691
  .int-btn {
647
692
  border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
648
693
  background: var(--sac-surface-2);
@@ -662,8 +707,14 @@ ${
662
707
  color: var(--sac-primary-text);
663
708
  box-shadow: 0 6px 14px -6px color-mix(in srgb, var(--sac-primary) 65%, transparent);
664
709
  }
665
- .int-row .int-btn { flex: 1; }
666
- .int-row .int-input + .int-btn { flex: 0 0 auto; }
710
+ .int-row .int-form { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
711
+ .int-form .pc-field input { width: 100%; box-sizing: border-box; }
712
+ .int-form .int-error { margin-top: 2px; }
713
+ .int-btn { flex: 1; }
714
+ .int-row .int-input + .int-form { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
715
+ .int-form .pc-field input { width: 100%; box-sizing: border-box; }
716
+ .int-form .int-error { margin-top: 2px; }
717
+ .int-btn { flex: 0 0 auto; }
667
718
  .int-error { margin-top: 8px; font-size: 12px; color: #f87171; }
668
719
  .int-card { position: relative; }
669
720
  .int-close {