diff --git a/packages/apollo-wind/apollo-ai-context.md b/packages/apollo-wind/apollo-ai-context.md
index f4612ece7..c5e4bdd57 100644
--- a/packages/apollo-wind/apollo-ai-context.md
+++ b/packages/apollo-wind/apollo-ai-context.md
@@ -316,6 +316,7 @@ Put the control inside `InputGroup`. Apollo's controls detect the group and beco
- `FieldMenu` is the trailing menu: the field's modes, then its actions (`actions`), or an overflow menu alone (`modesDisabled`). Built-in modes are listed by id (`modes={['literal', 'expression', 'variable', 'prompt']}`; default `['literal', 'expression']`); `builtInValueModes(strings)` returns their options. `ValueModeIndicator` shows `=` in expression mode; inside MetadataForm a mode definition's `indicator` marks any other mode.
- `InsertVariableAction` renders `variables` (`VariablePickerItem[]`) as supplied, with no root of its own; pass a function to resolve them when the picker opens. Its strings include the picker's search placeholder and empty state. `AiAssistAction`'s `onGenerate(prompt)` may return a promise (any thenable): the prompt stays busy until it settles and shows `strings.error` on rejection. `disabled` disables the trigger.
- Host picker: `VariablePicker`, `InsertVariableAction` and `VariableValueControl` take `renderPicker: RenderVariablePicker` (`({ value?, onSelect(value, item?), close }) => ReactNode`; `onSelect` commits and closes; `variables` is then optional), keeping wind's trigger, popover, focus return and strings. `VariableValueControl` also takes `describeReference(reference) => { label, icon? }` for its chip. The slot is called only while the popover is open.
+- Host AI panel: `AiAssistAction` takes `renderContent({ close, disabled })` in place of its prompt form, called only while the popover is open.
- Localization: every addon and action takes a `strings` prop merged over its `DEFAULT_*_STRINGS`. There is no provider.
#### Tabs
@@ -427,6 +428,8 @@ strings for every built-in through `FormPlugin.strings` (`valueModes`, `boolean`
A host with its own picker passes it as `renderPicker` to `createInsertVariableAction` and to
`createVariableModeControl` (registered as `FormPlugin.valueModes.definitions.variable.control`, with
`describeReference` for chip labels).
+`createAiAssistAction({ renderContent })` renders a host AI panel whose `apply({ mode?, value })`
+writes like `generate`'s result.
Keep plugin objects stable (module constants or memoized), pass `variables` as a function when the
list is long or changes, and keep codecs pure. The default codec stores every value as `{ $mode, value }` (no `value`
once cleared) and reads a raw value as literal, so code that reads form values must expect the
diff --git a/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md b/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md
index c9941ffb8..378984c92 100644
--- a/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md
+++ b/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md
@@ -139,7 +139,7 @@ Same props as Row.
| `FieldMenu` | Mode and actions menu | `mode`, `onSelect`, `modes`, `actions`, `modesDisabled`, `strings` |
| `ValueModeIndicator` | `=` before an expression | `mode`, `strings` |
| `InsertVariableAction` | Header variable picker | `variables`, `onInsert`, `compact`, `strings`. `renderPicker` renders the host's own picker instead, and `variables` is then optional |
-| `AiAssistAction` | Header AI prompt | `onGenerate`, `hint`, `strings` |
+| `AiAssistAction` | Header AI prompt | `onGenerate`, `hint`, `strings`. `renderContent({ close, disabled })` renders the host's own panel instead of the prompt form |
| `VariableValueControl` | Variable-bound control | `value`, `onChange`, `variables`, `strings`. `renderPicker` renders the host's own picker; `describeReference(reference)` gives the chip a `{ label, icon? }` |
| `PromptValueControl` | Prompt for an agent | `value`, `onChange`, `strings`. Render in `InputGroup` with `variant="agent"` `layout="grow"` |
| `QuickFormField` | Quick Form builder field | Only where end users configure fields in a HITL Quick Form. `fieldTypes`, `renderModeControl`, `variables`, `strings` |
@@ -963,6 +963,9 @@ Field actions:
over `variables`, and its pick takes the same path) and to `createVariableModeControl`, registered
as `FormPlugin.valueModes.definitions.variable.control`. That control also takes
`describeReference(reference, { field })` for the chip's label and icon.
+- `createAiAssistAction({ renderContent })` renders the host's own panel instead of the prompt
+ form. Its `apply({ mode?, value })` writes like a generator's result (nothing if the field was
+ disabled meanwhile) and closes the popover.
- `badge` adds text after the label, such as the value's type.
Any of `valueModes`, `headerActions`, `menuActions` or `badge` renders the field through
diff --git a/packages/apollo-wind/src/components/forms/README.md b/packages/apollo-wind/src/components/forms/README.md
index 08c892912..70cb7ea04 100644
--- a/packages/apollo-wind/src/components/forms/README.md
+++ b/packages/apollo-wind/src/components/forms/README.md
@@ -918,6 +918,7 @@ Validation: a fixed value is checked by its field type's `validation`; any other
| --------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `createInsertVariableAction({ variables?, renderPicker?, formatReference?, strings?, id? })` | Inserts a variable. An `insertable` control takes it at the caret; otherwise the field switches to `expression`, then `variable`, asking before it replaces a value. Hidden in Variable mode. `renderPicker` renders the host's own picker and wins over `variables`. |
| `createAiAssistAction({ generate, hint?, placeholder?, strings?, id? })` | Asks for a prompt and writes `generate`'s result in the field's mode, or the `mode` it returns. Skips the write if the field became disabled; closing the prompt does not cancel the request. |
+| `createAiAssistAction({ renderContent, strings?, id? })` | Renders the host's panel in the popover instead of the prompt form. It gets `{ close, disabled, apply, ctx }`; `apply({ mode?, value })` writes as `generate`'s result does, with the same disabled guard, and closes. |
| `createClearAction({ strings?, id? })` | Clears the value, keeping the mode. Registered as `clear` by default. |
Register an action under the same id to replace a built-in.
diff --git a/packages/apollo-wind/src/components/forms/field-actions.stories.tsx b/packages/apollo-wind/src/components/forms/field-actions.stories.tsx
index baf182cba..66f2b216f 100644
--- a/packages/apollo-wind/src/components/forms/field-actions.stories.tsx
+++ b/packages/apollo-wind/src/components/forms/field-actions.stories.tsx
@@ -27,7 +27,8 @@ first before it replaces a value other than a number or boolean. Their strings c
A host can render its own panels in these popovers.
\`createInsertVariableAction({ renderPicker })\` replaces the picker and wins over \`variables\`; a
-pick is written as a picked variable would be.
+pick is written as a picked variable would be. \`createAiAssistAction({ renderContent })\` replaces
+the prompt form; its \`apply\` writes a result the way a generator's does and closes the popover.
`,
},
},
@@ -181,11 +182,33 @@ const hostPlugin: FormPlugin = {
formatReference: (reference, { mode }) =>
mode === 'literal' ? `{{ ${reference} }}` : reference,
}),
+ 'ai-assist': createAiAssistAction({
+ // Stand-in for a host's own panel, such as one with retry and telemetry.
+ renderContent: ({ apply, ctx }) => (
+
+ Suggestions for {ctx.field.label}
+
+
+
+ ),
+ }),
},
},
};
-/** Insert variable rendering the host's own picker, through its factory. */
+/** Insert variable and AI assist rendering the host's own panels, through their factories. */
export const HostPanels: Story = {
render: () => ,
};
diff --git a/packages/apollo-wind/src/components/forms/field-actions.tsx b/packages/apollo-wind/src/components/forms/field-actions.tsx
index 4dbd4f0ad..5e0c117b7 100644
--- a/packages/apollo-wind/src/components/forms/field-actions.tsx
+++ b/packages/apollo-wind/src/components/forms/field-actions.tsx
@@ -4,6 +4,7 @@ import type { FieldValues, UseFormReturn } from 'react-hook-form';
import {
AiAssistAction,
type AiAssistActionStrings,
+ type AiAssistContentProps,
} from '@/components/ui/field-actions/ai-assist-action';
import {
InsertVariableAction,
@@ -120,7 +121,7 @@ export type FieldActionGenerate = (
request: { prompt: string },
ctx: FieldActionContext
// biome-ignore lint/suspicious/noConfusingVoidType: an `async` generator that writes nothing returns `Promise`
-) => Promise<{ mode?: ValueModeId; value: unknown } | undefined | void>;
+) => Promise;
export interface FieldActionsPluginConfig {
/** Header actions by id, in the order fields list them. */
@@ -245,8 +246,24 @@ export function createInsertVariableAction({
};
}
-export interface AiAssistActionOptions {
- generate: FieldActionGenerate;
+/** A generated or host-produced value: in `mode`, or else the mode the field is in when written. */
+export interface FieldActionResult {
+ mode?: ValueModeId;
+ value: unknown;
+}
+
+/** What a host-rendered AI-assist panel gets inside a form. */
+export interface AiAssistFieldContentProps extends AiAssistContentProps {
+ /**
+ * Writes `result` and closes the popover. Nothing is written if the field was disabled
+ * meanwhile.
+ */
+ apply(result: FieldActionResult): void;
+ /** The field the panel generates for, read when accessed. */
+ ctx: FieldActionContext;
+}
+
+interface AiAssistActionBaseOptions {
/** Guidance under the prompt, such as the value type expected. */
hint?: React.ReactNode | ((ctx: FieldActionContext) => React.ReactNode);
/** The prompt's placeholder, such as an example request for this field. */
@@ -256,9 +273,36 @@ export interface AiAssistActionOptions {
id?: string;
}
+export type AiAssistActionOptions = AiAssistActionBaseOptions &
+ (
+ | { generate: FieldActionGenerate; renderContent?: undefined }
+ | {
+ generate?: undefined;
+ /**
+ * The host's own panel, in place of the built-in prompt form, whose `hint` and
+ * `placeholder` it then does not use. Called only while the popover is open.
+ */
+ renderContent: (props: AiAssistFieldContentProps) => React.ReactNode;
+ }
+ );
+
+/** Writes a result unless the field was disabled while it was being produced. */
+function writeResult(
+ ctx: FieldActionContext,
+ // biome-ignore lint/suspicious/noConfusingVoidType: what an `async` generator that writes nothing resolves with
+ result: FieldActionResult | undefined | void
+) {
+ if (!result || ctx.disabled) return;
+ // `ctx.mode` is read now, not when the request went out.
+ const mode = result.mode ?? ctx.mode?.mode;
+ if (ctx.setModeValue && mode) ctx.setModeValue(mode, result.value);
+ else ctx.setValue(result.value);
+}
+
/** AI assist: a prompt behind the label whose generated value is written to the field. */
export function createAiAssistAction({
generate,
+ renderContent,
hint,
placeholder,
strings,
@@ -267,18 +311,29 @@ export function createAiAssistAction({
return {
id,
render: (ctx) => {
+ if (renderContent) {
+ return (
+
+ renderContent({
+ close,
+ disabled,
+ apply: (result) => {
+ writeResult(ctx, result);
+ close();
+ },
+ ctx,
+ })
+ }
+ disabled={ctx.disabled}
+ strings={{ ...ctx.strings.aiAssist, ...strings }}
+ />
+ );
+ }
const promptPlaceholder = typeof placeholder === 'function' ? placeholder(ctx) : placeholder;
return (
{
- const result = await generate({ prompt }, ctx);
- // The field may have been disabled while the request was out.
- if (!result || ctx.disabled) return;
- // `ctx.mode` is read now, not when the request went out.
- const mode = result.mode ?? ctx.mode?.mode;
- if (ctx.setModeValue && mode) ctx.setModeValue(mode, result.value);
- else ctx.setValue(result.value);
- }}
+ onGenerate={async (prompt) => writeResult(ctx, await generate({ prompt }, ctx))}
hint={typeof hint === 'function' ? hint(ctx) : hint}
disabled={ctx.disabled}
strings={{
diff --git a/packages/apollo-wind/src/components/forms/index.ts b/packages/apollo-wind/src/components/forms/index.ts
index d324e732d..254a1641f 100644
--- a/packages/apollo-wind/src/components/forms/index.ts
+++ b/packages/apollo-wind/src/components/forms/index.ts
@@ -14,6 +14,7 @@ export {
} from './data-fetcher';
export {
type AiAssistActionOptions,
+ type AiAssistFieldContentProps,
type ClearActionOptions,
createAiAssistAction,
createClearAction,
@@ -21,6 +22,7 @@ export {
type FieldActionContext,
type FieldActionGenerate,
type FieldActionRegistry,
+ type FieldActionResult,
type FieldActionsPluginConfig,
type FieldHeaderAction,
type FieldMenuAction,
diff --git a/packages/apollo-wind/src/components/forms/mode-aware-field.test.tsx b/packages/apollo-wind/src/components/forms/mode-aware-field.test.tsx
index f4f3a2c1c..e2c31c464 100644
--- a/packages/apollo-wind/src/components/forms/mode-aware-field.test.tsx
+++ b/packages/apollo-wind/src/components/forms/mode-aware-field.test.tsx
@@ -1,11 +1,12 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Hash } from 'lucide-react';
-import { forwardRef, useImperativeHandle } from 'react';
+import { forwardRef, type ReactNode, useImperativeHandle } from 'react';
import { renderToString } from 'react-dom/server';
import type { FieldValues, UseFormReturn } from 'react-hook-form';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
+ type AiAssistFieldContentProps,
createAiAssistAction,
createInsertVariableAction,
type FieldActionContext,
@@ -1769,3 +1770,74 @@ describe('host variable picker', () => {
});
});
});
+
+describe('host AI assist content', () => {
+ const aiField: FieldMetadata = {
+ name: 'a',
+ type: 'text',
+ label: 'A',
+ valueModes: MODES,
+ headerActions: ['ai-assist'],
+ };
+ const contentPlugin = (
+ renderContent: (props: AiAssistFieldContentProps) => ReactNode
+ ): FormPlugin => ({
+ name: 'host',
+ fieldActions: { header: { 'ai-assist': createAiAssistAction({ renderContent }) } },
+ });
+
+ it('writes what the host content applies, in the mode it gives, and closes', async () => {
+ const { user, values } = setup(
+ [aiField],
+ [
+ contentPlugin(({ apply, ctx }) => (
+
+ )),
+ ]
+ );
+ await settle();
+ const trigger = screen.getByRole('button', { name: 'AI assist' });
+ await user.click(trigger);
+ expect(screen.queryByLabelText('Describe what you want')).toBeNull();
+ await user.click(await screen.findByRole('button', { name: 'Use suggestion' }));
+ expect(values().a).toStrictEqual({ $mode: 'expression', value: 'a.id' });
+ expect(screen.queryByRole('button', { name: 'Use suggestion' })).toBeNull();
+ expect(trigger).toHaveFocus();
+ });
+
+ it('writes nothing once the field has been disabled', async () => {
+ let apply: ((result: { value: unknown }) => void) | undefined;
+ let form: UseFormReturn | undefined;
+ const plugins: FormPlugin[] = [
+ contentPlugin((props) => {
+ apply = props.apply;
+ return
);
+ render(
+
+
+
+ );
+ expect(renderContent).not.toHaveBeenCalled();
+ await user.click(screen.getByRole('button', { name: 'AI assist' }));
+ expect(await screen.findByText('Host panel')).toBeInTheDocument();
+ expect(screen.queryByLabelText('Describe what you want')).toBeNull();
+ expect(screen.queryByText('Unused hint')).toBeNull();
+ expect(renderContent).toHaveBeenCalledWith(expect.objectContaining({ disabled: false }));
+ });
+
+ it('closes from the host content and returns focus to the trigger', async () => {
+ const user = userEvent.setup();
+ render(
+
+ (
+
+ )}
+ />
+
+ );
+ const trigger = screen.getByRole('button', { name: 'AI assist' });
+ await user.click(trigger);
+ await user.click(await screen.findByRole('button', { name: 'Done' }));
+ expect(screen.queryByRole('button', { name: 'Done' })).toBeNull();
+ expect(trigger).toHaveFocus();
+ });
+ });
});
diff --git a/packages/apollo-wind/src/components/ui/field-actions/ai-assist-action.tsx b/packages/apollo-wind/src/components/ui/field-actions/ai-assist-action.tsx
index f434d0e76..3e09221c8 100644
--- a/packages/apollo-wind/src/components/ui/field-actions/ai-assist-action.tsx
+++ b/packages/apollo-wind/src/components/ui/field-actions/ai-assist-action.tsx
@@ -1,5 +1,5 @@
import { Loader2, Sparkles } from 'lucide-react';
-import { type ReactNode, useId, useState } from 'react';
+import { type ReactNode, useCallback, useId, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
@@ -28,9 +28,17 @@ export const DEFAULT_AI_ASSIST_ACTION_STRINGS: AiAssistActionStrings = {
error: 'Could not generate a value. Try again.',
};
+/** What a host-rendered AI-assist panel gets in place of the built-in prompt form. */
+export interface AiAssistContentProps {
+ /** Closes the popover, returning focus to the trigger. */
+ close(): void;
+ disabled: boolean;
+}
+
export interface AiAssistActionProps {
/**
- * Called with the entered prompt. Omitted, Generate is disabled. A returned promise keeps the
+ * Called with the entered prompt. Omitted, Generate is disabled; not called while
+ * `renderContent` is set. A returned promise keeps the
* prompt open and busy until it settles: it closes once the promise resolves and stays open,
* prompt intact and the error shown, if it rejects.
*/
@@ -38,6 +46,11 @@ export interface AiAssistActionProps {
disabled?: boolean;
/** A line under the prompt describing what will be generated, such as the value's type. */
hint?: ReactNode;
+ /**
+ * The host's own panel, rendered in the popover in place of the built-in prompt form, whose
+ * `hint` and prompt strings it then does not use. Called only while the popover is open.
+ */
+ renderContent?: (props: AiAssistContentProps) => ReactNode;
/** Overrides for any subset of the English strings. */
strings?: Partial;
}
@@ -45,7 +58,13 @@ export interface AiAssistActionProps {
/**
* The AI-assist header action: a prompt in a popover. Requires an ancestor `TooltipProvider`.
*/
-export function AiAssistAction({ onGenerate, hint, disabled, strings }: AiAssistActionProps) {
+export function AiAssistAction({
+ onGenerate,
+ hint,
+ renderContent,
+ disabled,
+ strings,
+}: AiAssistActionProps) {
const promptId = useId();
const errorId = useId();
const [prompt, setPrompt] = useState('');
@@ -58,6 +77,7 @@ export function AiAssistAction({ onGenerate, hint, disabled, strings }: AiAssist
setFailed(false);
setOpen(next);
};
+ const close = useCallback(() => setOpen(false), []);
const generate = async () => {
setFailed(false);
@@ -93,41 +113,55 @@ export function AiAssistAction({ onGenerate, hint, disabled, strings }: AiAssist
{text.tooltip}
-
-
-
- {hint && {hint}}
- {failed && (
-
- {text.error}
-
+ {renderContent ? (
+
+ ) : (
+ <>
+
+
+
+ {hint && {hint}}
+ {failed && (
+
+ {text.error}
+
+ )}
+
+ >
)}
-
);
}
+/** Renders the host's panel as an element, so it is called only once the popover mounts it. */
+function AiAssistSlot({
+ render,
+ ...props
+}: AiAssistContentProps & { render: (props: AiAssistContentProps) => ReactNode }) {
+ return render(props);
+}
+
function isThenable(value: unknown): value is PromiseLike {
return (
(typeof value === 'object' || typeof value === 'function') &&
diff --git a/packages/apollo-wind/src/components/ui/field-actions/field-actions.stories.tsx b/packages/apollo-wind/src/components/ui/field-actions/field-actions.stories.tsx
index 5e8d0af22..0fe23cc56 100644
--- a/packages/apollo-wind/src/components/ui/field-actions/field-actions.stories.tsx
+++ b/packages/apollo-wind/src/components/ui/field-actions/field-actions.stories.tsx
@@ -5,7 +5,7 @@ import { Input } from '../input';
import { InputGroup } from '../input-group';
import { TooltipProvider } from '../tooltip';
import type { RenderVariablePicker, VariablePickerItem } from '../variable-picker';
-import { AiAssistAction } from './ai-assist-action';
+import { AiAssistAction, type AiAssistContentProps } from './ai-assist-action';
import { InsertVariableAction } from './insert-variable-action';
const meta = {
@@ -22,9 +22,10 @@ Actions for a field's header row, passed to \`FormFieldHeader\`'s \`actions\`.
to insert or nowhere to insert it, and collapses to its icon below a 260px \`@container\`.
- **AiAssistAction** opens a prompt and reports it. It needs an ancestor \`TooltipProvider\`.
-Insert variable can render the host's own picker instead, through \`renderPicker\`. The action
-keeps its trigger, popover, collapse, focus return and strings, and calls the slot only while the
-popover is open. The picker's \`onSelect\` commits and closes; \`close\` closes without a pick.
+Either can render the host's own panel instead: \`renderPicker\` for Insert variable and
+\`renderContent\` for AI assist. The action keeps its trigger, popover, collapse, focus return and
+strings, and calls the slot only while the popover is open. A picker's \`onSelect\` commits and
+closes; both slots get \`close\`.
`,
},
},
@@ -170,7 +171,7 @@ export const InHeader: Story = {
},
};
-// Stand-in for a host's own picker, such as a virtualized one with live badges.
+// Stand-ins for a host's own panels, such as a virtualized picker with live badges.
const HOST_ROWS = [
{ value: '$vars.orderId', label: 'Order id', live: true },
{ value: '$assets.apiKey', label: 'API key', live: false },
@@ -197,7 +198,28 @@ const hostPicker: RenderVariablePicker = ({ value, onSelect, close }) => (
);
-/** Insert variable rendering the host's own picker inside its popover. */
+function HostSuggestions({ close, onPick }: AiAssistContentProps & { onPick(text: string): void }) {
+ return (
+
+ Suggestions from the host
+ {['Order received', 'Order shipped'].map((text) => (
+
+ ))}
+
+ );
+}
+
+/** Both actions rendering the host's own panel inside their popover. */
export const HostPanels: Story = {
render: () => {
const [value, setValue] = useState('Order ');
@@ -207,10 +229,15 @@ export const HostPanels: Story = {
label="Subject"
htmlFor="host-panels-subject"
actions={
- setValue((v) => (v ? `${v} ${variable}` : variable))}
- />
+ <>
+ }
+ />
+ setValue((v) => (v ? `${v} ${variable}` : variable))}
+ />
+ >
}
/>
diff --git a/packages/apollo-wind/src/components/ui/field-actions/index.ts b/packages/apollo-wind/src/components/ui/field-actions/index.ts
index e4907469e..f8e0692e3 100644
--- a/packages/apollo-wind/src/components/ui/field-actions/index.ts
+++ b/packages/apollo-wind/src/components/ui/field-actions/index.ts
@@ -2,6 +2,7 @@ export {
AiAssistAction,
type AiAssistActionProps,
type AiAssistActionStrings,
+ type AiAssistContentProps,
DEFAULT_AI_ASSIST_ACTION_STRINGS,
} from './ai-assist-action';
export {
diff --git a/packages/apollo-wind/src/index.ts b/packages/apollo-wind/src/index.ts
index 9b49e04d5..e113add31 100644
--- a/packages/apollo-wind/src/index.ts
+++ b/packages/apollo-wind/src/index.ts
@@ -15,10 +15,12 @@ export {
} from './components/forms/data-fetcher';
export type {
AiAssistActionOptions,
+ AiAssistFieldContentProps,
ClearActionOptions,
FieldActionContext,
FieldActionGenerate,
FieldActionRegistry,
+ FieldActionResult,
FieldActionsPluginConfig,
FieldHeaderAction,
FieldMenuAction,
@@ -416,6 +418,7 @@ export { EmptyState } from './components/ui/empty-state';
export type {
AiAssistActionProps,
AiAssistActionStrings,
+ AiAssistContentProps,
InsertVariableActionProps,
InsertVariableActionStrings,
} from './components/ui/field-actions';