@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
@@ -12,6 +12,18 @@ const VIEWBOX = 120;
12
12
  const CENTRE = VIEWBOX / 2;
13
13
  /** Horizontal sampling step when tracing each wave path. Lower = smoother. */
14
14
  const SAMPLE_STEP = 6;
15
+ /** Milliseconds in one second. */
16
+ const MS_PER_SECOND = 1000;
17
+ /** Baseline frame rate (fps) the wave speeds are tuned against. */
18
+ const BASELINE_FPS = 60;
19
+ /**
20
+ * Frame duration (ms) at the baseline rate. The paths are driven by elapsed
21
+ * wall-clock time divided by this — not by the rAF callback count — so they
22
+ * advance at a constant real-world rate regardless of the display's refresh
23
+ * rate or dropped frames. At the baseline rate the resulting frame index
24
+ * matches the old per-callback counter, preserving the current look.
25
+ */
26
+ const FRAME_MS = MS_PER_SECOND / BASELINE_FPS;
15
27
  const WAVES = [
16
28
  {
17
29
  colour: AI_COLOUR_AMBER,
@@ -75,10 +87,6 @@ let AiWavesIndicator = AiWavesIndicator_1 = class AiWavesIndicator extends Genes
75
87
  super(...arguments);
76
88
  /** Diameter of the circular window in px. Default: 56. */
77
89
  this.size = WAVES_DEFAULT_SIZE;
78
- // A rAF loop drives the wave paths for the same reason `<ai-halo-overlay>`
79
- // hand-drives its rotation: a pure-CSS approach can't produce per-frame sine
80
- // geometry, and SMIL/`<animate>` can't express the combined travel + slosh.
81
- this.frame = 0;
82
90
  }
83
91
  sizeChanged() {
84
92
  this.style.setProperty('--waves-size', `${this.size}px`);
@@ -96,8 +104,10 @@ let AiWavesIndicator = AiWavesIndicator_1 = class AiWavesIndicator extends Genes
96
104
  cancelAnimationFrame(this.animFrame);
97
105
  this.animFrame = undefined;
98
106
  }
107
+ // Reset the clock so a later reconnect restarts the waves cleanly at frame 0.
108
+ this.startTime = undefined;
99
109
  }
100
- tick() {
110
+ tick(now) {
101
111
  var _a, _b;
102
112
  // Stop ticking once disconnected (belt-and-braces alongside the cancel in
103
113
  // disconnectedCallback) so no frames are scheduled while detached.
@@ -110,11 +120,15 @@ let AiWavesIndicator = AiWavesIndicator_1 = class AiWavesIndicator extends Genes
110
120
  if (paths === null || paths === void 0 ? void 0 : paths.length)
111
121
  this.wavePaths = Array.from(paths);
112
122
  }
123
+ // Anchor to the first real rAF timestamp; the initial synchronous call (no
124
+ // timestamp) renders frame 0. `frame` is then elapsed time in 60fps frames.
125
+ if (now !== undefined && this.startTime === undefined)
126
+ this.startTime = now;
127
+ const frame = now === undefined || this.startTime === undefined ? 0 : (now - this.startTime) / FRAME_MS;
113
128
  (_b = this.wavePaths) === null || _b === void 0 ? void 0 : _b.forEach((path, i) => {
114
- path.setAttribute('d', AiWavesIndicator_1.buildWavePath(WAVES[i], this.frame));
129
+ path.setAttribute('d', AiWavesIndicator_1.buildWavePath(WAVES[i], frame));
115
130
  });
116
- this.frame += 1;
117
- this.animFrame = requestAnimationFrame(() => this.tick());
131
+ this.animFrame = requestAnimationFrame((t) => this.tick(t));
118
132
  }
119
133
  /** Trace one wave's polyline `d` attribute for the given frame. */
