@genesislcap/ai-assistant 14.458.3 → 14.460.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.
Files changed (43) hide show
  1. package/dist/ai-assistant.api.json +38 -11
  2. package/dist/ai-assistant.d.ts +49 -5
  3. package/dist/dts/components/chat-driver/chat-driver.d.ts +3 -1
  4. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  5. package/dist/dts/components/flowing-waves-indicator.d.ts +32 -0
  6. package/dist/dts/components/flowing-waves-indicator.d.ts.map +1 -0
  7. package/dist/dts/components/plasma-orb-indicator.d.ts +22 -0
  8. package/dist/dts/components/plasma-orb-indicator.d.ts.map +1 -0
  9. package/dist/dts/components/waves-indicator.d.ts +30 -0
  10. package/dist/dts/components/waves-indicator.d.ts.map +1 -0
  11. package/dist/dts/main/main.d.ts.map +1 -1
  12. package/dist/dts/main/main.styles.d.ts.map +1 -1
  13. package/dist/dts/main/main.template.d.ts.map +1 -1
  14. package/dist/dts/main/main.types.d.ts +44 -4
  15. package/dist/dts/main/main.types.d.ts.map +1 -1
  16. package/dist/dts/utils/animation-exclusivity.d.ts +23 -0
  17. package/dist/dts/utils/animation-exclusivity.d.ts.map +1 -0
  18. package/dist/dts/utils/animation-exclusivity.test.d.ts +2 -0
  19. package/dist/dts/utils/animation-exclusivity.test.d.ts.map +1 -0
  20. package/dist/esm/components/chat-driver/chat-driver.js +7 -2
  21. package/dist/esm/components/chat-driver/chat-driver.test.js +24 -0
  22. package/dist/esm/components/flowing-waves-indicator.js +222 -0
  23. package/dist/esm/components/plasma-orb-indicator.js +280 -0
  24. package/dist/esm/components/waves-indicator.js +189 -0
  25. package/dist/esm/main/main.js +20 -9
  26. package/dist/esm/main/main.styles.js +62 -7
  27. package/dist/esm/main/main.template.js +75 -21
  28. package/dist/esm/main/main.types.js +46 -3
  29. package/dist/esm/utils/animation-exclusivity.js +33 -0
  30. package/dist/esm/utils/animation-exclusivity.test.js +52 -0
  31. package/dist/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +16 -16
  33. package/src/components/chat-driver/chat-driver.test.ts +36 -0
  34. package/src/components/chat-driver/chat-driver.ts +10 -2
  35. package/src/components/flowing-waves-indicator.ts +260 -0
  36. package/src/components/plasma-orb-indicator.ts +281 -0
  37. package/src/components/waves-indicator.ts +221 -0
  38. package/src/main/main.styles.ts +62 -7
  39. package/src/main/main.template.ts +88 -27
  40. package/src/main/main.ts +24 -8
  41. package/src/main/main.types.ts +56 -5
  42. package/src/utils/animation-exclusivity.test.ts +72 -0
  43. package/src/utils/animation-exclusivity.ts +40 -0
