@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.
@@ -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`),
@@ -184,7 +181,7 @@ export const FoundationAiAssistantTemplate = (
184
181
  ): ViewTemplate<FoundationAiAssistant> => {
185
182
  const buttonTag = `${designSystemPrefix}-button`;
186
183
  const switchTag = `${designSystemPrefix}-switch`;
187
- const multiselectTag = `${designSystemPrefix}-multiselect`;
184
+ const categorizedMultiselectTag = `${designSystemPrefix}-categorized-multiselect`;
188
185
  const textareaTag = `${designSystemPrefix}-text-area`;
189
186
  const iconTag = `${designSystemPrefix}-icon`;
190
187
  const progressTag = `${designSystemPrefix}-progress`;
@@ -473,16 +470,14 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
473
470
  html<FoundationAiAssistant>`
474
471
  <div class="settings-animations">
475
472
  <span class="settings-label">Animations</span>
476
- <${multiselectTag}
473
+ <${categorizedMultiselectTag}
477
474
  part="toggle-animations"
478
475
  :selectedOptions=${(x) => x.enabledAnimations}
479
476
  :options=${() => animationOptions}
480
- :itemRenderer=${() => animationItemRenderer}
481
477
  @selectionChange=${(x, c) =>
482
478
  x.setEnabledAnimations((c.event as CustomEvent).detail)}
483
479
  search="false"
484
- all="false"
485
- ></${multiselectTag}>
480
+ ></${categorizedMultiselectTag}>
486
481
  </div>
487
482
  `,
488
483
  )}
@@ -590,7 +585,9 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
590
585
  ${when(
591
586
  (x) =>
592
587
  x.showLoadingIndicator &&
593
- (x.chatConfig.ui?.animations == null || x.enabledAnimations.includes('loading')),
588
+ (x.chatConfig.ui?.animations == null ||
589
+ x.enabledAnimations.includes('loading') ||
590
+ x.enabledAnimations.includes('waves')),
594
591
  html<FoundationAiAssistant>`
595
592
  <div class="message-row ai" part="thinking">
596
593
  <div class="avatar">
@@ -603,12 +600,22 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
603
600
  <span class="avatar-icon" :innerHTML="${() => x.assistantIconSafe}"></span>
604
601
  `}
605
602
  </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>
603
+ ${(x) =>
604
+ x.enabledAnimations.includes('waves')
605
+ ? html<FoundationAiAssistant>`
606
+ <div class="thinking-waves" part="thinking-waves">
607
+ <ai-waves-indicator></ai-waves-indicator>
608
+ <span class="thinking-caption">Thinking...</span>
609
+ </div>
610
+ `
611
+ : html<FoundationAiAssistant>`
612
+ <div class="thinking-dots">
613
+ <div class="dot dot-1"></div>
614
+ <div class="dot dot-2"></div>
615
+ <div class="dot dot-3"></div>
616
+ <div class="dot dot-4"></div>
617
+ </div>
618
+ `}
612
619
  </div>
613
620
  `,
614
621
  )}
package/src/main/main.ts CHANGED
@@ -49,6 +49,7 @@ import { AiChatInteractionWrapper } from '../components/chat-interaction-wrapper
49
49
  import { AiChatMarkdown } from '../components/chat-markdown/chat-markdown';
50
50
  import { AiHaloOverlay } from '../components/halo-overlay';
51
51
  import { OrchestratingDriver } from '../components/orchestrating-driver/orchestrating-driver';
52
+ import { AiWavesIndicator } from '../components/waves-indicator';
52
53
  import type { AgentConfig } from '../config/config';
