@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
@@ -0,0 +1,148 @@
1
+ import {
2
+ attr,
3
+ customElement,
4
+ GenesisElement,
5
+ html,
6
+ observable,
7
+ volatile,
8
+ } from '@genesislcap/web-core';
9
+ import type { AgentConfig } from '../../config/config';
10
+ import type { AgentPickerMode } from '../../main/main.types';
11
+ import { styles } from './agent-picker.styles';
12
+ import { AgentPickerTemplate } from './agent-picker.template';
13
+
14
+ /** Sentinel value used by the segmented control / select to represent "Auto". */
15
+ export const AGENT_PICKER_AUTO_VALUE = '__auto__';
16
+
17
+ /** Tolerance for `scrollWidth > clientWidth` overflow detection (sub-pixel rounding). */
18
+ const OVERFLOW_TOLERANCE_PX = 1;
19
+
20
+ /**
21
+ * User-facing agent picker rendered above the chat input.
22
+ *
23
+ * Renders `Auto` plus each top-level agent that has `manualSelection.enabled`.
24
+ * Sub-agents and non-selectable specialists are excluded. Hidden when fewer
25
+ * than two agents are configured or none are manually selectable.
26
+ *
27
+ * @fires agent-pinned - Fired when the user changes selection. `detail` is the
28
+ * agent name (string) or `null` for Auto.
29
+ *
30
+ * @beta
31
+ */
32
+ @customElement({
33
+ name: 'foundation-ai-agent-picker',
34
+ template: html`
35
+ ${(x: AgentPicker) => AgentPickerTemplate(x.designSystemPrefix)}
36
+ `,
37
+ styles,
38
+ })
39
+ export class AgentPicker extends GenesisElement {
40
+ /** Design-system tag prefix, e.g. `'rapid'` for `rapid-segmented-control`. */
41
+ @attr({ attribute: 'design-system-prefix' }) designSystemPrefix: string = 'rapid';
42
+ /** Picker variant, sourced from the assistant's `chatConfig.agentPicker`. */
43
+ @observable mode: AgentPickerMode = 'disabled';
44
+ /** Top-level agents passed to the assistant. */
45
+ @observable agents: AgentConfig[] = [];
46
+ /** Currently pinned agent name, or `null` for Auto. */
47
+ @observable pinnedAgentName: string | null = null;
48
+
49
+ /** @internal — set via the template `${ref(...)}` binding. */
50
+ segmentedRowEl?: HTMLElement;
51
+
52
+ /**
53
+ * @internal — true when the segmented row's content overflows its container.
54
+ * Drives the fallback to `select` rendering. Only meaningful when
55
+ * `mode === 'segmented-control'`.
56
+ */
57
+ @observable overflowing: boolean = false;
58
+
59
+ private resizeObserver?: ResizeObserver;
60
+
61
+ /** Top-level agents that opted in to manual selection. */
62
+ @volatile
63
+ get selectableAgents(): AgentConfig[] {
64
+ return (this.agents ?? []).filter((a) => a.manualSelection?.enabled);
65
+ }
66
+
67
+ /** Whether the picker should render at all. */
68
+ @volatile
69
+ get visible(): boolean {
70
+ if (this.mode === 'disabled') return false;
71
+ if ((this.agents?.length ?? 0) <= 1) return false;
72
+ if (this.selectableAgents.length === 0) return false;
73
+ return true;
74
+ }
75
+
76
+ /**
77
+ * The variant actually rendered: `'select'` if user-configured or if the
78
+ * segmented row currently overflows; `'segmented-control'` otherwise.
79
+ */
80
+ @volatile
81
+ get effectiveMode(): 'select' | 'segmented-control' {
82
+ if (this.mode === 'segmented-control') {
83
+ return this.overflowing ? 'select' : 'segmented-control';
84
+ }
85
+ return 'select';
86
+ }
87
+
88
+ override connectedCallback(): void {
89
+ super.connectedCallback();
90
+ if (typeof ResizeObserver === 'undefined') return;
91
+ this.resizeObserver = new ResizeObserver(() => this.measureOverflow());
92
+ this.resizeObserver.observe(this);
93
+ }
94
+
95
+ override disconnectedCallback(): void {
96
+ super.disconnectedCallback();
97
+ this.resizeObserver?.disconnect();
98
+ this.resizeObserver = undefined;
99
+ }
100
+
101
+ /**
102
+ * Re-measure when agents change — newly added selectable agents may push the
103
+ * segmented row past the container's width.
104
+ */
105
+ agentsChanged(): void {
106
+ queueMicrotask(() => this.measureOverflow());
107
+ }
108
+
109
+ modeChanged(): void {
110
+ queueMicrotask(() => this.measureOverflow());
111
+ }
112
+
113
+ /**
114
+ * Measures whether the segmented row's natural content width exceeds the
115
+ * container's visible width. Bails when the row isn't currently rendered
116
+ * (e.g. when `mode !== 'segmented-control'`).
117
+ */
118
+ private measureOverflow(): void {
119
+ const row = this.segmentedRowEl;
120
+ if (!row) {
121
+ this.overflowing = false;
122
+ return;
123
+ }
124
+ this.overflowing = row.scrollWidth > row.clientWidth + OVERFLOW_TOLERANCE_PX;
125
+ }
126
+
127
+ selectAgent(name: string | null): void {
128
+ this.pinnedAgentName = name;
129
+ // Always emit — the host may close a slide-out panel on click even when
130
+ // the selection didn't change.
131
+ this.$emit('agent-pinned', name);
132
+ }
133
+
134
+ /**
135
+ * Maps a value from the segmented-control or select (which uses
136
+ * {@link AGENT_PICKER_AUTO_VALUE} for Auto) back to the `string | null` form
137
+ * expected by `selectAgent`.
138
+ */
139
+ selectByValue(value: string): void {
140
+ this.selectAgent(value === AGENT_PICKER_AUTO_VALUE ? null : value);
141
+ }
142
+
143
+ /** Current pinned agent name as a value the segmented-control / select can bind to. */
144
+ @volatile
145
+ get currentValue(): string {
146
+ return this.pinnedAgentName ?? AGENT_PICKER_AUTO_VALUE;
147
+ }
148
+ }
@@ -0,0 +1 @@
1
+ export * from './agent-picker';
@@ -66,6 +66,13 @@ export class ChatDriver extends EventTarget implements AiDriver {
66
66
  private toolHandlers: ChatToolHandlers;
67
67
  private primerHistory?: ChatMessage[];
68
68
  private activeAgentName?: string;
69
+ /**
70
+ * When set, `requestInteraction` delegates to this callback instead of using
71
+ * this driver's own pending map. Wired by `invokeSubAgent` so a sub-agent's
72
+ * widget renders in — and resolves through — the parent (ultimately the
73
+ * root) driver, where the main UI is listening.
74
+ */
75
+ private hostInteractionRequester?: <T>(componentName: string, data: any) => Promise<T>;
69
76
  /**
70
77
  * When set (e.g. by OrchestratingDriver), applied only to the conversation slice
71
78
  * sent to the model — stored `history` stays unchanged for UI and logging.
@@ -237,14 +244,43 @@ export class ChatDriver extends EventTarget implements AiDriver {
237
244
  return this.busy;
238
245
  }
239
246
 
247
+ /**
248
+ * Wire a parent driver as the host for this driver's interactions. When set,
249
+ * `requestInteraction` delegates upward so the widget renders in (and
250
+ * resolves through) the parent's history and pending map. Calls chain
251
+ * naturally: a grandchild → child → root.
252
+ */
253
+ public setHostInteractionRequester(
254
+ fn: <T>(componentName: string, data: any) => Promise<T>,
255
+ ): void {
256
+ this.hostInteractionRequester = fn;
257
+ }
258
+
240
259
  /**
241
260
  * Request a custom UI interaction. Emits a new message with the interaction.
242
261
  * Tool handlers can call this to pause execution until the user completes the UI interaction.
243
262
  *
263
+ * If a host requester is wired (sub-agent case), the call delegates upward
264
+ * so the interaction lives on the parent — the main UI is only listening to
265
+ * the root driver. Only one interaction may be in flight at any time on a
266
+ * given root: concurrent calls (e.g. two parallel sub-agents both spawning a
267
+ * widget) throw. Parallel sub-agents are for parallel work, not for user
268
+ * interaction, which is inherently sequential.
269
+ *
244
270
  * @param componentName - The custom element name to render.
245
271
  * @param data - Data to pass to the component.
246
272
  */
247
273
  public async requestInteraction<T>(componentName: string, data: any): Promise<T> {
274
+ if (this.hostInteractionRequester) {
275
+ return this.hostInteractionRequester<T>(componentName, data);
276
+ }
277
+ if (this.pendingInteractions.size > 0) {
278
+ throw new Error(
279
+ 'requestInteraction: another user interaction is already in flight. ' +
280
+ 'Only one interaction may be active at a time — sequence them in a single tool handler ' +
281
+ 'rather than spawning widgets from parallel sub-agents or parallel tool calls.',
282
+ );
283
+ }
248
284
  const interactionId = crypto.randomUUID();
249
285
  return new Promise((resolve, reject) => {
250
286
  this.pendingInteractions.set(interactionId, { resolve, reject });
@@ -396,6 +432,14 @@ export class ChatDriver extends EventTarget implements AiDriver {
396
432
 
397
433
  const child = new ChatDriver(this.aiProvider);
398
434
  child.applyAgent({ ...subConfig, primerHistory: effectivePrimer });
435
+ // Route interactions back through this driver so widgets render in the
436
+ // parent's (ultimately the root's) history and resolve via the same
437
+ // pending map the main UI is wired to. Recurses naturally for nested
438
+ // sub-agents.
439
+ child.setHostInteractionRequester(
440
+ <R>(componentName: string, data: any): Promise<R> =>
441
+ this.requestInteraction<R>(componentName, data),
442
+ );
399
443
 
400
444
  const forwardTrace = (e: Event) => {
401
445
  this.dispatchEvent(
@@ -406,12 +450,18 @@ export class ChatDriver extends EventTarget implements AiDriver {
406
450
  };
407
451
  child.addEventListener('history-updated', forwardTrace);
408
452
 
409
- this.dispatchEvent(new CustomEvent('sub-agent-start', { detail: { name } }));
453
+ // Unique per-invocation id so listeners can pair start/stop reliably even
454
+ // when the same sub-agent runs multiple times in parallel.
455
+ const invocationId = crypto.randomUUID();
456
+ const chatInputDuringExecution = options?.chatInputDuringExecution;
457
+ const lifecycleDetail = { name, invocationId, chatInputDuringExecution };
458
+
459
+ this.dispatchEvent(new CustomEvent('sub-agent-start', { detail: lifecycleDetail }));
410
460
  try {
411
461
  await child.sendMessage(task ?? '');
412
462
  } finally {
413
463
  child.removeEventListener('history-updated', forwardTrace);
414
- this.dispatchEvent(new CustomEvent('sub-agent-stop', { detail: { name } }));
464
+ this.dispatchEvent(new CustomEvent('sub-agent-stop', { detail: lifecycleDetail }));
415
465
  }
416
466
 
417
467
  const trace = child.getHistory() as ChatMessage[];
@@ -651,17 +701,28 @@ export class ChatDriver extends EventTarget implements AiDriver {
651
701
  ? `${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.`
652
702
  : baseSystemPrompt;
653
703
 
704
+ // Capture the pending user input, then clear the slots BEFORE the chat
705
+ // call. `sendMessage` already appended the user message to `this.history`,
706
+ // so on retries (empty / malformed) we must rely on history alone —
707
+ // otherwise the message gets sent twice (once via history, once via
708
+ // `currentInput`), which Gemini answers with an empty response and then
709
+ // we retry forever.
710
+ const userInputForCall = currentInput;
711
+ const attachmentsForCall = currentAttachments;
712
+ currentInput = '';
713
+ currentAttachments = undefined;
714
+
654
715
  const options: ChatRequestOptions = {
655
716
  systemPrompt,
656
717
  // Strip fold-only properties (foldEvent, foldPath) before sending to provider
657
718
  tools: this.toolDefinitions.length ? this.toolDefinitions : undefined,
658
- attachments: currentAttachments,
719
+ attachments: attachmentsForCall,
659
720
  };
660
721
 
661
722
  let response: ChatMessage;
662
723
  try {
663
724
  // eslint-disable-next-line no-await-in-loop
664
- response = await this.aiProvider.chat!(historyForCall, currentInput, options);
725
+ response = await this.aiProvider.chat!(historyForCall, userInputForCall, options);
665
726
  } catch (e) {
666
727
  if (e instanceof MalformedFunctionCallError) {
667
728
  malformedAttempts += 1;
@@ -683,8 +744,6 @@ export class ChatDriver extends EventTarget implements AiDriver {
683
744
  throw e;
684
745
  }
685
746
 
686
- currentAttachments = undefined;
687
-
688
747
  const isThinkingStep = response.content && response.toolCalls?.length;
689
748
  const isEmptyResponse = !response.content?.trim() && !response.toolCalls?.length;
690
749
 
@@ -918,8 +977,6 @@ export class ChatDriver extends EventTarget implements AiDriver {
918
977
  if (this.subAgentCompletion) {
919
978
  return { reason: 'done' };
920
979
  }
921
-
922
- currentInput = '';
923
980
  }
924
981
 
925
982
  if (iterations >= this.maxToolIterations) {
@@ -69,6 +69,7 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
69
69
  private readonly maxHandoffs: number;
70
70
  private readonly classifierHistoryLength: number;
71
71
  private readonly classifierRetries: number;
72
+ private pinnedAgentName: string | null = null;
72
73
 
73
74
  activeAgent?: AgentConfig;
74
75
 
@@ -121,6 +122,9 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
121
122
  new CustomEvent('sub-agent-history-updated', { detail: (e as CustomEvent).detail }),
122
123
  );
123
124
  });
125
+ this.chatDriver.addEventListener('sub-agent-start', (e: Event) => {
126
+ this.dispatchEvent(new CustomEvent('sub-agent-start', { detail: (e as CustomEvent).detail }));
127
+ });
124
128
  this.chatDriver.addEventListener('sub-agent-stop', (e: Event) => {
125
129
  this.dispatchEvent(new CustomEvent('sub-agent-stop', { detail: (e as CustomEvent).detail }));
126
130
  });
@@ -134,6 +138,16 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
134
138
  return this.chatDriver.isBusy();
135
139
  }
136
140
 
141
+ /**
142
+ * Pins routing to a specific agent by name. While pinned, the classifier is
143
+ * skipped and the continuation tool is hidden from the agent's tool list, so
144
+ * the agent cannot quietly hand back to the orchestrator. Pass `null` to
145
+ * return to automatic routing.
146
+ */
147
+ setPinnedAgent(name: string | null): void {
148
+ this.pinnedAgentName = name;
149
+ }
150
+
137
151
  loadHistory(messages: ChatMessage[]): void {
138
152
  this.chatDriver.loadHistory(messages);
139
153
  }
@@ -148,7 +162,11 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
148
162
  count: number,
149
163
  allAgentInfo?: AllAgentSummary[],
150
164
  ): Promise<string[]> {
151
- const agentInfo = this.specialists.map((s) => ({
165
+ // When pinned to a specialist, scope suggestions to that agent so the
166
+ // proposed prompts reflect the active routing instead of the full panel.
167
+ const pinned = this.resolvePinnedAgent();
168
+ const candidates = pinned && isSpecialist(pinned) ? [pinned] : this.specialists;
169
+ const agentInfo = candidates.map((s) => ({
152
170
  name: s.name,
153
171
  description: s.description,
154
172
  tools: s.toolDefinitions ?? [],
@@ -167,8 +185,9 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
167
185
  }),
168
186
  );
169
187
 
170
- this.dispatchEvent(new CustomEvent('orchestrating-start'));
171
- let currentAgent = await this.classify(input, history, this.activeAgent);
188
+ const pinned = this.resolvePinnedAgent();
189
+ if (!pinned) this.dispatchEvent(new CustomEvent('orchestrating-start'));
190
+ let currentAgent = pinned ?? (await this.classify(input, history, this.activeAgent));
172
191
  let isHandoff = false;
173
192
  let handoffs = 0;
174
193
  let handoffSummary = '';
@@ -189,7 +208,9 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
189
208
  result = await this.chatDriver.sendMessage(input, attachments);
190
209
  }
191
210
 
192
- if (result.reason !== 'agent-handoff' || isFallback(currentAgent)) {
211
+ // Pinned agents never hand off — the continuation tool is filtered out in
212
+ // applyAgent, but this guards against a model hallucinating a handoff result.
213
+ if (result.reason !== 'agent-handoff' || isFallback(currentAgent) || pinned) {
193
214
  break;
194
215
  }
195
216
 
@@ -219,8 +240,9 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
219
240
  }
220
241
 
221
242
  private applyAgent(agent: AgentConfig): void {
222
- // Fallback agents are terminal and should not hand off to other specialists
223
- const agentToApply = isFallback(agent)
243
+ // Fallback and pinned agents are terminal neither should hand off.
244
+ const isTerminal = isFallback(agent) || this.pinnedAgentName !== null;
245
+ const agentToApply = isTerminal
224
246
  ? agent
225
247
  : {
226
248
  ...agent,
@@ -315,6 +337,23 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
315
337
  return this.specialists[0];
316
338
  }
317
339
 
340
+ /**
341
+ * Returns the pinned agent if `pinnedAgentName` matches a known specialist or
342
+ * fallback. Logs and returns `undefined` if pinned to a name that no longer
343
+ * exists in the agents array — caller falls back to the classifier.
344
+ */
345
+ private resolvePinnedAgent(): AgentConfig | undefined {
346
+ if (this.pinnedAgentName === null) return undefined;
347
+ const match = this.agents.find((a) => a.name === this.pinnedAgentName);
348
+ if (!match) {
349
+ logger.warn(
350
+ `OrchestratingDriver: pinned agent "${this.pinnedAgentName}" not found in agents array — falling back to classifier.`,
351
+ );
352
+ return undefined;
353
+ }
354
+ return match;
355
+ }
356
+
318
357
  private appendInlineMessage(content: string): void {
319
358
  const history = this.chatDriver.getHistory() as ChatMessage[];
320
359
  this.chatDriver.loadHistory([...history, { role: 'assistant', content }]);
@@ -1,20 +1,31 @@
1
- import type { ChatMessage, ChatToolDefinition, ChatToolHandlers } from '@genesislcap/foundation-ai';
1
+ import type {
2
+ ChatInputDuringExecutionMode,
3
+ ChatMessage,
4
+ ChatToolDefinition,
5
+ ChatToolHandlers,
6
+ } from '@genesislcap/foundation-ai';
7
+
8
+ export type { ChatInputDuringExecutionMode };
2
9
 
3
10
  /**
4
- * Controls how the main chat input area behaves while this agent is executing
5
- * (i.e. while `state === 'loading'` — covers both LLM thinking and pending
6
- * widget interactions).
11
+ * Opts an agent in to manual selection from the assistant's agent picker.
7
12
  *
8
- * - `'disabled'` (default): input stays visible but disabled.
9
- * - `'hidden'`: the entire input row (attach button, textarea, send button) is
10
- * hidden. Useful for agents whose interaction widgets contain their own
11
- * input affordances and where a second disabled chat input is redundant or
12
- * confusing — typically long-running agents (planners) where one transition
13
- * in/out is less jarring than mid-run flicker.
13
+ * Only applies to top-level agents sub-agents are never user-selectable.
14
+ * Has no effect unless the assistant's `agentPicker` is also enabled.
14
15
  *
15
16
  * @beta
16
17
  */
17
- export type ChatInputDuringExecutionMode = 'disabled' | 'hidden';
18
+ export interface ManualSelectionConfig {
19
+ /**
20
+ * Whether this agent appears in the picker.
21
+ */
22
+ enabled: boolean;
23
+ /**
24
+ * Optional short description shown as a tooltip on the picker entry, and as
25
+ * a secondary line in the dropdown variant.
26
+ */
27
+ hint?: string;
28
+ }
18
29
 
19
30
  interface BaseAgentConfig {
20
31
  /**
@@ -47,6 +58,12 @@ interface BaseAgentConfig {
47
58
  * Defaults to `'disabled'`. See {@link ChatInputDuringExecutionMode}.
48
59
  */
49
60
  chatInputDuringExecution?: ChatInputDuringExecutionMode;
61
+ /**
62
+ * Opts this agent in to manual selection from the assistant's agent picker.
63
+ * Has no effect on sub-agents or when the assistant's `agentPicker` is
64
+ * disabled. See {@link ManualSelectionConfig}.
65
+ */
66
+ manualSelection?: ManualSelectionConfig;
50
67
  }
51
68
 
52
69
  /**
package/src/index.ts 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';
@@ -522,10 +522,69 @@ export const styles = css`
522
522
  background-color: var(--neutral-layer-2);
523
523
  }
524
524
 
525
+ .input-left-controls {
526
+ display: flex;
527
+ flex-direction: column;
528
+ align-items: stretch;
529
+ gap: calc(var(--design-unit) * 1px);
530
+ }
531
+
532
+ /* Lock the width so the column doesn't reflow when the button content
533
+ switches between the "Auto" label, the pin icon, and the chevron. */
534
+ .agent-toggle-button {
535
+ width: 56px;
536
+ min-width: 56px;
537
+ max-width: 56px;
538
+ box-sizing: border-box;
539
+ }
540
+
541
+ .agent-toggle-label {
542
+ font-size: 0.75em;
543
+ font-weight: 600;
544
+ letter-spacing: 0.02em;
545
+ }
546
+
547
+ @keyframes agent-picker-slide-in {
548
+ from {
549
+ opacity: 0%;
550
+ transform: translateY(8px);
551
+ }
552
+
553
+ to {
554
+ opacity: 100%;
555
+ transform: translateY(0);
556
+ }
557
+ }
558
+
559
+ @keyframes agent-picker-slide-out {
560
+ from {
561
+ opacity: 100%;
562
+ transform: translateY(0);
563
+ }
564
+
565
+ to {
566
+ opacity: 0%;
567
+ transform: translateY(8px);
568
+ }
569
+ }
570
+
571
+ .agent-picker-panel {
572
+ animation: agent-picker-slide-in 0.2s ease-out;
573
+ overflow: hidden;
574
+ }
575
+
576
+ .agent-picker-panel.closing {
577
+ animation: agent-picker-slide-out 0.2s ease-in forwards;
578
+ }
579
+
525
580
  .chat-input {
526
581
  flex: 1;
527
582
  resize: none;
528
583
  max-height: 150px;
584
+
585
+ /* Match the height of the left-controls column when it has stacked buttons,
586
+ so the textarea fills the row instead of sitting flush to the send button. */
587
+ align-self: stretch;
529
588
  }
530
589
 
531
590
  .file-input {
@@ -268,6 +268,80 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
268
268
  )}
269
269
  `;
270
270
 
271
+ // ── Agent picker ────────────────────────────────────────────────────────────
272
+
273
+ const agentToggleButtonTemplate = html<FoundationAiAssistant>`
274
+ <${buttonTag}
275
+ class="agent-toggle-button"
276
+ part="agent-toggle-button"
277
+ appearance="stealth"
278
+ title=${(x) =>
279
+ x.agentPickerOpen
280
+ ? 'Close agent picker'
281
+ : x.pinnedAgentName !== null
282
+ ? x.pinnedAgentHint
283
+ ? `${x.pinnedAgentName} — ${x.pinnedAgentHint}`
284
+ : (x.pinnedAgentName ?? '')
285
+ : 'Attempts to route messages to the correct available agent. Click to manually pin an agent.'}
286
+ ?disabled=${(x) => x.state === 'loading'}
287
+ @click=${(x) => x.toggleAgentPicker()}
288
+ >
289
+ ${when(
290
+ (x) => x.agentPickerOpen,
291
+ html<FoundationAiAssistant>`<${iconTag} name="chevron-down"></${iconTag}>`,
292
+ )}
293
+ ${when(
294
+ (x) => !x.agentPickerOpen && x.pinnedAgentName !== null,
295
+ html<FoundationAiAssistant>`
296
+ <${iconTag}
297
+ name="thumbtack"
298
+ style="${(x) => (x.pinnedAgentColour ? `color: ${x.pinnedAgentColour}` : '')}"
299
+ ></${iconTag}>
300
+ `,
301
+ )}
302
+ ${when(
303
+ (x) => !x.agentPickerOpen && x.pinnedAgentName === null,
304
+ html<FoundationAiAssistant>`
305
+ <span class="agent-toggle-label">Auto</span>
306
+ `,
307
+ )}
308
+ </${buttonTag}>
309
+ `;
310
+
311
+ const attachButtonTemplate = html<FoundationAiAssistant>`
312
+ <${buttonTag}
313
+ class="attach-button"
314
+ part="attach-button"
315
+ appearance="stealth"
316
+ title=${(x) => `Attach file (${x.chatConfig.ui?.acceptedFiles})`}
317
+ ?disabled=${(x) => x.state === 'loading'}
318
+ @click=${(x) => x.triggerFileInput()}
319
+ ><${iconTag} name="paperclip"></${iconTag}></${buttonTag}>
320
+ `;
321
+
322
+ const inputLeftControlsTemplate = html<FoundationAiAssistant>`
323
+ <div class="input-left-controls" part="input-left-controls">
324
+ ${when((x) => x.agentPickerEnabled, agentToggleButtonTemplate)}
325
+ ${when((x) => !!x.chatConfig.ui?.acceptedFiles, attachButtonTemplate)}
326
+ </div>
327
+ `;
328
+
329
+ const agentPickerPanelTemplate = html<FoundationAiAssistant>`
330
+ <div class="agent-picker-panel" part="agent-picker-panel">
331
+ <foundation-ai-agent-picker
332
+ part="agent-picker"
333
+ :designSystemPrefix="${(x) => x.designSystemPrefix}"
334
+ :mode="${(x) => x.agentPicker}"
335
+ :agents="${(x) => x.agents ?? []}"
336
+ :pinnedAgentName="${(x) => x.pinnedAgentName}"
337
+ @agent-pinned="${(x, c) => {
338
+ x.pinnedAgentName = (c.event as CustomEvent<string | null>).detail;
339
+ x.toggleAgentPicker();
340
+ }}"
341
+ ></foundation-ai-agent-picker>
342
+ </div>
343
+ `;
344
+
271
345
  // ── Root template ───────────────────────────────────────────────────────────
272
346
 
273
347
  return html<FoundationAiAssistant>`
@@ -509,28 +583,20 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
509
583
  ></chat-suggestions>
510
584
  `,
511
585
  )}
586
+ ${when((x) => x.agentPickerEnabled && x.agentPickerOpen, agentPickerPanelTemplate)}
512
587
  ${when(
513
- (x) => !(x.state === 'loading' && x.activeAgent?.chatInputDuringExecution === 'hidden'),
588
+ (x) => !(x.state === 'loading' && x.effectiveChatInputDuringExecution === 'hidden'),
514
589
  html<FoundationAiAssistant>`
515
590
  <div class="input-row" part="input-row">
516
591
  ${when(
517
- (x) => !!x.chatConfig.ui?.acceptedFiles,
518
- html<FoundationAiAssistant>`
519
- <${buttonTag}
520
- class="attach-button"
521
- part="attach-button"
522
- appearance="stealth"
523
- title=${(x) => `Attach file (${x.chatConfig.ui?.acceptedFiles})`}
524
- ?disabled=${(x) => x.state === 'loading'}
525
- @click=${(x) => x.triggerFileInput()}
526
- ><${iconTag} name="paperclip"></${iconTag}></${buttonTag}>
527
- `,
592
+ (x) => x.agentPickerEnabled || !!x.chatConfig.ui?.acceptedFiles,
593
+ inputLeftControlsTemplate,
528
594
  )}
529
595
  <${textareaTag}
530
596
  ${ref('chatInputEl')}
531
597
  class="chat-input"
532
598
  part="input"
533
- placeholder=${(x) => x.placeholder}
599
+ placeholder=${(x) => x.effectivePlaceholder}
534
600
  :value=${(x) => x.inputValue}
535
601
  ?disabled=${(x) => x.state === 'loading'}
536
602
  @input=${(x, c) => (x.inputValue = (c.event.target as any).value)}