@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
@@ -1,4 +1,4 @@
1
- import { __awaiter } from "tslib";
1
+ import { __awaiter, __rest } from "tslib";
2
2
  import { isObservableAIProviderRegistry, MalformedFunctionCallError, ResponseTruncatedError, } from '@genesislcap/foundation-ai';
3
3
  import { agenticActivityBus } from '../../channel/ai-activity-bus';
4
4
  import { resolveChatProvider } from '../../config/validate-providers';
@@ -76,6 +76,13 @@ export class ChatDriver extends EventTarget {
76
76
  /** Epoch ms when the current turn loop began — drives the `turn.end` duration. */
77
77
  this.turnStartedAt = 0;
78
78
  this.pendingInteractions = new Map();
79
+ // GENC-1410 (Error 3): when several assistant views share this one driver (e.g. a bubble and a
80
+ // docked panel), each interaction's widget must be rendered live — and thus fire any side
81
+ // effect — on exactly ONE view. These fields elect that owner and de-dupe execution.
82
+ /** Assistant host ids currently wired to this driver, in registration (insertion) order. */
83
+ this.hosts = new Set();
84
+ /** Interaction id → host id that claimed its side effect (first-writer-wins latch). */
85
+ this.startedSideEffects = new Map();
79
86
  /**
80
87
  * Tool-declared condensation policies, keyed by tool-call id. Populated by
81
88
  * `condenseWhen` (first-wins per call); read by `applyCondensation` before each
@@ -761,6 +768,53 @@ export class ChatDriver extends EventTarget {
761
768
  isBusy() {
762
769
  return this.busy;
763
770
  }
771
+ // --- GENC-1410 (Error 3): single-instance execution of side-effecting widgets --------------
772
+ /** Register an assistant view (host) wired to this shared driver. @internal */
773
+ registerHost(hostId) {
774
+ this.hosts.add(hostId);
775
+ }
776
+ /** Deregister a host on unwire/disconnect. @internal */
777
+ unregisterHost(hostId) {
778
+ this.hosts.delete(hostId);
779
+ }
780
+ /**
781
+ * Record the host whose subtree the user most recently interacted in — the primary signal for
782
+ * electing which single view renders an interaction's widget. Sticky (unlike focus): it only
783
+ * moves when the user actually uses a different assistant view, not when they click elsewhere
784
+ * in the layout. @internal
785
+ */
786
+ setActiveHost(hostId) {
787
+ this.lastActiveHostId = hostId;
788
+ }
789
+ /**
790
+ * First-writer-wins latch guarding a side effect. A host claims before rendering the live
791
+ * widget: the first host to claim an interaction id gets `true` (run it); a *different* host
792
+ * gets `false` (render passive, do NOT re-fire). Re-claim by the SAME host returns `true`, so a
793
+ * benign re-render of the owner keeps its widget. Single-threaded, so check-and-set is race-free
794
+ * — the backstop that makes duplicate execution impossible even if owner election ever slips. @internal
795
+ */
796
+ claimSideEffect(interactionId, hostId) {
797
+ const existing = this.startedSideEffects.get(interactionId);
798
+ if (existing === undefined) {
799
+ this.startedSideEffects.set(interactionId, hostId);
800
+ return true;
801
+ }
802
+ return existing === hostId;
803
+ }
804
+ /**
805
+ * Elect the single host that owns (renders + runs) an interaction's widget: the most-recently-
806
+ * active host if still connected, else a deterministic default (first registered host — `Set`
807
+ * preserves insertion order) so exactly one owner is always chosen. Returns `undefined` only when
808
+ * no host is registered, in which case the interaction renders everywhere (fail-open) not nowhere.
809
+ */
810
+ electInteractionOwner() {
811
+ if (this.lastActiveHostId && this.hosts.has(this.lastActiveHostId)) {
812
+ return this.lastActiveHostId;
813
+ }
814
+ // First registered host (`Set` preserves insertion order); `undefined` when none.
815
+ const [firstHost] = this.hosts;
816
+ return firstHost;
817
+ }
764
818
  /**
765
819
  * Wire a parent driver as the host for this driver's interactions. When set,
766
820
  * `requestInteraction` delegates upward so the widget renders in (and
@@ -803,6 +857,10 @@ export class ChatDriver extends EventTarget {
803
857
  const chatInputDuringExecution = options === null || options === void 0 ? void 0 : options.chatInputDuringExecution;
804
858
  const timeoutMs = options === null || options === void 0 ? void 0 : options.timeoutMs;
805
859
  const presentation = options === null || options === void 0 ? void 0 : options.presentation;
860
+ // GENC-1410 (Error 3): a widget is rendered live by exactly one host. Elect the owner now
861
+ // (most-recently-active host, deterministic fallback) and stamp it on the interaction so every
862
+ // host can compare its own id and render passively when it differs — one server call, not N.
863
+ const ownerHostId = this.electInteractionOwner();
806
864
  return new Promise((resolve, reject) => {
807
865
  this.pendingInteractions.set(interactionId, {
808
866
  resolve,
@@ -839,9 +897,9 @@ export class ChatDriver extends EventTarget {
839
897
  this.appendToHistory({
840
898
  role: 'assistant',
841
899
  content: '',
842
- interaction: Object.assign({ interactionId,
900
+ interaction: Object.assign(Object.assign({ interactionId,
843
901
  componentName,
844
- data }, (presentation ? { presentation } : {})),
902
+ data }, (presentation ? { presentation } : {})), (ownerHostId ? { ownerHostId } : {})),
845
903
  });
846
904
  });
847
905
  });
@@ -895,6 +953,8 @@ export class ChatDriver extends EventTarget {
895
953
  agenticActivityBus.publish('interaction-resolved', undefined);
896
954
  interaction.resolve(result);
897
955
  this.pendingInteractions.delete(interactionId);
956
+ // GENC-1410: release the side-effect claim so a re-request with a fresh id starts clean.
957
+ this.startedSideEffects.delete(interactionId);
898
958
  }
899
959
  else {
900
960
  logger.warn(`Interaction with ID ${interactionId} not found.`);
@@ -1764,12 +1824,32 @@ export class ChatDriver extends EventTarget {
1764
1824
  }
1765
1825
  return { reason: 'done' };
1766
1826
  }
1767
- else if (isThinkingStep) {
1768
- this.appendToHistory(Object.assign(Object.assign({}, response), { toolCalls: undefined, thinking: true }));
1769
- this.appendToHistory(Object.assign(Object.assign({}, response), { content: '' }));
1770
- }
1771
1827
  else {
1772
- this.appendToHistory(response);
1828
+ // Split one model response into separate, individually-toggleable messages so each has its
1829
+ // own visibility toggle and debug-log category:
1830
+ // - reasoning (chain-of-thought summary) → category 'reasoning', hidden unless showThinkingSteps
1831
+ // - narration (interstitial prose emitted alongside a tool call) → category 'narration',
1832
+ // hidden unless showNarration
1833
+ // - the answer / tool-call message → always shown; carries this turn's usage.
1834
+ // Cost invariant (GENC-1410): exactly ONE message carries cost/tokens and is appended LAST, so
1835
+ // `sumCosts`/`sumTokens` don't double-count and `contextTokens` reads it. Reasoning/narration are
1836
+ // display-only (usage undefined) and are skipped when building the provider request. `model` /
1837
+ // `provider` / `providerName` stay on every split message so each is still attributed.
1838
+ const { reasoning } = response, rest = __rest(response, ["reasoning"]);
1839
+ const displayOnly = { cost: undefined, inputTokens: undefined, outputTokens: undefined };
1840
+ if (reasoning) {
1841
+ this.appendToHistory(Object.assign(Object.assign(Object.assign({}, rest), displayOnly), { content: reasoning, toolCalls: undefined, category: 'reasoning' }));
1842
+ }
1843
+ if (isThinkingStep) {
1844
+ // content + tool call → the content is interstitial narration, not the final answer.
1845
+ this.appendToHistory(Object.assign(Object.assign(Object.assign({}, rest), displayOnly), { toolCalls: undefined, category: 'narration' }));
1846
+ this.appendToHistory(Object.assign(Object.assign({}, rest), { content: '' }));
1847
+ }
1848
+ else {
1849
+ // No tool call → `content` is the final answer (always shown), or this is a bare tool-call
1850
+ // turn with no narration. Either way it carries this turn's usage; append it last.
1851
+ this.appendToHistory(rest);
1852
+ }
1773
1853
  }
1774
1854
  // Reset retry budgets on any productive (non-empty) response, so the caps mean
1775
1855
  // "N CONSECUTIVE failures" not "N total per turn".
@@ -3,6 +3,8 @@ import { isChatToolCallUnknown } from '@genesislcap/foundation-ai';
3
3
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
4
4
  import { agenticActivityBus } from '../../channel/ai-activity-bus';
5
5
  import { clearMetaEventRegistry, getMetaEvents } from '../../state/debug-event-log';
6
+ import { sumCosts } from '../../utils/sum-costs';
7
+ import { sumTokens } from '../../utils/sum-tokens';
6
8
  import { createToolFold } from '../../utils/tool-fold';
7
9
  // Side-effect import — MUST come before `./chat-driver` so the driver subclasses
8
10
  // jsdom's EventTarget rather than Node's native one (see the file). None of the
@@ -773,6 +775,63 @@ subagent('times out (without hanging) when the sub-agent is parked on a user int
773
775
  }));
774
776
  subagent.run();
775
777
  // ---------------------------------------------------------------------------
778
+ // cost / token attribution on the thinking-step split (GENC-1410)
779
+ // ---------------------------------------------------------------------------
780
+ const costAttribution = createLogicSuite('ChatDriver cost attribution (thinking-step split)');
781
+ costAttribution('a text-plus-tool-call response contributes its cost and tokens exactly once', () => __awaiter(void 0, void 0, void 0, function* () {
782
+ const config = agent({
783
+ name: 'Worker',
784
+ toolDefinitions: () => [def('do_thing')],
785
+ toolHandlers: () => ({ do_thing: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) }),
786
+ });
787
+ // A single response that emits reasoning + interstitial narration + a tool call, carrying that
788
+ // response's usage. The driver splits it into three messages — a `reasoning` bubble, a
789
+ // `narration` bubble, and the tool-call message — but only ONE may carry the cost/tokens or the
790
+ // session totals double-count this one response (GENC-1410).
791
+ const provider = scriptedProvider([
792
+ {
793
+ role: 'assistant',
794
+ content: 'Let me act.',
795
+ reasoning: 'Let me think.',
796
+ toolCalls: [{ id: 't1', name: 'do_thing', args: {} }],
797
+ cost: 0.05,
798
+ inputTokens: 1000,
799
+ outputTokens: 50,
800
+ },
801
+ // Turn-ending reply (no tool calls, no usage) so the loop terminates cleanly.
802
+ { role: 'assistant', content: 'Done.' },
803
+ ]);
804
+ const driver = makeDriver(config, provider);
805
+ yield driver.sendMessage('go');
806
+ const history = driver.getHistory();
807
+ const reasoning = history.find((m) => m.category === 'reasoning');
808
+ const narration = history.find((m) => m.category === 'narration');
809
+ const toolCallMsg = history.find((m) => { var _a; return (_a = m.toolCalls) === null || _a === void 0 ? void 0 : _a.some((c) => c.name === 'do_thing'); });
810
+ // The one response was split into reasoning + narration bubbles + the tool-call message.
811
+ assert.ok(reasoning, 'a reasoning bubble is emitted for the chain-of-thought summary');
812
+ assert.is(reasoning.content, 'Let me think.');
813
+ assert.ok(narration, 'a narration bubble is emitted for the interstitial prose');
814
+ assert.is(narration.content, 'Let me act.');
815
+ assert.ok(toolCallMsg, 'the tool call is on its own message');
816
+ assert.is(toolCallMsg.content, '');
817
+ // Only the tool-call message carries the turn's usage; the display bubbles carry none.
818
+ for (const m of [reasoning, narration]) {
819
+ assert.is(m.cost, undefined, 'a display bubble must not carry cost');
820
+ assert.is(m.inputTokens, undefined, 'nor inputTokens');
821
+ assert.is(m.outputTokens, undefined, 'nor outputTokens');
822
+ }
823
+ assert.is(toolCallMsg.cost, 0.05, 'the tool-call message carries the cost');
824
+ assert.is(toolCallMsg.inputTokens, 1000);
825
+ assert.is(toolCallMsg.outputTokens, 50);
826
+ // The reasoning/narration bubbles are UI-only; they must not carry the tool call either.
827
+ assert.is(reasoning.toolCalls, undefined, 'the reasoning bubble carries no tool call');
828
+ assert.is(narration.toolCalls, undefined, 'the narration bubble carries no tool call');
829
+ // The session totals count the one response once, not twice.
830
+ assert.is(sumCosts(history), 0.05, 'cost is summed once');
831
+ assert.is(sumTokens(history), 1050, 'tokens are summed once');
832
+ }));
833
+ costAttribution.run();
834
+ // ---------------------------------------------------------------------------
776
835
  // per-agent / per-state temperature & tool-call mode (GENC-1321)
777
836
  //
778
837
  // The driver resolves `temperature` and `toolChoice` the same way it resolves
@@ -972,6 +1031,51 @@ interactionPresentation('presentation is absent when the option is omitted', ()
972
1031
  }));
973
1032
  interactionPresentation.run();
974
1033
  // ---------------------------------------------------------------------------
1034
+ // single-instance interaction ownership (GENC-1410 Error 3) — when several views
1035
+ // share one driver, each interaction's widget is rendered/run by exactly one host.
1036
+ // The driver elects the owner (most-recently-active host, deterministic fallback),
1037
+ // stamps `ownerHostId` on every interaction, and de-dupes execution via a
1038
+ // first-writer claim.
1039
+ // ---------------------------------------------------------------------------
1040
+ const ownedInteraction = createLogicSuite('ChatDriver single-instance interaction ownership');
1041
+ ownedInteraction('stamps ownerHostId = most-recently-active host on the interaction', () => __awaiter(void 0, void 0, void 0, function* () {
1042
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1043
+ driver.registerHost('host-a');
1044
+ driver.registerHost('host-b');
1045
+ driver.setActiveHost('host-b');
1046
+ const pending = driver.requestInteraction('w', {});
1047
+ const interaction = driver.getHistory().at(-1).interaction;
1048
+ assert.is(interaction.ownerHostId, 'host-b', 'owner is the most-recently-active host');
1049
+ driver.resolveInteraction(interaction.interactionId, { status: 'approved' });
1050
+ yield pending;
1051
+ }));
1052
+ ownedInteraction('owner falls back to the first-registered host when none is active yet', () => __awaiter(void 0, void 0, void 0, function* () {
1053
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1054
+ driver.registerHost('host-a');
1055
+ driver.registerHost('host-b');
1056
+ // No setActiveHost — startup, before the user has engaged a view.
1057
+ const pending = driver.requestInteraction('w', {});
1058
+ const interaction = driver.getHistory().at(-1).interaction;
1059
+ assert.is(interaction.ownerHostId, 'host-a', 'deterministic default = first registered');
1060
+ driver.resolveInteraction(interaction.interactionId, { status: 'approved' });
1061
+ yield pending;
1062
+ }));
1063
+ ownedInteraction('no host registered → no owner (widget renders everywhere, fail-open)', () => __awaiter(void 0, void 0, void 0, function* () {
1064
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1065
+ const pending = driver.requestInteraction('w', {});
1066
+ const interaction = driver.getHistory().at(-1).interaction;
1067
+ assert.ok(!('ownerHostId' in interaction), 'no owner stamped when no host is registered');
1068
+ driver.resolveInteraction(interaction.interactionId, { status: 'approved' });
1069
+ yield pending;
1070
+ }));
1071
+ ownedInteraction('claimSideEffect is first-writer-wins, and re-entrant for the same host', () => __awaiter(void 0, void 0, void 0, function* () {
1072
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1073
+ assert.is(driver.claimSideEffect('i1', 'host-a'), true, 'first host claims it');
1074
+ assert.is(driver.claimSideEffect('i1', 'host-b'), false, 'a different host is denied');
1075
+ assert.is(driver.claimSideEffect('i1', 'host-a'), true, 're-claim by the owner (re-render) is fine');
1076
+ }));
1077
+ ownedInteraction.run();
1078
+ // ---------------------------------------------------------------------------
975
1079
  // interaction activity-bus signals (GENC-1346) — the driver brackets a parked
976
1080
  // widget interaction with `interaction-requested` / `interaction-resolved`, so
977
1081
  // turn-aware UI can distinguish "actively computing" from "parked awaiting the
@@ -18,6 +18,13 @@ let AiChatInteractionWrapper = class AiChatInteractionWrapper extends GenesisEle
18
18
  this.interactionId = '';
19
19
  /** The resolved result once the interaction has completed. Forwarded to the rendered component. @internal */
20
20
  this.resolved = undefined;
21
+ /**
22
+ * GENC-1410 (Error 3): id of the host the driver elected to render (and thus run) this
23
+ * interaction. When several views share one driver, only the matching host renders the live
24
+ * widget; others show a passive placeholder so the widget fires once. Empty = no owner elected
25
+ * (single view, or none registered) → render everywhere (fail-open). @internal
26
+ */
27
+ this.ownerHostId = '';
21
28
  }
