Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/apollo-wind/apollo-ai-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/apollo-wind/src/components/forms/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
`,
},
},
Expand Down Expand Up @@ -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 }) => (
<div className="grid gap-2 text-xs">
<span className="text-foreground-muted">Suggestions for {ctx.field.label}</span>
<button
type="button"
className="rounded border border-border px-2 py-1 text-left hover:bg-surface-overlay"
onClick={() => apply({ value: 'Thanks for your order.' })}
>
Thanks for your order.
</button>
<button
type="button"
className="rounded border border-border px-2 py-1 text-left font-mono hover:bg-surface-overlay"
onClick={() => apply({ mode: 'expression', value: "'Order ' + $vars.orderId" })}
>
As an expression
</button>
</div>
),
}),
},
},
};

/** 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: () => <Demo schema={schema} plugins={[hostPlugin]} />,
};
79 changes: 67 additions & 12 deletions packages/apollo-wind/src/components/forms/field-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void>`
) => Promise<{ mode?: ValueModeId; value: unknown } | undefined | void>;
) => Promise<FieldActionResult | undefined | void>;

export interface FieldActionsPluginConfig {
/** Header actions by id, in the order fields list them. */
Expand Down Expand Up @@ -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. */
Expand All @@ -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,
Expand All @@ -267,18 +311,29 @@ export function createAiAssistAction({
return {
id,
render: (ctx) => {
if (renderContent) {
return (
<AiAssistAction
renderContent={({ close, disabled }) =>
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 (
<AiAssistAction
onGenerate={async (prompt) => {
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={{
Expand Down
2 changes: 2 additions & 0 deletions packages/apollo-wind/src/components/forms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ export {
} from './data-fetcher';
export {
type AiAssistActionOptions,
type AiAssistFieldContentProps,
type ClearActionOptions,
createAiAssistAction,
createClearAction,
createInsertVariableAction,
type FieldActionContext,
type FieldActionGenerate,
type FieldActionRegistry,
type FieldActionResult,
type FieldActionsPluginConfig,
type FieldHeaderAction,
type FieldMenuAction,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 }) => (
<button
type="button"
onClick={() => apply({ mode: 'expression', value: `${ctx.field.name}.id` })}
>
Use suggestion
</button>
)),
]
);
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<FieldValues> | undefined;
const plugins: FormPlugin[] = [
contentPlugin((props) => {
apply = props.apply;
return <p>Host panel</p>;
}),
{
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(<MetadataForm schema={schema} plugins={plugins} />);
await settle();
await user.click(screen.getByRole('button', { name: 'AI assist' }));
await screen.findByText('Host panel');
rerender(<MetadataForm schema={schema} plugins={plugins} disabled />);
act(() => apply?.({ value: 'late' }));
expect(form?.getValues().a).toBe('kept');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => <p>Host panel</p>);
render(
<TooltipProvider>
<AiAssistAction renderContent={renderContent} hint="Unused hint" />
</TooltipProvider>
);
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(
<TooltipProvider>
<AiAssistAction
renderContent={({ close }) => (
<button type="button" onClick={close}>
Done
</button>
)}
/>
</TooltipProvider>
);
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();
});
});
});
Loading
Loading