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

Host panel

; + }), + { + name: 'probe', + onFormInit: (ctx) => { + form = ctx.form; + }, + }, + ]; + const schema: FormSchema = { + id: 'f', + title: 'F', + initialData: { a: 'kept' }, + sections: [{ id: 's', fields: [aiField] }], + }; + const user = userEvent.setup(); + const { rerender } = render(); + await settle(); + await user.click(screen.getByRole('button', { name: 'AI assist' })); + await screen.findByText('Host panel'); + rerender(); + act(() => apply?.({ value: 'late' })); + expect(form?.getValues().a).toBe('kept'); + }); +}); diff --git a/packages/apollo-wind/src/components/ui/field-actions/ai-assist-action.test.tsx b/packages/apollo-wind/src/components/ui/field-actions/ai-assist-action.test.tsx index f1c4f877a..478698986 100644 --- a/packages/apollo-wind/src/components/ui/field-actions/ai-assist-action.test.tsx +++ b/packages/apollo-wind/src/components/ui/field-actions/ai-assist-action.test.tsx @@ -97,4 +97,42 @@ describe('AiAssistAction', () => { expect(screen.getByRole('button', { name: 'Generating' })).toBeDisabled(); }); }); + + describe('with host content', () => { + it('renders it in place of the prompt form, only once opened', async () => { + const user = userEvent.setup(); + const renderContent = vi.fn(() =>

Host panel

); + 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} -
- -