@smooai/chat-widget 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smooai/chat-widget",
3
- "version": "0.12.0",
3
+ "version": "0.13.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
@@ -141,6 +141,16 @@ export interface ChatWidgetConfig {
141
141
  * `require*` flags are ignored and the pre-chat form is skipped.
142
142
  */
143
143
  allowAnonymous?: boolean;
144
+ /**
145
+ * Show the agent's tool activity (grep / read_file / bash / knowledge_search…)
146
+ * as inline chips interleaved with its prose, mirroring the smooth daemon SPA.
147
+ *
148
+ * Defaults to **`false`**: for a customer-facing support widget, surfacing raw
149
+ * tool calls to an end-user is usually undesirable, so tool activity is hidden
150
+ * and only the assistant's prose renders. Enable it for internal / power-user
151
+ * surfaces where seeing what the agent did mid-turn is valuable.
152
+ */
153
+ showToolActivity?: boolean;
144
154
  /** Theme overrides. */
145
155
  theme?: ChatWidgetTheme;
146
156
  }
@@ -194,6 +204,7 @@ export function resolveConfig(config: ChatWidgetConfig): ResolvedConfig {
194
204
  collectConsent: config.collectConsent ?? true,
195
205
  allowChatRestore: config.allowChatRestore ?? true,
196
206
  allowAnonymous: config.allowAnonymous ?? false,
207
+ showToolActivity: config.showToolActivity ?? false,
197
208
  theme: {
198
209
  text: theme.text ?? '#f8fafc',
199
210
  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,6 +276,52 @@ 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;
@@ -674,7 +752,8 @@ export class ConversationController {
674
752
  this.messages.push({ id: this.nextId('u'), role: 'user', text: trimmed, streaming: false });
675
753
 
676
754
  // 2. Placeholder assistant bubble we grow as tokens arrive.
677
- const assistant: ChatMessage = { id: this.nextId('a'), role: 'assistant', text: '', streaming: true };
755
+ const showTools = this.config.showToolActivity === true;
756
+ const assistant: ChatMessage = { id: this.nextId('a'), role: 'assistant', text: '', streaming: true, blocks: showTools ? [] : undefined };
678
757
  this.messages.push(assistant);
679
758
  this.emitMessages();
680
759
 
@@ -687,6 +766,14 @@ export class ConversationController {
687
766
  const token = event.token ?? event.data?.token ?? '';
688
767
  if (token) {
689
768
  assistant.text += token;
769
+ // Grow the trailing text block so prose interleaves with any
770
+ // tool chips in the order the model produced them.
771
+ if (showTools && assistant.blocks) growTextBlock(assistant.blocks, token);
772
+ this.emitMessages();
773
+ }
774
+ } else if (showTools && event.type === 'stream_chunk') {
775
+ // Tool activity (gated). Read state.rawResponse.toolCall/.toolResult.
776
+ if (assistant.blocks && applyToolChunk(assistant.blocks, event.data?.state)) {
690
777
  this.emitMessages();
691
778
  }
692
779
  } else {
@@ -715,6 +802,11 @@ export class ConversationController {
715
802
  if (suggestions.length > 0) {
716
803
  assistant.suggestions = suggestions;
717
804
  }
805
+ // Only keep blocks for turns that actually invoked a tool — a prose-only
806
+ // turn drops back to the normal markdown text path (with the final text).
807
+ if (assistant.blocks && !assistant.blocks.some((b) => b.kind === 'tool')) {
808
+ assistant.blocks = undefined;
809
+ }
718
810
  assistant.streaming = false;
719
811
  this.emitMessages();
720
812
  } catch (err) {
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, 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';
@@ -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).
@@ -75,6 +84,8 @@ const ICON = {
75
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>`,
76
85
  /** Tool-confirmation interrupt — a shield. */
77
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>`,
78
89
  } as const;
79
90
 
80
91
  /**
@@ -292,6 +303,10 @@ export class SmoothAgentChatElement extends HTMLElement {
292
303
  /** How many chars of {@link streamTarget} are currently shown. */
293
304
  private displayedLength = 0;
294
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 = '';
295
310
 
296
311
  constructor() {
297
312
  super();
@@ -376,6 +391,7 @@ export class SmoothAgentChatElement extends HTMLElement {
376
391
  collectConsent: this.overrides.collectConsent,
377
392
  allowChatRestore: this.overrides.allowChatRestore,
378
393
  allowAnonymous: this.overrides.allowAnonymous,
394
+ showToolActivity: this.overrides.showToolActivity ?? this.hasAttribute('show-tool-activity'),
379
395
  theme,
380
396
  };
381
397
  }
@@ -1046,14 +1062,19 @@ export class SmoothAgentChatElement extends HTMLElement {
1046
1062
  // finalize transition (streaming → done) needs the markdown render + sources
1047
1063
  last.streaming !== prevLast.streaming ||
1048
1064
  // first token after the typing indicator needs the bubble swapped in
1049
- (!!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;
1050
1068
 
1051
1069
  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;
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);
1057
1078
  this.ensureRevealLoop();
1058
1079
  return;
1059
1080
  }
@@ -1061,6 +1082,32 @@ export class SmoothAgentChatElement extends HTMLElement {
1061
1082
  this.renderMessages();
1062
1083
  }
1063
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
+
1064
1111
  private renderMessages(): void {
1065
1112
  if (!this.messagesEl) return;
1066
1113
  this.resetReveal();
@@ -1087,6 +1134,16 @@ export class SmoothAgentChatElement extends HTMLElement {
1087
1134
  }
1088
1135
 
1089
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
+
1090
1147
  const bubble = document.createElement('div');
1091
1148
  bubble.className = `bubble ${msg.role}`;
1092
1149
  if (msg.role === 'assistant' && msg.streaming && !msg.text) {
@@ -1168,22 +1225,89 @@ export class SmoothAgentChatElement extends HTMLElement {
1168
1225
  * doesn't restart the reveal from zero), then resumes the loop.
1169
1226
  */
1170
1227
  private bindReveal(msg: ChatMessage, bubble: HTMLElement): void {
1171
- 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;
1172
1234
  this.streamBubbleEl = bubble;
1173
- this.streamMsgId = msg.id;
1174
- this.streamTarget = msg.text;
1235
+ this.streamMsgId = key;
1236
+ this.streamTarget = target;
1175
1237
  this.displayedLength = carryOver;
1176
1238
 
1177
1239
  if (this.prefersReducedMotion()) {
1178
1240
  // No animation: show everything immediately.
1179
- this.displayedLength = msg.text.length;
1180
- bubble.textContent = msg.text;
1241
+ this.displayedLength = target.length;
1242
+ bubble.textContent = target;
1181
1243
  return;
1182
1244
  }
1183
- bubble.textContent = msg.text.slice(0, this.displayedLength);
1245
+ bubble.textContent = target.slice(0, this.displayedLength);
1184
1246
  this.ensureRevealLoop();
1185
1247
  }
1186
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
+
1187
1311
  /** Start the rAF loop if it isn't already running. */
1188
1312
  private ensureRevealLoop(): void {
1189
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. */