53
54
  import {
54
55
  recordMetaEvent,
@@ -66,6 +67,7 @@ import {
66
67
  } from '../styles/ai-colours';
67
68
  import { ChatSuggestions } from '../suggestions/chat-suggestions';
68
69
  import { AnimatedPanelToggle } from '../utils/animated-panel-toggle';
70
+ import { resolveExclusiveLoadingStyle } from '../utils/animation-exclusivity';
69
71
  import { logger } from '../utils/logger';
70
72
  import { filterVisibleMessages, trailingInteractionRow } from '../utils/message-partition';
71
73
  import { sumCosts } from '../utils/sum-costs';
@@ -80,7 +82,7 @@ import type {
80
82
  SubmitMessageResult,
81
83
  SuggestionsState,
82
84
  } from './main.types';
83
- import { ALL_ANIMATIONS } from './main.types';
85
+ import { DEFAULT_ANIMATIONS } from './main.types';
84
86
 
85
87
  /** Context window sizes (in tokens) for known models. */
86
88
  /**
@@ -137,6 +139,7 @@ avoidTreeShaking(
137
139
  AiChatMarkdown,
138
140
  AiChatInteractionWrapper,
139
141
  AiHaloOverlay,
142
+ AiWavesIndicator,
140
143
  AiChatBubble,
141
144
  AiActivityHalo,
142
145
  ChatSuggestions,
@@ -656,7 +659,9 @@ export class FoundationAiAssistant extends GenesisElement {
656
659
  return;
657
660
  }
658
661
  const last = this.messages[this.messages.length - 1];
659
- if (last?.interaction) {
662
+ // Hide only while a pending interaction blocks on the user; a resolved
663
+ // interaction means the assistant is computing again (keep the halo).
664
+ if (last?.interaction && !last.interaction.resolved) {
660
665
  this.showHalo = 'no';
661
666
  } else if (this.showHalo !== 'orchestrating') {
662
667
  this.showHalo = 'agent';
@@ -1047,9 +1052,10 @@ export class FoundationAiAssistant extends GenesisElement {
1047
1052
  this.showToolCalls = ui.showToolCalls === true;
1048
1053
  this.showThinkingSteps = ui.showThinkingSteps === true;
1049
1054
  this.showAgentSwitchIndicator = ui.showAgentSwitchIndicator === true;
1050
- this.enabledAnimations =
1055
+ this.enabledAnimations = resolveExclusiveLoadingStyle(
1051
1056
  (ui.animations?.enabled as AiAssistantAnimation[]) ??
1052
- (ui.animations ? [...ALL_ANIMATIONS] : []);
1057
+ (ui.animations ? [...DEFAULT_ANIMATIONS] : []),
1058
+ );
1053
1059
 
1054
1060
  const defaultAgent = this.chatConfig.picker?.defaultAgent;
1055
1061
  if (defaultAgent && (this.agents ?? []).some((a) => a.name === defaultAgent)) {
@@ -1216,9 +1222,13 @@ export class FoundationAiAssistant extends GenesisElement {
1216
1222
  // waiting for the user, not computing.
1217
1223
  if (this.busy) {
1218
1224
  const last = this.messages[this.messages.length - 1];
1219
- if (last?.interaction) {
1225
+ // Only a *pending* interaction means the assistant is blocked waiting on
1226
+ // the user. Once it's resolved — or for any normal step — the assistant is
1227
+ // computing again, so the indicator should resume (e.g. while it works out
1228
+ // the next planning question after the user answers a widget).
1229
+ if (last?.interaction && !last.interaction.resolved) {
1220
1230
  this.stopLoadingTimer();
1221
- } else if (last?.role === 'assistant') {
1231
+ } else {
1222
1232
  this.startLoadingTimer();
1223
1233
  }
1224
1234
  }
@@ -1302,7 +1312,7 @@ export class FoundationAiAssistant extends GenesisElement {
1302
1312
  });
1303
1313
  }
1304
1314
 
1305
- private static readonly DEFAULT_LOADING_DELAY_S = 5;
1315
+ private static readonly DEFAULT_LOADING_DELAY_S = 0;
1306
1316
  private static readonly DEFAULT_SUGGESTION_COUNT = 3;
1307
1317
  private static readonly MS_PER_SECOND = 1000;
1308
1318
 
@@ -1506,7 +1516,9 @@ export class FoundationAiAssistant extends GenesisElement {
1506
1516
  }
1507
1517
 
1508
1518
  setEnabledAnimations(animations: AiAssistantAnimation[]) {
1509
- this.enabledAnimations = animations;
1519
+ // The dots and waves loading styles are mutually exclusive — enabling one
1520
+ // disables the other (see resolveExclusiveLoadingStyle).
1521
+ this.enabledAnimations = resolveExclusiveLoadingStyle(animations, this.enabledAnimations);
1510
1522
  }
1511
1523
 
1512
1524
  getDebugLog() {
@@ -53,24 +53,41 @@ 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) and `waves` are two interchangeable styles of the same
68
+ * "is the assistant working" indicator and are therefore grouped under the same
69
+ * {@link AiAssistantAnimationDef.category}. They are mutually exclusive — see
70
+ * `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',
70
85
  },
71
86
  halo: {
72
87
  label: 'Halo',
73
- tooltip: 'Displays a glowing halo around the assistant avatar while a response is streaming.',
88
+ description:
89
+ 'Displays a glowing halo around the assistant avatar while a response is streaming.',
90
+ category: 'Effects',
74
91
  },
75
92
  } satisfies Record<string, AiAssistantAnimationDef>;
76
93
 
@@ -87,3 +104,23 @@ export type AiAssistantAnimation = keyof typeof ANIMATION_DEFS;
87
104
  * @internal
88
105
  */
89
106
  export const ALL_ANIMATIONS = Object.keys(ANIMATION_DEFS) as AiAssistantAnimation[];
107
+
108
+ /**
109
+ * The interchangeable "assistant is working" loading-indicator styles. At most
110
+ * one of these may be enabled at a time — enabling one disables the other.
111
+ *
112
+ * @internal
113
+ */
114
+ export const LOADING_STYLE_ANIMATIONS = [
115
+ 'loading',
116
+ 'waves',
117
+ ] as const satisfies readonly AiAssistantAnimation[];
118
+
119
+ /**
120
+ * Animations enabled by default when a consumer opts into the animations
121
+ * feature without specifying an explicit `enabled` list. Keeps the dots loading
122
+ * style (the long-standing default); the waves style is opt-in.
123
+ *
124
+ * @internal
125
+ */
126
+ export const DEFAULT_ANIMATIONS: AiAssistantAnimation[] = ['loading', 'halo'];
@@ -0,0 +1,53 @@
1
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
+ import type { AiAssistantAnimation } from '../main/main.types';
3
+ import { resolveExclusiveLoadingStyle } from './animation-exclusivity';
4
+
5
+ const suite = createLogicSuite('resolveExclusiveLoadingStyle');
6
+
7
+ suite('leaves a selection with only the dots loading style untouched', () => {
8
+ const next: AiAssistantAnimation[] = ['loading', 'halo'];
9
+ assert.equal(resolveExclusiveLoadingStyle(next, ['halo']), ['loading', 'halo']);
10
+ });
11
+
12
+ suite('leaves a selection with only the waves loading style untouched', () => {
13
+ const next: AiAssistantAnimation[] = ['waves', 'halo'];
14
+ assert.equal(resolveExclusiveLoadingStyle(next, ['halo']), ['waves', 'halo']);
15
+ });
16
+
17
+ suite('leaves a selection with no loading style untouched', () => {
18
+ assert.equal(resolveExclusiveLoadingStyle(['halo'], []), ['halo']);
19
+ });
20
+
21
+ suite('drops dots when waves was just added on top of dots', () => {
22
+ // Previously dots was on; user ticks waves → waves wins, dots removed.
23
+ const result = resolveExclusiveLoadingStyle(['loading', 'waves', 'halo'], ['loading', 'halo']);
24
+ assert.equal(result, ['waves', 'halo']);
25
+ });
26
+
27
+ suite('drops waves when dots was just added on top of waves', () => {
28
+ // Previously waves was on; user ticks dots → dots wins, waves removed.
29
+ const result = resolveExclusiveLoadingStyle(['loading', 'waves', 'halo'], ['waves', 'halo']);
30
+ assert.equal(result, ['loading', 'halo']);
31
+ });
32
+
33
+ suite('preserves the order of the surviving selection', () => {
34
+ const result = resolveExclusiveLoadingStyle(['halo', 'loading', 'waves'], ['halo', 'loading']);
35
+ // waves was just added → loading dropped; halo and waves keep their order.
36
+ assert.equal(result, ['halo', 'waves']);
37
+ });
38
+
39
+ suite('falls back to dots when both are present with no prior state (e.g. bad config)', () => {
40
+ const result = resolveExclusiveLoadingStyle(['loading', 'waves', 'halo'], []);
41
+ assert.equal(result, ['loading', 'halo']);
42
+ });
43
+
44
+ suite('falls back to dots when both were already present (neither newly added)', () => {
45
+ const result = resolveExclusiveLoadingStyle(['loading', 'waves'], ['loading', 'waves', 'halo']);
46
+ assert.equal(result, ['loading']);
47
+ });
48
+
49
+ suite('handles an empty selection', () => {
50
+ assert.equal(resolveExclusiveLoadingStyle([], ['loading']), []);
51
+ });
52
+
53
+ suite.run();
@@ -0,0 +1,40 @@
1
+ import type { AiAssistantAnimation } from '../main/main.types';
2
+ import { LOADING_STYLE_ANIMATIONS } from '../main/main.types';
3
+
4
+ /**
5
+ * Enforces that at most one loading-indicator style (dots vs. waves) is enabled
6
+ * at a time. The two are alternative presentations of the same "assistant is
7
+ * working" state, so selecting one must deselect the other.
8
+ *
9
+ * The settings control is a multiselect (checkboxes), which permits selecting
10
+ * both; this resolver makes the loading-style group behave like a radio group
11
+ * by dropping the previously-selected style whenever a new one is added.
12
+ *
13
+ * @param next - The freshly-selected animation list (e.g. emitted by the
14
+ * multiselect, or read from consumer config).
15
+ * @param previous - The animation list in effect before this change. Used to
16
+ * work out which loading style was just added so the other can be dropped.
17
+ * Pass `[]` when resolving an initial/config value with no prior state.
18
+ * @returns `next` with at most one loading style retained. When both are
19
+ * present and neither is newly added (e.g. a misconfigured `enabled` list),
20
+ * the first entry of `LOADING_STYLE_ANIMATIONS` (dots) wins.
21
+ *
22
+ * @internal
23
+ */
24
+ export function resolveExclusiveLoadingStyle(
25
+ next: AiAssistantAnimation[],
26
+ previous: AiAssistantAnimation[] = [],
27
+ ): AiAssistantAnimation[] {
28
+ const selectedStyles = LOADING_STYLE_ANIMATIONS.filter((style) => next.includes(style));
29
+ if (selectedStyles.length <= 1) {
30
+ return next;
31
+ }
32
+ // Both styles selected — keep whichever was just added (absent from
33
+ // `previous`); fall back to the first declared style on ambiguity.
34
+ const justAdded = selectedStyles.find((style) => !previous.includes(style)) ?? selectedStyles[0];
35
+ return next.filter(
36
+ (animation) =>
37
+ animation === justAdded ||
38
+ !(LOADING_STYLE_ANIMATIONS as readonly AiAssistantAnimation[]).includes(animation),
39
+ );
40
+ }