@genesislcap/ai-assistant 14.458.0 → 14.458.1-GENC-0.2

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.
@@ -95,14 +95,22 @@ export const styles = css `
95
95
  animation: settings-slide-out 0.2s ease-in forwards;
96
96
  }
97
97
 
98
- rapid-multiselect::part(root) {
99
- min-width: 80px;
100
- width: 300%;
98
+ /* Collapsed control footprint stays compact (100px)... */
99
+ rapid-categorized-multiselect::part(root) {
100
+ min-width: 0;
101
+ width: 100px;
101
102
  }
102
103
 
103
- rapid-multiselect::part(control),
104
- .settings-panel > [part='download-button'] {
105
- width: fit-content;
104
+ /* ...while the dropdown panel, being absolutely positioned, can be wider
105
+ (200px) without affecting the root's layout footprint. The control sits on
106
+ a different side of the settings panel depending on the container width
107
+ (see the layout rules below), so the dropdown must anchor to whichever side
108
+ keeps it inside the panel. Default (narrow) layout puts the control on the
109
+ LEFT, so the dropdown grows rightward. */
110
+ rapid-categorized-multiselect::part(options) {
111
+ width: 200px;
112
+ left: 0;
113
+ right: auto;
106
114
  }
107
115
 
108
116
  .settings-panel > [part='toggle-tool-calls'] {
@@ -121,6 +129,7 @@ export const styles = css `
121
129
  }
122
130
 
123
131
  .settings-panel > [part='download-button'] {
132
+ width: fit-content;
124
133
  grid-column: 2;
125
134
  grid-row: 2;
126
135
  }
@@ -170,6 +179,13 @@ export const styles = css `
170
179
  .settings-panel > .settings-animations {
171
180
  grid-row: 2;
172
181
  }
182
+
183
+ /* Control now sits on the RIGHT (justify-self: end / margin-left: auto in
184
+ the wider layouts), so the dropdown grows leftward to stay in the panel. */
185
+ rapid-categorized-multiselect::part(options) {
186
+ left: auto;
187
+ right: 0;
188
+ }
173
189
  }
174
190
 
175
191
  @container (min-width: 750px) {
@@ -803,6 +819,22 @@ export const styles = css `
803
819
  }
804
820
  }
805
821
 
822
+ .thinking-waves {
823
+ display: flex;
824
+ flex-direction: column;
825
+ align-items: center;
826
+ align-self: center;
827
+ gap: 6px;
828
+ padding: calc(var(--design-unit) * 2px) calc(var(--design-unit) * 3px);
829
+ }
830
+
831
+ .thinking-caption {
832
+ font-size: var(--type-ramp-minus-1-font-size, 12px);
833
+ line-height: var(--type-ramp-minus-1-line-height, 16px);
834
+ color: var(--neutral-foreground-rest);
835
+ opacity: 70%;
836
+ }
837
+
806
838
  .attachment-chips {
807
839
  display: flex;
808
840
  flex-wrap: wrap;
@@ -31,18 +31,16 @@ function unknownToolPayload(tc) {
31
31
  }
32
32
  return lines.join('\n');
33
33
  }
34
- const animationItemRenderer = (option) => html `
35
- <span part="option-label" title="${() => option.tooltip}">${() => option.label}</span>
36
- `;
37
34
  const HALO_SPEED_DEFAULT = 1.5;
38
35
  const HALO_SPEED_ORCHESTRATING = 0.4;
39
36
  const HALO_BORDER_SIZE_DEFAULT = 3;
40
37
  /** Decimal places shown for the running session cost (USD). 4 ≈ $0.0001 resolution. */
41
38
  const SESSION_COST_DECIMALS = 4;
42
39
  const animationOptions = Object.entries(ANIMATION_DEFS).map(([value, def]) => ({
40
+ type: def.category,
43
41
  value,
44
42
  label: def.label,
45
- tooltip: def.tooltip,
43
+ description: def.description,
46
44
  }));
47
45
  // Avatar markup is owned by the component (`assistantIconSafe` / `userIconSafe`),
48
46
  // which holds the sanitized SVG string for the default or any consumer override
