@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.
- package/dist/ai-assistant.api.json +546 -2
- package/dist/ai-assistant.d.ts +73 -1
- package/dist/dts/components/ai-driver/ai-driver.d.ts +8 -0
- package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +32 -0
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
- package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.d.ts +7 -0
- package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.d.ts.map +1 -1
- package/dist/dts/components/flowing-waves-indicator.d.ts +1 -1
- package/dist/dts/components/flowing-waves-indicator.d.ts.map +1 -1
- package/dist/dts/components/halo-overlay.d.ts +1 -0
- package/dist/dts/components/halo-overlay.d.ts.map +1 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +4 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
- package/dist/dts/components/waves-indicator.d.ts +1 -1
- package/dist/dts/components/waves-indicator.d.ts.map +1 -1
- package/dist/dts/main/main.d.ts +29 -1
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/main/main.styles.d.ts.map +1 -1
- package/dist/dts/main/main.template.d.ts.map +1 -1
- package/dist/dts/state/ai-assistant-slice.d.ts +2 -0
- package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
- package/dist/dts/state/session-store.d.ts +1 -0
- package/dist/dts/state/session-store.d.ts.map +1 -1
- package/dist/dts/utils/message-partition.d.ts +1 -0
- package/dist/dts/utils/message-partition.d.ts.map +1 -1
- package/dist/esm/components/chat-driver/chat-driver.js +88 -8
- package/dist/esm/components/chat-driver/chat-driver.test.js +104 -0
- package/dist/esm/components/chat-interaction-wrapper/chat-interaction-wrapper.js +48 -6
- package/dist/esm/components/chat-interaction-wrapper/chat-interaction-wrapper.test.js +62 -0
- package/dist/esm/components/flowing-waves-indicator.js +22 -11
- package/dist/esm/components/halo-overlay.js +30 -6
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +14 -0
- package/dist/esm/components/settings-modal/settings-modal.styles.js +7 -0
- package/dist/esm/components/settings-modal/settings-modal.template.js +18 -8
- package/dist/esm/components/waves-indicator.js +22 -8
- package/dist/esm/main/main.js +73 -6
- package/dist/esm/main/main.styles.js +6 -0
- package/dist/esm/main/main.template.js +21 -2
- package/dist/esm/state/ai-assistant-slice.js +4 -0
- package/dist/esm/utils/message-partition.js +10 -1
- package/dist/esm/utils/message-partition.test.js +2 -0
- package/package.json +17 -17
- package/src/components/ai-driver/ai-driver.ts +17 -0
- package/src/components/chat-driver/chat-driver.test.ts +143 -0
- package/src/components/chat-driver/chat-driver.ts +105 -4
- package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.test.ts +81 -0
- package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts +51 -5
- package/src/components/flowing-waves-indicator.ts +26 -6
- package/src/components/halo-overlay.ts +31 -6
- package/src/components/orchestrating-driver/orchestrating-driver.ts +18 -0
- package/src/components/settings-modal/settings-modal.styles.ts +7 -0
- package/src/components/settings-modal/settings-modal.template.ts +36 -16
- package/src/components/waves-indicator.ts +25 -5
- package/src/main/main.styles.ts +6 -0
- package/src/main/main.template.ts +25 -2
- package/src/main/main.ts +65 -1
- package/src/state/ai-assistant-slice.ts +5 -0
- package/src/utils/message-partition.test.ts +2 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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,
|
|
247
|
+
if (cfg) path.setAttribute('d', AiFlowingWavesIndicator.buildBand(cfg, frame));
|
|
227
248
|
});
|
|
228
|
-
this.
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
this.
|
|
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
|
-
'
|
|
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
|
|
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],
|
|
220
|
+
path.setAttribute('d', AiWavesIndicator.buildWavePath(WAVES[i], frame));
|
|
200
221
|
});
|
|
201
|
-
this.
|
|
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. */
|
package/src/main/main.styles.ts
CHANGED
|
@@ -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);
|