@@ -0,0 +1,221 @@
1
+ import { avoidTreeShaking } from '@genesislcap/foundation-utils';
2
+ import { attr, css, customElement, GenesisElement, html } from '@genesislcap/web-core';
3
+ import {
4
+ AI_COLOUR_AMBER,
5
+ AI_COLOUR_CYAN,
6
+ AI_COLOUR_PINK,
7
+ AI_COLOUR_VIOLET,
8
+ } from '../styles/ai-colours';
9
+ import { AiHaloOverlay } from './halo-overlay';
10
+
11
+ const WAVES_DEFAULT_SIZE = 56;
12
+ /** CSS-ready form of `WAVES_DEFAULT_SIZE` (the `css` tag rejects raw numbers). */
13
+ const WAVES_DEFAULT_SIZE_CSS = `${WAVES_DEFAULT_SIZE}px`;
14
+
15
+ /** SVG coordinate space the waves are drawn in (square; the circle fills it). */
16
+ const VIEWBOX = 120;
17
+ const CENTRE = VIEWBOX / 2;
18
+ /** Horizontal sampling step when tracing each wave path. Lower = smoother. */
19
+ const SAMPLE_STEP = 6;
20
+
21
+ /**
22
+ * Per-wave parameters. Each wave is a travelling sine (amplitude/frequency/phase)
23
+ * plus a slower, longer-wavelength term that makes the line "slosh" like liquid.
24
+ */
25
+ interface WaveConfig {
26
+ colour: string;
27
+ /** Peak height of the travelling wave, in viewBox units. */
28
+ amplitude: number;
29
+ /** Angular frequency of the travelling wave (radians per viewBox unit). */
30
+ frequency: number;
31
+ /** How fast the travelling wave scrolls (radians per frame). */
32
+ phaseSpeed: number;
33
+ /** Vertical centre offset so the waves stack rather than overlap exactly. */
34
+ verticalOffset: number;
35
+ /** Amplitude of the slow sloshing term. */
36
+ slosh: number;
37
+ /** Angular frequency of the sloshing term (radians per viewBox unit). */
38
+ sloshFrequency: number;
39
+ /** How fast the sloshing term evolves (radians per frame). */
40
+ sloshSpeed: number;
41
+ }
42
+
43
+ const WAVES: WaveConfig[] = [
44
+ {
45
+ colour: AI_COLOUR_AMBER,
46
+ amplitude: 11,
47
+ frequency: 0.085,
48
+ phaseSpeed: 0.05,
49
+ verticalOffset: -6,
50
+ slosh: 6,
51
+ sloshFrequency: 0.018,
52
+ sloshSpeed: 0.021,
53
+ },
54
+ {
55
+ colour: AI_COLOUR_PINK,
56
+ amplitude: 14,
57
+ frequency: 0.07,
58
+ phaseSpeed: -0.043,
59
+ verticalOffset: -2,
60
+ slosh: 7,
61
+ sloshFrequency: 0.022,
62
+ sloshSpeed: -0.017,
63
+ },
64
+ {
65
+ colour: AI_COLOUR_CYAN,
66
+ amplitude: 13,
67
+ frequency: 0.095,
68
+ phaseSpeed: 0.037,
69
+ verticalOffset: 2,
70
+ slosh: 5,
71
+ sloshFrequency: 0.015,
72
+ sloshSpeed: 0.025,
73
+ },
74
+ {
75
+ colour: AI_COLOUR_VIOLET,
76
+ amplitude: 10,
77
+ frequency: 0.06,
78
+ phaseSpeed: -0.055,
79
+ verticalOffset: 6,
80
+ slosh: 8,
81
+ sloshFrequency: 0.025,
82
+ sloshSpeed: -0.013,
83
+ },
84
+ ];
85
+
86
+ const wavePathsMarkup = WAVES.map(
87
+ (w, i) => `<path class="wave" data-wave="${i}" stroke="${w.colour}" />`,
88
+ ).join('');
89
+
90
+ /**
91
+ * Animated "waves inside a circle" loading indicator — coloured sine waves that
92
+ * slosh like glowing liquid inside a circular window, ringed by a rotating
93
+ * gradient halo (the same effect as `<ai-halo-overlay>`).
94
+ *
95
+ * Visual sibling of the dots loading indicator; the two are interchangeable
96
+ * styles of the same "assistant is working" state.
97
+ *
98
+ * @example
99
+ * ```html
100
+ * <ai-waves-indicator size="56"></ai-waves-indicator>
101
+ * ```
102
+ *
103
+ * @beta
104
+ */
105
+ @customElement({
106
+ name: 'ai-waves-indicator',
107
+ template: html<AiWavesIndicator>`
108
+ <div class="window" role="img" aria-label="Assistant is working">
109
+ <svg class="waves" viewBox="0 0 ${VIEWBOX} ${VIEWBOX}" preserveAspectRatio="none">
110
+ <defs>
111
+ <filter id="wave-glow" x="-20%" y="-20%" width="140%" height="140%">
112
+ <feGaussianBlur stdDeviation="1.6" result="blur" />
113
+ <feMerge>
114
+ <feMergeNode in="blur" />
115
+ <feMergeNode in="SourceGraphic" />
116
+ </feMerge>
117
+ </filter>
118
+ </defs>
119
+ <g filter="url(#wave-glow)">${wavePathsMarkup}</g>
120
+ </svg>
121
+ <ai-halo-overlay active border-size="2" glow-opacity="0.5" glow-spread="55"></ai-halo-overlay>
122
+ </div>
123
+ `,
124
+ styles: css`
125
+ :host {
126
+ display: inline-block;
127
+ width: var(--waves-size, ${WAVES_DEFAULT_SIZE_CSS});
128
+ height: var(--waves-size, ${WAVES_DEFAULT_SIZE_CSS});
129
+ }
130
+
131
+ .window {
132
+ position: relative;
133
+ width: 100%;
134
+ height: 100%;
135
+ border-radius: 50%;
136
+ overflow: hidden;
137
+ background: radial-gradient(circle at 50% 32%, #2b3140 0%, #11141c 55%, #05070c 100%);
138
+ }
139
+
140
+ .waves {
141
+ position: absolute;
142
+ inset: 0;
143
+ width: 100%;
144
+ height: 100%;
145
+ }
146
+
147
+ .wave {
148
+ fill: none;
149
+ stroke-width: 2;
150
+ stroke-linecap: round;
151
+ stroke-linejoin: round;
152
+ }
153
+ `,
154
+ })
155
+ export class AiWavesIndicator extends GenesisElement {
156
+ /** Diameter of the circular window in px. Default: 56. */
157
+ @attr({ converter: { fromView: Number, toView: String } }) size: number = WAVES_DEFAULT_SIZE;
158
+
159
+ sizeChanged() {
160
+ this.style.setProperty('--waves-size', `${this.size}px`);
161
+ }
162
+
163
+ // A rAF loop drives the wave paths for the same reason `<ai-halo-overlay>`
164
+ // hand-drives its rotation: a pure-CSS approach can't produce per-frame sine
165
+ // geometry, and SMIL/`<animate>` can't express the combined travel + slosh.
166
+
167
+ private frame = 0;
168
+ private animFrame?: number;
169
+ private wavePaths?: SVGPathElement[];
170
+
171
+ connectedCallback() {
172
+ super.connectedCallback();
173
+ // Guard against a reconnect starting a second concurrent loop.
174
+ if (this.animFrame === undefined) {
175
+ this.tick();
176
+ }
177
+ }
178
+
179
+ disconnectedCallback() {
180
+ super.disconnectedCallback();
181
+ if (this.animFrame !== undefined) {
182
+ cancelAnimationFrame(this.animFrame);
183
+ this.animFrame = undefined;
184
+ }
185
+ }
186
+
187
+ private tick() {
188
+ // Stop ticking once disconnected (belt-and-braces alongside the cancel in
189
+ // disconnectedCallback) so no frames are scheduled while detached.
190
+ if (!this.isConnected) {
191
+ this.animFrame = undefined;
192
+ return;
193
+ }
194
+ if (!this.wavePaths) {
195
+ const paths = this.shadowRoot?.querySelectorAll<SVGPathElement>('.wave');
196
+ if (paths?.length) this.wavePaths = Array.from(paths);
197
+ }
198
+ this.wavePaths?.forEach((path, i) => {
199
+ path.setAttribute('d', AiWavesIndicator.buildWavePath(WAVES[i], this.frame));
200
+ });
201
+ this.frame += 1;
202
+ this.animFrame = requestAnimationFrame(() => this.tick());
203
+ }
204
+
205
+ /** Trace one wave's polyline `d` attribute for the given frame. */
206
+ private static buildWavePath(cfg: WaveConfig, frame: number): string {
207
+ const segments: string[] = [];
208
+ for (let x = 0; x <= VIEWBOX; x += SAMPLE_STEP) {
209
+ const y =
210
+ CENTRE +
211
+ cfg.verticalOffset +
212
+ cfg.amplitude * Math.sin(x * cfg.frequency + frame * cfg.phaseSpeed) +
213
+ cfg.slosh * Math.sin(x * cfg.sloshFrequency + frame * cfg.sloshSpeed);
214
+ segments.push(`${x},${y.toFixed(2)}`);
215
+ }
216
+ return `M ${segments.join(' L ')}`;
217
+ }
218
+ }
219
+
220
+ // Ensure the halo overlay used for the ring is registered alongside this component.
221
+ avoidTreeShaking(AiHaloOverlay);
@@ -101,14 +101,22 @@ export const styles = css`
101
101
  animation: settings-slide-out 0.2s ease-in forwards;
102
102
  }
103
103
 
104
- rapid-multiselect::part(root) {
105
- min-width: 80px;
106
- width: 300%;
104
+ /* Collapsed control footprint stays compact (100px)... */
105
+ rapid-categorized-multiselect::part(root) {
106
+ min-width: 0;
107
+ width: 100px;
107
108
  }
108
109
 
109
- rapid-multiselect::part(control),
110
- .settings-panel > [part='download-button'] {
111
- width: fit-content;
110
+ /* ...while the dropdown panel, being absolutely positioned, can be wider
111
+ (200px) without affecting the root's layout footprint. The control sits on
112
+ a different side of the settings panel depending on the container width
113
+ (see the layout rules below), so the dropdown must anchor to whichever side
114
+ keeps it inside the panel. Default (narrow) layout puts the control on the
115
+ LEFT, so the dropdown grows rightward. */
116
+ rapid-categorized-multiselect::part(options) {
117
+ width: 200px;
118
+ left: 0;
119
+ right: auto;
112
120
  }
113
121
 
114
122
  .settings-panel > [part='toggle-tool-calls'] {
@@ -127,6 +135,7 @@ export const styles = css`
127
135
  }