22
29
  connectedCallback() {
23
30
  super.connectedCallback();
@@ -46,11 +53,19 @@ let AiChatInteractionWrapper = class AiChatInteractionWrapper extends GenesisEle
46
53
  this.renderComponent();
47
54
  }
48
55
  resolvedChanged() {
49
- var _a;
56
+ var _a, _b;
50
57
  const element = (_a = this.container) === null || _a === void 0 ? void 0 : _a.firstElementChild;
51
- if (element) {
58
+ // GENC-1410 (Error 3): a non-owner view rendered a passive placeholder for the unresolved
59
+ // exclusive widget. Now it's resolved (no side effect left to fire), so render the real widget
60
+ // — the gate below is keyed on `!resolved`, so this instantiates the resolved widget and every
61
+ // view shows the outcome. The owner already has the live widget: just forward `resolved` to it,
62
+ // preserving its DOM/state (no re-create).
63
+ if (element && ((_b = element.tagName) === null || _b === void 0 ? void 0 : _b.toLowerCase()) === this.componentName) {
52
64
  element.resolved = this.resolved;
53
65
  }
66
+ else if (this.resolved) {
67
+ this.renderComponent();
68
+ }
54
69
  }
55
70
  /**
56
71
  * Watch the rendered widget element for box-size changes. When the widget
@@ -94,17 +109,41 @@ let AiChatInteractionWrapper = class AiChatInteractionWrapper extends GenesisEle
94
109
  return;
95
110
  }
96
111
  try {
112
+ // Pierce the wrapper's shadow boundary to read state from the assistant host.
113
+ const root = this.getRootNode();
114
+ const host = root instanceof ShadowRoot
115
+ ? root.host
116
+ : null;
117
+ // GENC-1410 (Error 3): a widget must run on exactly ONE view. When several views share one
118
+ // driver, render the live widget only if this host is the elected owner AND wins the
119
+ // first-writer claim; otherwise show a passive placeholder and do NOT instantiate the widget
120
+ // (whose connectedCallback would fire a duplicate server call). A single-view app has no owner
121
+ // (or owns it) so it always renders. Resolved interactions always render (display only, no
122
+ // side effect) so state survives a pop.
123
+ if (!this.resolved) {
124
+ const isOwner = !this.ownerHostId || (host === null || host === void 0 ? void 0 : host.hostId) === this.ownerHostId;
125
+ // Only the elected owner attempts the claim. A non-owner must NEVER touch the latch: both
126
+ // views render the same history, so if a non-owner view (e.g. the hidden bubble while the
127
+ // panel is docked) renders its wrapper first, claiming here would grab the first-writer latch
128
+ // and the owner's own claim would then return false — leaving BOTH views on the placeholder,
129
+ // so the widget's side effect never fires (the interaction shows blank and hangs forever).
130
+ // Gating the claim behind `isOwner` makes the outcome independent of inter-host render order.
131
+ const claimed = isOwner && ((host === null || host === void 0 ? void 0 : host.claimSideEffect) ? host.claimSideEffect(this.interactionId) : true);
132
+ if (!claimed) {
133
+ const placeholder = document.createElement('div');
134
+ placeholder.className = 'interaction-running-elsewhere';
135
+ this.container.appendChild(placeholder);
136
+ return;
137
+ }
138
+ }
97
139
  const element = document.createElement(this.componentName);
98
140
  // Pass data and resolved state to the component
99
141
  if (this.data) {
100
142
  element.data = this.data;
101
143
  }
102
144
  element.resolved = this.resolved;
103
- // Pierce the wrapper's shadow boundary to read engagement from the
104
- // assistant host. False if the widget is already resolved — re-renders
145
+ // Engagement gates autofocus. False if the widget is already resolved — re-renders
105
146
  // from history (layout remount, dock/undock) must never steal focus.
106
- const root = this.getRootNode();
107
- const host = root instanceof ShadowRoot ? root.host : null;
108
147
  element.shouldAutoFocus = !this.resolved && !!(host === null || host === void 0 ? void 0 : host.isEngaged);
109
148
  // Handle completion from the inner component.
110
149
  // Guard against re-emission if the interaction was already resolved
@@ -138,6 +177,9 @@ __decorate([
138
177
  __decorate([
139
178
  observable
140
179
  ], AiChatInteractionWrapper.prototype, "resolved", void 0);
180
+ __decorate([
181
+ observable
182
+ ], AiChatInteractionWrapper.prototype, "ownerHostId", void 0);
141
183
  AiChatInteractionWrapper = __decorate([
142
184
  customElement({
143
185
  name: 'ai-chat-interaction-wrapper',
@@ -74,4 +74,66 @@ Suite('forwards resolved in place without rebuilding the widget', (_a) => __awai
74
74
  assert.is(element.container.firstElementChild, widget, 'resolving in place must not rebuild the widget');
75
75
  assert.equal(widget.resolved, { status: 'approved' }, 'resolved is forwarded to the widget');
76
76
  }));
77
+ /**
78
+ * GENC-1410 (Error 3): two assistant views (a floating bubble and a docked panel)
79
+ * share one driver and render the same history, so both mount a wrapper for the
80
+ * same interaction. Exactly one — the elected owner — must render the live widget
81
+ * and win the driver's first-writer side-effect latch; the other renders a passive
82
+ * placeholder. Regression: the claim used to be computed for EVERY host, so if a
83
+ * non-owner view rendered its wrapper first it grabbed the latch, and the owner's
84
+ * own claim then returned false — leaving BOTH views on the placeholder so the
85
+ * widget's side effect never fired (blank + stuck). The claim must be gated behind
86
+ * ownership, making the outcome independent of inter-host render order.
87
+ */
88
+ /** First-writer-wins latch, mirroring `ChatDriver.claimSideEffect`. */
89
+ function makeSharedLatch() {
90
+ const started = new Map();
91
+ return (interactionId, hostId) => {
92
+ const existing = started.get(interactionId);
93
+ if (existing === undefined) {
94
+ started.set(interactionId, hostId);
95
+ return true;
96
+ }
97
+ return existing === hostId;
98
+ };
99
+ }
100
+ /**
101
+ * Mount a wrapper inside a shadow host that exposes `hostId` + a shared claim,
102
+ * mimicking one `gc-assistant` view. The wrapper reads these across its shadow
103
+ * boundary via `getRootNode().host`, so a real ShadowRoot host is required.
104
+ */
105
+ function mountWrapperInHost(hostId, ownerHostId, interactionId, claim) {
106
+ return __awaiter(this, void 0, void 0, function* () {
107
+ const hostEl = document.createElement('div');
108
+ const shadow = hostEl.attachShadow({ mode: 'open' });
109
+ hostEl.hostId = hostId;
110
+ hostEl.isEngaged = true;
111
+ hostEl.claimSideEffect = (id) => claim(id, hostId);
112
+ const wrapper = document.createElement('ai-chat-interaction-wrapper');
113
+ shadow.appendChild(wrapper);
114
+ document.body.appendChild(hostEl);
115
+ yield DOM.nextUpdate();
116
+ wrapper.ownerHostId = ownerHostId;
117
+ wrapper.data = { label: 'q' };
118
+ wrapper.interactionId = interactionId;
119
+ wrapper.componentName = FAKE_WIDGET;
120
+ yield DOM.nextUpdate();
121
+ return { hostEl, wrapper };
122
+ });
123
+ }
124
+ Suite('a non-owner host does not poison the side-effect claim when it renders first', () => __awaiter(void 0, void 0, void 0, function* () {
125
+ var _a, _b, _c;
126
+ const claim = makeSharedLatch();
127
+ const ownerId = 'owner-host';
128
+ const otherId = 'other-host';
129
+ const interactionId = 'id-x';
130
+ // The NON-owner (e.g. the hidden bubble) renders FIRST — the race that used to poison the latch.
131
+ const other = yield mountWrapperInHost(otherId, ownerId, interactionId, claim);
132
+ // The elected owner renders SECOND.
133
+ const owner = yield mountWrapperInHost(ownerId, ownerId, interactionId, claim);
134
+ assert.equal((_a = other.wrapper.container.firstElementChild) === null || _a === void 0 ? void 0 : _a.className, 'interaction-running-elsewhere', 'the non-owner view renders the passive placeholder');
135
+ assert.equal((_c = (_b = owner.wrapper.container.firstElementChild) === null || _b === void 0 ? void 0 : _b.tagName) === null || _c === void 0 ? void 0 : _c.toLowerCase(), FAKE_WIDGET, 'the owner renders the live widget even though the non-owner rendered first');
136
+ other.hostEl.remove();
137
+ owner.hostEl.remove();
138
+ }));
77
139
  Suite.run();
@@ -20,6 +20,18 @@ const EDGE = 0.16;
20
20
  const MORPH_SPEED = 3;
21
21
  /** Global multiplier on the harmonic frequencies — raise for narrower peaks. */
22
22
  const FREQ_SCALE = 2;
23
+ /** Milliseconds in one second. */
24
+ const MS_PER_SECOND = 1000;
25
+ /** Baseline frame rate (fps) the harmonic speeds are tuned against. */
26
+ const BASELINE_FPS = 60;
27
+ /**
28
+ * Frame duration (ms) at the baseline rate. The morph is driven by elapsed
29
+ * wall-clock time divided by this — not by the rAF callback count — so it
30
+ * advances at a constant real-world rate regardless of the display's refresh
31
+ * rate or dropped frames. At the baseline rate the resulting frame index
32
+ * matches the old per-callback counter, preserving the current look.
33
+ */
34
+ const FRAME_MS = MS_PER_SECOND / BASELINE_FPS;
23
35
  /**
24
36
  * Half-thickness (viewBox units) each band keeps even between peaks, so the
25
37
  * waves emerge from a continuous line rather than leaving gaps. The four bands
@@ -88,13 +100,6 @@ const bandLayersMarkup = BANDS.map((_b, i) => `<svg class="layer band-layer" vie
88
100
  * @beta
89
101
  */
90
102
  let AiFlowingWavesIndicator = AiFlowingWavesIndicator_1 = class AiFlowingWavesIndicator extends GenesisElement {
91
- constructor() {
92
- // A rAF loop morphs the band shapes: pure CSS can't continuously reshape the
93
- // bumps (different sizes appearing at different times), and the per-frame sine
94
- // sums are cheap. Same approach as the sine-tracing waves indicator.
95
- super(...arguments);
96
- this.frame = 0;
97
- }
98
103
  connectedCallback() {
99
104
  super.connectedCallback();
100
105
  // Guard against a reconnect starting a second concurrent loop.
@@ -108,8 +113,10 @@ let AiFlowingWavesIndicator = AiFlowingWavesIndicator_1 = class AiFlowingWavesIn
108
113
  cancelAnimationFrame(this.animFrame);
109
114
  this.animFrame = undefined;
110
115
  }
116
+ // Reset the clock so a later reconnect restarts the morph cleanly at frame 0.
117
+ this.startTime = undefined;
111
118
  }
112
- tick() {
119
+ tick(now) {
113
120
  var _a, _c;
114
121
  // Stop ticking once disconnected (belt-and-braces alongside the cancel in
115
122
  // disconnectedCallback) so no frames are scheduled while detached.
@@ -122,13 +129,17 @@ let AiFlowingWavesIndicator = AiFlowingWavesIndicator_1 = class AiFlowingWavesIn
122
129
  if (paths === null || paths === void 0 ? void 0 : paths.length)
123
130
  this.bandPaths = Array.from(paths);
124
131
  }
132
+ // Anchor to the first real rAF timestamp; the initial synchronous call (no
133
+ // timestamp) renders frame 0. `frame` is then elapsed time in 60fps frames.
134
+ if (now !== undefined && this.startTime === undefined)
135
+ this.startTime = now;
136
+ const frame = now === undefined || this.startTime === undefined ? 0 : (now - this.startTime) / FRAME_MS;
125
137
  (_c = this.bandPaths) === null || _c === void 0 ? void 0 : _c.forEach((path, i) => {
126
138
  const cfg = BANDS[i];
127
139
  if (cfg)
128
- path.setAttribute('d', AiFlowingWavesIndicator_1.buildBand(cfg, this.frame));
140
+ path.setAttribute('d', AiFlowingWavesIndicator_1.buildBand(cfg, frame));
129
141
  });
130
- this.frame += 1;
131
- this.animFrame = requestAnimationFrame(() => this.tick());
142
+ this.animFrame = requestAnimationFrame((t) => this.tick(t));
132
143
  }
133
144
  /**
134
145
  * Trace one band for the given frame: a baseline-plus-harmonics half-thickness
@@ -6,6 +6,17 @@ const HALO_DEFAULT_SPEED = 1.5;
6
6
  const HALO_DEFAULT_BORDER_SIZE = 3;
7
7
  const HALO_DEFAULT_GLOW_OPACITY = 0.35;
8
8
  const HALO_DEFAULT_GLOW_SPREAD = 70;
9
+ /** Milliseconds in one second. */
10
+ const MS_PER_SECOND = 1000;
11
+ /** Baseline frame rate (fps) the `speed` (degrees/frame) is calibrated against. */
12
+ const BASELINE_FPS = 60;
13
+ /**
14
+ * Frame duration (ms) at the baseline rate. Rotation advances by the elapsed
15
+ * wall-clock time divided by this — not a fixed step per rAF callback — so it
16
+ * holds a constant real-world speed regardless of the display's refresh rate or
17
+ * dropped frames.
18
+ */
19
+ const FRAME_MS = MS_PER_SECOND / BASELINE_FPS;
9
20
  /**
10
21
  * Animated halo overlay — rotating conic-gradient border with an inward glow.
11
22
  *
@@ -52,14 +63,27 @@ let AiHaloOverlay = AiHaloOverlay_1 = class AiHaloOverlay extends GenesisElement
52
63
  super.disconnectedCallback();
53
64
  if (this.animFrame !== undefined) {
54
65
  cancelAnimationFrame(this.animFrame);
66
+ this.animFrame = undefined;
55
67
  }
68
+ // Reset the clock so a later reconnect doesn't apply one huge time delta.
69
+ this.lastTime = undefined;
56
70
  }
57
- tick() {
58
- const step = this.direction === 'ccw' ? -this.speed : this.speed;
59
- this.angle =
60
- (this.angle + step + AiHaloOverlay_1.FULL_ROTATION_DEG) % AiHaloOverlay_1.FULL_ROTATION_DEG;
61
- this.style.setProperty('--halo-angle', `${this.angle}deg`);
62
- this.animFrame = requestAnimationFrame(() => this.tick());
71
+ tick(now) {
72
+ // Advance by elapsed wall-clock time (expressed in 60fps-equivalent frames)
73
+ // rather than a fixed step per callback, so the rotation runs at a constant
74
+ // real-world speed regardless of the display's refresh rate or dropped
75
+ // frames. The first tick just seeds the clock (no delta yet).
76
+ if (this.lastTime !== undefined && now !== undefined) {
77
+ const frames = (now - this.lastTime) / FRAME_MS;
78
+ const step = this.direction === 'ccw' ? -this.speed : this.speed;
79
+ const raw = this.angle + step * frames;
80
+ this.angle =
81
+ ((raw % AiHaloOverlay_1.FULL_ROTATION_DEG) + AiHaloOverlay_1.FULL_ROTATION_DEG) %
82
+ AiHaloOverlay_1.FULL_ROTATION_DEG;
83
+ this.style.setProperty('--halo-angle', `${this.angle}deg`);
84
+ }
85
+ this.lastTime = now;
86
+ this.animFrame = requestAnimationFrame((t) => this.tick(t));
63
87
  }
64
88
  };
65
89
  // TODO: The rAF loop is fine for demos but has two drawbacks vs a pure CSS @property animation:
@@ -132,6 +132,20 @@ export class OrchestratingDriver extends EventTarget {
132
132
  isBusy() {
133
133
  return this.chatDriver.isBusy();
134
134
  }
135
+ // GENC-1410 (Error 3): forward single-instance / side-effect coordination to the inner driver,
136
+ // which owns the interaction history and pending map.
137
+ registerHost(hostId) {
138
+ this.chatDriver.registerHost(hostId);
139
+ }
140
+ unregisterHost(hostId) {
141
+ this.chatDriver.unregisterHost(hostId);
142
+ }
143
+ setActiveHost(hostId) {
144
+ this.chatDriver.setActiveHost(hostId);
145
+ }
146
+ claimSideEffect(interactionId, hostId) {
147
+ return this.chatDriver.claimSideEffect(interactionId, hostId);
148
+ }
135
149
  /** Currently active provider name from the underlying ChatDriver. */
136
150
  getActiveProviderName() {
137
151
  return this.chatDriver.getActiveProviderName();
@@ -127,6 +127,13 @@ export const settingsModalStyles = css `
127
127
  gap: calc(var(--design-unit) * 2px);
128
128
  }
129
129
 
130
+ .settings-modal-section-note {
131
+ margin: calc(var(--design-unit) * -2px) 0 calc(var(--design-unit) * 3px);
132
+ font-size: 12px;
133
+ line-height: 1.5;
134
+ color: var(--neutral-foreground-hint);
135
+ }
136
+
130
137
  .settings-modal-section-body {
131
138
  display: flex;
132
139
  flex-direction: column;
@@ -116,16 +116,12 @@ const chatBotSettingsTemplate = (switchTag, buttonTag) => html `
116
116
  ${when((x) => { var _a; return ((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.showThinkingSteps) != null; }, chatBotToggleRow('Show thinking', "Reveal the assistant's reasoning as it works through your request, surfacing its intermediate thought process before it commits to a response.", switchTag, 'toggle-thinking', (x) => x.showThinkingSteps, (x, c) => {
117
117
  x.showThinkingSteps = readSwitchChecked(c.event);
118
118
  }))}
119
- ${when((x) => { var _a; return ((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.showAgentSwitchIndicator) != null; }, chatBotToggleRow('Enable agents', 'Show in the chat window when tasks are delegated to different agents.', switchTag, 'toggle-agent-switch', (x) => x.showAgentSwitchIndicator, (x, c) => {
119
+ ${when((x) => { var _a; return ((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.showNarration) != null; }, chatBotToggleRow('Show working', "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.", switchTag, 'toggle-narration', (x) => x.showNarration, (x, c) => {
120
+ x.showNarration = readSwitchChecked(c.event);
121
+ }))}
122
+ ${when((x) => { var _a; return ((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.showAgentSwitchIndicator) != null; }, chatBotToggleRow('Show delegation', 'Show in the chat window when tasks are delegated to different agents.', switchTag, 'toggle-agent-switch', (x) => x.showAgentSwitchIndicator, (x, c) => {
120
123
  x.showAgentSwitchIndicator = readSwitchChecked(c.event);
121
124
  }))}
122
- ${when((x) => { var _a; return ((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.allowDebugDownload) === true; }, settingsToggleRow('Download agent log', "Export a structured debug timeline of this session's agent activity.", 'download-agent-log', html `
123
- <${buttonTag}
124
- part="download-button"
125
- appearance="outline"
126
- @click=${(x) => x.downloadDebugLog()}
127
- >Download</${buttonTag}>
128
- `))}
129
125
  ${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; }, settingsToggleRow('Animations', 'Choose which loading and activity indicators appear while the assistant is working.', 'toggle-animations', animationsPickerTemplate(switchTag), 'column'))}
130
126
  `;
131
127
  const appSettingsTemplate = (switchTag) => html `
@@ -316,10 +312,24 @@ export const SettingsModalTemplate = (designSystemPrefix) => {
316
312
  ${appSettingsTemplate(switchTag)}
317
313
  </div>
318
314
  </section>
315
+ ${when((x) => { var _a; return ((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.allowDebugDownload) === true; }, html `
316
+ <section class="settings-modal-section" part="settings-section-agent-log">
317
+ <div class="settings-modal-section-body">
318
+ ${settingsToggleRow('Download agent log', "Export a structured debug timeline of this session's agent activity.", 'download-agent-log', html `
319
+ <${buttonTag}
320
+ part="download-button"
321
+ appearance="outline"
322
+ @click=${(x) => x.downloadDebugLog()}
323
+ >Download</${buttonTag}>
324
+ `)}
325
+ </div>
326
+ </section>
327
+ `)}
319
328
  <section class="settings-modal-section" part="settings-section-chat">
320
329
  <h3 class="settings-modal-section-title">
321
330
  <${iconTag} name="comment"></${iconTag}> AI Chat Bot Settings
322
331
  </h3>
332
+ <p class="settings-modal-section-note">These options only control what you see in the chat window and do not control model behaviour.</p>
323
333
  <div class="settings-modal-section-body">
324
334
  ${chatBotSettingsTemplate(switchTag, buttonTag)}
325
335
  </div>