@@ -141,7 +139,7 @@ const liveSubAgentTraceTemplate = html `
141
139
  export const FoundationAiAssistantTemplate = (designSystemPrefix) => {
142
140
  const buttonTag = `${designSystemPrefix}-button`;
143
141
  const switchTag = `${designSystemPrefix}-switch`;
144
- const multiselectTag = `${designSystemPrefix}-multiselect`;
142
+ const categorizedMultiselectTag = `${designSystemPrefix}-categorized-multiselect`;
145
143
  const textareaTag = `${designSystemPrefix}-text-area`;
146
144
  const iconTag = `${designSystemPrefix}-icon`;
147
145
  const progressTag = `${designSystemPrefix}-progress`;
@@ -356,15 +354,13 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
356
354
  ${when((x) => { var _a, _b; return ((_b = (_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.animations) === null || _b === void 0 ? void 0 : _b.userConfigurable) === true; }, html `
357
355
  <div class="settings-animations">
358
356
  <span class="settings-label">Animations</span>
359
- <${multiselectTag}
357
+ <${categorizedMultiselectTag}
360
358
  part="toggle-animations"
361
359
  :selectedOptions=${(x) => x.enabledAnimations}
362
360
  :options=${() => animationOptions}
363
- :itemRenderer=${() => animationItemRenderer}
364
361
  @selectionChange=${(x, c) => x.setEnabledAnimations(c.event.detail)}
365
362
  search="false"
366
- all="false"
367
- ></${multiselectTag}>
363
+ ></${categorizedMultiselectTag}>
368
364
  </div>
369
365
  `)}
370
366
  </div>
@@ -453,7 +449,9 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
453
449
  ${when((x) => {
454
450
  var _a;
455
451
  return x.showLoadingIndicator &&
456
- (((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.animations) == null || x.enabledAnimations.includes('loading'));
452
+ (((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.animations) == null ||
453
+ x.enabledAnimations.includes('loading') ||
454
+ x.enabledAnimations.includes('waves'));
457
455
  }, html `
458
456
  <div class="message-row ai" part="thinking">
459
457
  <div class="avatar">
@@ -465,12 +463,21 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
465
463
  <span class="avatar-icon" :innerHTML="${() => x.assistantIconSafe}"></span>
466
464
  `}
467
465
  </div>
468
- <div class="thinking-dots">
469
- <div class="dot dot-1"></div>
470
- <div class="dot dot-2"></div>
471
- <div class="dot dot-3"></div>
472
- <div class="dot dot-4"></div>
473
- </div>
466
+ ${(x) => x.enabledAnimations.includes('waves')
467
+ ? html `
468
+ <div class="thinking-waves" part="thinking-waves">
469
+ <ai-waves-indicator></ai-waves-indicator>
470
+ <span class="thinking-caption">Thinking...</span>
471
+ </div>
472
+ `
473
+ : html `
474
+ <div class="thinking-dots">
475
+ <div class="dot dot-1"></div>
476
+ <div class="dot dot-2"></div>
477
+ <div class="dot dot-3"></div>
478
+ <div class="dot dot-4"></div>
479
+ </div>
480
+ `}
474
481
  </div>
475
482
  `)}
476
483
  </div>
@@ -2,16 +2,29 @@
2
2
  * Registry of all available animations with their display metadata.
3
3
  * Adding an entry here automatically extends the {@link AiAssistantAnimation} type.
4
4
  *
5
+ * @remarks
6
+ * `loading` (dots) and `waves` are two interchangeable styles of the same
7
+ * "is the assistant working" indicator and are therefore grouped under the same
8
+ * {@link AiAssistantAnimationDef.category}. They are mutually exclusive — see
9
+ * `LOADING_STYLE_ANIMATIONS`.
10
+ *
5
11
  * @beta
6
12
  */
7
13
  export const ANIMATION_DEFS = {
8
14
  loading: {
9
- label: 'Loading indicator',
10
- tooltip: 'Shows a pulsing animation while the assistant is generating a response.',
15
+ label: 'Dots',
16
+ description: 'Shows pulsing dots while the assistant is generating a response.',
17
+ category: 'Loading style',
18
+ },
19
+ waves: {
20
+ label: 'Waves',
21
+ description: 'Shows glowing sine waves inside a circle while the assistant is generating a response.',
22
+ category: 'Loading style',
11
23
  },
12
24
  halo: {
13
25
  label: 'Halo',
14
- tooltip: 'Displays a glowing halo around the assistant avatar while a response is streaming.',
26
+ description: 'Displays a glowing halo around the assistant avatar while a response is streaming.',
27
+ category: 'Effects',
15
28
  },
16
29
  };
