feat(ai): add_input_with_action_v0 — input + inline action button (75th tool)

The "Subscribe to newsletter" / "Apply discount code" / "Send chat
message" pattern. Two action variants:
  - text (default): pill button with label like "Subscribe"
  - icon: 44×44 square icon button (chat send arrow / search apply)

Distinct from add_form_field_v0 (label-above, no inline button)
and add_search_bar_v0 (no trailing action button). Optional
leading_icon adds an icon inside the input itself. Plus the v1
corpus prompt landing-newsletter-signup (25 v1 prompts total).
This commit is contained in:
Fini 2026-04-25 08:00:00 +08:00
parent 3527f6ff6a
commit 1acd31a2e1
14 changed files with 467 additions and 7 deletions

View file

@ -45,6 +45,7 @@ import {
buildRangeSlider,
buildEmptyChartV1,
buildPhoneInput,
buildInputWithAction,
buildKbd,
buildLink,
buildListRow,
@ -212,6 +213,8 @@ const SERVER_BUILDERS: Record<string, BuilderFn> = {
add_range_slider_v0: (a) => buildRangeSlider(a as Parameters<typeof buildRangeSlider>[0]),
add_empty_chart_v1: (a) => buildEmptyChartV1(a as Parameters<typeof buildEmptyChartV1>[0]),
add_phone_input_v0: (a) => buildPhoneInput(a as Parameters<typeof buildPhoneInput>[0]),
add_input_with_action_v0: (a) =>
buildInputWithAction(a as Parameters<typeof buildInputWithAction>[0]),
};
interface ExecToolBody {

View file

@ -38,6 +38,7 @@ import {
buildHeading,
buildIconButton,
buildIconLabel,
buildInputWithAction,
buildImagePlaceholder,
buildVideoPlaceholder,
buildModalShell,
@ -519,6 +520,11 @@ const CASES: BuilderCase[] = [
args: { label: 'Phone number', country_code: '+1', country_flag: '🇺🇸', value: '555 123 4567' },
build: (a) => buildPhoneInput(a as unknown as Parameters<typeof buildPhoneInput>[0]),
},
{
toolName: 'add_input_with_action_v0',
args: { placeholder: 'Enter your email', action_label: 'Subscribe', leading_icon: 'mail' },
build: (a) => buildInputWithAction(a as unknown as Parameters<typeof buildInputWithAction>[0]),
},
];
/**

View file

@ -63,6 +63,7 @@ import {
buildRangeSlider,
buildEmptyChartV1,
buildPhoneInput,
buildInputWithAction,
buildKbd,
buildLink,
buildListRow,
@ -138,6 +139,7 @@ import {
type RangeSliderParams,
type EmptyChartV1Params,
type PhoneInputParams,
type InputWithActionParams,
type KbdParams,
type LinkParams,
type ListRowParams,
@ -332,6 +334,7 @@ export const ELEMENT_SHIMS: Record<string, ElementShim> = {
add_range_slider_v0: wrap<RangeSliderParams>(buildRangeSlider),
add_empty_chart_v1: wrap<EmptyChartV1Params>(buildEmptyChartV1),
add_phone_input_v0: wrap<PhoneInputParams>(buildPhoneInput),
add_input_with_action_v0: wrap<InputWithActionParams>(buildInputWithAction),
};
export function getElementShim(name: string): ElementShim | undefined {

View file

@ -0,0 +1,22 @@
id: landing-newsletter-signup
category: landing
difficulty: obvious
prompt: |
Design ONLY the newsletter signup row at the bottom of a landing
page: a single 44px-tall row with two side-by-side elements:
- on the left, a wide rounded input field with a small mail
icon inside on the left and the placeholder text "Enter
your email" in muted slate
- on the right, a "Subscribe" pill button in accent blue with
white text
Both pieces are on the same horizontal row at the same height.
Do NOT render a heading, supporting copy, privacy disclaimer,
or surrounding section — just the email-input + Subscribe row.
expected:
must_contain_roles:
- input-with-action
- input-with-action-input
- input-with-action-text
- input-with-action-button
- input-with-action-label
expected_tool_if_any: add_input_with_action_v0

View file

@ -218,7 +218,9 @@ Input / forms:
66. International phone number input with country-code prefix selector → `add_phone_input_v0`
67. None match → fall through to `batch_design`
67. Input field with inline action button (newsletter signup, apply discount code, send chat message) → `add_input_with_action_v0`
68. None match → fall through to `batch_design`
**Disambiguation**: if you need a ROW of 3 metrics that should NOT scroll (e.g. a stats strip inside a card), use `add_stat_grid_v0`, NOT `add_metric_row_v0`. The grid uses `fill_container` per cell so it never overflows; the metric row uses fixed-px cells + scroll wrapper.
@ -298,6 +300,7 @@ PREFER an element tool when the spec says any of:
- "pricing card", "plan card", "SaaS tier", "subscription plan", "pricing tier", "billing card", "价格卡", "套餐卡", "定价卡片" → `add_pricing_card_v0` (set one tile's `emphasis: "featured"` to visually recommend it — auto-gets "Most popular" badge unless `badge` overrides). For a 3-tier pricing section, call this 3× under the same parent section.
- "slider", "range input", "volume control", "opacity slider", "brightness slider", "filter slider", "滑块", "滑动条", "音量条" → `add_range_slider_v0` (single-handle; set `show_value=true` + `value_suffix="%"` to render the readout). For a dual-handle range (min+max), still fall through to batch_design.
- "phone input", "phone field", "international phone", "country code input", "+1 (555) ...", "电话号码", "手机号输入", "国际电话" → `add_phone_input_v0` (renders country dial code button + digits input in a 44px row; pass `country_flag` for emoji prefix). For a plain single-line text input without the country prefix, use `add_form_field_v0`.
- "newsletter signup", "subscribe form", "subscribe to newsletter", "promo code input", "apply discount", "send message input", "chat composer", "search with submit", "订阅", "应用优惠码", "发送消息" → `add_input_with_action_v0` (action_kind="text" for "Subscribe" pill button, action_kind="icon" for chat send arrow). Different from `add_form_field_v0` (no inline button) and `add_search_bar_v0` (no trailing action button).
STILL use batch_design when:
@ -554,6 +557,10 @@ add_empty_chart_v1({ icon: "bar-chart-2", theme: "system" }) // $color-
add_phone_input_v0({ label: "Phone number", country_code: "+1", country_flag: "🇺🇸", required: true })
add_phone_input_v0({ country_code: "+86", country_flag: "🇨🇳", value: "138 0000 0000" }) // populated state
add_input_with_action_v0({ placeholder: "Enter your email", action_label: "Subscribe", leading_icon: "mail" }) // newsletter signup
add_input_with_action_v0({ placeholder: "Apply discount code", action_label: "Apply" }) // checkout discount
add_input_with_action_v0({ placeholder: "Type a message…", action_kind: "icon", action_icon: "send" }) // chat composer
```
## Composition pattern

View file

@ -65,20 +65,20 @@ describe('loadCorpus — real corpus', () => {
describe('loadCorpus — v1 supplemental corpus (new tools)', () => {
// v1 covers tools added after the v0 freeze, in chronological batches:
// 2026-04-22 (12 tools) / 2026-04-24 (5 tools, 4 v0 + first v1) /
// 2026-04-25 (7 tools, 5 v0 + 2 v1). All obvious — one prompt per
// 2026-04-25 (8 tools, 6 v0 + 2 v1). All obvious — one prompt per
// tool so A/B runs can measure routing + legality on the new surface
// without re-running the v0 corpus. See `corpus/ab-v1/README.md`.
it('loads 24 prompts, all obvious, one per new tool', () => {
it('loads 25 prompts, all obvious, one per new tool', () => {
const prompts = loadCorpus(REPO_CORPUS_V1_DIR);
expect(prompts).toHaveLength(24);
expect(new Set(prompts.map((p) => p.id)).size).toBe(24);
expect(prompts).toHaveLength(25);
expect(new Set(prompts.map((p) => p.id)).size).toBe(25);
for (const p of prompts) {
expect(p.difficulty).toBe('obvious');
expect(p.expected_tool_if_any).toMatch(/^add_[a-z_]+_v\d+$/);
}
});
it('covers 2026-04-22 (12) + 2026-04-24 (5) + 2026-04-25 (7) batches', () => {
it('covers 2026-04-22 (12) + 2026-04-24 (5) + 2026-04-25 (8) batches', () => {
const prompts = loadCorpus(REPO_CORPUS_V1_DIR);
const tools = new Set(prompts.map((p) => p.expected_tool_if_any));
expect(tools).toEqual(
@ -102,12 +102,13 @@ describe('loadCorpus — v1 supplemental corpus (new tools)', () => {
'add_attachment_row_v0',
'add_chat_bubble_v0',
'add_modal_shell_v1',
// 2026-04-25 batch (7 tools) — 5 new v0 + 2 new v1
// 2026-04-25 batch (8 tools) — 6 new v0 + 2 new v1
'add_social_login_row_v0',
'add_pricing_card_v0',
'add_stat_card_v0',
'add_range_slider_v0',
'add_phone_input_v0',
'add_input_with_action_v0',
'add_toast_v1',
'add_empty_chart_v1',
]),

View file

@ -98,3 +98,8 @@ export {
type EmptyChartV1Theme,
} from './empty-chart-v1.js';
export { buildPhoneInput, type PhoneInputParams } from './phone-input.js';
export {
buildInputWithAction,
type InputWithActionParams,
type InputWithActionKind,
} from './input-with-action.js';

View file

@ -0,0 +1,167 @@
import type { ElementTree } from './helpers.js';
export type InputWithActionKind = 'text' | 'icon';
export interface InputWithActionParams {
/** Placeholder text shown when value is empty. Required. */
placeholder: string;
/** Pre-filled input value. Omit for placeholder state. */
value?: string;
/** Action button label (e.g. "Subscribe", "Apply", "Send"). Required when action_kind="text". */
action_label?: string;
/** Lucide icon name. Required when action_kind="icon". */
action_icon?: string;
/**
* Kind of action button. Default `'text'` (label-only pill). Use
* `'icon'` for icon-only square button (chat send / search apply).
*/
action_kind?: InputWithActionKind;
/** Optional leading icon shown inside the input. */
leading_icon?: string;
/** Total field width in px. Default 400. Min 280. */
width?: number;
}
const FIELD_HEIGHT = 44;
/**
* Input field with inline action button on the right the
* "Subscribe to newsletter" / "Apply discount" / "Send message"
* pattern. Different from:
*
* - `add_form_field_v0`: input with label-above, no inline button
* - `add_search_bar_v0`: input with leading search icon, no
* trailing action button
* - `add_chip_input_v0`: input that grows chip pills inline
*
* Two action variants:
*
* - `text` (default): pill button with label like "Subscribe".
* Padding [12, 20], accent fill, white text.
* - `icon`: square 44×44 icon button (chat send arrow, search-
* apply magnifying glass). Centered icon only.
*
* Structure:
* frame(width, h=44, horizontal, gap=8, role='input-with-action')
* frame(input, fill_container, cornerRadius=10, stroke=slate-300,
* layout=horizontal, align=center, role='input-with-action-input')
* icon_font(leading_icon, role='input-with-action-leading-icon') if leading_icon
* text(value or placeholder, role='input-with-action-text')
* frame(action button, fit_content or 44×44 square, role='input-with-action-button')
* text(label) OR icon_font(action_icon)
*/
export function buildInputWithAction(params: InputWithActionParams): ElementTree {
const width = Math.max(280, Math.floor(params.width ?? 400));
const kind: InputWithActionKind = params.action_kind ?? 'text';
const isFilled = params.value !== undefined && params.value !== '';
const inputContent = isFilled ? params.value! : params.placeholder;
const inputColor = isFilled ? '#0F172A' : '#94A3B8';
const inputChildren: ElementTree[] = [];
if (params.leading_icon) {
inputChildren.push({
type: 'icon_font',
name: 'Leading Icon',
role: 'input-with-action-leading-icon',
iconFontName: params.leading_icon,
iconFontFamily: 'lucide',
width: 18,
height: 18,
fill: [{ type: 'solid', color: '#64748B' }],
});
}
inputChildren.push({
type: 'text',
name: 'Text',
role: 'input-with-action-text',
content: inputContent,
fontSize: 14,
fontWeight: 400,
fill: [{ type: 'solid', color: inputColor }],
});
const inputFrame: ElementTree = {
type: 'frame',
name: 'Input',
role: 'input-with-action-input',
width: 'fill_container',
height: FIELD_HEIGHT,
cornerRadius: 10,
layout: 'horizontal',
alignItems: 'center',
gap: params.leading_icon ? 8 : 0,
paddingLeft: 14,
paddingRight: 14,
fill: [{ type: 'solid', color: '#FFFFFF' }],
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#CBD5E1' }] },
children: inputChildren,
};
let buttonFrame: ElementTree;
if (kind === 'icon') {
const icon = params.action_icon ?? 'arrow-right';
buttonFrame = {
type: 'frame',
name: 'Action',
role: 'input-with-action-button',
width: FIELD_HEIGHT,
height: FIELD_HEIGHT,
cornerRadius: 10,
layout: 'horizontal',
alignItems: 'center',
justifyContent: 'center',
fill: [{ type: 'solid', color: '#2563EB' }],
children: [
{
type: 'icon_font',
name: 'Action Icon',
role: 'input-with-action-icon',
iconFontName: icon,
iconFontFamily: 'lucide',
width: 18,
height: 18,
fill: [{ type: 'solid', color: '#FFFFFF' }],
},
],
};
} else {
const label = params.action_label ?? 'Submit';
buttonFrame = {
type: 'frame',
name: 'Action',
role: 'input-with-action-button',
width: 'fit_content',
height: FIELD_HEIGHT,
cornerRadius: 10,
layout: 'horizontal',
alignItems: 'center',
justifyContent: 'center',
paddingLeft: 20,
paddingRight: 20,
fill: [{ type: 'solid', color: '#2563EB' }],
children: [
{
type: 'text',
name: 'Action Label',
role: 'input-with-action-label',
content: label,
fontSize: 14,
fontWeight: 600,
fill: [{ type: 'solid', color: '#FFFFFF' }],
},
],
};
}
return {
type: 'frame',
name: 'Input With Action',
role: 'input-with-action',
width,
height: FIELD_HEIGHT,
layout: 'horizontal',
alignItems: 'center',
gap: 8,
children: [inputFrame, buttonFrame],
};
}

View file

@ -261,6 +261,7 @@ export {
buildRangeSlider,
buildEmptyChartV1,
buildPhoneInput,
buildInputWithAction,
cjkFontFamily,
detectCjkScript,
type ElementTree,
@ -360,4 +361,6 @@ export {
type EmptyChartV1Params,
type EmptyChartV1Theme,
type PhoneInputParams,
type InputWithActionParams,
type InputWithActionKind,
} from './element-builders/index.js';

View file

@ -0,0 +1,169 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { writeFile, unlink, readFile, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DESIGN_TOOL_DEFINITIONS, DESIGN_TOOL_NAMES } from '../routes/design-routes';
import { handleAddInputWithActionV0 } from '../tools/add-input-with-action-v0';
import { invalidateCache } from '../document-manager';
const TMP = join(tmpdir(), 'openpencil-add-input-with-action-v0');
const EMPTY = JSON.stringify({ version: '1.0.0', children: [] });
async function fresh(name: string): Promise<string> {
const fp = join(TMP, name);
await writeFile(fp, EMPTY, 'utf-8');
return fp;
}
async function readDoc(fp: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(fp, 'utf-8'));
}
function getRoot(doc: Record<string, unknown>): Record<string, unknown> {
const pages = doc['pages'] as Array<{ children?: Record<string, unknown>[] }> | undefined;
const top = doc['children'] as Record<string, unknown>[] | undefined;
const root = (top ?? pages?.[0]?.children)?.[0];
if (!root) throw new Error('no root');
return root;
}
function findByRole(n: Record<string, unknown>, role: string): Record<string, unknown> | undefined {
if (n.role === role) return n;
const kids = (n.children ?? []) as Record<string, unknown>[];
for (const c of kids) {
const hit = findByRole(c, role);
if (hit) return hit;
}
return undefined;
}
function fillColor(n: Record<string, unknown> | undefined): string | undefined {
const fills = n?.fill as Array<{ color?: string }> | undefined;
return fills?.[0]?.color;
}
beforeEach(async () => {
await mkdir(TMP, { recursive: true });
});
afterEach(async () => {
for (const f of ['i.op']) {
try {
const fp = join(TMP, f);
invalidateCache(fp);
await unlink(fp);
} catch {}
}
});
describe('add_input_with_action_v0', () => {
it('registered; required=[placeholder]', () => {
expect(DESIGN_TOOL_NAMES.has('add_input_with_action_v0')).toBe(true);
const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_input_with_action_v0');
expect(def?.inputSchema.required).toEqual(['placeholder']);
});
it('default text variant: pill button with "Submit" label and accent fill', async () => {
const fp = await fresh('i.op');
await handleAddInputWithActionV0({ filePath: fp, placeholder: 'Enter email' });
const root = getRoot(await readDoc(fp));
expect(root.role).toBe('input-with-action');
const button = findByRole(root, 'input-with-action-button')!;
expect(button.width).toBe('fit_content');
expect(fillColor(button)).toBe('#2563EB');
expect(findByRole(root, 'input-with-action-label')!.content).toBe('Submit');
});
it('custom action_label renders as button text', async () => {
const fp = await fresh('i.op');
await handleAddInputWithActionV0({
filePath: fp,
placeholder: 'Enter email',
action_label: 'Subscribe',
});
const root = getRoot(await readDoc(fp));
expect(findByRole(root, 'input-with-action-label')!.content).toBe('Subscribe');
});
it('icon variant: 44x44 square button with action icon', async () => {
const fp = await fresh('i.op');
await handleAddInputWithActionV0({
filePath: fp,
placeholder: 'Type a message',
action_kind: 'icon',
action_icon: 'send',
});
const root = getRoot(await readDoc(fp));
const button = findByRole(root, 'input-with-action-button')!;
expect(button.width).toBe(44);
expect(button.height).toBe(44);
expect(findByRole(root, 'input-with-action-icon')!.iconFontName).toBe('send');
expect(findByRole(root, 'input-with-action-label')).toBeUndefined();
});
it('icon variant defaults to arrow-right when action_icon omitted', async () => {
const fp = await fresh('i.op');
await handleAddInputWithActionV0({
filePath: fp,
placeholder: 'Search',
action_kind: 'icon',
});
const root = getRoot(await readDoc(fp));
expect(findByRole(root, 'input-with-action-icon')!.iconFontName).toBe('arrow-right');
});
it('value renders populated state in slate-900', async () => {
const fp = await fresh('i.op');
await handleAddInputWithActionV0({
filePath: fp,
placeholder: 'Enter email',
value: 'user@example.com',
});
const root = getRoot(await readDoc(fp));
expect(findByRole(root, 'input-with-action-text')!.content).toBe('user@example.com');
expect(fillColor(findByRole(root, 'input-with-action-text'))).toBe('#0F172A');
});
it('placeholder state has slate-400 muted color', async () => {
const fp = await fresh('i.op');
await handleAddInputWithActionV0({ filePath: fp, placeholder: 'Enter email' });
const root = getRoot(await readDoc(fp));
expect(fillColor(findByRole(root, 'input-with-action-text'))).toBe('#94A3B8');
});
it('leading icon renders inside input frame on the left', async () => {
const fp = await fresh('i.op');
await handleAddInputWithActionV0({
filePath: fp,
placeholder: 'Enter email',
leading_icon: 'mail',
});
const root = getRoot(await readDoc(fp));
const leadingIcon = findByRole(root, 'input-with-action-leading-icon')!;
expect(leadingIcon.iconFontName).toBe('mail');
// Leading icon is BEFORE the text in the input frame
const inputFrame = findByRole(root, 'input-with-action-input')!;
const inputKids = inputFrame.children as Array<Record<string, unknown>>;
expect(inputKids[0].role).toBe('input-with-action-leading-icon');
expect(inputKids[1].role).toBe('input-with-action-text');
});
it('row height is 44px (matches form-field standard)', async () => {
const fp = await fresh('i.op');
await handleAddInputWithActionV0({ filePath: fp, placeholder: 'X' });
const root = getRoot(await readDoc(fp));
expect(root.height).toBe(44);
expect(findByRole(root, 'input-with-action-input')!.height).toBe(44);
});
it('width clamps (< 280 → 280)', async () => {
const fp = await fresh('i.op');
await handleAddInputWithActionV0({ filePath: fp, placeholder: 'X', width: 100 });
const root = getRoot(await readDoc(fp));
expect(root.width).toBe(280);
});
it('throws on bogus parent_id AND leaves file untouched', async () => {
const fp = await fresh('i.op');
const before = await readFile(fp, 'utf-8');
await expect(
handleAddInputWithActionV0({ filePath: fp, placeholder: 'X', parent_id: 'nope' }),
).rejects.toThrow(/parent_id.*not found/);
expect(await readFile(fp, 'utf-8')).toBe(before);
});
});

View file

@ -83,6 +83,7 @@ const ELEMENT_TOOL_NAMES = [
'add_range_slider_v0',
'add_empty_chart_v1',
'add_phone_input_v0',
'add_input_with_action_v0',
];
describe('element tools — v0-MUST contract', () => {

View file

@ -651,4 +651,51 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT_3 = [
},
},
},
{
name: 'add_input_with_action_v0',
description:
'Input field with inline action button on the right — the "Subscribe to newsletter" / ' +
'"Apply discount code" / "Send chat message" pattern. Different from add_form_field_v0 ' +
'(label-above, no inline button) and add_search_bar_v0 (leading search icon, no trailing ' +
'action). Two action variants: action_kind="text" (default, pill button with label like ' +
'"Subscribe") or action_kind="icon" (44×44 square icon button — chat send arrow / search ' +
'apply). Set `value` to render populated state (slate-900 text); omit for placeholder ' +
'state (slate-400). Optional `leading_icon` adds an icon inside the input itself. Use for ' +
'"newsletter signup", "apply discount code", "send message", "subscribe form", "promo code", ' +
'"订阅输入", "发送消息输入". schemaVersion 1.0',
inputSchema: {
type: 'object' as const,
properties: {
schemaVersion: schemaVersionProp,
filePath: filePathProp,
placeholder: { type: 'string', description: 'Placeholder text (e.g. "Enter email")' },
value: {
type: 'string',
description: 'Pre-filled input value. Omit for placeholder state.',
},
action_label: {
type: 'string',
description: 'Button text when action_kind="text" (default "Submit")',
},
action_icon: {
type: 'string',
description: 'Lucide icon name when action_kind="icon" (default "arrow-right")',
},
action_kind: {
type: 'string',
enum: ['text', 'icon'],
description:
'"text" (default) = pill button with label. "icon" = 44×44 square icon button.',
},
leading_icon: {
type: 'string',
description: 'Optional lucide icon shown inside the input itself (left side)',
},
width: { type: 'number', description: 'Field width in px (default 400, min 280)' },
parent_id: parentIdProp,
pageId: pageIdProp,
},
required: ['placeholder'],
},
},
];