128
136
 
129
137
  .settings-panel > [part='download-button'] {
138
+ width: fit-content;
130
139
  grid-column: 2;
131
140
  grid-row: 2;
132
141
  }
@@ -176,6 +185,13 @@ export const styles = css`
176
185
  .settings-panel > .settings-animations {
177
186
  grid-row: 2;
178
187
  }
188
+
189
+ /* Control now sits on the RIGHT (justify-self: end / margin-left: auto in
190
+ the wider layouts), so the dropdown grows leftward to stay in the panel. */
191
+ rapid-categorized-multiselect::part(options) {
192
+ left: auto;
193
+ right: 0;
194
+ }
179
195
  }
180
196
 
181
197
  @container (min-width: 750px) {
@@ -417,6 +433,13 @@ export const styles = css`
417
433
  animation: slide-in-left 0.25s ease-out;
418
434
  }
419
435
 
436
+ /* A 'bubble' interaction reads like a normal assistant message: avatar to the
437
+ left, bubble to the right — not the full-width column the other interaction
438
+ presentations use. */
439
+ .message-row.interaction.present-bubble {
440
+ flex-direction: row;
441
+ }
442
+
420
443
  .avatar {
421
444
  position: relative;
422
445
  width: 32px;
@@ -525,7 +548,10 @@ export const styles = css`
525
548
  border: 1px solid color-mix(in srgb, var(--ai-function-color, #86efac) 20%, transparent);
526
549
  }
527
550
 
528
- .message.interaction {
551
+ /* 'label' (default) and 'bare' interactions render the widget chrome-free and
552
+ full-width — the widget owns its body. */
553
+ .message-row.interaction.present-label .message,
554
+ .message-row.interaction.present-bare .message {
529
555
  background: none;
530
556
  border: none;
531
557
  padding: 0;
@@ -533,6 +559,17 @@ export const styles = css`
533
559
  width: 100%;
534
560
  }
