@genesislcap/ai-assistant 14.479.0 → 14.480.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 (63) hide show
  1. package/dist/ai-assistant.api.json +546 -2
  2. package/dist/ai-assistant.d.ts +73 -1
  3. package/dist/dts/components/ai-driver/ai-driver.d.ts +8 -0
  4. package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
  5. package/dist/dts/components/chat-driver/chat-driver.d.ts +32 -0
  6. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  7. package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
  8. package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.d.ts +7 -0
  9. package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.d.ts.map +1 -1
  10. package/dist/dts/components/flowing-waves-indicator.d.ts +1 -1
  11. package/dist/dts/components/flowing-waves-indicator.d.ts.map +1 -1
  12. package/dist/dts/components/halo-overlay.d.ts +1 -0
  13. package/dist/dts/components/halo-overlay.d.ts.map +1 -1
  14. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +4 -0
  15. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  16. package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
  17. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  18. package/dist/dts/components/waves-indicator.d.ts +1 -1
  19. package/dist/dts/components/waves-indicator.d.ts.map +1 -1
  20. package/dist/dts/main/main.d.ts +29 -1
  21. package/dist/dts/main/main.d.ts.map +1 -1
  22. package/dist/dts/main/main.styles.d.ts.map +1 -1
  23. package/dist/dts/main/main.template.d.ts.map +1 -1
  24. package/dist/dts/state/ai-assistant-slice.d.ts +2 -0
  25. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  26. package/dist/dts/state/session-store.d.ts +1 -0
  27. package/dist/dts/state/session-store.d.ts.map +1 -1
  28. package/dist/dts/utils/message-partition.d.ts +1 -0
  29. package/dist/dts/utils/message-partition.d.ts.map +1 -1
  30. package/dist/esm/components/chat-driver/chat-driver.js +88 -8
  31. package/dist/esm/components/chat-driver/chat-driver.test.js +104 -0
  32. package/dist/esm/components/chat-interaction-wrapper/chat-interaction-wrapper.js +48 -6
  33. package/dist/esm/components/chat-interaction-wrapper/chat-interaction-wrapper.test.js +62 -0
  34. package/dist/esm/components/flowing-waves-indicator.js +22 -11
  35. package/dist/esm/components/halo-overlay.js +30 -6
  36. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +14 -0
  37. package/dist/esm/components/settings-modal/settings-modal.styles.js +7 -0
  38. package/dist/esm/components/settings-modal/settings-modal.template.js +18 -8
  39. package/dist/esm/components/waves-indicator.js +22 -8
  40. package/dist/esm/main/main.js +73 -6
  41. package/dist/esm/main/main.styles.js +6 -0
  42. package/dist/esm/main/main.template.js +21 -2
  43. package/dist/esm/state/ai-assistant-slice.js +4 -0
  44. package/dist/esm/utils/message-partition.js +10 -1
  45. package/dist/esm/utils/message-partition.test.js +2 -0
  46. package/package.json +17 -17
  47. package/src/components/ai-driver/ai-driver.ts +17 -0
  48. package/src/components/chat-driver/chat-driver.test.ts +143 -0
  49. package/src/components/chat-driver/chat-driver.ts +105 -4
  50. package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.test.ts +81 -0
  51. package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts +51 -5
  52. package/src/components/flowing-waves-indicator.ts +26 -6
  53. package/src/components/halo-overlay.ts +31 -6
  54. package/src/components/orchestrating-driver/orchestrating-driver.ts +18 -0
  55. package/src/components/settings-modal/settings-modal.styles.ts +7 -0
  56. package/src/components/settings-modal/settings-modal.template.ts +36 -16
  57. package/src/components/waves-indicator.ts +25 -5
  58. package/src/main/main.styles.ts +6 -0
  59. package/src/main/main.template.ts +25 -2
  60. package/src/main/main.ts +65 -1
  61. package/src/state/ai-assistant-slice.ts +5 -0
  62. package/src/utils/message-partition.test.ts +2 -0
  63. package/src/utils/message-partition.ts +11 -1
@@ -208,6 +208,16 @@ export class ChatDriver extends EventTarget implements AiDriver {
208
208
  }
209
209
  >();
210
210
 
211
+ // GENC-1410 (Error 3): when several assistant views share this one driver (e.g. a bubble and a
212
+ // docked panel), each interaction's widget must be rendered live — and thus fire any side
213
+ // effect — on exactly ONE view. These fields elect that owner and de-dupe execution.
214
+ /** Assistant host ids currently wired to this driver, in registration (insertion) order. */
215
+ private hosts = new Set<string>();
216
+ /** The host whose subtree the user most recently interacted in — the primary owner signal. */
217
+ private lastActiveHostId?: string;
218
+ /** Interaction id → host id that claimed its side effect (first-writer-wins latch). */
219
+ private startedSideEffects = new Map<string, string>();
220
+
211
221
  private systemPrompt?: SystemPromptInput;
