@genesislcap/ai-assistant 14.432.2 → 14.433.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 (64) hide show
  1. package/api-extractor.json +8 -1
  2. package/dist/ai-assistant.api.json +1216 -141
  3. package/dist/ai-assistant.d.ts +216 -15
  4. package/dist/dts/components/agent-picker/agent-picker.d.ts +69 -0
  5. package/dist/dts/components/agent-picker/agent-picker.d.ts.map +1 -0
  6. package/dist/dts/components/agent-picker/agent-picker.styles.d.ts +2 -0
  7. package/dist/dts/components/agent-picker/agent-picker.styles.d.ts.map +1 -0
  8. package/dist/dts/components/agent-picker/agent-picker.template.d.ts +5 -0
  9. package/dist/dts/components/agent-picker/agent-picker.template.d.ts.map +1 -0
  10. package/dist/dts/components/agent-picker/index.d.ts +2 -0
  11. package/dist/dts/components/agent-picker/index.d.ts.map +1 -0
  12. package/dist/dts/components/chat-driver/chat-driver.d.ts +21 -0
  13. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  14. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +14 -0
  15. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  16. package/dist/dts/config/config.d.ts +22 -12
  17. package/dist/dts/config/config.d.ts.map +1 -1
  18. package/dist/dts/index.d.ts +1 -0
  19. package/dist/dts/index.d.ts.map +1 -1
  20. package/dist/dts/main/main.d.ts +72 -4
  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/main/main.types.d.ts +1 -0
  25. package/dist/dts/main/main.types.d.ts.map +1 -1
  26. package/dist/dts/state/ai-assistant-slice.d.ts +39 -1
  27. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  28. package/dist/dts/state/session-store.d.ts +6 -0
  29. package/dist/dts/state/session-store.d.ts.map +1 -1
  30. package/dist/dts/utils/animated-panel-toggle.d.ts +26 -0
  31. package/dist/dts/utils/animated-panel-toggle.d.ts.map +1 -0
  32. package/dist/dts/utils/index.d.ts +1 -0
  33. package/dist/dts/utils/index.d.ts.map +1 -1
  34. package/dist/esm/components/agent-picker/agent-picker.js +157 -0
  35. package/dist/esm/components/agent-picker/agent-picker.styles.js +73 -0
  36. package/dist/esm/components/agent-picker/agent-picker.template.js +72 -0
  37. package/dist/esm/components/agent-picker/index.js +1 -0
  38. package/dist/esm/components/chat-driver/chat-driver.js +48 -6
  39. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +43 -6
  40. package/dist/esm/index.js +1 -0
  41. package/dist/esm/main/main.js +215 -21
  42. package/dist/esm/main/main.styles.js +59 -0
  43. package/dist/esm/main/main.template.js +66 -12
  44. package/dist/esm/state/ai-assistant-slice.js +15 -0
  45. package/dist/esm/utils/animated-panel-toggle.js +62 -0
  46. package/dist/esm/utils/index.js +1 -0
  47. package/dist/tsconfig.tsbuildinfo +1 -1
  48. package/docs/gemini-empty-response.md +110 -0
  49. package/package.json +16 -16
  50. package/src/components/agent-picker/agent-picker.styles.ts +74 -0
  51. package/src/components/agent-picker/agent-picker.template.ts +88 -0
  52. package/src/components/agent-picker/agent-picker.ts +148 -0
  53. package/src/components/agent-picker/index.ts +1 -0
  54. package/src/components/chat-driver/chat-driver.ts +65 -8
  55. package/src/components/orchestrating-driver/orchestrating-driver.ts +45 -6
  56. package/src/config/config.ts +28 -11
  57. package/src/index.ts +1 -0
  58. package/src/main/main.styles.ts +59 -0
  59. package/src/main/main.template.ts +79 -13
  60. package/src/main/main.ts +220 -19
  61. package/src/main/main.types.ts +2 -0
  62. package/src/state/ai-assistant-slice.ts +51 -1
  63. package/src/utils/animated-panel-toggle.ts +62 -0
  64. package/src/utils/index.ts +1 -0