120
134
  static buildWavePath(cfg, frame) {
@@ -190,8 +190,20 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
190
190
  * to avoid stealing focus from elsewhere on the page.
191
191
  */
192
192
  this.isEngaged = true;
193
+ /**
194
+ * GENC-1410 (Error 3): stable id for this assistant view. Reported to the shared driver so it
195
+ * can elect a single owner to render (and run) each interaction's widget when more than one view
196
+ * (e.g. a bubble and a docked panel) is wired to the same driver — so it fires once, not per view.
197
+ */
198
+ this.hostId = crypto.randomUUID();
193
199
  this._handleGlobalInteraction = (e) => {
200
+ var _a, _b;
194
201
  const next = e.composedPath().includes(this);
202
+ // GENC-1410: sticky "last active host" — whenever the user interacts inside THIS view, claim
203
+ // ownership on the shared driver. Unlike `isEngaged` this does NOT clear when the user later
204
+ // clicks elsewhere in the layout; it only moves when they use a *different* assistant view.
205
+ if (next)
206
+ (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.setActiveHost) === null || _b === void 0 ? void 0 : _b.call(_a, this.hostId);
195
207
  if (next === this.isEngaged)
196
208
  return;
197
209
  this.isEngaged = next;
@@ -368,6 +380,15 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
368
380
  var _a;
369
381
  (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setShowThinkingSteps(value);
370
382
  }
383
+ /** Current user-facing toggle state for narration (interstitial prose) visibility. */
384
+ get showNarration() {
385
+ var _a, _b;
386
+ return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.showNarration) !== null && _b !== void 0 ? _b : false;
387
+ }
388
+ set showNarration(value) {
389
+ var _a;
390
+ (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setShowNarration(value);
391
+ }
371
392
  /** Current user-facing toggle state for agent switch indicator visibility. */
372
393
  get showAgentSwitchIndicator() {
373
394
  var _a, _b;
@@ -687,7 +708,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
687
708
  this.showHalo = 'agent';
688
709
  }
689
710
  }
690
- /** True when there is a pending (unresolved) interaction — disables the popout button. */
711
+ /** True when there is a pending (unresolved) interaction. */
691
712
  get hasActivePendingInteraction() {
692
713
  if (!this.busy)
693
714
  return false;
@@ -741,6 +762,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
741
762
  return filterVisibleMessages(listed, {
742
763
  showToolCalls: this.showToolCalls,
743
764
  showThinkingSteps: this.showThinkingSteps,
765
+ showNarration: this.showNarration,
744
766
  showAgentSwitchIndicator,
745
767
  });
746
768
  }
@@ -916,11 +938,15 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
916
938
  * times — unwires any existing listeners first.
917
939
  */
918
940
  wireDriver() {
941
+ var _a;
919
942
  const { driver } = this;
920
943
  if (!driver)
921
944
  return;
922
945
  // Idempotent — unwire previous listeners on this component before re-wiring.
923
946
  this.unwireDriver();
947
+ // GENC-1410 (Error 3): announce this view to the (shared) driver so it can elect a single
948
+ // owner for side-effecting interactions when more than one view is wired to it.
949
+ (_a = driver.registerHost) === null || _a === void 0 ? void 0 : _a.call(driver, this.hostId);
924
950
  const onHistoryUpdated = (e) => {
925
951
  this.messages = [...e.detail];
926
952
  };
@@ -1002,6 +1028,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1002
1028
  driver.addEventListener('interaction-stop', onInteractionStop);
1003
1029
  driver.addEventListener('provider-changed', onProviderChanged);
1004
1030
  const cleanups = [
1031
+ () => { var _a; return (_a = driver.unregisterHost) === null || _a === void 0 ? void 0 : _a.call(driver, this.hostId); },
1005
1032
  () => driver.removeEventListener('history-updated', onHistoryUpdated),
1006
1033
  () => driver.removeEventListener('sub-agent-history-updated', onSubAgentHistoryUpdated),
1007
1034
  () => driver.removeEventListener('sub-agent-start', onSubAgentStart),
@@ -1071,7 +1098,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1071
1098
  this.driverCleanup = undefined;
1072
1099
  }
1073
1100
  connectedCallback() {
1074
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
1101
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
1075
1102
  // Initialise the store reference BEFORE super.connectedCallback() so that
1076
1103
  // the first FAST render has access to the store. The store Proxy calls
1077
1104
  // Observable.track(observableStore, sliceName) whenever a slice is read,
@@ -1087,6 +1114,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1087
1114
  const ui = (_a = this.chatConfig.ui) !== null && _a !== void 0 ? _a : {};
1088
1115
  this.showToolCalls = ui.showToolCalls === true;
1089
1116
  this.showThinkingSteps = ui.showThinkingSteps === true;
1117
+ this.showNarration = ui.showNarration === true;
1090
1118
  this.showAgentSwitchIndicator = ui.showAgentSwitchIndicator === true;
1091
1119
  this.enabledAnimations = resolveExclusiveLoadingStyle((_c = (_b = ui.animations) === null || _b === void 0 ? void 0 : _b.enabled) !== null && _c !== void 0 ? _c : (ui.animations ? [...DEFAULT_ANIMATIONS] : []));
1092
1120
  const defaultAgent = (_d = this.chatConfig.picker) === null || _d === void 0 ? void 0 : _d.defaultAgent;
@@ -1109,9 +1137,15 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1109
1137
  this._finalizeCostSessionOnPageHide = false;
1110
1138
  });
1111
1139
  const unsubPopin = agenticActivityBus.subscribe('chat-popin', () => {
1140
+ var _a, _b;
1112
1141
  this.logMeta('driver.wired', { reason: 'popin' });
1113
1142
  this.wireDriver();
1114
1143
  this._finalizeCostSessionOnPageHide = true;
1144
+ // GENC-1410: popin makes THIS (bubble) view the visible one. Take interaction ownership on
1145
+ // the shared driver so it elects this view to render widgets — not the now-hidden docked
1146
+ // panel. Ownership transfers as part of the dock lifecycle, deterministically, rather than
1147
+ // being inferred from wherever the user last happened to click.
1148
+ (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.setActiveHost) === null || _b === void 0 ? void 0 : _b.call(_a, this.hostId);
1115
1149
  });
1116
1150
  this.unsubBus = () => {
1117
1151
  unsubPopout();
@@ -1120,6 +1154,11 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1120
1154
  }
1121
1155
  else if (this.popoutMode === 'collapse') {
1122
1156
  this._finalizeCostSessionOnPageHide = true;
1157
+ // GENC-1410: a collapse-mode view exists only while docked, and it connects (running this
1158
+ // setup) as part of `onDock` — the moment it becomes the visible view. Take interaction
1159
+ // ownership now so the shared driver renders widgets here, not in the hidden bubble. This is
1160
+ // the dock counterpart to the bubble's ownership claim on `chat-popin` above.
1161
+ (_g = (_f = this.driver) === null || _f === void 0 ? void 0 : _f.setActiveHost) === null || _g === void 0 ? void 0 : _g.call(_f, this.hostId);
1123
1162
  const unsubPopout = agenticActivityBus.subscribe('chat-popout', () => {
1124
1163
  this._finalizeCostSessionOnPageHide = true;
1125
1164
  });
@@ -1134,13 +1173,13 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1134
1173
  this.syncShowingSplash();
1135
1174
  // Restore loading state if the driver is still executing mid-lifecycle.
1136
1175
  // disconnectedCallback resets state to 'idle', so we must check real driver state here.
1137
- if ((_f = this.driver) === null || _f === void 0 ? void 0 : _f.isBusy()) {
1176
+ if ((_h = this.driver) === null || _h === void 0 ? void 0 : _h.isBusy()) {
1138
1177
  this.state = 'loading';
1139
1178
  this.startLoadingTimer();
1140
1179
  // Subscribe once so that when the originating send() completes (possibly on a
1141
1180
  // different element instance that has since disconnected), this element cleans up
1142
1181
  // its own timer, syncs the halo, and triggers the post-response suggestion fetch.
1143
- this._executionCompletionUnsub = (_g = this._sessionRef) === null || _g === void 0 ? void 0 : _g.subscribeKey((s) => s.aiAssistant.state, () => {
1182
+ this._executionCompletionUnsub = (_j = this._sessionRef) === null || _j === void 0 ? void 0 : _j.subscribeKey((s) => s.aiAssistant.state, () => {
1144
1183
  var _a, _b;
1145
1184
  if (((_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.state) === 'idle') {
1146
1185
  this.stopLoadingTimer();
@@ -1163,7 +1202,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1163
1202
  // `provider-changed`. Feature-detected: a no-op for immutable registries.
1164
1203
  // Re-subscribed per connect (docking/popout remounts); balanced in
1165
1204
  // disconnectedCallback.
1166
- (_h = this.unsubProviderRegistry) === null || _h === void 0 ? void 0 : _h.call(this);
1205
+ (_k = this.unsubProviderRegistry) === null || _k === void 0 ? void 0 : _k.call(this);
1167
1206
  this.unsubProviderRegistry = undefined;
1168
1207
  if (isObservableAIProviderRegistry(this.providerRegistry)) {
1169
1208
  this.unsubProviderRegistry = this.providerRegistry.subscribe(() => {
@@ -1199,7 +1238,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1199
1238
  restoredMessages: this.messages.length,
1200
1239
  driver: driverExisted ? 'reused' : 'created',
1201
1240
  driverKind: this.driver instanceof OrchestratingDriver ? 'orchestrating' : 'chat',
1202
- driverBusy: (_k = (_j = this.driver) === null || _j === void 0 ? void 0 : _j.isBusy()) !== null && _k !== void 0 ? _k : false,
1241
+ driverBusy: (_m = (_l = this.driver) === null || _l === void 0 ? void 0 : _l.isBusy()) !== null && _m !== void 0 ? _m : false,
1203
1242
  });
1204
1243
  }
1205
1244
  disconnectedCallback() {
@@ -1414,6 +1453,30 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1414
1453
  this.showLoadingIndicator = false;
1415
1454
  }
1416
1455
  /** Called when the user clicks the popout button. */
1456
+ /**
1457
+ * GENC-1410 (Error 3): claim the right to run a side-effecting interaction's server work for
1458
+ * THIS view, via the shared driver's first-writer-wins latch. Returns `true` when this view may
1459
+ * run it (first/owning claimant, or a re-render of the same view), `false` when another view
1460
+ * already holds it. Fail-open when the driver has no latch. Called by `ai-chat-interaction-wrapper`.
1461
+ */
1462
+ claimSideEffect(interactionId) {
1463
+ var _a, _b, _c;
1464
+ return (_c = (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.claimSideEffect) === null || _b === void 0 ? void 0 : _b.call(_a, interactionId, this.hostId)) !== null && _c !== void 0 ? _c : true;
1465
+ }
1466
+ /**
1467
+ * GENC-1410 (Error 3): mark THIS view as the interaction owner on the shared driver, so it (not a
1468
+ * sibling view sharing the same driver) is elected to render side-effecting widgets. The bubble
1469
+ * and docked panel normally claim this automatically on the pop-in / dock lifecycle, but that
1470
+ * lifecycle only runs once `popout-mode` is active. A host that establishes a view outside that
1471
+ * lifecycle — e.g. a page that restores a docked assistant from a persisted layout before the
1472
+ * pop-out affordance is enabled — calls this to seize ownership deterministically rather than
1473
+ * leaving it to the last-interaction / first-registered fallback. Idempotent; safe to call on
1474
+ * every layout (re)establish. No-op if the driver was never wired.
1475
+ */
1476
+ claimInteractionOwnership() {
1477
+ var _a, _b;
1478
+ (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.setActiveHost) === null || _b === void 0 ? void 0 : _b.call(_a, this.hostId);
1479
+ }
1417
1480
  handlePopout() {
1418
1481
  if (this.popoutMode === 'expand') {
1419
1482
  this.logMeta('assistant.popout');
@@ -1573,6 +1636,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1573
1636
  !!this.headerTitle ||
1574
1637
  (ui === null || ui === void 0 ? void 0 : ui.showToolCalls) != null ||
1575
1638
  (ui === null || ui === void 0 ? void 0 : ui.showThinkingSteps) != null ||
1639
+ (ui === null || ui === void 0 ? void 0 : ui.showNarration) != null ||
1576
1640
  (ui === null || ui === void 0 ? void 0 : ui.showAgentSwitchIndicator) != null ||
1577
1641
  (ui === null || ui === void 0 ? void 0 : ui.allowDebugDownload) === true ||
1578
1642
  (ui === null || ui === void 0 ? void 0 : ui.animations) != null ||
@@ -1754,6 +1818,9 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1754
1818
  toggleShowThinkingSteps() {
1755
1819
  this.showThinkingSteps = !this.showThinkingSteps;
1756
1820
  }
1821
+ toggleShowNarration() {
1822
+ this.showNarration = !this.showNarration;
1823
+ }
1757
1824
  toggleShowAgentSwitchIndicator() {
1758
1825
  this.showAgentSwitchIndicator = !this.showAgentSwitchIndicator;
1759
1826
  }
@@ -273,6 +273,12 @@ const baseStyles = css `
273
273
  font-size: 0.95em;
274
274
  }
275
275
 
276
+ /* Narration shares the Thinking bubble but is the assistant's commentary (labelled "Working"),
277
+ not its inner monologue — keep it upright so it reads as action, distinct from italic reasoning. */
278
+ .message.ai-thinking.narration {
279
+ font-style: normal;
280
+ }
281
+
276
282
  .message.ai-function {
277
283
  background: linear-gradient(135deg, rgb(15 32 39 / 90%) 0%, rgb(30 58 58 / 80%) 100%);
278
284
  color: var(--ai-function-color, #86efac);
@@ -76,6 +76,12 @@ const messageType = (m) => {
76
76
  // outcome; render it identically to a real user message.
77
77
  case m.role === 'synthetic-user':
78
78
  return 'user';
79
+ // Reasoning (chain-of-thought summary) and narration (interstitial prose the model emits
80
+ // alongside a tool call) both render as the "Thinking" bubble — visually set apart from a real
81
+ // final agent reply, even though they stay separate categories (own log entry + own toggle:
82
+ // `showThinkingSteps` vs `showNarration`). `thinking` is the legacy flag for pre-split sessions.
83
+ case m.category === 'reasoning':
84
+ case m.category === 'narration':
79
85
  case !!m.thinking:
80
86
  return 'ai-thinking';
81
87
  case !!m.toolCalls:
@@ -93,6 +99,12 @@ const senderLabel = {
93
99
  interaction: 'Assistant',
94
100
  ai: 'Assistant',
95
101
  };
102
+ /**
103
+ * Sender label for a message. Reasoning and narration share the "Thinking" bubble, so the label is
104
+ * the only thing that tells them apart: narration reads "Working" (the action it narrates alongside
105
+ * its tool call), reasoning stays "Thinking". Everything else uses the {@link messageType} map.
106
+ */
107
+ const senderLabelFor = (m) => m.category === 'narration' ? 'Working' : senderLabel[messageType(m)];
96
108
  /**
97
109
  * Resolves how the host frames an interaction widget, defaulting to `'label'`
98
110
  * (the historical "Assistant" label, no bubble). Callers guard on
@@ -224,7 +236,9 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
224
236
  // assistant one.
225
237
  (m) => messageType(m) === 'user', userAvatarTemplate)}${when((m) => messageType(m) !== 'user', assistantAvatarTemplate)}
226
238
  </div>
227
- <div class="message ${(m) => messageType(m)}">
239
+ <div
240
+ class="message ${(m) => messageType(m)} ${(m) => classNames(['narration', m.category === 'narration'])}"
241
+ >
228
242
  ${when(
229
243
  // A 'bare' interaction owns its full presentation — suppress the
230
244
  // host sender label. Every other message keeps it.
@@ -236,7 +250,7 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
236
250
  m.agentName &&
237
251
  c.parent.showAgentSwitchIndicator
238
252
  ? `Tool Call · ${(_a = m.agentLabel) !== null && _a !== void 0 ? _a : m.agentName}`
239
- : senderLabel[messageType(m)];
253
+ : senderLabelFor(m);
240
254
  }}
241
255
  </div>
242
256
  `)}
@@ -249,6 +263,11 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
249
263
  `)}
250
264
  ${when((m) => m.interaction, html `
251
265
  <ai-chat-interaction-wrapper
266
+ ${
267
+ /* GENC-1410: bind ownerHostId BEFORE componentName/interactionId — bindings
268
+ apply in template order and both trigger renderComponent, so the ownership
269
+ gate prop must be set by the time the first render runs. */ ''}
270
+ :ownerHostId=${(m) => { var _a; return (_a = m.interaction.ownerHostId) !== null && _a !== void 0 ? _a : ''; }}
252
271
  :componentName=${(m) => m.interaction.componentName}
253
272
  :data=${(m) => m.interaction.data}
254
273
  :interactionId=${(m) => m.interaction.interactionId}
@@ -4,6 +4,7 @@ export const defaultSessionState = {
4
4
  state: 'idle',
5
5
  showToolCalls: false,
6
6
  showThinkingSteps: false,
7
+ showNarration: false,
7
8
  showAgentSwitchIndicator: false,
8
9
  enabledAnimations: [],
9
10
  suggestionsState: { status: 'idle' },
@@ -39,6 +40,9 @@ export const aiAssistantSlice = createSlice({
39
40
  setShowThinkingSteps(state, action) {
40
41
  state.showThinkingSteps = action.payload;
41
42
  },
43
+ setShowNarration(state, action) {
44
+ state.showNarration = action.payload;
45
+ },
42
46
  setShowAgentSwitchIndicator(state, action) {
43
47
  state.showAgentSwitchIndicator = action.payload;
44
48
  },
@@ -35,7 +35,16 @@ export function filterVisibleMessages(messages, toggles) {
35
35
  if (m.role === 'tool') {
36
36
  return false;
37
37
  }
38
- // Thinking messages follow their own toggle.
38
+ // Reasoning (chain-of-thought summary) follows the thinking toggle.
39
+ if (m.category === 'reasoning') {
40
+ return toggles.showThinkingSteps;
41
+ }
42
+ // Narration (interstitial prose alongside a tool call) follows its own toggle.
43
+ if (m.category === 'narration') {
44
+ return toggles.showNarration;
45
+ }
46
+ // Legacy: sessions persisted before the reasoning/narration split tagged reasoning-ish
47
+ // content with `thinking`; keep honouring the flag so their history still hides it.
39
48
  if (m.thinking && !toggles.showThinkingSteps) {
40
49
  return false;
41
50
  }
@@ -5,11 +5,13 @@ const interaction = (interactionId, resolved = false) => (Object.assign({ intera
5
5
  const ALL_ON = {
6
6
  showToolCalls: true,
7
7
  showThinkingSteps: true,
8
+ showNarration: true,
8
9
  showAgentSwitchIndicator: true,
9
10
  };
10
11
  const ALL_OFF = {
11
12
  showToolCalls: false,
12
13
  showThinkingSteps: false,
14
+ showNarration: false,
13
15
  showAgentSwitchIndicator: false,
14
16
  };
15
17
  const trailing = createLogicSuite('trailingInteractionRow');
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.479.0",
4
+ "version": "14.480.0",
5
5
  "license": "SEE LICENSE IN license.txt",
6
6
  "main": "dist/esm/index.js",
7
7
  "types": "dist/ai-assistant.d.ts",
@@ -64,25 +64,25 @@
64
64
  }
65
65
  },
66
66
  "devDependencies": {
67
- "@genesislcap/foundation-testing": "14.479.0",
68
- "@genesislcap/genx": "14.479.0",
69
- "@genesislcap/rollup-builder": "14.479.0",
70
- "@genesislcap/ts-builder": "14.479.0",
71
- "@genesislcap/uvu-playwright-builder": "14.479.0",
72
- "@genesislcap/vite-builder": "14.479.0",
73
- "@genesislcap/webpack-builder": "14.479.0",
67
+ "@genesislcap/foundation-testing": "14.480.0",
68
+ "@genesislcap/genx": "14.480.0",
69
+ "@genesislcap/rollup-builder": "14.480.0",
70
+ "@genesislcap/ts-builder": "14.480.0",
71
+ "@genesislcap/uvu-playwright-builder": "14.480.0",
72
+ "@genesislcap/vite-builder": "14.480.0",
73
+ "@genesislcap/webpack-builder": "14.480.0",
74
74
  "@types/dompurify": "^3.0.5",
75
75
  "@types/marked": "^5.0.2"
76
76
  },
77
77
  "dependencies": {
78
- "@genesislcap/foundation-ai": "14.479.0",
79
- "@genesislcap/foundation-logger": "14.479.0",
80
- "@genesislcap/foundation-notifications": "14.479.0",
81
- "@genesislcap/foundation-redux": "14.479.0",
82
- "@genesislcap/foundation-ui": "14.479.0",
83
- "@genesislcap/foundation-utils": "14.479.0",
84
- "@genesislcap/rapid-design-system": "14.479.0",
85
- "@genesislcap/web-core": "14.479.0",
78
+ "@genesislcap/foundation-ai": "14.480.0",
79
+ "@genesislcap/foundation-logger": "14.480.0",
80
+ "@genesislcap/foundation-notifications": "14.480.0",
81
+ "@genesislcap/foundation-redux": "14.480.0",
82
+ "@genesislcap/foundation-ui": "14.480.0",
83
+ "@genesislcap/foundation-utils": "14.480.0",
84
+ "@genesislcap/rapid-design-system": "14.480.0",
85
+ "@genesislcap/web-core": "14.480.0",
86
86
  "dompurify": "^3.3.1",
87
87
  "marked": "^17.0.3"
88
88
  },
@@ -94,5 +94,5 @@
94
94
  "publishConfig": {
95
95
  "access": "public"
96
96
  },
97
- "gitHead": "ec6335b224815a5e52af0692b2d202b765415b59"
97
+ "gitHead": "6d2bc60f9700ab9f5a6d8c1834baebbd66ae5e6b"
98
98
  }
@@ -98,4 +98,21 @@ export interface AiDriver extends EventTarget {
98
98
  * Optional; the host wires the stop button to it when present.
99
99
  */
100
100
  cancel?(): void;
101
+
102
+ // --- GENC-1410 (Error 3): single-instance execution of interaction widgets ------------------
103
+ // When several assistant views share one driver, these coordinate which single view renders
104
+ // (and thus runs) each interaction's widget, so its side effect fires once. Optional — a driver
105
+ // that never hosts duplicate views can omit them; the host calls with `?.`.
106
+
107
+ /** Register an assistant view (host) wired to this driver. */
108
+ registerHost?(hostId: string): void;
109
+
110
+ /** Deregister a host on unwire/disconnect. */
111
+ unregisterHost?(hostId: string): void;
112
+
113
+ /** Record the host whose subtree the user most recently interacted in (owner election signal). */
114
+ setActiveHost?(hostId: string): void;
115
+
116
+ /** First-writer-wins latch: the first host to claim an interaction id runs its side effect. */
117
+ claimSideEffect?(interactionId: string, hostId: string): boolean;
101
118
  }
@@ -14,6 +14,8 @@ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
14
14
  import { agenticActivityBus } from '../../channel/ai-activity-bus';
15
15
  import type { AgentConfig } from '../../config/config';
16
16
  import { clearMetaEventRegistry, getMetaEvents } from '../../state/debug-event-log';
17
+ import { sumCosts } from '../../utils/sum-costs';
18
+ import { sumTokens } from '../../utils/sum-tokens';
17
19
  import { createToolFold } from '../../utils/tool-fold';
18
20
  // Side-effect import — MUST come before `./chat-driver` so the driver subclasses
19
21
  // jsdom's EventTarget rather than Node's native one (see the file). None of the
@@ -1080,6 +1082,76 @@ subagent(
1080
1082
 
1081
1083
  subagent.run();
1082
1084
 
1085
+ // ---------------------------------------------------------------------------
1086
+ // cost / token attribution on the thinking-step split (GENC-1410)
1087
+ // ---------------------------------------------------------------------------
1088
+
1089
+ const costAttribution = createLogicSuite('ChatDriver cost attribution (thinking-step split)');
1090
+
1091
+ costAttribution(
1092
+ 'a text-plus-tool-call response contributes its cost and tokens exactly once',
1093
+ async () => {
1094
+ const config = agent({
1095
+ name: 'Worker',
1096
+ toolDefinitions: () => [def('do_thing')],
1097
+ toolHandlers: () => ({ do_thing: async () => 'ok' }),
1098
+ });
1099
+ // A single response that emits reasoning + interstitial narration + a tool call, carrying that
1100
+ // response's usage. The driver splits it into three messages — a `reasoning` bubble, a
1101
+ // `narration` bubble, and the tool-call message — but only ONE may carry the cost/tokens or the
1102
+ // session totals double-count this one response (GENC-1410).
1103
+ const provider = scriptedProvider([
1104
+ {
1105
+ role: 'assistant',
1106
+ content: 'Let me act.',
1107
+ reasoning: 'Let me think.',
1108
+ toolCalls: [{ id: 't1', name: 'do_thing', args: {} }],
1109
+ cost: 0.05,
1110
+ inputTokens: 1000,
1111
+ outputTokens: 50,
1112
+ },
1113
+ // Turn-ending reply (no tool calls, no usage) so the loop terminates cleanly.
1114
+ { role: 'assistant', content: 'Done.' },
1115
+ ]);
1116
+ const driver = makeDriver(config, provider);
1117
+
1118
+ await driver.sendMessage('go');
1119
+
1120
+ const history = driver.getHistory();
1121
+ const reasoning = history.find((m) => m.category === 'reasoning');
1122
+ const narration = history.find((m) => m.category === 'narration');
1123
+ const toolCallMsg = history.find((m) => m.toolCalls?.some((c) => c.name === 'do_thing'));
1124
+
1125
+ // The one response was split into reasoning + narration bubbles + the tool-call message.
1126
+ assert.ok(reasoning, 'a reasoning bubble is emitted for the chain-of-thought summary');
1127
+ assert.is(reasoning!.content, 'Let me think.');
1128
+ assert.ok(narration, 'a narration bubble is emitted for the interstitial prose');
1129
+ assert.is(narration!.content, 'Let me act.');
1130
+ assert.ok(toolCallMsg, 'the tool call is on its own message');
1131
+ assert.is(toolCallMsg!.content, '');
1132
+
1133
+ // Only the tool-call message carries the turn's usage; the display bubbles carry none.
1134
+ for (const m of [reasoning!, narration!]) {
1135
+ assert.is(m.cost, undefined, 'a display bubble must not carry cost');
1136
+ assert.is(m.inputTokens, undefined, 'nor inputTokens');
1137
+ assert.is(m.outputTokens, undefined, 'nor outputTokens');
1138
+ }
1139
+ assert.is(toolCallMsg!.cost, 0.05, 'the tool-call message carries the cost');
1140
+ assert.is(toolCallMsg!.inputTokens, 1000);
1141
+ assert.is(toolCallMsg!.outputTokens, 50);
1142
+
1143
+ // The reasoning/narration bubbles are UI-only; they must not carry the tool call either.
1144
+ assert.is(reasoning!.toolCalls, undefined, 'the reasoning bubble carries no tool call');
1145
+ assert.is(narration!.toolCalls, undefined, 'the narration bubble carries no tool call');
1146
+
1147
+ // The session totals count the one response once, not twice.
1148
+ assert.is(sumCosts(history), 0.05, 'cost is summed once');
1149
+ assert.is(sumTokens(history), 1050, 'tokens are summed once');
1150
+ },
1151
+ );
1152
+
1153
+ costAttribution.run();
1154
+
1083
1155
  // ---------------------------------------------------------------------------
1084
1156
  // per-agent / per-state temperature & tool-call mode (GENC-1321)
1085
1157
  //
@@ -1342,6 +1414,77 @@ interactionPresentation('presentation is absent when the option is omitted', asy
1342
1414
 
1343
1415
  interactionPresentation.run();
1344
1416
 
1417
+ // ---------------------------------------------------------------------------
1418
+ // single-instance interaction ownership (GENC-1410 Error 3) — when several views
1419
+ // share one driver, each interaction's widget is rendered/run by exactly one host.
1420
+ // The driver elects the owner (most-recently-active host, deterministic fallback),
1421
+ // stamps `ownerHostId` on every interaction, and de-dupes execution via a
1422
+ // first-writer claim.
1423
+ // ---------------------------------------------------------------------------
1424
+
1425
+ const ownedInteraction = createLogicSuite('ChatDriver single-instance interaction ownership');
1426
+
1427
+ ownedInteraction('stamps ownerHostId = most-recently-active host on the interaction', async () => {
1428
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1429
+ driver.registerHost('host-a');
1430
+ driver.registerHost('host-b');
1431
+ driver.setActiveHost('host-b');
1432
+
1433
+ const pending = driver.requestInteraction('w', {});
1434
+ const interaction = driver.getHistory().at(-1)!.interaction!;
1435
+ assert.is(interaction.ownerHostId, 'host-b', 'owner is the most-recently-active host');
1436
+
1437
+ driver.resolveInteraction(interaction.interactionId, { status: 'approved' });
1438
+ await pending;
1439
+ });
1440
+
1441
+ ownedInteraction(
1442
+ 'owner falls back to the first-registered host when none is active yet',
1443
+ async () => {
1444
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1445
+ driver.registerHost('host-a');
1446
+ driver.registerHost('host-b');
1447
+ // No setActiveHost — startup, before the user has engaged a view.
1448
+
1449
+ const pending = driver.requestInteraction('w', {});
1450
+ const interaction = driver.getHistory().at(-1)!.interaction!;
1451
+ assert.is(interaction.ownerHostId, 'host-a', 'deterministic default = first registered');
1452
+
1453
+ driver.resolveInteraction(interaction.interactionId, { status: 'approved' });
1454
+ await pending;
1455
+ },
1456
+ );
1457
+
1458
+ ownedInteraction(
1459
+ 'no host registered → no owner (widget renders everywhere, fail-open)',
1460
+ async () => {
1461
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1462
+
1463
+ const pending = driver.requestInteraction('w', {});
1464
+ const interaction = driver.getHistory().at(-1)!.interaction!;
1465
+ assert.ok(!('ownerHostId' in interaction), 'no owner stamped when no host is registered');
1466
+
1467
+ driver.resolveInteraction(interaction.interactionId, { status: 'approved' });
1468
+ await pending;
1469
+ },
1470
+ );
1471
+
1472
+ ownedInteraction(
1473
+ 'claimSideEffect is first-writer-wins, and re-entrant for the same host',
1474
+ async () => {
1475
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1476
+ assert.is(driver.claimSideEffect('i1', 'host-a'), true, 'first host claims it');
1477
+ assert.is(driver.claimSideEffect('i1', 'host-b'), false, 'a different host is denied');
1478
+ assert.is(
1479
+ driver.claimSideEffect('i1', 'host-a'),
1480
+ true,
1481
+ 're-claim by the owner (re-render) is fine',
1482
+ );
1483
+ },
1484
+ );
1485
+
1486
+ ownedInteraction.run();
1487
+
1345
1488
  // ---------------------------------------------------------------------------
1346
1489
  // interaction activity-bus signals (GENC-1346) — the driver brackets a parked
1347
1490
  // widget interaction with `interaction-requested` / `interaction-resolved`, so