17
30
  /**
@@ -20,3 +33,21 @@ export const ANIMATION_DEFS = {
20
33
  * @internal
21
34
  */
22
35
  export const ALL_ANIMATIONS = Object.keys(ANIMATION_DEFS);
36
+ /**
37
+ * The interchangeable "assistant is working" loading-indicator styles. At most
38
+ * one of these may be enabled at a time — enabling one disables the other.
39
+ *
40
+ * @internal
41
+ */
42
+ export const LOADING_STYLE_ANIMATIONS = [
43
+ 'loading',
44
+ 'waves',
45
+ ];
46
+ /**
47
+ * Animations enabled by default when a consumer opts into the animations
48
+ * feature without specifying an explicit `enabled` list. Keeps the dots loading
49
+ * style (the long-standing default); the waves style is opt-in.
50
+ *
51
+ * @internal
52
+ */
53
+ export const DEFAULT_ANIMATIONS = ['loading', 'halo'];
@@ -0,0 +1,33 @@
1
+ import { LOADING_STYLE_ANIMATIONS } from '../main/main.types';
2
+ /**
3
+ * Enforces that at most one loading-indicator style (dots vs. waves) is enabled
4
+ * at a time. The two are alternative presentations of the same "assistant is
5
+ * working" state, so selecting one must deselect the other.
6
+ *
7
+ * The settings control is a multiselect (checkboxes), which permits selecting
8
+ * both; this resolver makes the loading-style group behave like a radio group
9
+ * by dropping the previously-selected style whenever a new one is added.
10
+ *
11
+ * @param next - The freshly-selected animation list (e.g. emitted by the
12
+ * multiselect, or read from consumer config).
13
+ * @param previous - The animation list in effect before this change. Used to
14
+ * work out which loading style was just added so the other can be dropped.
15
+ * Pass `[]` when resolving an initial/config value with no prior state.
16
+ * @returns `next` with at most one loading style retained. When both are
17
+ * present and neither is newly added (e.g. a misconfigured `enabled` list),
18
+ * the first entry of `LOADING_STYLE_ANIMATIONS` (dots) wins.
19
+ *
20
+ * @internal
21
+ */
22
+ export function resolveExclusiveLoadingStyle(next, previous = []) {
23
+ var _a;
24
+ const selectedStyles = LOADING_STYLE_ANIMATIONS.filter((style) => next.includes(style));
25
+ if (selectedStyles.length <= 1) {
26
+ return next;
27
+ }
28
+ // Both styles selected — keep whichever was just added (absent from
29
+ // `previous`); fall back to the first declared style on ambiguity.
30
+ const justAdded = (_a = selectedStyles.find((style) => !previous.includes(style))) !== null && _a !== void 0 ? _a : selectedStyles[0];
31
+ return next.filter((animation) => animation === justAdded ||
32
+ !LOADING_STYLE_ANIMATIONS.includes(animation));
33
+ }
@@ -0,0 +1,41 @@
1
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
+ import { resolveExclusiveLoadingStyle } from './animation-exclusivity';
3
+ const suite = createLogicSuite('resolveExclusiveLoadingStyle');
4
+ suite('leaves a selection with only the dots loading style untouched', () => {
5
+ const next = ['loading', 'halo'];
6
+ assert.equal(resolveExclusiveLoadingStyle(next, ['halo']), ['loading', 'halo']);
7
+ });
8
+ suite('leaves a selection with only the waves loading style untouched', () => {
9
+ const next = ['waves', 'halo'];
10
+ assert.equal(resolveExclusiveLoadingStyle(next, ['halo']), ['waves', 'halo']);
11
+ });
12
+ suite('leaves a selection with no loading style untouched', () => {
13
+ assert.equal(resolveExclusiveLoadingStyle(['halo'], []), ['halo']);
14
+ });
15
+ suite('drops dots when waves was just added on top of dots', () => {
16
+ // Previously dots was on; user ticks waves → waves wins, dots removed.
17
+ const result = resolveExclusiveLoadingStyle(['loading', 'waves', 'halo'], ['loading', 'halo']);
18
+ assert.equal(result, ['waves', 'halo']);
19
+ });
20
+ suite('drops waves when dots was just added on top of waves', () => {
21
+ // Previously waves was on; user ticks dots → dots wins, waves removed.
22
+ const result = resolveExclusiveLoadingStyle(['loading', 'waves', 'halo'], ['waves', 'halo']);
23
+ assert.equal(result, ['loading', 'halo']);
24
+ });
25
+ suite('preserves the order of the surviving selection', () => {
26
+ const result = resolveExclusiveLoadingStyle(['halo', 'loading', 'waves'], ['halo', 'loading']);
27
+ // waves was just added → loading dropped; halo and waves keep their order.
28
+ assert.equal(result, ['halo', 'waves']);
29
+ });
30
+ suite('falls back to dots when both are present with no prior state (e.g. bad config)', () => {
31
+ const result = resolveExclusiveLoadingStyle(['loading', 'waves', 'halo'], []);
32
+ assert.equal(result, ['loading', 'halo']);
33
+ });
34
+ suite('falls back to dots when both were already present (neither newly added)', () => {
35
+ const result = resolveExclusiveLoadingStyle(['loading', 'waves'], ['loading', 'waves', 'halo']);
36
+ assert.equal(result, ['loading']);
37
+ });
38
+ suite('handles an empty selection', () => {
39
+ assert.equal(resolveExclusiveLoadingStyle([], ['loading']), []);
40
+ });
41
+ suite.run();
@@ -1 +1 @@
1
- {"root":["../src/index.ts","../src/channel/ai-activity-bus.ts","../src/channel/ai-activity-channel.ts","../src/components/halo-overlay.ts","../src/components/activity-halo/activity-halo.ts","../src/components/agent-picker/agent-picker.constants.ts","../src/components/agent-picker/agent-picker.styles.ts","../src/components/agent-picker/agent-picker.template.ts","../src/components/agent-picker/agent-picker.ts","../src/components/agent-picker/index.ts","../src/components/ai-driver/ai-driver.ts","../src/components/ai-driver/index.ts","../src/components/chat-bubble/chat-bubble.styles.ts","../src/components/chat-bubble/chat-bubble.template.ts","../src/components/chat-bubble/chat-bubble.ts","../src/components/chat-bubble/index.ts","../src/components/chat-driver/align-event-globals.ts","../src/components/chat-driver/chat-driver.test.ts","../src/components/chat-driver/chat-driver.ts","../src/components/chat-driver/index.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.styles.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.template.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.test.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts","../src/components/chat-interaction-wrapper/index.ts","../src/components/chat-markdown/chat-markdown.ts","../src/components/chat-markdown/index.ts","../src/components/orchestrating-driver/index.ts","../src/components/orchestrating-driver/orchestrating-driver.ts","../src/components/popout-manager/index.ts","../src/components/popout-manager/popout-manager.ts","../src/config/config.ts","../src/config/define-stateful-agent.ts","../src/config/fallback-agents.ts","../src/config/index.ts","../src/config/validate-providers.test.ts","../src/config/validate-providers.ts","../src/main/index.ts","../src/main/main.styles.ts","../src/main/main.template.ts","../src/main/main.ts","../src/main/main.types.ts","../src/state/ai-assistant-slice.ts","../src/state/debug-event-log.ts","../src/state/driver-registry.ts","../src/state/session-store.ts","../src/styles/ai-colours.ts","../src/styles/index.ts","../src/styles/styles.ts","../src/suggestions/chat-suggestions.ts","../src/tags/index.ts","../src/types/ai-chat-widget.ts","../src/utils/animated-panel-toggle.ts","../src/utils/history-transform.ts","../src/utils/index.ts","../src/utils/logger.ts","../src/utils/message-partition.test.ts","../src/utils/message-partition.ts","../src/utils/sum-costs.test.ts","../src/utils/sum-costs.ts","../src/utils/tool-fold.ts"],"version":"5.9.2"}
1
+ {"root":["../src/index.ts","../src/channel/ai-activity-bus.ts","../src/channel/ai-activity-channel.ts","../src/components/halo-overlay.ts","../src/components/waves-indicator.ts","../src/components/activity-halo/activity-halo.ts","../src/components/agent-picker/agent-picker.constants.ts","../src/components/agent-picker/agent-picker.styles.ts","../src/components/agent-picker/agent-picker.template.ts","../src/components/agent-picker/agent-picker.ts","../src/components/agent-picker/index.ts","../src/components/ai-driver/ai-driver.ts","../src/components/ai-driver/index.ts","../src/components/chat-bubble/chat-bubble.styles.ts","../src/components/chat-bubble/chat-bubble.template.ts","../src/components/chat-bubble/chat-bubble.ts","../src/components/chat-bubble/index.ts","../src/components/chat-driver/align-event-globals.ts","../src/components/chat-driver/chat-driver.test.ts","../src/components/chat-driver/chat-driver.ts","../src/components/chat-driver/index.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.styles.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.template.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.test.ts","../src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts","../src/components/chat-interaction-wrapper/index.ts","../src/components/chat-markdown/chat-markdown.ts","../src/components/chat-markdown/index.ts","../src/components/orchestrating-driver/index.ts","../src/components/orchestrating-driver/orchestrating-driver.ts","../src/components/popout-manager/index.ts","../src/components/popout-manager/popout-manager.ts","../src/config/config.ts","../src/config/define-stateful-agent.ts","../src/config/fallback-agents.ts","../src/config/index.ts","../src/config/validate-providers.test.ts","../src/config/validate-providers.ts","../src/main/index.ts","../src/main/main.styles.ts","../src/main/main.template.ts","../src/main/main.ts","../src/main/main.types.ts","../src/state/ai-assistant-slice.ts","../src/state/debug-event-log.ts","../src/state/driver-registry.ts","../src/state/session-store.ts","../src/styles/ai-colours.ts","../src/styles/index.ts","../src/styles/styles.ts","../src/suggestions/chat-suggestions.ts","../src/tags/index.ts","../src/types/ai-chat-widget.ts","../src/utils/animated-panel-toggle.ts","../src/utils/animation-exclusivity.test.ts","../src/utils/animation-exclusivity.ts","../src/utils/history-transform.ts","../src/utils/index.ts","../src/utils/logger.ts","../src/utils/message-partition.test.ts","../src/utils/message-partition.ts","../src/utils/sum-costs.test.ts","../src/utils/sum-costs.ts","../src/utils/tool-fold.ts"],"version":"5.9.2"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@genesislcap/ai-assistant",
3
3
  "description": "Genesis AI Assistant micro-frontend",
4
- "version": "14.458.0",
4
+ "version": "14.458.1-GENC-0.2",
5
5
  "license": "SEE LICENSE IN license.txt",
6
6
  "main": "dist/esm/index.js",
7
7
  "types": "dist/ai-assistant.d.ts",
@@ -64,24 +64,24 @@
64
64
  }
