@genesislcap/ai-assistant 14.458.3 → 14.459.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 (37) hide show
  1. package/dist/ai-assistant.api.json +37 -10
  2. package/dist/ai-assistant.d.ts +46 -4
  3. package/dist/dts/components/flowing-waves-indicator.d.ts +32 -0
  4. package/dist/dts/components/flowing-waves-indicator.d.ts.map +1 -0
  5. package/dist/dts/components/plasma-orb-indicator.d.ts +22 -0
  6. package/dist/dts/components/plasma-orb-indicator.d.ts.map +1 -0
  7. package/dist/dts/components/waves-indicator.d.ts +30 -0
  8. package/dist/dts/components/waves-indicator.d.ts.map +1 -0
  9. package/dist/dts/main/main.d.ts.map +1 -1
  10. package/dist/dts/main/main.styles.d.ts.map +1 -1
  11. package/dist/dts/main/main.template.d.ts.map +1 -1
  12. package/dist/dts/main/main.types.d.ts +44 -4
  13. package/dist/dts/main/main.types.d.ts.map +1 -1
  14. package/dist/dts/utils/animation-exclusivity.d.ts +23 -0
  15. package/dist/dts/utils/animation-exclusivity.d.ts.map +1 -0
  16. package/dist/dts/utils/animation-exclusivity.test.d.ts +2 -0
  17. package/dist/dts/utils/animation-exclusivity.test.d.ts.map +1 -0
  18. package/dist/esm/components/flowing-waves-indicator.js +222 -0
  19. package/dist/esm/components/plasma-orb-indicator.js +280 -0
  20. package/dist/esm/components/waves-indicator.js +189 -0
  21. package/dist/esm/main/main.js +20 -9
  22. package/dist/esm/main/main.styles.js +40 -6
  23. package/dist/esm/main/main.template.js +58 -17
  24. package/dist/esm/main/main.types.js +46 -3
  25. package/dist/esm/utils/animation-exclusivity.js +33 -0
  26. package/dist/esm/utils/animation-exclusivity.test.js +52 -0
  27. package/dist/tsconfig.tsbuildinfo +1 -1
  28. package/package.json +16 -16
  29. package/src/components/flowing-waves-indicator.ts +260 -0
  30. package/src/components/plasma-orb-indicator.ts +281 -0
  31. package/src/components/waves-indicator.ts +221 -0
  32. package/src/main/main.styles.ts +40 -6
  33. package/src/main/main.template.ts +60 -18
  34. package/src/main/main.ts +24 -8
  35. package/src/main/main.types.ts +56 -5
  36. package/src/utils/animation-exclusivity.test.ts +72 -0
  37. 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) {
@@ -809,6 +825,24 @@ export const styles = css`
809
825
  }
810
826
  }
811
827
 
828
+ .thinking-waves,
829
+ .thinking-flowing-waves,
830
+ .thinking-plasma {
831
+ display: flex;
832
+ flex-direction: column;
833
+ align-items: center;
834
+ align-self: center;
835
+ gap: 6px;
836
+ padding: calc(var(--design-unit) * 2px) calc(var(--design-unit) * 3px);
837
+ }
838
+
839
+ .thinking-caption {
840
+ font-size: var(--type-ramp-minus-1-font-size, 12px);
841
+ line-height: var(--type-ramp-minus-1-line-height, 16px);
842
+ color: var(--neutral-foreground-rest);
843
+ opacity: 70%;
844
+ }
845
+
812
846
  .attachment-chips {
813
847
  display: flex;
814
848
  flex-wrap: wrap;
@@ -24,7 +24,7 @@ import type {
24
24
  import { isChatToolCallUnknown } from '@genesislcap/foundation-ai';
25
25
  import { classNames, html, ref, repeat, when, ViewTemplate } from '@genesislcap/web-core';
26
26
  import type { FoundationAiAssistant } from './main';
27
- import { ANIMATION_DEFS } from './main.types';
27
+ import { ANIMATION_DEFS, LOADING_STYLE_ANIMATIONS } from './main.types';
28
28
 
29
29
  function unknownToolPayload(tc: ChatToolCall): string {
30
30
  if (!isChatToolCallUnknown(tc)) return '';
@@ -40,10 +40,6 @@ function unknownToolPayload(tc: ChatToolCall): string {
40
40
  return lines.join('\n');
41
41
  }
42
42
 
43
- const animationItemRenderer = (option: any): ViewTemplate => html`
44
- <span part="option-label" title="${() => option.tooltip}">${() => option.label}</span>
45
- `;
46
-
47
43
  const HALO_SPEED_DEFAULT = 1.5;
48
44
  const HALO_SPEED_ORCHESTRATING = 0.4;
49
45
  const HALO_BORDER_SIZE_DEFAULT = 3;
@@ -52,9 +48,10 @@ const HALO_BORDER_SIZE_DEFAULT = 3;
52
48
  const SESSION_COST_DECIMALS = 4;
53
49
 
54
50
  const animationOptions = Object.entries(ANIMATION_DEFS).map(([value, def]) => ({
51
+ type: def.category,
55
52
  value,
56
53
  label: def.label,
57
- tooltip: def.tooltip,
54
+ description: def.description,
58
55
  }));
59
56
 
60
57
  // Avatar markup is owned by the component (`assistantIconSafe` / `userIconSafe`),
@@ -176,6 +173,57 @@ const liveSubAgentTraceTemplate = html<FoundationAiAssistant>`
176
173
  </div>
177
174
  `;
178
175
 
176
+ // The interchangeable loading indicators. These MUST be stable module-level
177
+ // instances: the binding that selects between them reads `enabledAnimations`,
178
+ // which is backed by the redux store proxy and re-evaluates on every change to
179
+ // the aiAssistant slice (i.e. every new message, including hidden tool-call and
180
+ // thinking-step messages). Returning a fresh `html` instance from that binding
181
+ // would make FAST tear down and rebuild the indicator DOM each time, restarting
182
+ // the CSS animations and rAF loops. Stable references let FAST reuse the
183
+ // existing view so the animation runs uninterrupted.
184
+ const thinkingDotsTemplate = html<FoundationAiAssistant>`
185
+ <div class="thinking-dots">
186
+ <div class="dot dot-1"></div>
187
+ <div class="dot dot-2"></div>
188
+ <div class="dot dot-3"></div>
189
+ <div class="dot dot-4"></div>
190
+ </div>
191
+ `;
192
+
193
+ const thinkingWavesTemplate = html<FoundationAiAssistant>`
194
+ <div class="thinking-waves" part="thinking-waves">
195
+ <ai-waves-indicator></ai-waves-indicator>
196
+ <span class="thinking-caption">Thinking...</span>
197
+ </div>
198
+ `;
199
+
200
+ const thinkingFlowingWavesTemplate = html<FoundationAiAssistant>`
201
+ <div class="thinking-flowing-waves" part="thinking-flowing-waves">
202
+ <ai-flowing-waves-indicator></ai-flowing-waves-indicator>
203
+ <span class="thinking-caption">Thinking...</span>
204
+ </div>
205
+ `;
206
+
207
+ const thinkingPlasmaTemplate = html<FoundationAiAssistant>`
208
+ <div class="thinking-plasma" part="thinking-plasma">
209
+ <ai-plasma-orb-indicator></ai-plasma-orb-indicator>
210
+ <span class="thinking-caption">Thinking...</span>
211
+ </div>
212
+ `;
213
+
214
+ /**
215
+ * Picks the loading indicator for the currently enabled style, falling back to
216
+ * the dots (also the default when no animations config is supplied). Returns a
217
+ * stable template instance per style — see the note above.
218
+ */
219
+ const selectThinkingTemplate = (x: FoundationAiAssistant): ViewTemplate<FoundationAiAssistant> => {
220
+ const enabled = x.enabledAnimations;
221
+ if (enabled.includes('waves')) return thinkingWavesTemplate;
222
+ if (enabled.includes('flowingWaves')) return thinkingFlowingWavesTemplate;
223
+ if (enabled.includes('plasma')) return thinkingPlasmaTemplate;
224
+ return thinkingDotsTemplate;
225
+ };
226
+
179
227
  // ─── Public factory ───────────────────────────────────────────────────────────
180
228
 
181
229
  /** @internal */
@@ -184,7 +232,7 @@ export const FoundationAiAssistantTemplate = (
184
232
  ): ViewTemplate<FoundationAiAssistant> => {
185
233
  const buttonTag = `${designSystemPrefix}-button`;
186
234
  const switchTag = `${designSystemPrefix}-switch`;
187
- const multiselectTag = `${designSystemPrefix}-multiselect`;
235
+ const categorizedMultiselectTag = `${designSystemPrefix}-categorized-multiselect`;
188
236
  const textareaTag = `${designSystemPrefix}-text-area`;
189
237
  const iconTag = `${designSystemPrefix}-icon`;
190
238
  const progressTag = `${designSystemPrefix}-progress`;
@@ -473,16 +521,14 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
473
521
  html<FoundationAiAssistant>`
474
522
  <div class="settings-animations">
475
523
  <span class="settings-label">Animations</span>
476
- <${multiselectTag}
524
+ <${categorizedMultiselectTag}
477
525
  part="toggle-animations"
478
526
  :selectedOptions=${(x) => x.enabledAnimations}
479
527
  :options=${() => animationOptions}
480
- :itemRenderer=${() => animationItemRenderer}
481
528
  @selectionChange=${(x, c) =>
482
529
  x.setEnabledAnimations((c.event as CustomEvent).detail)}
483
530
  search="false"
484
- all="false"
485
- ></${multiselectTag}>
531
+ ></${categorizedMultiselectTag}>
486
532
  </div>
487
533
  `,
488
534
  )}
@@ -590,7 +636,8 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
590
636
  ${when(
591
637
  (x) =>
592
638
  x.showLoadingIndicator &&
593
- (x.chatConfig.ui?.animations == null || x.enabledAnimations.includes('loading')),
639
+ (x.chatConfig.ui?.animations == null ||
640
+ LOADING_STYLE_ANIMATIONS.some((s) => x.enabledAnimations.includes(s))),
594
641
  html<FoundationAiAssistant>`
595
642
  <div class="message-row ai" part="thinking">
596
643
  <div class="avatar">
@@ -603,12 +650,7 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
603
650
  <span class="avatar-icon" :innerHTML="${() => x.assistantIconSafe}"></span>
604
651
  `}
605
652
  </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>
653
+ ${(x) => selectThinkingTemplate(x)}
612
654
  </div>
613
655
  `,
614
656
  )}
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() {
@@ -53,24 +53,53 @@ export type SuggestionsState =
53
53
  export interface AiAssistantAnimationDef {
54
54
  /** Display label shown in the settings multiselect. */
55
55
  label: string;
56
- /** Short description shown as a tooltip on the option. */
57
- tooltip: string;
56
+ /** Short description shown beneath the label in the categorized multiselect. */
57
+ description: string;
58
+ /** Group heading the option is listed under in the settings multiselect. */
59
+ category: string;
58
60
  }
59
61
 
60
62
  /**
61
63
  * Registry of all available animations with their display metadata.
62
64
  * Adding an entry here automatically extends the {@link AiAssistantAnimation} type.
63
65
  *
66
+ * @remarks
67
+ * `loading` (dots), `waves`, `flowingWaves` and `plasma` are interchangeable
68
+ * styles of the same "is the assistant working" indicator and are therefore
69
+ * grouped under the same {@link AiAssistantAnimationDef.category}. They are
70
+ * mutually exclusive — see `LOADING_STYLE_ANIMATIONS`.
71
+ *
64
72
  * @beta
65
73
  */
66
74
  export const ANIMATION_DEFS = {
67
75
  loading: {
68
- label: 'Loading indicator',
69
- tooltip: 'Shows a pulsing animation while the assistant is generating a response.',
76
+ label: 'Dots',
77
+ description: 'Shows pulsing dots while the assistant is generating a response.',
78
+ category: 'Loading style',
79
+ },
80
+ waves: {
81
+ label: 'Waves',
82
+ description:
83
+ 'Shows glowing sine waves inside a circle while the assistant is generating a response.',
84
+ category: 'Loading style',
85
+ },
86
+ flowingWaves: {
87
+ label: 'Flowing waves',
88
+ description:
89
+ 'Shows coloured waves rising and settling from a line while the assistant is generating a response.',
90
+ category: 'Loading style',
91
+ },
92
+ plasma: {
93
+ label: 'Plasma orb',
94
+ description:
95
+ 'Shows a glowing plasma sphere with drifting energy while the assistant is generating a response.',
96
+ category: 'Loading style',
70
97
  },
71
98
  halo: {
72
99
  label: 'Halo',
73
- tooltip: 'Displays a glowing halo around the assistant avatar while a response is streaming.',
100
+ description:
101
+ 'Displays a glowing halo around the assistant avatar while a response is streaming.',
102
+ category: 'Effects',
74
103
  },
75
104
  } satisfies Record<string, AiAssistantAnimationDef>;
76
105
 
@@ -87,3 +116,25 @@ export type AiAssistantAnimation = keyof typeof ANIMATION_DEFS;
87
116
  * @internal
88
117
  */
89
118
  export const ALL_ANIMATIONS = Object.keys(ANIMATION_DEFS) as AiAssistantAnimation[];
119
+
120
+ /**
121
+ * The interchangeable "assistant is working" loading-indicator styles. At most
122
+ * one of these may be enabled at a time — enabling one disables the other.
123
+ *
124
+ * @internal
125
+ */
126
+ export const LOADING_STYLE_ANIMATIONS = [
127
+ 'loading',
128
+ 'waves',
129
+ 'flowingWaves',
130
+ 'plasma',
131
+ ] as const satisfies readonly AiAssistantAnimation[];
132
+
133
+ /**
134
+ * Animations enabled by default when a consumer opts into the animations
135
+ * feature without specifying an explicit `enabled` list. Keeps the dots loading
136
+ * style (the long-standing default); the waves style is opt-in.
137
+ *
138
+ * @internal
139
+ */
140
+ export const DEFAULT_ANIMATIONS: AiAssistantAnimation[] = ['loading', 'halo'];