View file

@ -89,6 +89,7 @@ import { handleAddToastV1 } from '../tools/add-toast-v1';
import { handleAddRangeSliderV0 } from '../tools/add-range-slider-v0';
import { handleAddEmptyChartV1 } from '../tools/add-empty-chart-v1';
import { handleAddPhoneInputV0 } from '../tools/add-phone-input-v0';
import { handleAddInputWithActionV0 } from '../tools/add-input-with-action-v0';
import { recordElementToolCall } from '../metrics/element-tool-metrics';
import { ELEMENT_TOOL_DEFINITIONS_BASE } from './element-tool-defs-base';
import { ELEMENT_TOOL_DEFINITIONS_EXT } from './element-tool-defs-ext';
@ -269,6 +270,8 @@ async function dispatchElementToolCall(name: string, a: any): Promise<string> {
return JSON.stringify(await handleAddEmptyChartV1(a), null, 2);
case 'add_phone_input_v0':
return JSON.stringify(await handleAddPhoneInputV0(a), null, 2);
case 'add_input_with_action_v0':
return JSON.stringify(await handleAddInputWithActionV0(a), null, 2);
default:
return '';
}

View file

@ -0,0 +1,23 @@
import {
assignIdsRecursively,
buildInputWithAction,
type InputWithActionParams,
} from '@zseven-w/pen-core';
import type { handleBatchDesign } from './batch-design';
import { ensureParentExists, insertElementTree } from './element-tool-helpers';
export interface AddInputWithActionV0Params extends InputWithActionParams {
parent_id?: string;
filePath?: string;
pageId?: string;
}
/** Input field with inline action button. Tree build delegated to `buildInputWithAction`. */
export async function handleAddInputWithActionV0(
params: AddInputWithActionV0Params,
): Promise<Awaited<ReturnType<typeof handleBatchDesign>>> {
await ensureParentExists(params);
const r = buildInputWithAction(params);
assignIdsRecursively(r);
return insertElementTree({ binding: 'inputWithAction', tree: r, ...params });
}