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
42 changes: 42 additions & 0 deletions package/src/elicitation/ElicitationForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,45 @@ describe('elicitation/ElicitationForm', () => {
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
});

describe('elicitation/a string over several lines', () => {
const multiline: ElicitationRequestedSchema = {
type: 'object',
properties: {
story: { type: 'string', title: 'What happened?', multiline: true, maxLength: 8000 },
short: { type: 'string', title: 'Order number' },
},
required: ['story'],
};

it('gives a multiline string a text area and keeps the plain one an input', () => {
render(<ElicitationForm message="Tell us" requestedSchema={multiline} />);

expect(screen.getByLabelText(/What happened\?/).tagName).toBe('TEXTAREA');
expect(screen.getByLabelText('Order number').tagName).toBe('INPUT');
});

it('keeps the line breaks the person typed', async () => {
const onAccept = jest.fn();
render(<ElicitationForm message="Tell us" requestedSchema={multiline} onAccept={onAccept} />);

const field = screen.getByLabelText(/What happened\?/);
await userEvent.type(field, 'First line{Enter}second line');
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));

expect(onAccept).toHaveBeenCalledWith(
expect.objectContaining({ story: 'First line\nsecond line' })
);
});

it('keeps the required mark and the limit of the schema', async () => {
const onAccept = jest.fn();
render(<ElicitationForm message="Tell us" requestedSchema={multiline} onAccept={onAccept} />);

expect(screen.getByLabelText(/What happened\?/)).toHaveAttribute('maxlength', '8000');

await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
expect(screen.getByText('Required')).toBeInTheDocument();
expect(onAccept).not.toHaveBeenCalled();
});
});
24 changes: 24 additions & 0 deletions package/src/elicitation/ElicitationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Stack,
Switch,
Text,
Textarea,
TextInput,
} from '@mantine/core';
import { IconExternalLink, IconForms } from '@tabler/icons-react';
Expand Down Expand Up @@ -101,11 +102,17 @@ function isCompactField(field: ElicitationField): boolean {
return true;
}
if (field.kind === 'text') {
if (field.schema.multiline) {
return false;
}
return field.inputType !== 'text' || (field.schema.maxLength ?? Infinity) <= 120;
}
return field.kind === 'select' && field.options.length > 3;
}

const MIN_TEXTAREA_ROWS = 3;
const MAX_TEXTAREA_ROWS = 8;

const EMPTY_SCHEMA: ElicitationRequestedSchema = { type: 'object', properties: {} };

