@smooai/chat-widget 0.12.0 → 0.14.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.12.0",
3
+ "version": "0.14.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",
package/src/config.ts CHANGED
@@ -6,6 +6,19 @@
6
6
  * {@link mountChatWidget} / `element.configure(...)`).
7
7
  */
8
8
  import { safeHttpUrl } from './markdown.js';
9
+ import { DEFAULT_VOICE_URL } from './voice-session.js';
10
+
11
+ /**
12
+ * Browser voice input/output (SMOODEV-2534). OFF by default — when disabled the
13
+ * widget renders zero voice UI. When enabled, a mic toggle appears in the
14
+ * composer and speech flows over the browser-voice WebSocket.
15
+ */
16
+ export interface ChatWidgetVoiceConfig {
17
+ /** Turn the voice feature on. Default `false`. */
18
+ enabled?: boolean;
19
+ /** Browser-voice WS endpoint. Defaults to the hosted SmooAI voice service. */
20
+ url?: string;
21
+ }
9
22
 
10
23
  export interface ChatWidgetTheme {
11
24
  /** Foreground text color for the widget chrome. */
@@ -141,6 +154,18 @@ export interface ChatWidgetConfig {
141
154
  * `require*` flags are ignored and the pre-chat form is skipped.
142
155
  */
143
156
  allowAnonymous?: boolean;
157
+ /**
158
+ * Show the agent's tool activity (grep / read_file / bash / knowledge_search…)
159
+ * as inline chips interleaved with its prose, mirroring the smooth daemon SPA.
160
+ *
161
+ * Defaults to **`false`**: for a customer-facing support widget, surfacing raw
162
+ * tool calls to an end-user is usually undesirable, so tool activity is hidden
163
+ * and only the assistant's prose renders. Enable it for internal / power-user
164
+ * surfaces where seeing what the agent did mid-turn is valuable.
165
+ */
166
+ showToolActivity?: boolean;
167
+ /** Browser voice input/output. OFF by default (zero UI when off). */
168
+ voice?: ChatWidgetVoiceConfig;
144
169
  /** Theme overrides. */
145
170
  theme?: ChatWidgetTheme;
146
171
  }
@@ -148,8 +173,9 @@ export interface ChatWidgetConfig {
148
173
  /** The fully-resolved theme (canonical keys only — aliases are folded in). */
149
174
  export type ResolvedTheme = Required<Omit<ChatWidgetTheme, 'chatBubbleInbound' | 'chatBubbleInboundText' | 'chatBubbleOutbound' | 'chatBubbleOutboundText'>>;
150
175
 
151
- export type ResolvedConfig = Required<Omit<ChatWidgetConfig, 'theme' | 'userName' | 'userEmail' | 'userPhone' | 'authContext' | 'logoUrl'>> & {
176
+ export type ResolvedConfig = Required<Omit<ChatWidgetConfig, 'theme' | 'userName' | 'userEmail' | 'userPhone' | 'authContext' | 'logoUrl' | 'voice'>> & {
152
177
  theme: ResolvedTheme;
178
+ voice: { enabled: boolean; url: string };
153
179
  userName?: string;
154
180
  userEmail?: string;
155
181
  userPhone?: string;
@@ -194,6 +220,8 @@ export function resolveConfig(config: ChatWidgetConfig): ResolvedConfig {
194
220
  collectConsent: config.collectConsent ?? true,
195
221
  allowChatRestore: config.allowChatRestore ?? true,
196
222
  allowAnonymous: config.allowAnonymous ?? false,
223
+ showToolActivity: config.showToolActivity ?? false,
224
+ voice: { enabled: config.voice?.enabled ?? false, url: config.voice?.url ?? DEFAULT_VOICE_URL },
197
225
  theme: {
198
226
  text: theme.text ?? '#f8fafc',
199
227
  background: theme.background ?? '#040d30',
@@ -61,6 +61,32 @@ export type { Citation };
61
61
 
62
62
  export type Role = 'user' | 'assistant';
63
63
 
64
+ /**
65
+ * One tool invocation within an assistant turn. Mirrors the smooth daemon SPA's
66
+ * `ToolCall` (`crates/smooth-web/web/src/operator.ts`): opens `done: false` on the
67
+ * tool call and resolves on the tool result.
68
+ */
69
+ export interface ToolCall {
70
+ /** Stable id for keyed rendering (assigned when the call opens). */
71
+ id: string;
72
+ name: string;
73
+ /** Raw arguments, JSON-stringified. */
74
+ args: string;
75
+ /** Present once the tool resolves. */
76
+ result?: string;
77
+ isError?: boolean;
78
+ done: boolean;
79
+ }
80
+
81
+ /**
82
+ * One ordered segment of an assistant turn: a run of prose, or a tool call.
83
+ * Preserves the interleave order the model produced (say a bit → call a tool →
84
+ * say a bit → …) so the UI can render tool chips INLINE where the model called
85
+ * them. Mirrors the daemon SPA's `MessageBlock`. Only populated when the widget
86
+ * is configured with `showToolActivity: true`.
87
+ */
88
+ export type MessageBlock = { kind: 'text'; text: string } | { kind: 'tool'; tool: ToolCall };
89
+
64
90
  export interface ChatMessage {
65
91
  id: string;
66
92
  role: Role;
@@ -68,6 +94,12 @@ export interface ChatMessage {
68
94
  text: string;
69
95
  /** True while an assistant message is still streaming. */
70
96
  streaming: boolean;
97
+ /**
98
+ * Ordered text + tool segments, interleaved as the model produced them. Present
99
+ * only on assistant messages when `showToolActivity` is enabled (absent
100
+ * otherwise — the default popover renders `text` alone, byte-for-byte unchanged).
101
+ */
102
+ blocks?: MessageBlock[];
71
103
  /**
72
104
  * Sources that grounded an assistant answer, when the terminal
73
105
  * `eventual_response` carried any. Optional + back-compatible: absent when
@@ -244,12 +276,60 @@ function wireMessageToChat(m: WireMessage, idx: number): ChatMessage | null {
244
276
  return { id: typeof m.id === 'string' ? m.id : `hist-${idx}`, role, text, streaming: false };
245
277
  }
246
278
 
279
+ let toolSeq = 0;
280
+ const nextToolId = (): string => `tool-${++toolSeq}`;
281
+
282
+ /** Grow the trailing text block, or open a new one if the last block was a tool. */
283
+ function growTextBlock(blocks: MessageBlock[], text: string): void {
284
+ if (!text) return;
285
+ const last = blocks[blocks.length - 1];
286
+ if (last && last.kind === 'text') last.text += text;
287
+ else blocks.push({ kind: 'text', text });
288
+ }
289
+
290
+ /**
291
+ * Fold a `stream_chunk` node-state into the ordered block list, returning `true`
292
+ * when the chunk carried tool activity.
293
+ *
294
+ * Tool activity rides `state.rawResponse.toolCall` / `state.rawResponse.toolResult`
295
+ * — **NOT** `state.toolResult`. Reading the wrong path leaves every chip stuck on
296
+ * "running…" forever (the exact bug the daemon SPA hit and this mirror avoids).
297
+ */
298
+ function applyToolChunk(blocks: MessageBlock[], state: unknown): boolean {
299
+ const raw = (state as { rawResponse?: unknown } | null | undefined)?.rawResponse;
300
+ if (!raw || typeof raw !== 'object') return false;
301
+ const call = (raw as { toolCall?: { name?: string; arguments?: unknown } }).toolCall;
302
+ const res = (raw as { toolResult?: { name?: string; isError?: boolean; result?: unknown } }).toolResult;
303
+ if (call) {
304
+ const args = typeof call.arguments === 'string' ? call.arguments : JSON.stringify(call.arguments ?? {});
305
+ blocks.push({ kind: 'tool', tool: { id: nextToolId(), name: call.name ?? '', args, done: false } });
306
+ return true;
307
+ }
308
+ if (res) {
309
+ const result = typeof res.result === 'string' ? res.result : JSON.stringify(res.result ?? '');
310
+ // Complete the most-recent still-open tool block matching this name.
311
+ for (let i = blocks.length - 1; i >= 0; i--) {
312
+ const b = blocks[i];
313
+ if (b && b.kind === 'tool' && b.tool.name === (res.name ?? '') && !b.tool.done) {
314
+ b.tool.done = true;
315
+ b.tool.isError = !!res.isError;
316
+ b.tool.result = result;
317
+ break;
318
+ }
319
+ }
320
+ return true;
321
+ }
322
+ return false;
323
+ }
324
+
247
325
  export class ConversationController {
248
326
  private readonly config: ChatWidgetConfig;
249
327
  private readonly events: ConversationEvents;
250
328
  private readonly store: StoreApi<WidgetStore>;
251
329
  private client: SmoothAgentClient | null = null;
252
330
  private sessionId: string | null = null;
331
+ /** Conversation id of the live session (create or resume) — lets voice join the same thread. */
332
+ private conversationId: string | null = null;
253
333
  private readonly messages: ChatMessage[] = [];
254
334
  private status: ConnectionStatus = 'idle';
255
335
  private seq = 0;
@@ -306,6 +386,23 @@ export class ConversationController {
306
386
  return this.status;
307
387
  }
308
388
 
389
+ /** Conversation id of the live session, or null before connect (voice passes this as `conversation_id`). */
390
+ get currentConversationId(): string | null {
391
+ return this.conversationId;
392
+ }
393
+
394
+ /**
395
+ * Append an already-finalized message to the transcript and emit — the voice
396
+ * path reuses this so `transcript_final` (user) and `reply_text` (assistant)
397
+ * turns land in the same message list / render pipeline as typed chat.
398
+ */
399
+ appendLocalMessage(role: Role, text: string): void {
400
+ const trimmed = text.trim();
401
+ if (!trimmed) return;
402
+ this.messages.push({ id: this.nextId(role === 'user' ? 'u' : 'a'), role, text: trimmed, streaming: false });
403
+ this.emitMessages();
404
+ }
405
+
309
406
  /** The persisted store, exposed so the view can read identity for the pre-chat gate. */
310
407
  getStore(): StoreApi<WidgetStore> {
311
408
  return this.store;
@@ -612,6 +709,7 @@ export class ConversationController {
612
709
  ...(metadata ? { metadata } : {}),
613
710
  });
614
711
  this.sessionId = session.sessionId;
712
+ this.conversationId = session.conversationId ?? null;
615
713
  this.store.getState().setSessionId(session.sessionId);
616
714
  }
617
715
 
@@ -621,7 +719,7 @@ export class ConversationController {
621
719
  */
622
720
  private async tryResume(sessionId: string): Promise<boolean> {
623
721
  if (!this.client) return false;
624
- let snap: { status?: 'active' | 'idle' | 'ended' };
722
+ let snap: { status?: 'active' | 'idle' | 'ended'; conversationId?: string };
625
723
  try {
626
724
  snap = await this.client.getSession({ sessionId });
627
725
  } catch {
@@ -630,6 +728,7 @@ export class ConversationController {
630
728
  if (snap.status === 'ended') return false;
631
729
 
632
730
  this.sessionId = sessionId;
731
+ this.conversationId = snap.conversationId ?? null;
633
732
  await this.hydrateHistory(sessionId);
634
733
  return true;
635
734
  }
@@ -674,7 +773,8 @@ export class ConversationController {
674
773
  this.messages.push({ id: this.nextId('u'), role: 'user', text: trimmed, streaming: false });
675
774
 
676
775
  // 2. Placeholder assistant bubble we grow as tokens arrive.
677
- const assistant: ChatMessage = { id: this.nextId('a'), role: 'assistant', text: '', streaming: true };
776
+ const showTools = this.config.showToolActivity === true;
777
+ const assistant: ChatMessage = { id: this.nextId('a'), role: 'assistant', text: '', streaming: true, blocks: showTools ? [] : undefined };
678
778
  this.messages.push(assistant);
679
779
  this.emitMessages();
680
780
 
@@ -687,6 +787,14 @@ export class ConversationController {
687
787
  const token = event.token ?? event.data?.token ?? '';
688
788
  if (token) {
689
789
  assistant.text += token;
790
+ // Grow the trailing text block so prose interleaves with any
791
+ // tool chips in the order the model produced them.
792
+ if (showTools && assistant.blocks) growTextBlock(assistant.blocks, token);
793
+ this.emitMessages();
794
+ }
795
+ } else if (showTools && event.type === 'stream_chunk') {
796
+ // Tool activity (gated). Read state.rawResponse.toolCall/.toolResult.
797
+ if (assistant.blocks && applyToolChunk(assistant.blocks, event.data?.state)) {
690
798
  this.emitMessages();
691
799
  }
692
800
  } else {
@@ -715,6 +823,11 @@ export class ConversationController {
715
823
  if (suggestions.length > 0) {
716
824
  assistant.suggestions = suggestions;
717
825
  }
826
+ // Only keep blocks for turns that actually invoked a tool — a prose-only
827
+ // turn drops back to the normal markdown text path (with the final text).
828
+ if (assistant.blocks && !assistant.blocks.some((b) => b.kind === 'tool')) {
829
+ assistant.blocks = undefined;
830
+ }
718
831
  assistant.streaming = false;
719
832
  this.emitMessages();
720
833
  } catch (err) {
@@ -960,6 +1073,7 @@ export class ConversationController {
960
1073
  this.client?.disconnect('widget closed');
961
1074
  this.client = null;
962
1075
  this.sessionId = null;
1076
+ this.conversationId = null;
963
1077
  this.activeRequestId = null;
964
1078
  // A full teardown ends the controller lifecycle: a subsequent connect() is a
965
1079
  // genuine re-open and may resume again, so re-arm the resume probe.
package/src/element.ts CHANGED
@@ -17,10 +17,20 @@
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, SUPPORTED_INTERACTION_CAPABILITIES } 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';
33
+ import { VoiceSession } from './voice-session.js';
24
34
 
25
35
  export const ELEMENT_TAG = 'smooth-agent-chat';
26
36
 
@@ -52,7 +62,7 @@ function phoneToE164(value: string): string | null {
52
62
  /** Public smooth-operator repo — the "powered by" header tag + footer link here. */
53
63
  const SMOOTH_OPERATOR_URL = 'https://github.com/SmooAI/smooth-operator';
54
64
 
55
- const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'logo-url', 'placeholder', 'greeting', 'start-open', 'mode', 'hide-branding'] as const;
65
+ const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'logo-url', 'placeholder', 'greeting', 'start-open', 'mode', 'hide-branding', 'show-tool-activity'] as const;
56
66
 
57
67
  /**
58
68
  * Inline SVG icons (static, trusted strings — never interpolated with user data).
@@ -75,6 +85,10 @@ const ICON = {
75
85
  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>`,
76
86
  /** Tool-confirmation interrupt — a shield. */
77
87
  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>`,
88
+ /** Tool-activity chip — a wrench. */
89
+ 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>`,
90
+ /** Voice toggle — a microphone. */
91
+ mic: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="9" y="3.5" width="6" height="11" rx="3" stroke="currentColor" stroke-width="1.7"/><path d="M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v2.5M9 20.5h6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>`,
78
92
  } as const;
79
93
 
80
94
  /**
@@ -266,6 +280,10 @@ export class SmoothAgentChatElement extends HTMLElement {
266
280
  private allowChatRestore = true;
267
281
  /** True while the pre-chat identity gate is showing (blocks premature connect). */
268
282
  private gating = false;
283
+ /** Voice config (SMOODEV-2534) — enabled=false renders zero voice UI. */
284
+ private voiceCfg: { enabled: boolean; url: string } = { enabled: false, url: '' };
285
+ /** Live voice session, or null when voice is off. */
286
+ private voiceSession: VoiceSession | null = null;
269
287
 
270
288
  // Cached DOM refs (populated in render()).
271
289
  private panelEl: HTMLElement | null = null;
@@ -275,6 +293,7 @@ export class SmoothAgentChatElement extends HTMLElement {
275
293
  private dotEl: HTMLElement | null = null;
276
294
  private inputEl: HTMLTextAreaElement | null = null;
277
295
  private sendBtn: HTMLButtonElement | null = null;
296
+ private micBtn: HTMLButtonElement | null = null;
278
297
  private suggestionsEl: HTMLElement | null = null;
279
298
 
280
299
  // ── Smooth streaming reveal ──
@@ -292,6 +311,10 @@ export class SmoothAgentChatElement extends HTMLElement {
292
311
  /** How many chars of {@link streamTarget} are currently shown. */
293
312
  private displayedLength = 0;
294
313
  private rafId = 0;
314
+ /** Block structure signature of the last-rendered streaming message (tool chips
315
+ * only — text growth doesn't change it), so a chip add/resolve forces a rebuild
316
+ * while plain trailing-text growth stays on the fast reveal path. */
317
+ private prevBlockSig = '';
295
318
 
296
319
  constructor() {
297
320
  super();
@@ -306,6 +329,7 @@ export class SmoothAgentChatElement extends HTMLElement {
306
329
  disconnectedCallback(): void {
307
330
  this.mounted = false;
308
331
  this.resetReveal();
332
+ this.stopVoice();
309
333
  this.controller?.disconnect();
310
334
  this.controller = null;
311
335
  }
@@ -376,6 +400,8 @@ export class SmoothAgentChatElement extends HTMLElement {
376
400
  collectConsent: this.overrides.collectConsent,
377
401
  allowChatRestore: this.overrides.allowChatRestore,
378
402
  allowAnonymous: this.overrides.allowAnonymous,
403
+ showToolActivity: this.overrides.showToolActivity ?? this.hasAttribute('show-tool-activity'),
404
+ voice: this.overrides.voice,
379
405
  theme,
380
406
  };
381
407
  }
@@ -511,6 +537,11 @@ export class SmoothAgentChatElement extends HTMLElement {
511
537
  const restoreBtn = this.allowChatRestore ? `<button type="button" class="restore-link">Restore my chats</button>` : '';
512
538
  const footerInner = [brandingHtml, restoreBtn].filter(Boolean).join(' · ');
513
539
  const footerHtml = footerInner ? `<div class="footer">${footerInner}</div>` : '';
540
+ // Voice (SMOODEV-2534): mic toggle in the composer, only when enabled.
541
+ this.voiceCfg = resolved.voice;
542
+ const micHtml = resolved.voice.enabled
543
+ ? `<button class="mic" type="button" aria-label="Start voice" aria-pressed="false" title="Talk to ${escapeHtml(resolved.agentName)}">${ICON.mic}</button>`
544
+ : '';
514
545
  const chatHtml = `
515
546
  <div class="messages"></div>
516
547
  <div class="reply-suggestions"></div>
@@ -518,6 +549,7 @@ export class SmoothAgentChatElement extends HTMLElement {
518
549
  <div class="composer-wrap">
519
550
  <div class="composer">
520
551
  <textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
552
+ ${micHtml}
521
553
  <button class="send" type="button" aria-label="Send message">${ICON.send}</button>
522
554
  </div>
523
555
  ${footerHtml}
@@ -539,8 +571,10 @@ export class SmoothAgentChatElement extends HTMLElement {
539
571
  if (logoSvg) logoSvg.setAttribute('class', 'logo');
540
572
 
541
573
  // A full DOM rebuild invalidates any cached streaming-bubble ref; cancel
542
- // the in-flight reveal loop so renderMessages can re-bind cleanly.
574
+ // the in-flight reveal loop so renderMessages can re-bind cleanly. A live
575
+ // voice session is bound to the old composer's mic button — end it too.
543
576
  this.resetReveal();
577
+ this.stopVoice();
544
578
  this.root.replaceChildren(style, container);
545
579
 
546
580
  this.launcherEl = container.querySelector('.launcher');
@@ -550,12 +584,14 @@ export class SmoothAgentChatElement extends HTMLElement {
550
584
  this.dotEl = container.querySelector('.dot');
551
585
  this.inputEl = container.querySelector('textarea');
552
586
  this.sendBtn = container.querySelector('.send');
587
+ this.micBtn = container.querySelector('.mic');
553
588
  this.interruptEl = container.querySelector('.interrupt');
554
589
  this.suggestionsEl = container.querySelector('.reply-suggestions');
555
590
 
556
591
  this.launcherEl?.addEventListener('click', () => this.openChat());
557
592
  container.querySelector('.close')?.addEventListener('click', () => this.closeChat());
558
593
  this.sendBtn?.addEventListener('click', () => this.submit());
594
+ this.micBtn?.addEventListener('click', () => this.toggleVoice());
559
595
  this.inputEl?.addEventListener('input', () => this.autosize());
560
596
  this.inputEl?.addEventListener('keydown', (ev) => {
561
597
  if (ev.key === 'Enter' && !ev.shiftKey) {
@@ -1046,14 +1082,19 @@ export class SmoothAgentChatElement extends HTMLElement {
1046
1082
  // finalize transition (streaming → done) needs the markdown render + sources
1047
1083
  last.streaming !== prevLast.streaming ||
1048
1084
  // first token after the typing indicator needs the bubble swapped in
1049
- (!!last.streaming && !prevLast.text && !!last.text);
1085
+ (!!last.streaming && !prevLast.text && !!last.text) ||
1086
+ // a tool chip was added or resolved (text growth alone doesn't change this)
1087
+ this.blockSig(last) !== this.prevBlockSig;
1050
1088
 
1051
1089
  this.messages = messages;
1052
-
1053
- if (!structural && last && last.streaming && last.id === this.streamMsgId) {
1054
- // Fast path: the streaming tail just grew. Bump the reveal target and
1055
- // let the rAF loop catch up no DOM rebuild, no per-token reflow.
1056
- this.streamTarget = last.text;
1090
+ this.prevBlockSig = this.blockSig(last);
1091
+
1092
+ if (!structural && last && last.streaming && this.tailKey(last) === this.streamMsgId) {
1093
+ // Fast path: the streaming (trailing) text just grew. Bump the reveal
1094
+ // target and let the rAF loop catch up — no DOM rebuild, no per-token
1095
+ // reflow. `tailText` is the live trailing text block for a tool turn, or
1096
+ // the whole message for a plain-prose turn.
1097
+ this.streamTarget = this.tailText(last);
1057
1098
  this.ensureRevealLoop();
1058
1099
  return;
1059
1100
  }
@@ -1061,6 +1102,32 @@ export class SmoothAgentChatElement extends HTMLElement {
1061
1102
  this.renderMessages();
1062
1103
  }
1063
1104
 
1105
+ /** True when an assistant message's turn invoked at least one tool. */
1106
+ private hasToolBlocks(m?: ChatMessage): boolean {
1107
+ return !!m?.blocks?.some((b) => b.kind === 'tool');
1108
+ }
1109
+
1110
+ /** Signature that changes only when a tool chip is added or resolved (text growth is 'x'). */
1111
+ private blockSig(m?: ChatMessage): string {
1112
+ if (!m?.blocks) return '';
1113
+ return m.blocks.map((b) => (b.kind === 'tool' ? `t:${b.tool.id}:${b.tool.done ? 1 : 0}` : 'x')).join('|');
1114
+ }
1115
+
1116
+ /** The live (last) text block for a tool turn, else the whole message text. */
1117
+ private tailText(m: ChatMessage): string {
1118
+ if (this.hasToolBlocks(m) && m.blocks) {
1119
+ const last = m.blocks[m.blocks.length - 1];
1120
+ return last?.kind === 'text' ? last.text : '';
1121
+ }
1122
+ return m.text;
1123
+ }
1124
+
1125
+ /** Reveal-binding key — composite for a tool-turn tail (so a new trailing block rebinds), else msg id. */
1126
+ private tailKey(m: ChatMessage): string {
1127
+ if (this.hasToolBlocks(m) && m.blocks) return `${m.id}#${m.blocks.length - 1}`;
1128
+ return m.id;
1129
+ }
1130
+
1064
1131
  private renderMessages(): void {
1065
1132
  if (!this.messagesEl) return;
1066
1133
  this.resetReveal();
@@ -1087,6 +1154,16 @@ export class SmoothAgentChatElement extends HTMLElement {
1087
1154
  }
1088
1155
 
1089
1156
  for (const msg of this.messages) {
1157
+ // Tool-activity turns (gated by `showToolActivity`) render as an ordered
1158
+ // strip of prose bubbles + inline tool chips instead of one bubble.
1159
+ if (msg.role === 'assistant' && this.hasToolBlocks(msg)) {
1160
+ this.messagesEl.appendChild(this.buildRow('assistant', this.renderAssistantBlocks(msg)));
1161
+ if (!msg.streaming && msg.citations && msg.citations.length > 0) {
1162
+ this.messagesEl.appendChild(this.renderSources(msg.citations));
1163
+ }
1164
+ continue;
1165
+ }
1166
+
1090
1167
  const bubble = document.createElement('div');
1091
1168
  bubble.className = `bubble ${msg.role}`;
1092
1169
  if (msg.role === 'assistant' && msg.streaming && !msg.text) {
@@ -1168,22 +1245,89 @@ export class SmoothAgentChatElement extends HTMLElement {
1168
1245
  * doesn't restart the reveal from zero), then resumes the loop.
1169
1246
  */
1170
1247
  private bindReveal(msg: ChatMessage, bubble: HTMLElement): void {
1171
- const carryOver = msg.id === this.streamMsgId ? Math.min(this.displayedLength, msg.text.length) : 0;
1248
+ // `tailKey`/`tailText` collapse to the message id + full text for a plain
1249
+ // prose turn, and to the live trailing text block for a tool turn — so both
1250
+ // the single streaming bubble and a tool turn's trailing prose share this path.
1251
+ const key = this.tailKey(msg);
1252
+ const target = this.tailText(msg);
1253
+ const carryOver = key === this.streamMsgId ? Math.min(this.displayedLength, target.length) : 0;
1172
1254
  this.streamBubbleEl = bubble;
1173
- this.streamMsgId = msg.id;
1174
- this.streamTarget = msg.text;
1255
+ this.streamMsgId = key;
1256
+ this.streamTarget = target;
1175
1257
  this.displayedLength = carryOver;
1176
1258
 
1177
1259
  if (this.prefersReducedMotion()) {
1178
1260
  // No animation: show everything immediately.
1179
- this.displayedLength = msg.text.length;
1180
- bubble.textContent = msg.text;
1261
+ this.displayedLength = target.length;
1262
+ bubble.textContent = target;
1181
1263
  return;
1182
1264
  }
1183
- bubble.textContent = msg.text.slice(0, this.displayedLength);
1265
+ bubble.textContent = target.slice(0, this.displayedLength);
1184
1266
  this.ensureRevealLoop();
1185
1267
  }
1186
1268
 
1269
+ /**
1270
+ * Render a tool-activity assistant turn as an ordered strip: prose bubbles and
1271
+ * inline tool chips in the order the model produced them (mirrors the daemon
1272
+ * SPA's `blocks`). The live trailing text block (while streaming) binds to the
1273
+ * rAF reveal; earlier/finalized text blocks render as sanitized markdown.
1274
+ */
1275
+ private renderAssistantBlocks(msg: ChatMessage): HTMLElement {
1276
+ const wrap = document.createElement('div');
1277
+ wrap.className = 'blocks';
1278
+ const blocks = msg.blocks ?? [];
1279
+ const lastIdx = blocks.length - 1;
1280
+ blocks.forEach((block, i) => {
1281
+ if (block.kind === 'tool') {
1282
+ wrap.appendChild(this.buildToolChip(block.tool));
1283
+ return;
1284
+ }
1285
+ const bubble = document.createElement('div');
1286
+ bubble.className = 'bubble assistant';
1287
+ if (msg.streaming && i === lastIdx) {
1288
+ // Live trailing prose → plain text + cursor, driven by the reveal loop.
1289
+ bubble.classList.add('cursor');
1290
+ this.bindReveal(msg, bubble);
1291
+ } else {
1292
+ // Settled prose → sanitized markdown (same allowlisted renderer as bubbles).
1293
+ bubble.classList.add('md');
1294
+ bubble.innerHTML = renderMarkdown(block.text);
1295
+ }
1296
+ wrap.appendChild(bubble);
1297
+ });
1298
+ return wrap;
1299
+ }
1300
+
1301
+ /**
1302
+ * A single tool-activity chip: icon + tool name + status (running… / done / error),
1303
+ * with a truncated args preview. Tool name/args are set via `textContent` so a
1304
+ * tool payload can never inject markup.
1305
+ */
1306
+ private buildToolChip(tool: ToolCall): HTMLElement {
1307
+ const chip = document.createElement('div');
1308
+ chip.className = `toolchip ${tool.done ? (tool.isError ? 'error' : 'done') : 'running'}`;
1309
+ chip.setAttribute('part', 'tool-chip');
1310
+
1311
+ const icon = document.createElement('span');
1312
+ icon.className = 'ti';
1313
+ icon.innerHTML = ICON.tool; // static, trusted
1314
+ const name = document.createElement('span');
1315
+ name.className = 'tn';
1316
+ name.textContent = tool.name || 'tool';
1317
+ const status = document.createElement('span');
1318
+ status.className = 'ts';
1319
+ status.textContent = tool.done ? (tool.isError ? 'error' : 'done') : 'running…';
1320
+ chip.append(icon, name, status);
1321
+
1322
+ if (tool.args && tool.args !== '{}' && tool.args !== '""') {
1323
+ const args = document.createElement('span');
1324
+ args.className = 'ta';
1325
+ args.textContent = tool.args.length > 80 ? `${tool.args.slice(0, 80)}…` : tool.args;
1326
+ chip.appendChild(args);
1327
+ }
1328
+ return chip;
1329
+ }
1330
+
1187
1331
  /** Start the rAF loop if it isn't already running. */
1188
1332
  private ensureRevealLoop(): void {
1189
1333
  if (this.prefersReducedMotion() || typeof requestAnimationFrame !== 'function') {
@@ -1401,6 +1545,99 @@ export class SmoothAgentChatElement extends HTMLElement {
1401
1545
  if (this.inputEl) this.inputEl.disabled = busy;
1402
1546
  }
1403
1547
 
1548
+ // ───────────────────────── Voice (SMOODEV-2534) ─────────────────────────────
1549
+
1550
+ /**
1551
+ * Mic button: start a voice session, or — when one is live — end it. Hitting
1552
+ * the button while the agent's TTS is playing barges in first (interrupt +
1553
+ * playback flush) so the audio dies instantly, then the session ends.
1554
+ */
1555
+ private toggleVoice(): void {
1556
+ if (this.voiceSession) {
1557
+ if (this.voiceSession.isSpeaking) this.voiceSession.interrupt();
1558
+ this.stopVoice();
1559
+ return;
1560
+ }
1561
+ void this.startVoice();
1562
+ }
1563
+
1564
+ private async startVoice(): Promise<void> {
1565
+ if (!this.controller || this.voiceSession || !this.voiceCfg.enabled) return;
1566
+ const config = this.readConfig();
1567
+ if (!config) return;
1568
+ // Best-effort: join the text thread when one exists. If the text session
1569
+ // hasn't connected yet, voice starts a fresh thread (the frozen protocol
1570
+ // never returns the voice-created conversation id, so it can't be adopted
1571
+ // for later text turns — tracked as a follow-up on the server protocol).
1572
+ const conversationId = this.controller.currentConversationId ?? undefined;
1573
+ const controller = this.controller;
1574
+ const session = new VoiceSession(
1575
+ { url: this.voiceCfg.url, agentId: config.agentId, conversationId },
1576
+ {
1577
+ onTranscriptPartial: (text) => {
1578
+ // Live partial transcript in the input area while listening.
1579
+ if (this.inputEl) {
1580
+ this.inputEl.value = text;
1581
+ this.autosize();
1582
+ }
1583
+ },
1584
+ onTranscriptFinal: (text) => {
1585
+ if (this.inputEl) {
1586
+ this.inputEl.value = '';
1587
+ this.autosize();
1588
+ }
1589
+ // Finalized speech lands as a normal user message.
1590
+ controller.appendLocalMessage('user', text);
1591
+ },
1592
+ onReplyText: (text) => {
1593
+ // Agent replies render through the normal chat message path.
1594
+ controller.appendLocalMessage('assistant', text);
1595
+ },
1596
+ onSpeaking: (speaking) => {
1597
+ this.micBtn?.classList.toggle('speaking', speaking);
1598
+ },
1599
+ onError: () => this.stopVoice(),
1600
+ onEnded: () => {
1601
+ // Server-side end (handoff / close) — reset the UI. stopVoice()
1602
+ // is idempotent, so a local stop lands here harmlessly too.
1603
+ this.voiceSession = null;
1604
+ this.syncVoiceUi(false);
1605
+ },
1606
+ },
1607
+ );
1608
+ this.voiceSession = session;
1609
+ this.syncVoiceUi(true);
1610
+ try {
1611
+ await session.start();
1612
+ } catch {
1613
+ // Mic permission denied / audio unavailable — back to text.
1614
+ this.voiceSession = null;
1615
+ this.syncVoiceUi(false);
1616
+ }
1617
+ }
1618
+
1619
+ /** End any live voice session and reset the composer UI. Idempotent. */
1620
+ private stopVoice(): void {
1621
+ const session = this.voiceSession;
1622
+ this.voiceSession = null;
1623
+ session?.stop();
1624
+ this.syncVoiceUi(false);
1625
+ }
1626
+
1627
+ /** Toggle the mic button's listening state + clear the partial transcript. */
1628
+ private syncVoiceUi(active: boolean): void {
1629
+ this.micBtn?.classList.toggle('active', active);
1630
+ this.micBtn?.setAttribute('aria-pressed', String(active));
1631
+ this.micBtn?.setAttribute('aria-label', active ? 'Stop voice' : 'Start voice');
1632
+ if (!active) {
1633
+ this.micBtn?.classList.remove('speaking');
1634
+ if (this.inputEl && this.inputEl.value) {
1635
+ this.inputEl.value = '';
1636
+ this.autosize();
1637
+ }
1638
+ }
1639
+ }
1640
+
1404
1641
  private submit(): void {
1405
1642
  if (!this.inputEl || !this.controller) return;
1406
1643
  const text = this.inputEl.value;