65
65
  },
66
66
  "devDependencies": {
67
- "@genesislcap/foundation-testing": "14.458.0",
68
- "@genesislcap/genx": "14.458.0",
69
- "@genesislcap/rollup-builder": "14.458.0",
70
- "@genesislcap/ts-builder": "14.458.0",
71
- "@genesislcap/uvu-playwright-builder": "14.458.0",
72
- "@genesislcap/vite-builder": "14.458.0",
73
- "@genesislcap/webpack-builder": "14.458.0",
67
+ "@genesislcap/foundation-testing": "14.458.1-GENC-0.2",
68
+ "@genesislcap/genx": "14.458.1-GENC-0.2",
69
+ "@genesislcap/rollup-builder": "14.458.1-GENC-0.2",
70
+ "@genesislcap/ts-builder": "14.458.1-GENC-0.2",
71
+ "@genesislcap/uvu-playwright-builder": "14.458.1-GENC-0.2",
72
+ "@genesislcap/vite-builder": "14.458.1-GENC-0.2",
73
+ "@genesislcap/webpack-builder": "14.458.1-GENC-0.2",
74
74
  "@types/dompurify": "^3.0.5",
75
75
  "@types/marked": "^5.0.2"
76
76
  },
77
77
  "dependencies": {
78
- "@genesislcap/foundation-ai": "14.458.0",
79
- "@genesislcap/foundation-logger": "14.458.0",
80
- "@genesislcap/foundation-redux": "14.458.0",
81
- "@genesislcap/foundation-ui": "14.458.0",
82
- "@genesislcap/foundation-utils": "14.458.0",
83
- "@genesislcap/rapid-design-system": "14.458.0",
84
- "@genesislcap/web-core": "14.458.0",
78
+ "@genesislcap/foundation-ai": "14.458.1-GENC-0.2",
79
+ "@genesislcap/foundation-logger": "14.458.1-GENC-0.2",
80
+ "@genesislcap/foundation-redux": "14.458.1-GENC-0.2",
81
+ "@genesislcap/foundation-ui": "14.458.1-GENC-0.2",
82
+ "@genesislcap/foundation-utils": "14.458.1-GENC-0.2",
83
+ "@genesislcap/rapid-design-system": "14.458.1-GENC-0.2",
84
+ "@genesislcap/web-core": "14.458.1-GENC-0.2",
85
85
  "dompurify": "^3.3.1",
86
86
  "marked": "^17.0.3"
87
87
  },