type FieldControlProps = {
Expand All @@ -128,6 +135,23 @@ function FieldControl({ field, value, error, disabled, onChange, onBlur }: Field

switch (field.kind) {
case 'text':
if (field.schema.multiline) {
return (
<Textarea
{...common}
autosize
minRows={Math.min(
Math.max(field.schema.rows ?? MIN_TEXTAREA_ROWS, 1),
MAX_TEXTAREA_ROWS
)}
maxRows={MAX_TEXTAREA_ROWS}
value={String(value ?? '')}
maxLength={field.schema.maxLength}
onChange={(event) => onChange(event.currentTarget.value)}
onBlur={onBlur}
/>
);
}
return (
<TextInput
{...common}
Expand Down
4 changes: 4 additions & 0 deletions package/src/elicitation/elicitation-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export type ElicitationStringSchema = {
minLength?: number;
maxLength?: number;
format?: 'email' | 'uri' | 'date' | 'date-time';
/** The answer is expected to run over several lines, so the field is a text area */
multiline?: boolean;
/** Height of a multiline field in lines, `3` by default and never past eight */
rows?: number;
enum?: string[];
enumNames?: string[];
oneOf?: ElicitationEnumOption[];
Expand Down
11 changes: 11 additions & 0 deletions package/src/question/QuestionPrompt.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,14 @@
border: 0;
background-color: transparent;
}

/* The options as the boxes people know: the row keeps the padding of a lettered one. */
.box {
padding: rem(6px) rem(8px);
border-radius: var(--ae-control-radius);
transition: background-color 150ms ease;

@mixin hover {
background-color: alpha(var(--ae-fg), 0.04);
}
}
72 changes: 72 additions & 0 deletions package/src/question/QuestionPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,75 @@ describe('question/QuestionPrompt', () => {
expect(screen.getByPlaceholderText('Notiz')).toBeInTheDocument();
});
});

describe('question/options as boxes', () => {
it('draws squares for a multiple choice and keeps the answer the same', async () => {
const onSubmit = jest.fn();
render(
<QuestionPrompt
questions={[
{
kind: 'multi',
control: 'checkbox',
title: 'What should the agent touch?',
options: [
{ id: 'tests', label: 'Tests' },
{ id: 'docs', label: 'Docs' },
],
},
]}
onSubmit={onSubmit}
/>
);

const boxes = screen.getAllByRole('checkbox');
expect(boxes).toHaveLength(2);

await userEvent.click(screen.getByRole('checkbox', { name: /Tests/ }));
await userEvent.click(screen.getByRole('checkbox', { name: /Docs/ }));
await userEvent.click(screen.getByRole('button', { name: 'Send' }));

expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'multi', selectedIds: ['tests', 'docs'] })
);
});

it('draws circles for a single choice', () => {
render(
<QuestionPrompt
questions={[
{
kind: 'single',
control: 'checkbox',
title: 'Which environment?',
options: [
{ id: 'staging', label: 'Staging' },
{ id: 'production', label: 'Production' },
],
},
]}
onSubmit={() => {}}
/>
);

expect(screen.getAllByRole('radio')).toHaveLength(2);
});

it('keeps the lettered rows without the flag', () => {
render(
<QuestionPrompt
questions={[
{
kind: 'multi',
title: 'What should the agent touch?',
options: [{ id: 'tests', label: 'Tests' }],
},
]}
onSubmit={() => {}}
/>
);

expect(screen.queryByRole('checkbox')).toBeNull();
expect(screen.getByRole('button', { name: /Tests/ })).toBeInTheDocument();
});
});
61 changes: 48 additions & 13 deletions package/src/question/QuestionPrompt.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Box, Textarea, TextInput, UnstyledButton } from '@mantine/core';
import { Box, Checkbox, Radio, Textarea, TextInput, UnstyledButton } from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import { Markdown } from '../Markdown/Markdown';
import { cx } from '../utils/cx';
Expand Down Expand Up @@ -34,6 +34,11 @@ export type QuestionConfig = {
title: string;
description?: string;
options?: QuestionOption[];
/**
* Shape of the options: `button` keeps the lettered rows, `checkbox` draws the familiar boxes —
* squares for `multi`, circles for `single`. `button` by default.
*/
control?: 'button' | 'checkbox';
/** Adds a free-text option after the predefined ones */
allowCustom?: boolean;
customLabel?: string;
Expand Down Expand Up @@ -156,6 +161,7 @@ export function QuestionPrompt({
const [activeOptionId, setActiveOptionId] = useState<string | null>(null);
const { ref, width } = useElementSize();
const customEnabled = activeQuestion?.allowCustom ?? false;
const asBoxes = activeQuestion?.control === 'checkbox';
const showNav = resolvedTotal > 1 && (!!onPreviousQuestion || !!onNextQuestion);
const canGoPrev = clampedIndex > 1;
const canGoNext = clampedIndex < resolvedTotal;
Expand Down Expand Up @@ -247,21 +253,50 @@ export function QuestionPrompt({
<div className={classes.options}>
{options.map((option, idx) => {
const checked = selectedIds.includes(option.id);
const pick = () => {
if (activeQuestion.kind === 'single') {
handleSingleSelect(option.id);
if (customEnabled) {
setCustomText('');
}
} else {
toggleMulti(option.id);
}
};
const hover = option.preview ? () => setActiveOptionId(option.id) : undefined;
if (asBoxes) {
const Control = activeQuestion.kind === 'single' ? Radio : Checkbox;
return (
<React.Fragment key={option.id}>
<Control
size="sm"
checked={checked}
onChange={pick}
onMouseEnter={hover}
onFocus={hover}
className={classes.box}
label={
<span className={classes.optionLabel}>
{option.label}
{option.description && (
<span className={classes.optionDescription}> {option.description}</span>
)}
</span>
}
/>
{!isWide &&
previewOption?.id === option.id &&
(checked || activeOptionId === option.id) &&
renderPreview(classes.inlinePreview)}
</React.Fragment>
);
}
return (
<React.Fragment key={option.id}>
<UnstyledButton
onMouseEnter={option.preview ? () => setActiveOptionId(option.id) : undefined}
onFocus={option.preview ? () => setActiveOptionId(option.id) : undefined}
onClick={() => {
if (activeQuestion.kind === 'single') {
handleSingleSelect(option.id);
if (customEnabled) {
setCustomText('');
}
} else {
toggleMulti(option.id);
}
}}
onMouseEnter={hover}
onFocus={hover}
onClick={pick}
className={classes.option}
data-checked={checked || undefined}
aria-pressed={checked}
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const entries: Entry[] = [
name: 'import *',
source: "import * as kit from '@sinups/ai-kit'; console.log(kit);",
stylesheets: ['styles.css'],
gzipBudget: 242_200,
gzipBudget: 243_500,
},
];

Expand Down
24 changes: 12 additions & 12 deletions site/app/data/bundle-size.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,29 @@
"styles/base.css",
"styles/AgentChat.css"
],
"jsGzip": 85062,
"cssGzip": 12498,
"totalGzip": 97560
"jsGzip": 85285,
"cssGzip": 12676,
"totalGzip": 97961
},
{
"name": "MessageList",
"stylesheets": [
"styles/base.css",
"styles/MessageList.css"
],
"jsGzip": 70186,
"cssGzip": 9742,
"totalGzip": 79928
"jsGzip": 70431,
"cssGzip": 9867,
"totalGzip": 80298
},
{
"name": "InputBar",
"stylesheets": [
"styles/base.css",
"styles/InputBar.css"
],
"jsGzip": 30396,
"cssGzip": 6985,
"totalGzip": 37381
"jsGzip": 30507,
"cssGzip": 7087,
"totalGzip": 37594
},
{
"name": "Markdown",
Expand Down Expand Up @@ -285,9 +285,9 @@
"stylesheets": [
"styles.css"
],
"jsGzip": 218497,
"cssGzip": 23145,
"totalGzip": 241642
"jsGzip": 218879,
"cssGzip": 23369,
"totalGzip": 242248
},
{
"name": "embed widget.js",
Expand Down
2 changes: 1 addition & 1 deletion site/app/data/component-docs-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ export function Example({ answer }: { answer: (value: unknown) => void }) {
type: 'usage',
title: 'Usage',
content:
'The form for one question the agent asks the user. `kind` is `single` (lettered options, one choice), `multi` (several choices, bounded by `minSelections` and `maxSelections`) or `text` (a free-text area). `allowCustom` adds a typed answer as the last option and `allowNotes` adds an optional notes field. An option can carry a `preview` (Markdown or code): from 640px of prompt width it is shown beside the options, below that under the chosen or hovered option. The primary button reads Next while more questions follow and Send on the last one; it stays disabled until the answer is valid. Skip calls `onSkip`, or `onSubmit({ kind: "skip" })` when `onSkip` is not set. `initialAnswer` is read only on mount, so remount with `key` when the question changes. QuestionTool and InputBar `questionBar` use this component and handle multi-step navigation for you; render it directly only for a custom flow. Related helpers: `getInitialQuestionDraft`, `canSubmitQuestion`, `buildQuestionAnswer`, `formatQuestionAnswer`.',
'The form for one question the agent asks the user. `kind` is `single` (lettered options, one choice), `multi` (several choices, bounded by `minSelections` and `maxSelections`) or `text` (a free-text area). `control: "checkbox"` swaps the lettered rows for the boxes people already know — squares for `multi`, circles for `single` — with the same answer and the same bounds; the letters stay by default. `allowCustom` adds a typed answer as the last option and `allowNotes` adds an optional notes field. An option can carry a `preview` (Markdown or code): from 640px of prompt width it is shown beside the options, below that under the chosen or hovered option. The primary button reads Next while more questions follow and Send on the last one; it stays disabled until the answer is valid. Skip calls `onSkip`, or `onSubmit({ kind: "skip" })` when `onSkip` is not set. `initialAnswer` is read only on mount, so remount with `key` when the question changes. QuestionTool and InputBar `questionBar` use this component and handle multi-step navigation for you; render it directly only for a custom flow. Related helpers: `getInitialQuestionDraft`, `canSubmitQuestion`, `buildQuestionAnswer`, `formatQuestionAnswer`.',
},
{
type: 'example',
Expand Down
2 changes: 1 addition & 1 deletion site/app/data/component-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2251,7 +2251,7 @@ export function Example() {
type: 'usage',
title: 'Usage',
content:
'Answer MCP elicitation requests. Pass the server\'s requestedSchema and the form builds Mantine fields for strings (email, uri, date, date-time), numbers, booleans, single and multi-select enums, validates required fields and bounds, and calls onAccept with only the filled values. Use mode="url" when the server asks the user to open a link. Short fields go into two columns once the form itself is wider than 520px, so the same component fits a narrow widget and a full-page chat.',
'Answer MCP elicitation requests. Pass the server\'s requestedSchema and the form builds Mantine fields for strings (email, uri, date, date-time), numbers, booleans, single and multi-select enums, validates required fields and bounds, and calls onAccept with only the filled values. A string that expects more than a line takes `multiline: true`, and then the field is a text area that grows from three lines to eight (`rows` sets where it starts); it never shares a row with another field, and its label, limit and required mark work as they do on a one-line field. Use mode="url" when the server asks the user to open a link. Short fields go into two columns once the form itself is wider than 520px, so the same component fits a narrow widget and a full-page chat.',
},
{
type: 'example',
Expand Down
Loading