535
561
 
562
+ /* 'bubble' interactions reuse the standard assistant-message skin so the
563
+ widget sits inside a normal chat bubble. Padding, max-width and min-width
564
+ come from the base .message rule. */
565
+ .message-row.interaction.present-bubble .message {
566
+ background: linear-gradient(135deg, var(--neutral-layer-3) 0%, var(--neutral-layer-4) 100%);
567
+ color: var(--neutral-foreground-rest);
568
+ border-radius: 4px 18px 18px;
569
+ border: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-rest);
570
+ box-shadow: var(--ai-message-shadow, 0 2px 8px rgb(0 0 0 / 15%));
571
+ }
572
+
536
573
  .sender {
537
574
  font-weight: bold;
538
575
  font-size: 0.9em;
@@ -809,6 +846,24 @@ export const styles = css`
809
846
  }
810
847
  }
811
848
 
849
+ .thinking-waves,
850
+ .thinking-flowing-waves,
851
+ .thinking-plasma {
852
+ display: flex;
853
+ flex-direction: column;
854
+ align-items: center;
855
+ align-self: center;
856
+ gap: 6px;
857
+ padding: calc(var(--design-unit) * 2px) calc(var(--design-unit) * 3px);
858
+ }
859
+
860
+ .thinking-caption {
861
+ font-size: var(--type-ramp-minus-1-font-size, 12px);
862
+ line-height: var(--type-ramp-minus-1-line-height, 16px);
863
+ color: var(--neutral-foreground-rest);
864
+ opacity: 70%;
865
+ }
866
+
812
867
  .attachment-chips {
813
868
  display: flex;
814
869
  flex-wrap: wrap;
@@ -20,11 +20,12 @@ import type {
20
20
  ChatAttachment,
21
21
  ChatMessage,
22
22
  ChatToolCall,
23
+ InteractionPresentation,
23
24
  } from '@genesislcap/foundation-ai';
24
25
  import { isChatToolCallUnknown } from '@genesislcap/foundation-ai';
25
26
  import { classNames, html, ref, repeat, when, ViewTemplate } from '@genesislcap/web-core';
26
27
  import type { FoundationAiAssistant } from './main';
27
- import { ANIMATION_DEFS } from './main.types';
28
+ import { ANIMATION_DEFS, LOADING_STYLE_ANIMATIONS } from './main.types';
28
29
 
29
30
  function unknownToolPayload(tc: ChatToolCall): string {
30
31
  if (!isChatToolCallUnknown(tc)) return '';
@@ -40,10 +41,6 @@ function unknownToolPayload(tc: ChatToolCall): string {
40
41
  return lines.join('\n');
41
42
  }
42
43
 
43
- const animationItemRenderer = (option: any): ViewTemplate => html`
44
- <span part="option-label" title="${() => option.tooltip}">${() => option.label}</span>
45
- `;
46
-
47
44
  const HALO_SPEED_DEFAULT = 1.5;
48
45
  const HALO_SPEED_ORCHESTRATING = 0.4;
49
46
  const HALO_BORDER_SIZE_DEFAULT = 3;
@@ -52,9 +49,10 @@ const HALO_BORDER_SIZE_DEFAULT = 3;
52
49
  const SESSION_COST_DECIMALS = 4;
53
50
 
54
51
  const animationOptions = Object.entries(ANIMATION_DEFS).map(([value, def]) => ({
52
+ type: def.category,
55
53
  value,
56
54
  label: def.label,
57
- tooltip: def.tooltip,
55
+ description: def.description,
58
56
  }));
59
57
 
60
58
  // Avatar markup is owned by the component (`assistantIconSafe` / `userIconSafe`),
@@ -120,6 +118,14 @@ const senderLabel: Record<string, string> = {
120
118
  ai: 'Assistant',
121
119
  };
122
120
 
121
+ /**
122
+ * Resolves how the host frames an interaction widget, defaulting to `'label'`
123
+ * (the historical "Assistant" label, no bubble). Callers guard on
124
+ * `m.interaction` first; non-interaction messages never consult this.
125
+ */
126
+ const interactionPresentation = (m: ChatMessage): InteractionPresentation =>
127
+ m.interaction?.presentation ?? 'label';
128
+
123
129
  // ─── Sub-agent trace fragments ────────────────────────────────────────────────
124
130
 
125
131
  const subAgentAssistantTemplate = html<ChatMessage>`
@@ -176,6 +182,57 @@ const liveSubAgentTraceTemplate = html<FoundationAiAssistant>`
176
182
  </div>
177
183
  `;
178
184
 
185
+ // The interchangeable loading indicators. These MUST be stable module-level
186
+ // instances: the binding that selects between them reads `enabledAnimations`,
187
+ // which is backed by the redux store proxy and re-evaluates on every change to
188
+ // the aiAssistant slice (i.e. every new message, including hidden tool-call and
189
+ // thinking-step messages). Returning a fresh `html` instance from that binding
190
+ // would make FAST tear down and rebuild the indicator DOM each time, restarting
191
+ // the CSS animations and rAF loops. Stable references let FAST reuse the
192
+ // existing view so the animation runs uninterrupted.
193
+ const thinkingDotsTemplate = html<FoundationAiAssistant>`
194
+ <div class="thinking-dots">
195
+ <div class="dot dot-1"></div>
196
+ <div class="dot dot-2"></div>
197
+ <div class="dot dot-3"></div>
198
+ <div class="dot dot-4"></div>
199
+ </div>
200
+ `;
201
+
202
+ const thinkingWavesTemplate = html<FoundationAiAssistant>`
203
+ <div class="thinking-waves" part="thinking-waves">
204
+ <ai-waves-indicator></ai-waves-indicator>
205
+ <span class="thinking-caption">Thinking...</span>
206
+ </div>
207
+ `;
208
+
209
+ const thinkingFlowingWavesTemplate = html<FoundationAiAssistant>`
210
+ <div class="thinking-flowing-waves" part="thinking-flowing-waves">
211
+ <ai-flowing-waves-indicator></ai-flowing-waves-indicator>
212
+ <span class="thinking-caption">Thinking...</span>
213
+ </div>
214
+ `;
215
+
216
+ const thinkingPlasmaTemplate = html<FoundationAiAssistant>`
217
+ <div class="thinking-plasma" part="thinking-plasma">
218
+ <ai-plasma-orb-indicator></ai-plasma-orb-indicator>
219
+ <span class="thinking-caption">Thinking...</span>
220
+ </div>
221
+ `;
222
+
223
+ /**
224
+ * Picks the loading indicator for the currently enabled style, falling back to
225
+ * the dots (also the default when no animations config is supplied). Returns a
226
+ * stable template instance per style — see the note above.
227
+ */
228
+ const selectThinkingTemplate = (x: FoundationAiAssistant): ViewTemplate<FoundationAiAssistant> => {
229
+ const enabled = x.enabledAnimations;
230
+ if (enabled.includes('waves')) return thinkingWavesTemplate;
231
+ if (enabled.includes('flowingWaves')) return thinkingFlowingWavesTemplate;
232
+ if (enabled.includes('plasma')) return thinkingPlasmaTemplate;
233
+ return thinkingDotsTemplate;
234
+ };
235
+
179
236
  // ─── Public factory ───────────────────────────────────────────────────────────
180
237
 
181
238
  /** @internal */
@@ -184,7 +241,7 @@ export const FoundationAiAssistantTemplate = (
184
241
  ): ViewTemplate<FoundationAiAssistant> => {
185
242
  const buttonTag = `${designSystemPrefix}-button`;
186
243
  const switchTag = `${designSystemPrefix}-switch`;
187
- const multiselectTag = `${designSystemPrefix}-multiselect`;
244
+ const categorizedMultiselectTag = `${designSystemPrefix}-categorized-multiselect`;
188
245
  const textareaTag = `${designSystemPrefix}-text-area`;
189
246
  const iconTag = `${designSystemPrefix}-icon`;
190
247
  const progressTag = `${designSystemPrefix}-progress`;
@@ -236,7 +293,10 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
236
293
  ${when(
237
294
  (m) => m.role !== 'system-event',
238
295
  html<ChatMessage, FoundationAiAssistant>`
239
- <div class="message-row ${(m) => messageType(m)}">
296
+ <div
297
+ class="message-row ${(m) => messageType(m)} ${(m) =>
298
+ m.interaction ? `present-${interactionPresentation(m)}` : ''}"
299
+ >
240
300
  <div class="avatar ${(m) => messageType(m)}">
241
301
  ${when(
242
302
  // Keyed on messageType (not raw role) so a synthetic-user message,
@@ -247,14 +307,21 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
247
307
  )}${when((m) => messageType(m) !== 'user', assistantAvatarTemplate)}
248
308
  </div>
249
309
  <div class="message ${(m) => messageType(m)}">
250
- <div class="sender">
251
- ${(m, c) =>
252
- messageType(m) === 'ai-function' &&
253
- m.agentName &&
254
- (c.parent as FoundationAiAssistant).showAgentSwitchIndicator
255
- ? `Tool Call · ${m.agentLabel ?? m.agentName}`
256
- : senderLabel[messageType(m)]}
257
- </div>
310
+ ${when(
311
+ // A 'bare' interaction owns its full presentation — suppress the
312
+ // host sender label. Every other message keeps it.
313
+ (m) => !(m.interaction && interactionPresentation(m) === 'bare'),
314
+ html<ChatMessage, FoundationAiAssistant>`
315
+ <div class="sender">
316
+ ${(m, c) =>
317
+ messageType(m) === 'ai-function' &&
318
+ m.agentName &&
319
+ (c.parent as FoundationAiAssistant).showAgentSwitchIndicator
320
+ ? `Tool Call · ${m.agentLabel ?? m.agentName}`
321
+ : senderLabel[messageType(m)]}
322
+ </div>
323
+ `,
324
+ )}
258
325
  <div class="content">
259
326
  ${when(
260
327
  (m) => m.content,
@@ -473,16 +540,14 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
473
540
  html<FoundationAiAssistant>`
474
541
  <div class="settings-animations">
475
542
  <span class="settings-label">Animations</span>
476
- <${multiselectTag}
543
+ <${categorizedMultiselectTag}
477
544
  part="toggle-animations"
478
545
  :selectedOptions=${(x) => x.enabledAnimations}
479
546
  :options=${() => animationOptions}
480
- :itemRenderer=${() => animationItemRenderer}
481
547
  @selectionChange=${(x, c) =>
482
548
  x.setEnabledAnimations((c.event as CustomEvent).detail)}
483
549
  search="false"
484
- all="false"
485
- ></${multiselectTag}>
550
+ ></${categorizedMultiselectTag}>
486
551
  </div>
487
552
  `,
488
553
  )}
@@ -590,7 +655,8 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
590
655
  ${when(
591
656
  (x) =>
592
657
  x.showLoadingIndicator &&
593
- (x.chatConfig.ui?.animations == null || x.enabledAnimations.includes('loading')),
658
+ (x.chatConfig.ui?.animations == null ||
659
+ LOADING_STYLE_ANIMATIONS.some((s) => x.enabledAnimations.includes(s))),
594
660
  html<FoundationAiAssistant>`
595
661
  <div class="message-row ai" part="thinking">
596
662
  <div class="avatar">
@@ -603,12 +669,7 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
603
669
  <span class="avatar-icon" :innerHTML="${() => x.assistantIconSafe}"></span>
604
670
  `}
605
671
  </div>
606
- <div class="thinking-dots">
607
- <div class="dot dot-1"></div>
608
- <div class="dot dot-2"></div>
609
- <div class="dot dot-3"></div>
610
- <div class="dot dot-4"></div>
611
- </div>
672
+ ${(x) => selectThinkingTemplate(x)}
612
673
  </div>
613
674
  `,
614
675
  )}
package/src/main/main.ts CHANGED
@@ -47,8 +47,11 @@ import { AiChatBubble } from '../components/chat-bubble/chat-bubble';
47
47
  import { ChatDriver } from '../components/chat-driver/chat-driver';
48
48
  import { AiChatInteractionWrapper } from '../components/chat-interaction-wrapper/chat-interaction-wrapper';
49
49
  import { AiChatMarkdown } from '../components/chat-markdown/chat-markdown';
50
+ import { AiFlowingWavesIndicator } from '../components/flowing-waves-indicator';
50
51
  import { AiHaloOverlay } from '../components/halo-overlay';
51
52
  import { OrchestratingDriver } from '../components/orchestrating-driver/orchestrating-driver';
53
+ import { AiPlasmaOrbIndicator } from '../components/plasma-orb-indicator';
54
+ import { AiWavesIndicator } from '../components/waves-indicator';
52
55
  import type { AgentConfig } from '../config/config';
53
56
  import {
54
57
  recordMetaEvent,
@@ -66,6 +69,7 @@ import {
66
69
  } from '../styles/ai-colours';
67
70
  import { ChatSuggestions } from '../suggestions/chat-suggestions';
68
71
  import { AnimatedPanelToggle } from '../utils/animated-panel-toggle';
72
+ import { resolveExclusiveLoadingStyle } from '../utils/animation-exclusivity';
69
73
  import { logger } from '../utils/logger';
70
74
  import { filterVisibleMessages, trailingInteractionRow } from '../utils/message-partition';
71
75
  import { sumCosts } from '../utils/sum-costs';
@@ -80,7 +84,7 @@ import type {
80
84
  SubmitMessageResult,
81
85
  SuggestionsState,
82
86
  } from './main.types';
83
- import { ALL_ANIMATIONS } from './main.types';
87
+ import { DEFAULT_ANIMATIONS } from './main.types';
84
88
 
85
89
  /** Context window sizes (in tokens) for known models. */
86
90
  /**
@@ -137,6 +141,9 @@ avoidTreeShaking(
137
141
  AiChatMarkdown,
138
142
  AiChatInteractionWrapper,
139
143
  AiHaloOverlay,
144
+ AiWavesIndicator,
145
+ AiFlowingWavesIndicator,
146
+ AiPlasmaOrbIndicator,
140
147
  AiChatBubble,
141
148
  AiActivityHalo,
142
149
  ChatSuggestions,
@@ -656,7 +663,9 @@ export class FoundationAiAssistant extends GenesisElement {
656
663
  return;
657
664
  }
658
665
  const last = this.messages[this.messages.length - 1];
659
- if (last?.interaction) {
666
+ // Hide only while a pending interaction blocks on the user; a resolved
667
+ // interaction means the assistant is computing again (keep the halo).
668
+ if (last?.interaction && !last.interaction.resolved) {
660
669
  this.showHalo = 'no';
661
670
  } else if (this.showHalo !== 'orchestrating') {
662
671
  this.showHalo = 'agent';
@@ -1047,9 +1056,10 @@ export class FoundationAiAssistant extends GenesisElement {
1047
1056
  this.showToolCalls = ui.showToolCalls === true;
1048
1057
  this.showThinkingSteps = ui.showThinkingSteps === true;
1049
1058
  this.showAgentSwitchIndicator = ui.showAgentSwitchIndicator === true;
1050
- this.enabledAnimations =
1059
+ this.enabledAnimations = resolveExclusiveLoadingStyle(
1051
1060
  (ui.animations?.enabled as AiAssistantAnimation[]) ??
1052
- (ui.animations ? [...ALL_ANIMATIONS] : []);
1061
+ (ui.animations ? [...DEFAULT_ANIMATIONS] : []),
1062
+ );
1053
1063
 
1054
1064
  const defaultAgent = this.chatConfig.picker?.defaultAgent;
1055
1065
  if (defaultAgent && (this.agents ?? []).some((a) => a.name === defaultAgent)) {
@@ -1216,9 +1226,13 @@ export class FoundationAiAssistant extends GenesisElement {
1216
1226
  // waiting for the user, not computing.
1217
1227
  if (this.busy) {
1218
1228
  const last = this.messages[this.messages.length - 1];
1219
- if (last?.interaction) {
1229
+ // Only a *pending* interaction means the assistant is blocked waiting on
1230
+ // the user. Once it's resolved — or for any normal step — the assistant is
1231
+ // computing again, so the indicator should resume (e.g. while it works out
1232
+ // the next planning question after the user answers a widget).
1233
+ if (last?.interaction && !last.interaction.resolved) {
1220
1234
  this.stopLoadingTimer();
1221
- } else if (last?.role === 'assistant') {
1235
+ } else {
1222
1236
  this.startLoadingTimer();
1223
1237
  }
1224
1238
  }
@@ -1302,7 +1316,7 @@ export class FoundationAiAssistant extends GenesisElement {
1302
1316
  });
1303
1317
  }
1304
1318
 
1305
- private static readonly DEFAULT_LOADING_DELAY_S = 5;
1319
+ private static readonly DEFAULT_LOADING_DELAY_S = 0;
1306
1320
  private static readonly DEFAULT_SUGGESTION_COUNT = 3;
1307
1321
  private static readonly MS_PER_SECOND = 1000;
1308
1322
 
@@ -1506,7 +1520,9 @@ export class FoundationAiAssistant extends GenesisElement {
1506
1520
  }
1507
1521
 
1508
1522
  setEnabledAnimations(animations: AiAssistantAnimation[]) {
1509
- this.enabledAnimations = animations;
1523
+ // The dots and waves loading styles are mutually exclusive — enabling one
1524
+ // disables the other (see resolveExclusiveLoadingStyle).
1525
+ this.enabledAnimations = resolveExclusiveLoadingStyle(animations, this.enabledAnimations);
1510
1526
  }
1511
1527
 
1512
1528
  getDebugLog() {