@@ -93,5 +93,5 @@
93
93
  "publishConfig": {
94
94
  "access": "public"
95
95
  },
96
- "gitHead": "be04e474ce5154f026d9d01372eec7c6a24b012f"
96
+ "gitHead": "7506d57ff2fb4954bdba714195727a48ebb3d9a3"
97
97
  }
@@ -0,0 +1,212 @@
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
+ this.tick();
174
+ }
175
+
176
+ disconnectedCallback() {
177
+ super.disconnectedCallback();
178
+ if (this.animFrame !== undefined) {
179
+ cancelAnimationFrame(this.animFrame);
180
+ this.animFrame = undefined;
181
+ }
182
+ }
183
+
184
+ private tick() {
185
+ if (!this.wavePaths) {
186
+ const paths = this.shadowRoot?.querySelectorAll<SVGPathElement>('.wave');
187
+ if (paths?.length) this.wavePaths = Array.from(paths);
188
+ }
189
+ this.wavePaths?.forEach((path, i) => {
190
+ path.setAttribute('d', AiWavesIndicator.buildWavePath(WAVES[i], this.frame));
191
+ });
192
+ this.frame += 1;
193
+ this.animFrame = requestAnimationFrame(() => this.tick());
194
+ }
195
+
196
+ /** Trace one wave's polyline `d` attribute for the given frame. */
197
+ private static buildWavePath(cfg: WaveConfig, frame: number): string {
198
+ const segments: string[] = [];
199
+ for (let x = 0; x <= VIEWBOX; x += SAMPLE_STEP) {
200
+ const y =
201
+ CENTRE +
202
+ cfg.verticalOffset +
203
+ cfg.amplitude * Math.sin(x * cfg.frequency + frame * cfg.phaseSpeed) +
204
+ cfg.slosh * Math.sin(x * cfg.sloshFrequency + frame * cfg.sloshSpeed);
205
+ segments.push(`${x},${y.toFixed(2)}`);
206
+ }
207
+ return `M ${segments.join(' L ')}`;
208
+ }
209
+ }
210
+
211
+ // Ensure the halo overlay used for the ring is registered alongside this component.
212
+ 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,22 @@ export const styles = css`
809
825
  }
810
826
  }
811
827
 
828
+ .thinking-waves {
829
+ display: flex;
830
+ flex-direction: column;
831
+ align-items: center;
832
+ align-self: center;
833
+ gap: 6px;
834
+ padding: calc(var(--design-unit) * 2px) calc(var(--design-unit) * 3px);
835
+ }
836
+
837
+ .thinking-caption {
838
+ font-size: var(--type-ramp-minus-1-font-size, 12px);
839
+ line-height: var(--type-ramp-minus-1-line-height, 16px);
840
+ color: var(--neutral-foreground-rest);
841
+ opacity: 70%;
842
+ }
843
+
812
844
  .attachment-chips {
813
845
  display: flex;
814
846
  flex-wrap: wrap;