@@ -164,15 +164,39 @@ export class ChatDriver extends EventTarget {
164
164
  isBusy() {
165
165
  return this.busy;
166
166
  }
167
+ /**
168
+ * Wire a parent driver as the host for this driver's interactions. When set,
169
+ * `requestInteraction` delegates upward so the widget renders in (and
170
+ * resolves through) the parent's history and pending map. Calls chain
171
+ * naturally: a grandchild → child → root.
172
+ */
173
+ setHostInteractionRequester(fn) {
174
+ this.hostInteractionRequester = fn;
175
+ }
167
176
  /**
168
177
  * Request a custom UI interaction. Emits a new message with the interaction.
169
178
  * Tool handlers can call this to pause execution until the user completes the UI interaction.
170
179
  *
180
+ * If a host requester is wired (sub-agent case), the call delegates upward
181
+ * so the interaction lives on the parent — the main UI is only listening to
182
+ * the root driver. Only one interaction may be in flight at any time on a
183
+ * given root: concurrent calls (e.g. two parallel sub-agents both spawning a
184
+ * widget) throw. Parallel sub-agents are for parallel work, not for user
185
+ * interaction, which is inherently sequential.
186
+ *
171
187
  * @param componentName - The custom element name to render.
172
188
  * @param data - Data to pass to the component.
173
189
  */
174
190
  requestInteraction(componentName, data) {
175
191
  return __awaiter(this, void 0, void 0, function* () {
192
+ if (this.hostInteractionRequester) {
193
+ return this.hostInteractionRequester(componentName, data);
194
+ }
195
+ if (this.pendingInteractions.size > 0) {
196
+ throw new Error('requestInteraction: another user interaction is already in flight. ' +
197
+ 'Only one interaction may be active at a time — sequence them in a single tool handler ' +
198
+ 'rather than spawning widgets from parallel sub-agents or parallel tool calls.');
199
+ }
176
200
  const interactionId = crypto.randomUUID();
177
201
  return new Promise((resolve, reject) => {
178
202
  this.pendingInteractions.set(interactionId, { resolve, reject });
@@ -297,19 +321,29 @@ export class ChatDriver extends EventTarget {
297
321
  ];
298
322
  const child = new ChatDriver(this.aiProvider);
299
323
  child.applyAgent(Object.assign(Object.assign({}, subConfig), { primerHistory: effectivePrimer }));
324
+ // Route interactions back through this driver so widgets render in the
325
+ // parent's (ultimately the root's) history and resolve via the same
326
+ // pending map the main UI is wired to. Recurses naturally for nested
327
+ // sub-agents.
328
+ child.setHostInteractionRequester((componentName, data) => this.requestInteraction(componentName, data));
300
329
  const forwardTrace = (e) => {
301
330
  this.dispatchEvent(new CustomEvent('sub-agent-history-updated', {
302
331
  detail: { agentName: subConfig.name, history: e.detail },
303
332
  }));
304
333
  };
305
334
  child.addEventListener('history-updated', forwardTrace);
306
- this.dispatchEvent(new CustomEvent('sub-agent-start', { detail: { name } }));
335
+ // Unique per-invocation id so listeners can pair start/stop reliably even
336
+ // when the same sub-agent runs multiple times in parallel.
337
+ const invocationId = crypto.randomUUID();
338
+ const chatInputDuringExecution = options === null || options === void 0 ? void 0 : options.chatInputDuringExecution;
339
+ const lifecycleDetail = { name, invocationId, chatInputDuringExecution };
340
+ this.dispatchEvent(new CustomEvent('sub-agent-start', { detail: lifecycleDetail }));
307
341
  try {
308
342
  yield child.sendMessage(task !== null && task !== void 0 ? task : '');
309
343
  }
310
344
  finally {
311
345
  child.removeEventListener('history-updated', forwardTrace);
312
- this.dispatchEvent(new CustomEvent('sub-agent-stop', { detail: { name } }));
346
+ this.dispatchEvent(new CustomEvent('sub-agent-stop', { detail: lifecycleDetail }));
313
347
  }
314
348
  const trace = child.getHistory();
315
349
  const completion = child.getSubAgentCompletion();
@@ -514,16 +548,26 @@ export class ChatDriver extends EventTarget {
514
548
  : emptyResponseAttempts > 0
515
549
  ? `${baseSystemPrompt !== null && baseSystemPrompt !== void 0 ? baseSystemPrompt : ''}\n\nIMPORTANT: You must respond to the user's message. Call the appropriate tool or provide a text response — do not return an empty response.`
516
550
  : baseSystemPrompt;
551
+ // Capture the pending user input, then clear the slots BEFORE the chat
552
+ // call. `sendMessage` already appended the user message to `this.history`,
553
+ // so on retries (empty / malformed) we must rely on history alone —
554
+ // otherwise the message gets sent twice (once via history, once via
555
+ // `currentInput`), which Gemini answers with an empty response and then
556
+ // we retry forever.
557
+ const userInputForCall = currentInput;
558
+ const attachmentsForCall = currentAttachments;
559
+ currentInput = '';
560
+ currentAttachments = undefined;
517
561
  const options = {
518
562
  systemPrompt,
519
563
  // Strip fold-only properties (foldEvent, foldPath) before sending to provider
520
564
  tools: this.toolDefinitions.length ? this.toolDefinitions : undefined,
521
- attachments: currentAttachments,
565
+ attachments: attachmentsForCall,
522
566
  };
523
567
  let response;
524
568
  try {
525
569
  // eslint-disable-next-line no-await-in-loop
526
- response = yield this.aiProvider.chat(historyForCall, currentInput, options);
570
+ response = yield this.aiProvider.chat(historyForCall, userInputForCall, options);
527
571
  }
528
572
  catch (e) {
529
573
  if (e instanceof MalformedFunctionCallError) {
@@ -542,7 +586,6 @@ export class ChatDriver extends EventTarget {
542
586
  }
543
587
  throw e;
544
588
  }
545
- currentAttachments = undefined;
546
589
  const isThinkingStep = response.content && ((_b = response.toolCalls) === null || _b === void 0 ? void 0 : _b.length);
547
590
  const isEmptyResponse = !((_c = response.content) === null || _c === void 0 ? void 0 : _c.trim()) && !((_d = response.toolCalls) === null || _d === void 0 ? void 0 : _d.length);
548
591
  if (isEmptyResponse) {
@@ -739,7 +782,6 @@ export class ChatDriver extends EventTarget {
739
782
  if (this.subAgentCompletion) {
740
783
  return { reason: 'done' };
741
784
  }
742
- currentInput = '';
743
785
  }
744
786
  if (iterations >= this.maxToolIterations) {
745
787
  logger.warn('ChatDriver: reached max tool iterations, stopping');
@@ -49,6 +49,7 @@ export class OrchestratingDriver extends EventTarget {
49
49
  super();
50
50
  this.aiProvider = aiProvider;
51
51
  this.agents = agents;
52
+ this.pinnedAgentName = null;
52
53
  this.maxHandoffs = (_a = options.maxHandoffs) !== null && _a !== void 0 ? _a : DEFAULT_MAX_HANDOFFS;
53
54
  this.classifierHistoryLength =
54
55
  (_b = options.classifierHistoryLength) !== null && _b !== void 0 ? _b : DEFAULT_CLASSIFIER_HISTORY_LENGTH;
@@ -69,6 +70,9 @@ export class OrchestratingDriver extends EventTarget {
69
70
  this.chatDriver.addEventListener('sub-agent-history-updated', (e) => {
70
71
  this.dispatchEvent(new CustomEvent('sub-agent-history-updated', { detail: e.detail }));
71
72
  });
73
+ this.chatDriver.addEventListener('sub-agent-start', (e) => {
74
+ this.dispatchEvent(new CustomEvent('sub-agent-start', { detail: e.detail }));
75
+ });
72
76
  this.chatDriver.addEventListener('sub-agent-stop', (e) => {
73
77
  this.dispatchEvent(new CustomEvent('sub-agent-stop', { detail: e.detail }));
74
78
  });
@@ -79,6 +83,15 @@ export class OrchestratingDriver extends EventTarget {
79
83
  isBusy() {
80
84
  return this.chatDriver.isBusy();
81
85
  }
86
+ /**
87
+ * Pins routing to a specific agent by name. While pinned, the classifier is
88
+ * skipped and the continuation tool is hidden from the agent's tool list, so
89
+ * the agent cannot quietly hand back to the orchestrator. Pass `null` to
90
+ * return to automatic routing.
91
+ */
92
+ setPinnedAgent(name) {
93
+ this.pinnedAgentName = name;
94
+ }
82
95
  loadHistory(messages) {
83
96
  this.chatDriver.loadHistory(messages);
84
97
  }
@@ -87,7 +100,11 @@ export class OrchestratingDriver extends EventTarget {
87
100
  }
88
101
  getSuggestions(history, prompt, count, allAgentInfo) {
89
102
  return __awaiter(this, void 0, void 0, function* () {
90
- const agentInfo = this.specialists.map((s) => {
103
+ // When pinned to a specialist, scope suggestions to that agent so the
104
+ // proposed prompts reflect the active routing instead of the full panel.
105
+ const pinned = this.resolvePinnedAgent();
106
+ const candidates = pinned && isSpecialist(pinned) ? [pinned] : this.specialists;
107
+ const agentInfo = candidates.map((s) => {
91
108
  var _a;
92
109
  return ({
93
110
  name: s.name,
@@ -106,8 +123,10 @@ export class OrchestratingDriver extends EventTarget {
106
123
  this.dispatchEvent(new CustomEvent('history-updated', {
107
124
  detail: [...history, { role: 'user', content: input, attachments }],
108
125
  }));
109
- this.dispatchEvent(new CustomEvent('orchestrating-start'));
110
- let currentAgent = yield this.classify(input, history, this.activeAgent);
126
+ const pinned = this.resolvePinnedAgent();
127
+ if (!pinned)
128
+ this.dispatchEvent(new CustomEvent('orchestrating-start'));
129
+ let currentAgent = pinned !== null && pinned !== void 0 ? pinned : (yield this.classify(input, history, this.activeAgent));
111
130
  let isHandoff = false;
112
131
  let handoffs = 0;
113
132
  let handoffSummary = '';
@@ -126,7 +145,9 @@ export class OrchestratingDriver extends EventTarget {
126
145
  // eslint-disable-next-line no-await-in-loop
127
146
  result = yield this.chatDriver.sendMessage(input, attachments);
128
147
  }
129
- if (result.reason !== 'agent-handoff' || isFallback(currentAgent)) {
148
+ // Pinned agents never hand off — the continuation tool is filtered out in
149
+ // applyAgent, but this guards against a model hallucinating a handoff result.
150
+ if (result.reason !== 'agent-handoff' || isFallback(currentAgent) || pinned) {
130
151
  break;
131
152
  }
132
153
  handoffs += 1;
@@ -152,8 +173,9 @@ export class OrchestratingDriver extends EventTarget {
152
173
  }
153
174
  applyAgent(agent) {
154
175
  var _a;
155
- // Fallback agents are terminal and should not hand off to other specialists
156
- const agentToApply = isFallback(agent)
176
+ // Fallback and pinned agents are terminal neither should hand off.
177
+ const isTerminal = isFallback(agent) || this.pinnedAgentName !== null;
178
+ const agentToApply = isTerminal
157
179
  ? agent
158
180
  : Object.assign(Object.assign({}, agent), { toolDefinitions: [...((_a = agent.toolDefinitions) !== null && _a !== void 0 ? _a : []), REQUEST_CONTINUATION_DEFINITION] });
159
181
  const previousAgent = this.activeAgent;
@@ -230,6 +252,21 @@ export class OrchestratingDriver extends EventTarget {
230
252
  return this.specialists[0];
231
253
  });
232
254
  }
255
+ /**
256
+ * Returns the pinned agent if `pinnedAgentName` matches a known specialist or
257
+ * fallback. Logs and returns `undefined` if pinned to a name that no longer
258
+ * exists in the agents array — caller falls back to the classifier.
259
+ */
260
+ resolvePinnedAgent() {
261
+ if (this.pinnedAgentName === null)
262
+ return undefined;
263
+ const match = this.agents.find((a) => a.name === this.pinnedAgentName);
264
+ if (!match) {
265
+ logger.warn(`OrchestratingDriver: pinned agent "${this.pinnedAgentName}" not found in agents array — falling back to classifier.`);
266
+ return undefined;
267
+ }
268
+ return match;
269
+ }
233
270
  appendInlineMessage(content) {
234
271
  const history = this.chatDriver.getHistory();
235
272
  this.chatDriver.loadHistory([...history, { role: 'assistant', content }]);
package/dist/esm/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './main/main';
2
2
  export * from './main/main.types';
3
3
  export * from './main/main.template';
4
+ export * from './components/agent-picker';
4
5
  export * from './components/ai-driver';
5
6
  export * from './components/chat-driver';
6
7
  export * from './components/orchestrating-driver';
@@ -27,6 +27,7 @@ import { avoidTreeShaking } from '@genesislcap/foundation-utils';
27
27
  import { customElement, html, GenesisElement, observable, volatile, attr, } from '@genesislcap/web-core';
28
28
  import { agenticActivityBus } from '../channel/ai-activity-bus';
29
29
  import { AiActivityHalo } from '../components/activity-halo/activity-halo';
30
+ import { AgentPicker } from '../components/agent-picker/agent-picker';
30
31
  import { AiChatBubble } from '../components/chat-bubble/chat-bubble';
31
32
  import { ChatDriver } from '../components/chat-driver/chat-driver';
32
33
  import { AiChatInteractionWrapper } from '../components/chat-interaction-wrapper/chat-interaction-wrapper';
@@ -35,13 +36,30 @@ import { AiHaloOverlay } from '../components/halo-overlay';
35
36
  import { OrchestratingDriver } from '../components/orchestrating-driver/orchestrating-driver';
36
37
  import { getOrCreateDriver, deleteDriver } from '../state/driver-registry';
37
38
  import { getSessionStore, hasSessionStore } from '../state/session-store';
39
+ import { AI_COLOUR_AMBER, AI_COLOUR_CYAN, AI_COLOUR_PINK, AI_COLOUR_VIOLET, } from '../styles/ai-colours';
38
40
  import { ChatSuggestions } from '../suggestions/chat-suggestions';
41
+ import { AnimatedPanelToggle } from '../utils/animated-panel-toggle';
39
42
  import { logger } from '../utils/logger';
40
43
  import { expandToolTree } from '../utils/tool-fold';
41
44
  import { styles } from './main.styles';
42
45
  import { FoundationAiAssistantTemplate } from './main.template';
43
46
  import { ALL_ANIMATIONS } from './main.types';
44
47
  /** Context window sizes (in tokens) for known models. */
48
+ /**
49
+ * Pin tint palette, cycled by agent position. Matches the four brand colours
50
+ * used by the halo overlay and loading dots.
51
+ */
52
+ const PIN_COLOURS = [AI_COLOUR_AMBER, AI_COLOUR_PINK, AI_COLOUR_CYAN, AI_COLOUR_VIOLET];
53
+ /**
54
+ * Duration of the agent-picker slide-out animation. Must stay in sync with the
55
+ * `agent-picker-slide-out` keyframe duration in `main.styles.ts`.
56
+ */
57
+ const AGENT_PICKER_CLOSE_MS = 200;
58
+ /**
59
+ * Duration of the settings panel slide-out animation. Must stay in sync with
60
+ * the `settings-slide-out` keyframe duration in `main.styles.ts`.
61
+ */
62
+ const SETTINGS_CLOSE_MS = 200;
45
63
  const MODEL_CONTEXT_LIMITS = {
46
64
  'gemini-2.5-flash': 1048576,
47
65
  'gemini-2.5-flash-lite': 1048576,
@@ -50,7 +68,7 @@ const MODEL_CONTEXT_LIMITS = {
50
68
  'gpt-4-turbo': 128000,
51
69
  };
52
70
  // Register supporting components when the main component module is imported.
53
- avoidTreeShaking(AiChatMarkdown, AiChatInteractionWrapper, AiHaloOverlay, AiChatBubble, AiActivityHalo, ChatSuggestions);
71
+ avoidTreeShaking(AiChatMarkdown, AiChatInteractionWrapper, AiHaloOverlay, AiChatBubble, AiActivityHalo, ChatSuggestions, AgentPicker);
54
72
  /** Recursively strips `toolHandlers` from an agent and all its sub-agents. */
55
73
  function stripHandlers(agent) {
56
74
  const { toolHandlers: _, subAgents } = agent, rest = __rest(agent, ["toolHandlers", "subAgents"]);
@@ -107,6 +125,20 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
107
125
  /** True when the user has intentionally scrolled away from the bottom — suppresses auto-scroll. */
108
126
  this._userScrolledAway = false;
109
127
  this.showHalo = 'no';
128
+ this._settingsToggle = new AnimatedPanelToggle(this, '.settings-panel', SETTINGS_CLOSE_MS, () => this.settingsOpen, (v) => {
129
+ this.settingsOpen = v;
130
+ });
131
+ this._agentPickerToggle = new AnimatedPanelToggle(this, '.agent-picker-panel', AGENT_PICKER_CLOSE_MS, () => this.agentPickerOpen, (v) => {
132
+ this.agentPickerOpen = v;
133
+ });
134
+ }
135
+ /**
136
+ * Resolved agent picker mode from `chatConfig.picker.mode`. Defaults to
137
+ * `'disabled'`.
138
+ */
139
+ get agentPicker() {
140
+ var _a, _b;
141
+ return (_b = (_a = this.chatConfig.picker) === null || _a === void 0 ? void 0 : _a.mode) !== null && _b !== void 0 ? _b : 'disabled';
110
142
  }
111
143
  get messages() {
112
144
  var _a, _b;
@@ -208,6 +240,41 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
208
240
  var _a;
209
241
  (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setEnabledAnimations(value);
210
242
  }
243
+ /**
244
+ * Whether the agent picker slide-out panel is open. Lives on the session
245
+ * store so the bubble-dialog and popout-panel instances stay in sync.
246
+ */
247
+ get agentPickerOpen() {
248
+ var _a, _b;
249
+ return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.agentPickerOpen) !== null && _b !== void 0 ? _b : false;
250
+ }
251
+ set agentPickerOpen(value) {
252
+ var _a;
253
+ (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setAgentPickerOpen(value);
254
+ }
255
+ /**
256
+ * Name of the agent the user has pinned via the agent picker. `null` means
257
+ * automatic routing (Auto). Persisted on the session store, so it survives
258
+ * pop-in/pop-out but resets on page refresh.
259
+ */
260
+ get pinnedAgentName() {
261
+ var _a, _b;
262
+ return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.pinnedAgentName) !== null && _b !== void 0 ? _b : null;
263
+ }
264
+ set pinnedAgentName(value) {
265
+ var _a, _b, _c;
266
+ const previous = (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.pinnedAgentName) !== null && _b !== void 0 ? _b : null;
267
+ (_c = this._sessionRef) === null || _c === void 0 ? void 0 : _c.actions.aiAssistant.setPinnedAgentName(value);
268
+ if (this.driver instanceof OrchestratingDriver) {
269
+ this.driver.setPinnedAgent(value);
270
+ }
271
+ // Suggestions are scoped to the active routing — resetting forces a fresh
272
+ // fetch when the user pins/unpins so the prompts match the new agent.
273
+ if (previous !== value) {
274
+ this.suggestionsState = { status: 'idle' };
275
+ this.fetchSuggestions();
276
+ }
277
+ }
211
278
  get liveSubAgentTrace() {
212
279
  var _a, _b;
213
280
  return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.liveSubAgentTrace) !== null && _b !== void 0 ? _b : [];
@@ -224,6 +291,29 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
224
291
  var _a;
225
292
  (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setLiveSubAgentName(value);
226
293
  }
294
+ /**
295
+ * In-flight per-call chat-input overrides pushed by `requestSubAgent`
296
+ * invocations. Empty means no override is active.
297
+ */
298
+ get subAgentInputOverrides() {
299
+ var _a, _b;
300
+ return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.subAgentInputOverrides) !== null && _b !== void 0 ? _b : [];
301
+ }
302
+ /**
303
+ * Resolves the effective chat-input behaviour while the assistant is
304
+ * executing. Sub-agent overrides take precedence over the agent-level
305
+ * config; among overrides the most restrictive wins (`'hidden'` >
306
+ * `'disabled'`). Falls back to the active agent's config, then `'disabled'`.
307
+ */
308
+ get effectiveChatInputDuringExecution() {
309
+ var _a, _b;
310
+ const overrides = this.subAgentInputOverrides;
311
+ if (overrides.some((o) => o.mode === 'hidden'))
312
+ return 'hidden';
313
+ if (overrides.some((o) => o.mode === 'disabled'))
314
+ return 'disabled';
315
+ return (_b = (_a = this.activeAgent) === null || _a === void 0 ? void 0 : _a.chatInputDuringExecution) !== null && _b !== void 0 ? _b : 'disabled';
316
+ }
227
317
  /** Most recent prompt token count from the AI provider, if available. */
228
318
  get contextTokens() {
229
319
  var _a;
@@ -418,18 +508,38 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
418
508
  // driver's own history array (which is still being mutated by the tool loop).
419
509
  this.liveSubAgentTrace = structuredClone(history);
420
510
  };
421
- const onSubAgentStop = () => {
511
+ const onSubAgentStart = (e) => {
512
+ var _a;
513
+ const { invocationId, chatInputDuringExecution } = e.detail;
514
+ if (!chatInputDuringExecution)
515
+ return;
516
+ (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.addSubAgentInputOverride({
517
+ id: invocationId,
518
+ mode: chatInputDuringExecution,
519
+ });
520
+ };
521
+ const onSubAgentStop = (e) => {
522
+ var _a;
422
523
  this.liveSubAgentTrace = [];
423
524
  this.liveSubAgentName = null;
525
+ const { invocationId } = e.detail;
526
+ if (invocationId) {
527
+ (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.removeSubAgentInputOverride({ id: invocationId });
528
+ }
424
529
  };
425
530
  driver.addEventListener('sub-agent-history-updated', onSubAgentHistoryUpdated);
531
+ driver.addEventListener('sub-agent-start', onSubAgentStart);
426
532
  driver.addEventListener('sub-agent-stop', onSubAgentStop);
427
533
  const cleanups = [
428
534
  () => driver.removeEventListener('history-updated', onHistoryUpdated),
429
535
  () => driver.removeEventListener('sub-agent-history-updated', onSubAgentHistoryUpdated),
536
+ () => driver.removeEventListener('sub-agent-start', onSubAgentStart),
430
537
  () => driver.removeEventListener('sub-agent-stop', onSubAgentStop),
431
538
  ];
432
539
  if (driver instanceof OrchestratingDriver) {
540
+ // Restore any pinned agent from the session store onto the freshly built
541
+ // driver, so pop-in/pop-out and agents-array reassignment preserve the pin.
542
+ driver.setPinnedAgent(this.pinnedAgentName);
433
543
  const onOrchStart = () => {
434
544
  this.showHalo = 'orchestrating';
435
545
  };
@@ -460,7 +570,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
460
570
  this.driverCleanup = undefined;
461
571
  }
462
572
  connectedCallback() {
463
- var _a, _b, _c, _d, _e;
573
+ var _a, _b, _c, _d, _e, _f, _g;
464
574
  // Initialise the store reference BEFORE super.connectedCallback() so that
465
575
  // the first FAST render has access to the store. The store Proxy calls
466
576
  // Observable.track(observableStore, sliceName) whenever a slice is read,
@@ -479,6 +589,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
479
589
  this.showAgentSwitchIndicator = ui.showAgentSwitchIndicator === true;
480
590
  this.enabledAnimations =
481
591
  (_c = (_b = ui.animations) === null || _b === void 0 ? void 0 : _b.enabled) !== null && _c !== void 0 ? _c : (ui.animations ? [...ALL_ANIMATIONS] : []);
592
+ const defaultAgent = (_d = this.chatConfig.picker) === null || _d === void 0 ? void 0 : _d.defaultAgent;
593
+ if (defaultAgent && ((_e = this.agents) !== null && _e !== void 0 ? _e : []).some((a) => a.name === defaultAgent)) {
594
+ this.pinnedAgentName = defaultAgent;
595
+ }
482
596
  }
483
597
  this.driver = getOrCreateDriver(key, () => this.createDriver());
484
598
  this._driverAgentsKey = this.getAgentsKey(this.agents);
@@ -501,13 +615,13 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
501
615
  this.syncShowingSplash();
502
616
  // Restore loading state if the driver is still executing mid-lifecycle.
503
617
  // disconnectedCallback resets state to 'idle', so we must check real driver state here.
504
- if ((_d = this.driver) === null || _d === void 0 ? void 0 : _d.isBusy()) {
618
+ if ((_f = this.driver) === null || _f === void 0 ? void 0 : _f.isBusy()) {
505
619
  this.state = 'loading';
506
620
  this.startLoadingTimer();
507
621
  // Subscribe once so that when the originating send() completes (possibly on a
508
622
  // different element instance that has since disconnected), this element cleans up
509
623
  // its own timer, syncs the halo, and triggers the post-response suggestion fetch.
510
- this._executionCompletionUnsub = (_e = this._sessionRef) === null || _e === void 0 ? void 0 : _e.subscribeKey((s) => s.aiAssistant.state, () => {
624
+ this._executionCompletionUnsub = (_g = this._sessionRef) === null || _g === void 0 ? void 0 : _g.subscribeKey((s) => s.aiAssistant.state, () => {
511
625
  var _a, _b;
512
626
  if (((_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.state) === 'idle') {
513
627
  this.stopLoadingTimer();
@@ -556,6 +670,11 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
556
670
  this._userScrolledAway = false;
557
671
  document.removeEventListener('pointerdown', this._handleGlobalInteraction, true);
558
672
  document.removeEventListener('focusin', this._handleGlobalInteraction, true);
673
+ // Finalise any in-flight panel-close animations before clearing
674
+ // _sessionRef, otherwise the closed state never makes it to the store and
675
+ // the panel re-mounts open.
676
+ this._agentPickerToggle.finalize();
677
+ this._settingsToggle.finalize();
559
678
  // Clear local references only — driver and store stay in their registries.
560
679
  this.driver = undefined;
561
680
  this._sessionRef = undefined;
@@ -691,23 +810,79 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
691
810
  return parts.length ? parts.join('::') : undefined;
692
811
  }
693
812
  toggleSettings() {
694
- var _a;
695
- if (this.settingsOpen) {
696
- const panel = (_a = this.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector('.settings-panel');
697
- if (panel) {
698
- panel.classList.add('closing');
699
- panel.addEventListener('animationend', () => {
700
- panel.classList.remove('closing');
701
- this.settingsOpen = false;
702
- }, { once: true });
703
- }
704
- else {
705
- this.settingsOpen = false;
706
- }
707
- }
708
- else {
709
- this.settingsOpen = true;
813
+ this._settingsToggle.toggle();
814
+ }
815
+ toggleAgentPicker() {
816
+ this._agentPickerToggle.toggle();
817
+ }
818
+ /**
819
+ * Programmatically pin an agent by name. Returns `true` if the pin was
820
+ * applied, `false` if the agent isn't in the configured agents array or
821
+ * the call was suppressed by `force: false`.
822
+ *
823
+ * With `force: false`, the call is a no-op when a `picker.defaultAgent` is
824
+ * configured and the user has already moved away from it (either by picking
825
+ * another agent or by switching to Auto). This lets hosts seed an opinion
826
+ * without overriding an explicit user choice.
827
+ *
828
+ * @public
829
+ */
830
+ setAgent(agentName, options) {
831
+ var _a, _b, _c;
832
+ if (!((_a = this.agents) !== null && _a !== void 0 ? _a : []).some((a) => a.name === agentName))
833
+ return false;
834
+ const force = (_b = options === null || options === void 0 ? void 0 : options.force) !== null && _b !== void 0 ? _b : true;
835
+ if (!force) {
836
+ const defaultAgent = (_c = this.chatConfig.picker) === null || _c === void 0 ? void 0 : _c.defaultAgent;
837
+ if (defaultAgent && this.pinnedAgentName !== defaultAgent)
838
+ return false;
710
839
  }
840
+ this.pinnedAgentName = agentName;
841
+ return true;
842
+ }
843
+ /** Whether the picker toggle button should appear. Mirrors the picker's own visibility rule. */
844
+ get agentPickerEnabled() {
845
+ var _a, _b, _c;
846
+ if (this.agentPicker === 'disabled')
847
+ return false;
848
+ if (((_b = (_a = this.agents) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) <= 1)
849
+ return false;
850
+ return ((_c = this.agents) !== null && _c !== void 0 ? _c : []).some((a) => { var _a; return (_a = a.manualSelection) === null || _a === void 0 ? void 0 : _a.enabled; });
851
+ }
852
+ /** Hint text for the currently pinned agent, if any. Used in the toggle button tooltip. */
853
+ get pinnedAgentHint() {
854
+ var _a, _b, _c;
855
+ if (this.pinnedAgentName === null)
856
+ return undefined;
857
+ return (_c = (_b = (_a = this.agents) === null || _a === void 0 ? void 0 : _a.find((a) => a.name === this.pinnedAgentName)) === null || _b === void 0 ? void 0 : _b.manualSelection) === null || _c === void 0 ? void 0 : _c.hint;
858
+ }
859
+ /**
860
+ * Tint applied to the pin icon when an agent is pinned. Picked from the
861
+ * brand palette by agent position (modulo the palette length), so each agent
862
+ * gets a consistent colour across renders. `undefined` when nothing is
863
+ * pinned, the agent isn't in the current array, or the host has opted out
864
+ * via `chatConfig.picker.disablePinColours`.
865
+ */
866
+ get pinnedAgentColour() {
867
+ var _a, _b, _c;
868
+ if (this.pinnedAgentName === null)
869
+ return undefined;
870
+ if ((_a = this.chatConfig.picker) === null || _a === void 0 ? void 0 : _a.disablePinColours)
871
+ return undefined;
872
+ const idx = (_c = (_b = this.agents) === null || _b === void 0 ? void 0 : _b.findIndex((a) => a.name === this.pinnedAgentName)) !== null && _c !== void 0 ? _c : -1;
873
+ if (idx < 0)
874
+ return undefined;
875
+ return PIN_COLOURS[idx % PIN_COLOURS.length];
876
+ }
877
+ /**
878
+ * Placeholder shown in the chat input. Substitutes the pinned agent's name
879
+ * when one is selected so the user has a clearer signal of where their
880
+ * message will go; otherwise falls back to the host-provided placeholder.
881
+ */
882
+ get effectivePlaceholder() {
883
+ if (this.pinnedAgentName)
884
+ return `Message ${this.pinnedAgentName}...`;
885
+ return this.placeholder;
711
886
  }
712
887
  toggleShowToolCalls() {
713
888
  this.showToolCalls = !this.showToolCalls;
@@ -903,6 +1078,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
903
1078
  this.attachmentErrors = [];
904
1079
  this.suggestionsState = { status: 'idle' };
905
1080
  this._userScrolledAway = false;
1081
+ // Close the picker on send — the toggle button is disabled during loading,
1082
+ // so without this an open panel would be stuck open with clickable segments.
1083
+ if (this.agentPickerOpen)
1084
+ this.toggleAgentPicker();
906
1085
  this.state = 'loading';
907
1086
  this.startLoadingTimer();
908
1087
  const displayInput = (pendingAttachments === null || pendingAttachments === void 0 ? void 0 : pendingAttachments.length)
@@ -980,6 +1159,9 @@ __decorate([
980
1159
  __decorate([
981
1160
  attr({ attribute: 'debug-redux', mode: 'boolean' })
982
1161
  ], FoundationAiAssistant.prototype, "debugRedux", void 0);
1162
+ __decorate([
1163
+ volatile
1164
+ ], FoundationAiAssistant.prototype, "effectiveChatInputDuringExecution", null);
983
1165
  __decorate([
984
1166
  observable
985
1167
  ], FoundationAiAssistant.prototype, "attachments", void 0);
@@ -1004,6 +1186,18 @@ __decorate([
1004
1186
  __decorate([
1005
1187
  volatile
1006
1188
  ], FoundationAiAssistant.prototype, "visibleMessages", null);
1189
+ __decorate([
1190
+ volatile
1191
+ ], FoundationAiAssistant.prototype, "agentPickerEnabled", null);
1192
+ __decorate([
1193
+ volatile
1194
+ ], FoundationAiAssistant.prototype, "pinnedAgentHint", null);
1195
+ __decorate([
1196
+ volatile
1197
+ ], FoundationAiAssistant.prototype, "pinnedAgentColour", null);
1198
+ __decorate([
1199
+ volatile
1200
+ ], FoundationAiAssistant.prototype, "effectivePlaceholder", null);
1007
1201
  FoundationAiAssistant = FoundationAiAssistant_1 = __decorate([
1008
1202
  customElement({
1009
1203
  name: 'foundation-ai-assistant',
@@ -516,10 +516,69 @@ export const styles = css `
516
516
  background-color: var(--neutral-layer-2);
517
517
  }
518
518
 
519
+ .input-left-controls {
520
+ display: flex;
521
+ flex-direction: column;
522
+ align-items: stretch;
523
+ gap: calc(var(--design-unit) * 1px);
524
+ }
525
+
526
+ /* Lock the width so the column doesn't reflow when the button content
527
+ switches between the "Auto" label, the pin icon, and the chevron. */
528
+ .agent-toggle-button {
529
+ width: 56px;
530
+ min-width: 56px;
531
+ max-width: 56px;
532
+ box-sizing: border-box;
533
+ }
534
+
535
+ .agent-toggle-label {
536
+ font-size: 0.75em;
537
+ font-weight: 600;
538
+ letter-spacing: 0.02em;
539
+ }
540
+
541
+ @keyframes agent-picker-slide-in {
542
+ from {
543
+ opacity: 0%;
544
+ transform: translateY(8px);
545
+ }
546
+
547
+ to {
548
+ opacity: 100%;
549
+ transform: translateY(0);
550
+ }
551
+ }
552
+
553
+ @keyframes agent-picker-slide-out {
554
+ from {
555
+ opacity: 100%;
556
+ transform: translateY(0);
557
+ }
558
+
559
+ to {
560
+ opacity: 0%;
561
+ transform: translateY(8px);
562
+ }
563
+ }
564
+
565
+ .agent-picker-panel {
566
+ animation: agent-picker-slide-in 0.2s ease-out;
567
+ overflow: hidden;
568
+ }
569
+
570
+ .agent-picker-panel.closing {
571
+ animation: agent-picker-slide-out 0.2s ease-in forwards;
572
+ }
573
+
519
574
  .chat-input {
520
575
  flex: 1;
521
576
  resize: none;
522
577
  max-height: 150px;
578
+
579
+ /* Match the height of the left-controls column when it has stacked buttons,
580
+ so the textarea fills the row instead of sitting flush to the send button. */
581
+ align-self: stretch;
523
582
  }
524
583
 
525
584
  .file-input {