212
222
  /**
213
223
  * Resolved tool definitions visible to the LLM. Folds mutate this in place
@@ -1078,6 +1088,59 @@ export class ChatDriver extends EventTarget implements AiDriver {
1078
1088
  return this.busy;
1079
1089
  }
1080
1090
 
1091
+ // --- GENC-1410 (Error 3): single-instance execution of side-effecting widgets --------------
1092
+
1093
+ /** Register an assistant view (host) wired to this shared driver. @internal */
1094
+ registerHost(hostId: string): void {
1095
+ this.hosts.add(hostId);
1096
+ }
1097
+
1098
+ /** Deregister a host on unwire/disconnect. @internal */
1099
+ unregisterHost(hostId: string): void {
1100
+ this.hosts.delete(hostId);
1101
+ }
1102
+
1103
+ /**
1104
+ * Record the host whose subtree the user most recently interacted in — the primary signal for
1105
+ * electing which single view renders an interaction's widget. Sticky (unlike focus): it only
1106
+ * moves when the user actually uses a different assistant view, not when they click elsewhere
1107
+ * in the layout. @internal
1108
+ */
1109
+ setActiveHost(hostId: string): void {
1110
+ this.lastActiveHostId = hostId;
1111
+ }
1112
+
1113
+ /**
1114
+ * First-writer-wins latch guarding a side effect. A host claims before rendering the live
1115
+ * widget: the first host to claim an interaction id gets `true` (run it); a *different* host
1116
+ * gets `false` (render passive, do NOT re-fire). Re-claim by the SAME host returns `true`, so a
1117
+ * benign re-render of the owner keeps its widget. Single-threaded, so check-and-set is race-free
1118
+ * — the backstop that makes duplicate execution impossible even if owner election ever slips. @internal
1119
+ */
1120
+ claimSideEffect(interactionId: string, hostId: string): boolean {
1121
+ const existing = this.startedSideEffects.get(interactionId);
1122
+ if (existing === undefined) {
1123
+ this.startedSideEffects.set(interactionId, hostId);
1124
+ return true;
1125
+ }
1126
+ return existing === hostId;
1127
+ }
1128
+
1129
+ /**
1130
+ * Elect the single host that owns (renders + runs) an interaction's widget: the most-recently-
1131
+ * active host if still connected, else a deterministic default (first registered host — `Set`
1132
+ * preserves insertion order) so exactly one owner is always chosen. Returns `undefined` only when
1133
+ * no host is registered, in which case the interaction renders everywhere (fail-open) not nowhere.
1134
+ */
1135
+ private electInteractionOwner(): string | undefined {
1136
+ if (this.lastActiveHostId && this.hosts.has(this.lastActiveHostId)) {
1137
+ return this.lastActiveHostId;
1138
+ }
1139
+ // First registered host (`Set` preserves insertion order); `undefined` when none.
1140
+ const [firstHost] = this.hosts;
1141
+ return firstHost;
1142
+ }
1143
+
1081
1144
  /**
1082
1145
  * Wire a parent driver as the host for this driver's interactions. When set,
1083
1146
  * `requestInteraction` delegates upward so the widget renders in (and
@@ -1128,6 +1191,10 @@ export class ChatDriver extends EventTarget implements AiDriver {
1128
1191
  const chatInputDuringExecution = options?.chatInputDuringExecution;
1129
1192
  const timeoutMs = options?.timeoutMs;
1130
1193
  const presentation = options?.presentation;
1194
+ // GENC-1410 (Error 3): a widget is rendered live by exactly one host. Elect the owner now
1195
+ // (most-recently-active host, deterministic fallback) and stamp it on the interaction so every
1196
+ // host can compare its own id and render passively when it differs — one server call, not N.
1197
+ const ownerHostId = this.electInteractionOwner();
1131
1198
  return new Promise((resolve, reject) => {
1132
1199
  this.pendingInteractions.set(interactionId, {
1133
1200
  resolve,
@@ -1172,6 +1239,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
1172
1239
  componentName,
1173
1240
  data,
1174
1241
  ...(presentation ? { presentation } : {}),
1242
+ ...(ownerHostId ? { ownerHostId } : {}),
1175
1243
  },
1176
1244
  });
1177
1245
  });
@@ -1231,6 +1299,8 @@ export class ChatDriver extends EventTarget implements AiDriver {
1231
1299
  agenticActivityBus.publish('interaction-resolved', undefined);
1232
1300
  interaction.resolve(result);
1233
1301
  this.pendingInteractions.delete(interactionId);
1302
+ // GENC-1410: release the side-effect claim so a re-request with a fresh id starts clean.
1303
+ this.startedSideEffects.delete(interactionId);
1234
1304
  } else {
1235
1305
  logger.warn(`Interaction with ID ${interactionId} not found.`);
1236
1306
  }
@@ -2235,11 +2305,42 @@ export class ChatDriver extends EventTarget implements AiDriver {
2235
2305
  });
2236
2306
  }
2237
2307
  return { reason: 'done' };
2238
- } else if (isThinkingStep) {
2239
- this.appendToHistory({ ...response, toolCalls: undefined, thinking: true });
2240
- this.appendToHistory({ ...response, content: '' });
2241
2308
  } else {
2242
- this.appendToHistory(response);
2309
+ // Split one model response into separate, individually-toggleable messages so each has its
2310
+ // own visibility toggle and debug-log category:
2311
+ // - reasoning (chain-of-thought summary) → category 'reasoning', hidden unless showThinkingSteps
2312
+ // - narration (interstitial prose emitted alongside a tool call) → category 'narration',
2313
+ // hidden unless showNarration
2314
+ // - the answer / tool-call message → always shown; carries this turn's usage.
2315
+ // Cost invariant (GENC-1410): exactly ONE message carries cost/tokens and is appended LAST, so
2316
+ // `sumCosts`/`sumTokens` don't double-count and `contextTokens` reads it. Reasoning/narration are
2317
+ // display-only (usage undefined) and are skipped when building the provider request. `model` /
2318
+ // `provider` / `providerName` stay on every split message so each is still attributed.
2319
+ const { reasoning, ...rest } = response;
2320
+ const displayOnly = { cost: undefined, inputTokens: undefined, outputTokens: undefined };
2321
+ if (reasoning) {
2322
+ this.appendToHistory({
2323
+ ...rest,
2324
+ ...displayOnly,
2325
+ content: reasoning,
2326
+ toolCalls: undefined,
2327
+ category: 'reasoning',
2328
+ });
2329
+ }
2330
+ if (isThinkingStep) {
2331
+ // content + tool call → the content is interstitial narration, not the final answer.
2332
+ this.appendToHistory({
2333
+ ...rest,
2334
+ ...displayOnly,
2335
+ toolCalls: undefined,
2336
+ category: 'narration',
2337
+ });
2338
+ this.appendToHistory({ ...rest, content: '' });
2339
+ } else {
2340
+ // No tool call → `content` is the final answer (always shown), or this is a bare tool-call
2341
+ // turn with no narration. Either way it carries this turn's usage; append it last.
2342
+ this.appendToHistory(rest);
2343
+ }
2243
2344
  }
2244
2345
 
2245
2346
  // Reset retry budgets on any productive (non-empty) response, so the caps mean
@@ -102,4 +102,85 @@ Suite('forwards resolved in place without rebuilding the widget', async ({ eleme
102
102
  assert.equal(widget.resolved, { status: 'approved' }, 'resolved is forwarded to the widget');
103
103
  });
104
104
 
105
+ /**
106
+ * GENC-1410 (Error 3): two assistant views (a floating bubble and a docked panel)
107
+ * share one driver and render the same history, so both mount a wrapper for the
108
+ * same interaction. Exactly one — the elected owner — must render the live widget
109
+ * and win the driver's first-writer side-effect latch; the other renders a passive
110
+ * placeholder. Regression: the claim used to be computed for EVERY host, so if a
111
+ * non-owner view rendered its wrapper first it grabbed the latch, and the owner's
112
+ * own claim then returned false — leaving BOTH views on the placeholder so the
113
+ * widget's side effect never fired (blank + stuck). The claim must be gated behind
114
+ * ownership, making the outcome independent of inter-host render order.
115
+ */
116
+
117
+ /** First-writer-wins latch, mirroring `ChatDriver.claimSideEffect`. */
118
+ function makeSharedLatch() {
119
+ const started = new Map<string, string>();
120
+ return (interactionId: string, hostId: string) => {
121
+ const existing = started.get(interactionId);
122
+ if (existing === undefined) {
123
+ started.set(interactionId, hostId);
124
+ return true;
125
+ }
126
+ return existing === hostId;
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Mount a wrapper inside a shadow host that exposes `hostId` + a shared claim,
132
+ * mimicking one `gc-assistant` view. The wrapper reads these across its shadow
133
+ * boundary via `getRootNode().host`, so a real ShadowRoot host is required.
134
+ */
135
+ async function mountWrapperInHost(
136
+ hostId: string,
137
+ ownerHostId: string,
138
+ interactionId: string,
139
+ claim: (interactionId: string, hostId: string) => boolean,
140
+ ) {
141
+ const hostEl = document.createElement('div');
142
+ const shadow = hostEl.attachShadow({ mode: 'open' });
143
+ (hostEl as any).hostId = hostId;
144
+ (hostEl as any).isEngaged = true;
145
+ (hostEl as any).claimSideEffect = (id: string) => claim(id, hostId);
146
+
147
+ const wrapper = document.createElement('ai-chat-interaction-wrapper') as AiChatInteractionWrapper;
148
+ shadow.appendChild(wrapper);
149
+ document.body.appendChild(hostEl);
150
+ await DOM.nextUpdate();
151
+
152
+ wrapper.ownerHostId = ownerHostId;
153
+ wrapper.data = { label: 'q' };
154
+ wrapper.interactionId = interactionId;
155
+ wrapper.componentName = FAKE_WIDGET;
156
+ await DOM.nextUpdate();
157
+ return { hostEl, wrapper };
158
+ }
159
+
160
+ Suite('a non-owner host does not poison the side-effect claim when it renders first', async () => {
161
+ const claim = makeSharedLatch();
162
+ const ownerId = 'owner-host';
163
+ const otherId = 'other-host';
164
+ const interactionId = 'id-x';
165
+
166
+ // The NON-owner (e.g. the hidden bubble) renders FIRST — the race that used to poison the latch.
167
+ const other = await mountWrapperInHost(otherId, ownerId, interactionId, claim);
168
+ // The elected owner renders SECOND.
169
+ const owner = await mountWrapperInHost(ownerId, ownerId, interactionId, claim);
170
+
171
+ assert.equal(
172
+ (other.wrapper.container.firstElementChild as HTMLElement)?.className,
173
+ 'interaction-running-elsewhere',
174
+ 'the non-owner view renders the passive placeholder',
175
+ );
176
+ assert.equal(
177
+ (owner.wrapper.container.firstElementChild as HTMLElement)?.tagName?.toLowerCase(),
178
+ FAKE_WIDGET,
179
+ 'the owner renders the live widget even though the non-owner rendered first',
180
+ );
181
+
182
+ other.hostEl.remove();
183
+ owner.hostEl.remove();
184
+ });
185
+
105
186
  Suite.run();
@@ -28,6 +28,13 @@ export class AiChatInteractionWrapper extends GenesisElement {
28
28
  @observable interactionId: string = '';
29
29
  /** The resolved result once the interaction has completed. Forwarded to the rendered component. @internal */
30
30
  @observable resolved: InteractionResult<unknown> | undefined = undefined;
31
+ /**
32
+ * GENC-1410 (Error 3): id of the host the driver elected to render (and thus run) this
33
+ * interaction. When several views share one driver, only the matching host renders the live
34
+ * widget; others show a passive placeholder so the widget fires once. Empty = no owner elected
35
+ * (single view, or none registered) → render everywhere (fail-open). @internal
36
+ */
37
+ @observable ownerHostId = '';
31
38
 
32
39
  /** @internal */
33
40
  container!: HTMLElement;
@@ -66,8 +73,15 @@ export class AiChatInteractionWrapper extends GenesisElement {
66
73
 
67
74
  resolvedChanged() {
68
75
  const element = this.container?.firstElementChild as any;
69
- if (element) {
76
+ // GENC-1410 (Error 3): a non-owner view rendered a passive placeholder for the unresolved
77
+ // exclusive widget. Now it's resolved (no side effect left to fire), so render the real widget
78
+ // — the gate below is keyed on `!resolved`, so this instantiates the resolved widget and every
79
+ // view shows the outcome. The owner already has the live widget: just forward `resolved` to it,
80
+ // preserving its DOM/state (no re-create).
81
+ if (element && element.tagName?.toLowerCase() === this.componentName) {
70
82
  element.resolved = this.resolved;
83
+ } else if (this.resolved) {
84
+ this.renderComponent();
71
85
  }
72
86
  }
73
87
 
@@ -118,6 +132,41 @@ export class AiChatInteractionWrapper extends GenesisElement {
118
132
  }
119
133
 
120
134
  try {
135
+ // Pierce the wrapper's shadow boundary to read state from the assistant host.
136
+ const root = this.getRootNode();
137
+ const host =
138
+ root instanceof ShadowRoot
139
+ ? (root.host as {
140
+ isEngaged?: boolean;
141
+ hostId?: string;
142
+ claimSideEffect?: (interactionId: string) => boolean;
143
+ })
144
+ : null;
145
+
146
+ // GENC-1410 (Error 3): a widget must run on exactly ONE view. When several views share one
147
+ // driver, render the live widget only if this host is the elected owner AND wins the
148
+ // first-writer claim; otherwise show a passive placeholder and do NOT instantiate the widget
149
+ // (whose connectedCallback would fire a duplicate server call). A single-view app has no owner
150
+ // (or owns it) so it always renders. Resolved interactions always render (display only, no
151
+ // side effect) so state survives a pop.
152
+ if (!this.resolved) {
153
+ const isOwner = !this.ownerHostId || host?.hostId === this.ownerHostId;
154
+ // Only the elected owner attempts the claim. A non-owner must NEVER touch the latch: both
155
+ // views render the same history, so if a non-owner view (e.g. the hidden bubble while the
156
+ // panel is docked) renders its wrapper first, claiming here would grab the first-writer latch
157
+ // and the owner's own claim would then return false — leaving BOTH views on the placeholder,
158
+ // so the widget's side effect never fires (the interaction shows blank and hangs forever).
159
+ // Gating the claim behind `isOwner` makes the outcome independent of inter-host render order.
160
+ const claimed =
161
+ isOwner && (host?.claimSideEffect ? host.claimSideEffect(this.interactionId) : true);
162
+ if (!claimed) {
163
+ const placeholder = document.createElement('div');
164
+ placeholder.className = 'interaction-running-elsewhere';
165
+ this.container.appendChild(placeholder);
166
+ return;
167
+ }
168
+ }
169
+
121
170
  const element = document.createElement(this.componentName) as any;
122
171
 
123
172
  // Pass data and resolved state to the component
@@ -125,11 +174,8 @@ export class AiChatInteractionWrapper extends GenesisElement {
125
174
  element.data = this.data;
126
175
  }
127
176
  element.resolved = this.resolved;
128
- // Pierce the wrapper's shadow boundary to read engagement from the
129
- // assistant host. False if the widget is already resolved — re-renders
177
+ // Engagement gates autofocus. False if the widget is already resolved — re-renders
130
178
  // from history (layout remount, dock/undock) must never steal focus.
131
- const root = this.getRootNode();
132
- const host = root instanceof ShadowRoot ? (root.host as { isEngaged?: boolean }) : null;
133
179
  element.shouldAutoFocus = !this.resolved && !!host?.isEngaged;
134
180
 
135
181
  // Handle completion from the inner component.
@@ -24,6 +24,18 @@ const EDGE = 0.16;
24
24
  const MORPH_SPEED = 3;
25
25
  /** Global multiplier on the harmonic frequencies — raise for narrower peaks. */
26
26
  const FREQ_SCALE = 2;
27
+ /** Milliseconds in one second. */
28
+ const MS_PER_SECOND = 1000;
29
+ /** Baseline frame rate (fps) the harmonic speeds are tuned against. */
30
+ const BASELINE_FPS = 60;
31
+ /**
32
+ * Frame duration (ms) at the baseline rate. The morph is driven by elapsed
33
+ * wall-clock time divided by this — not by the rAF callback count — so it
34
+ * advances at a constant real-world rate regardless of the display's refresh
35
+ * rate or dropped frames. At the baseline rate the resulting frame index
36
+ * matches the old per-callback counter, preserving the current look.
37
+ */
38
+ const FRAME_MS = MS_PER_SECOND / BASELINE_FPS;
27
39
  /**
28
40
  * Half-thickness (viewBox units) each band keeps even between peaks, so the
29
41
  * waves emerge from a continuous line rather than leaving gaps. The four bands
@@ -188,9 +200,11 @@ const bandLayersMarkup = BANDS.map(
188
200
  export class AiFlowingWavesIndicator extends GenesisElement {
189
201
  // A rAF loop morphs the band shapes: pure CSS can't continuously reshape the
190
202
  // bumps (different sizes appearing at different times), and the per-frame sine
191
- // sums are cheap. Same approach as the sine-tracing waves indicator.
203
+ // sums are cheap. Same approach as the sine-tracing waves indicator. The morph
204
+ // is keyed to elapsed wall-clock time (see FRAME_MS), not the rAF callback
205
+ // count, so it holds a constant speed even when frames are dropped.
192
206
 
193
- private frame = 0;
207
+ private startTime?: number;
194
208
  private animFrame?: number;
195
209
  private bandPaths?: SVGPathElement[];
196
210
 
@@ -208,9 +222,11 @@ export class AiFlowingWavesIndicator extends GenesisElement {
208
222
  cancelAnimationFrame(this.animFrame);
209
223
  this.animFrame = undefined;
210
224
  }
225
+ // Reset the clock so a later reconnect restarts the morph cleanly at frame 0.
226
+ this.startTime = undefined;
211
227
  }
212
228
 
213
- private tick() {
229
+ private tick(now?: number) {
214
230
  // Stop ticking once disconnected (belt-and-braces alongside the cancel in
215
231
  // disconnectedCallback) so no frames are scheduled while detached.
216
232
  if (!this.isConnected) {
@@ -221,12 +237,16 @@ export class AiFlowingWavesIndicator extends GenesisElement {
221
237
  const paths = this.shadowRoot?.querySelectorAll<SVGPathElement>('.band');
222
238
  if (paths?.length) this.bandPaths = Array.from(paths);
223
239
  }
240
+ // Anchor to the first real rAF timestamp; the initial synchronous call (no
241
+ // timestamp) renders frame 0. `frame` is then elapsed time in 60fps frames.
242
+ if (now !== undefined && this.startTime === undefined) this.startTime = now;
243
+ const frame =
244
+ now === undefined || this.startTime === undefined ? 0 : (now - this.startTime) / FRAME_MS;
224
245
  this.bandPaths?.forEach((path, i) => {
225
246
  const cfg = BANDS[i];
226
- if (cfg) path.setAttribute('d', AiFlowingWavesIndicator.buildBand(cfg, this.frame));
247
+ if (cfg) path.setAttribute('d', AiFlowingWavesIndicator.buildBand(cfg, frame));
227
248
  });
228
- this.frame += 1;
229
- this.animFrame = requestAnimationFrame(() => this.tick());
249
+ this.animFrame = requestAnimationFrame((t) => this.tick(t));
230
250
  }
231
251
 
232
252
  /**
@@ -10,6 +10,17 @@ const HALO_DEFAULT_SPEED = 1.5;
10
10
  const HALO_DEFAULT_BORDER_SIZE = 3;
11
11
  const HALO_DEFAULT_GLOW_OPACITY = 0.35;
12
12
  const HALO_DEFAULT_GLOW_SPREAD = 70;
13
+ /** Milliseconds in one second. */
14
+ const MS_PER_SECOND = 1000;
15
+ /** Baseline frame rate (fps) the `speed` (degrees/frame) is calibrated against. */
16
+ const BASELINE_FPS = 60;
17
+ /**
18
+ * Frame duration (ms) at the baseline rate. Rotation advances by the elapsed
19
+ * wall-clock time divided by this — not a fixed step per rAF callback — so it
20
+ * holds a constant real-world speed regardless of the display's refresh rate or
21
+ * dropped frames.
22
+ */
23
+ const FRAME_MS = MS_PER_SECOND / BASELINE_FPS;
13
24
 
14
25
  /**
15
26
  * Animated halo overlay — rotating conic-gradient border with an inward glow.
@@ -135,6 +146,7 @@ export class AiHaloOverlay extends GenesisElement {
135
146
  private static readonly FULL_ROTATION_DEG = 360;
136
147
 
137
148
  private angle = 0;
149
+ private lastTime?: number;
138
150
  private animFrame?: number;
139
151
 
140
152
  connectedCallback() {
@@ -146,14 +158,27 @@ export class AiHaloOverlay extends GenesisElement {
146
158
  super.disconnectedCallback();
147
159
  if (this.animFrame !== undefined) {
148
160
  cancelAnimationFrame(this.animFrame);
161
+ this.animFrame = undefined;
149
162
  }
163
+ // Reset the clock so a later reconnect doesn't apply one huge time delta.
164
+ this.lastTime = undefined;
150
165
  }
151
166
 
152
- private tick() {
153
- const step = this.direction === 'ccw' ? -this.speed : this.speed;
154
- this.angle =
155
- (this.angle + step + AiHaloOverlay.FULL_ROTATION_DEG) % AiHaloOverlay.FULL_ROTATION_DEG;
156
- this.style.setProperty('--halo-angle', `${this.angle}deg`);
157
- this.animFrame = requestAnimationFrame(() => this.tick());
167
+ private tick(now?: number) {
168
+ // Advance by elapsed wall-clock time (expressed in 60fps-equivalent frames)
169
+ // rather than a fixed step per callback, so the rotation runs at a constant
170
+ // real-world speed regardless of the display's refresh rate or dropped
171
+ // frames. The first tick just seeds the clock (no delta yet).
172
+ if (this.lastTime !== undefined && now !== undefined) {
173
+ const frames = (now - this.lastTime) / FRAME_MS;
174
+ const step = this.direction === 'ccw' ? -this.speed : this.speed;
175
+ const raw = this.angle + step * frames;
176
+ this.angle =
177
+ ((raw % AiHaloOverlay.FULL_ROTATION_DEG) + AiHaloOverlay.FULL_ROTATION_DEG) %
178
+ AiHaloOverlay.FULL_ROTATION_DEG;
179
+ this.style.setProperty('--halo-angle', `${this.angle}deg`);
180
+ }
181
+ this.lastTime = now;
182
+ this.animFrame = requestAnimationFrame((t) => this.tick(t));
158
183
  }
159
184
  }
@@ -209,6 +209,24 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
209
209
  return this.chatDriver.isBusy();
210
210
  }
211
211
 
212
+ // GENC-1410 (Error 3): forward single-instance / side-effect coordination to the inner driver,
213
+ // which owns the interaction history and pending map.
214
+ registerHost(hostId: string): void {
215
+ this.chatDriver.registerHost(hostId);
216
+ }
217
+
218
+ unregisterHost(hostId: string): void {
219
+ this.chatDriver.unregisterHost(hostId);
220
+ }
221
+
222
+ setActiveHost(hostId: string): void {
223
+ this.chatDriver.setActiveHost(hostId);
224
+ }
225
+
226
+ claimSideEffect(interactionId: string, hostId: string): boolean {
227
+ return this.chatDriver.claimSideEffect(interactionId, hostId);
228
+ }
229
+
212
230
  /** Currently active provider name from the underlying ChatDriver. */
213
231
  getActiveProviderName(): string {
214
232
  return this.chatDriver.getActiveProviderName();
@@ -128,6 +128,13 @@ export const settingsModalStyles = css`
128
128
  gap: calc(var(--design-unit) * 2px);
129
129
  }
130
130
 
131
+ .settings-modal-section-note {
132
+ margin: calc(var(--design-unit) * -2px) 0 calc(var(--design-unit) * 3px);
133
+ font-size: 12px;
134
+ line-height: 1.5;
135
+ color: var(--neutral-foreground-hint);
136
+ }
137
+
131
138
  .settings-modal-section-body {
132
139
  display: flex;
133
140
  flex-direction: column;
@@ -212,10 +212,23 @@ const chatBotSettingsTemplate = (
212
212
  },
213
213
  ),
214
214
  )}
215
+ ${when(
216
+ (x) => x.chatConfig.ui?.showNarration != null,
217
+ chatBotToggleRow(
218
+ 'Show working',
219
+ "Show the assistant's running commentary — the short notes it writes alongside its actions as it works, separate from its reasoning and its final answer.",
220
+ switchTag,
221
+ 'toggle-narration',
222
+ (x) => x.showNarration,
223
+ (x, c) => {
224
+ x.showNarration = readSwitchChecked(c.event);
225
+ },
226
+ ),
227
+ )}
215
228
  ${when(
216
229
  (x) => x.chatConfig.ui?.showAgentSwitchIndicator != null,
217
230
  chatBotToggleRow(
218
- 'Enable agents',
231
+ 'Show delegation',
219
232
  'Show in the chat window when tasks are delegated to different agents.',
220
233
  switchTag,
221
234
  'toggle-agent-switch',
@@ -225,21 +238,6 @@ const chatBotSettingsTemplate = (
225
238
  },
226
239
  ),
227
240
  )}
228
- ${when(
229
- (x) => x.chatConfig.ui?.allowDebugDownload === true,
230
- settingsToggleRow(
231
- 'Download agent log',
232
- "Export a structured debug timeline of this session's agent activity.",
233
- 'download-agent-log',
234
- html<FoundationAiAssistant>`
235
- <${buttonTag}
236
- part="download-button"
237
- appearance="outline"
238
- @click=${(x) => x.downloadDebugLog()}
239
- >Download</${buttonTag}>
240
- `,
241
- ),
242
- )}
243
241
  ${when(
244
242
  (x) => x.chatConfig.ui?.animations?.userConfigurable === true,
245
243
  settingsToggleRow(
@@ -480,10 +478,32 @@ export const SettingsModalTemplate = (
480
478
  ${appSettingsTemplate(switchTag)}
481
479
  </div>
482
480
  </section>
481
+ ${when(
482
+ (x) => x.chatConfig.ui?.allowDebugDownload === true,
483
+ html<FoundationAiAssistant>`
484
+ <section class="settings-modal-section" part="settings-section-agent-log">
485
+ <div class="settings-modal-section-body">
486
+ ${settingsToggleRow(
487
+ 'Download agent log',
488
+ "Export a structured debug timeline of this session's agent activity.",
489
+ 'download-agent-log',
490
+ html<FoundationAiAssistant>`
491
+ <${buttonTag}
492
+ part="download-button"
493
+ appearance="outline"
494
+ @click=${(x) => x.downloadDebugLog()}
495
+ >Download</${buttonTag}>
496
+ `,
497
+ )}
498
+ </div>
499
+ </section>
500
+ `,
501
+ )}
483
502
  <section class="settings-modal-section" part="settings-section-chat">
484
503
  <h3 class="settings-modal-section-title">
485
504
  <${iconTag} name="comment"></${iconTag}> AI Chat Bot Settings
486
505
  </h3>
506
+ <p class="settings-modal-section-note">These options only control what you see in the chat window and do not control model behaviour.</p>
487
507
  <div class="settings-modal-section-body">
488
508
  ${chatBotSettingsTemplate(switchTag, buttonTag)}
489
509
  </div>
@@ -17,6 +17,18 @@ const VIEWBOX = 120;
17
17
  const CENTRE = VIEWBOX / 2;
18
18
  /** Horizontal sampling step when tracing each wave path. Lower = smoother. */
19
19
  const SAMPLE_STEP = 6;
20
+ /** Milliseconds in one second. */
21
+ const MS_PER_SECOND = 1000;
22
+ /** Baseline frame rate (fps) the wave speeds are tuned against. */
23
+ const BASELINE_FPS = 60;
24
+ /**
25
+ * Frame duration (ms) at the baseline rate. The paths are driven by elapsed
26
+ * wall-clock time divided by this — not by the rAF callback count — so they
27
+ * advance at a constant real-world rate regardless of the display's refresh
28
+ * rate or dropped frames. At the baseline rate the resulting frame index
29
+ * matches the old per-callback counter, preserving the current look.
30
+ */
31
+ const FRAME_MS = MS_PER_SECOND / BASELINE_FPS;
20
32
 
21
33
  /**
22
34
  * Per-wave parameters. Each wave is a travelling sine (amplitude/frequency/phase)
@@ -163,8 +175,10 @@ export class AiWavesIndicator extends GenesisElement {
163
175
  // A rAF loop drives the wave paths for the same reason `<ai-halo-overlay>`
164
176
  // hand-drives its rotation: a pure-CSS approach can't produce per-frame sine
165
177
  // geometry, and SMIL/`<animate>` can't express the combined travel + slosh.
178
+ // The paths are keyed to elapsed wall-clock time (see FRAME_MS), not the rAF
179
+ // callback count, so they hold a constant speed even when frames are dropped.
166
180
 
167
- private frame = 0;
181
+ private startTime?: number;
168
182
  private animFrame?: number;
169
183
  private wavePaths?: SVGPathElement[];
170
184
 
@@ -182,9 +196,11 @@ export class AiWavesIndicator extends GenesisElement {
182
196
  cancelAnimationFrame(this.animFrame);
183
197
  this.animFrame = undefined;
184
198
  }
199
+ // Reset the clock so a later reconnect restarts the waves cleanly at frame 0.
200
+ this.startTime = undefined;
185
201
  }
186
202
 
187
- private tick() {
203
+ private tick(now?: number) {
188
204
  // Stop ticking once disconnected (belt-and-braces alongside the cancel in
189
205
  // disconnectedCallback) so no frames are scheduled while detached.
190
206
  if (!this.isConnected) {
@@ -195,11 +211,15 @@ export class AiWavesIndicator extends GenesisElement {
195
211
  const paths = this.shadowRoot?.querySelectorAll<SVGPathElement>('.wave');
196
212
  if (paths?.length) this.wavePaths = Array.from(paths);
197
213
  }
214
+ // Anchor to the first real rAF timestamp; the initial synchronous call (no
215
+ // timestamp) renders frame 0. `frame` is then elapsed time in 60fps frames.
216
+ if (now !== undefined && this.startTime === undefined) this.startTime = now;
217
+ const frame =
218
+ now === undefined || this.startTime === undefined ? 0 : (now - this.startTime) / FRAME_MS;
198
219
  this.wavePaths?.forEach((path, i) => {
199
- path.setAttribute('d', AiWavesIndicator.buildWavePath(WAVES[i], this.frame));
220
+ path.setAttribute('d', AiWavesIndicator.buildWavePath(WAVES[i], frame));
200
221
  });
201
- this.frame += 1;
202
- this.animFrame = requestAnimationFrame(() => this.tick());
222
+ this.animFrame = requestAnimationFrame((t) => this.tick(t));
203
223
  }
204
224
 
205
225
  /** Trace one wave's polyline `d` attribute for the given frame. */
@@ -279,6 +279,12 @@ const baseStyles = css`
279
279
  font-size: 0.95em;
280
280
  }
281
281
 
282
+ /* Narration shares the Thinking bubble but is the assistant's commentary (labelled "Working"),
283
+ not its inner monologue — keep it upright so it reads as action, distinct from italic reasoning. */
284
+ .message.ai-thinking.narration {
285
+ font-style: normal;
286
+ }
287
+
282
288
  .message.ai-function {
283
289
  background: linear-gradient(135deg, rgb(15 32 39 / 90%) 0%, rgb(30 58 58 / 80%) 100%);
284
290
  color: var(--ai-function-color, #86efac);