feat(ai): add_attachment_row_v0 — file attachment list unit (66th tool)

Fills another common UI gap: the "here's an already-uploaded file"
row you see in email composers, chat attachments, and form upload
summaries. Compact horizontal layout: type-icon + filename (bold) +
optional muted size string + optional right-side × remove affordance.

Structure: horizontal frame (slate-50 bg, cornerRadius=8) with
three children:
  1. attachment-icon — lucide file-* (caller picks: file / file-
     text / file-image / file-video / file-audio / file-archive /
     file-spreadsheet / file-code)
  2. attachment-meta — vertical frame with filename + optional size
  3. attachment-remove — × icon, suppressed via removable=false

Intentionally NOT embedding an upload-progress variant in v0. The
pen-core schema lacks percentage-width primitives, so a %-filled
progress bar would either need a fixed track width (brittle across
parents) or a caller-computed pixel value (awkward API). Callers
who need the uploading state compose `add_progress_bar_v0` directly
below the row — cleaner separation.

Wired through all standard points: schema into ext-1 (balanced
shards 23/23 after upload-dropzone landed there last commit) +
shim + SERVER_BUILDERS + parity CASES + contract allow-list +
elements.md decision tree + triggers + minimal usage.

Handler test covers 7 cases: registration + minimal (no size) +
size rendered + custom icon + removable=false + default icon +
bogus parent_id rejection.
This commit is contained in:
Fini 2026-04-22 21:53:47 +08:00
parent dc73885015
commit 49d7d1baf2
12 changed files with 329 additions and 1 deletions

View file

@ -36,6 +36,7 @@ import {
buildModalShellV1,
buildUploadDropzone,
buildOtpInput,
buildAttachmentRow,
buildKbd,
buildLink,
buildListRow,
@ -193,6 +194,7 @@ const SERVER_BUILDERS: Record<string, BuilderFn> = {
add_upload_dropzone_v0: (a) =>
buildUploadDropzone(a as Parameters<typeof buildUploadDropzone>[0]),
add_otp_input_v0: (a) => buildOtpInput(a as Parameters<typeof buildOtpInput>[0]),
add_attachment_row_v0: (a) => buildAttachmentRow(a as Parameters<typeof buildAttachmentRow>[0]),
};
interface ExecToolBody {

View file

@ -43,6 +43,7 @@ import {
buildModalShellV1,
buildUploadDropzone,
buildOtpInput,
buildAttachmentRow,
buildKbd,
buildLink,
buildListRow,
@ -453,6 +454,11 @@ const CASES: BuilderCase[] = [
args: { length: 6, digits: ['1', '2', '3'], focused_index: 3 },
build: (a) => buildOtpInput(a as unknown as Parameters<typeof buildOtpInput>[0]),
},
{
toolName: 'add_attachment_row_v0',
args: { filename: 'report.pdf', size: '1.2 MB', icon: 'file-text' },
build: (a) => buildAttachmentRow(a as unknown as Parameters<typeof buildAttachmentRow>[0]),
},
];
/**

View file

@ -54,6 +54,7 @@ import {
buildModalShellV1,
buildUploadDropzone,
buildOtpInput,
buildAttachmentRow,
buildKbd,
buildLink,
buildListRow,
@ -120,6 +121,7 @@ import {
type ModalShellV1Params,
type UploadDropzoneParams,
type OtpInputParams,
type AttachmentRowParams,
type KbdParams,
type LinkParams,
type ListRowParams,
@ -305,6 +307,7 @@ export const ELEMENT_SHIMS: Record<string, ElementShim> = {
add_modal_shell_v1: wrap<ModalShellV1Params>(buildModalShellV1),
add_upload_dropzone_v0: wrap<UploadDropzoneParams>(buildUploadDropzone),
add_otp_input_v0: wrap<OtpInputParams>(buildOtpInput),
add_attachment_row_v0: wrap<AttachmentRowParams>(buildAttachmentRow),
};
export function getElementShim(name: string): ElementShim | undefined {

View file

@ -192,7 +192,11 @@ Auth / verification:
59. OTP / PIN code input (row of N square slots, 4..8 digits; blank / partial / full states) → `add_otp_input_v0`
60. None match → fall through to `batch_design`
Attachments:
60. File attachment row (type-icon + filename + optional size + remove ×) → `add_attachment_row_v0`
61. 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.
@ -263,6 +267,7 @@ PREFER an element tool when the spec says any of:
- "date picker", "date input", "date field", "due date", "picker closed", "日期选择器", "日期输入" → `add_date_picker_v0` (for the calendar grid shown after clicking, use `add_calendar_grid_v0`)
- "upload", "drop files here", "drag and drop", "file picker", "dropzone", "upload area", "上传区", "文件拖放" → `add_upload_dropzone_v0` (visually similar to empty-chart but semantically different — pick by intent)
- "OTP", "PIN code", "verification code", "2FA code", "6-digit code", "enter code", "验证码", "PIN 码" → `add_otp_input_v0`
- "attachment", "attached file", "uploaded file", "file item", "file list row", "附件", "已上传文件" → `add_attachment_row_v0` (for upload-in-progress state, compose `add_progress_bar_v0` below)
STILL use batch_design when:
@ -489,6 +494,9 @@ add_upload_dropzone_v0({ icon: "file-up", title: "Drop resume here", subtitle: "
add_otp_input_v0({}) // 6 blank slots, first focused
add_otp_input_v0({ length: 6, digits: ["1","2","3"], focused_index: 3 }) // partial state, 4th slot focused
add_otp_input_v0({ length: 4, digits: ["1","2","3","4"] }) // 4-digit PIN, all filled
add_attachment_row_v0({ filename: "report.pdf", size: "1.2 MB", icon: "file-text" })
add_attachment_row_v0({ filename: "sealed.zip", icon: "file-archive", removable: false })
```
## Composition pattern

View file

@ -0,0 +1,122 @@
import type { ElementTree } from './helpers.js';
export interface AttachmentRowParams {
/** File name (shown bold). */
filename: string;
/** Optional size label (e.g. "1.2 MB", "340 KB"). Rendered muted. */
size?: string;
/**
* File-type icon. Default "file". Caller picks from lucide's
* file-* set (file, file-text, file-image, file-video, file-audio,
* file-archive, file-spreadsheet, file-code) so the row reads
* semantically at a glance.
*/
icon?: string;
/**
* When true, appends a small × icon on the right as a remove
* affordance. Default true (matches every compose / upload UI).
*/
removable?: boolean;
}
/**
* File attachment row compact list unit showing one uploaded file.
* Pattern from email composers, chat message attachments, and form
* upload summaries.
*
* Structure:
* frame(horizontal, width=fill_container, cornerRadius=8, bg=slate-50,
* padding=[10,12], gap=10, align=center, role='attachment-row')
* icon_font(icon, 24×24, slate-500, role='attachment-icon')
* frame(vertical, fill, gap=4, role='attachment-meta')
* text(filename, 14/600, role='attachment-filename')
* text(size, 12/400 slate-500, role='attachment-size') only if size given
* icon_font('x', 16×16, muted, role='attachment-remove') only if removable
*
* Upload-in-progress state is intentionally NOT baked in the
* caller composes `add_progress_bar_v0` (or a batch_design
* rectangle) directly below the row if needed. Keeping this builder
* focused on the "done uploaded" 95% case avoids percentage-width
* plumbing that pen-core doesn't natively support.
*/
export function buildAttachmentRow(params: AttachmentRowParams): ElementTree {
const removable = params.removable !== false;
const icon = params.icon ?? 'file';
const metaChildren: ElementTree[] = [
{
type: 'text',
name: 'Filename',
role: 'attachment-filename',
content: params.filename,
fontSize: 14,
fontWeight: 600,
fill: [{ type: 'solid', color: '#0F172A' }],
},
];
if (params.size) {
metaChildren.push({
type: 'text',
name: 'Size',
role: 'attachment-size',
content: params.size,
fontSize: 12,
fontWeight: 400,
fill: [{ type: 'solid', color: '#64748B' }],
});
}
const rowChildren: ElementTree[] = [
{
type: 'icon_font',
name: 'Type Icon',
role: 'attachment-icon',
iconFontName: icon,
iconFontFamily: 'lucide',
width: 24,
height: 24,
fill: [{ type: 'solid', color: '#64748B' }],
},
{
type: 'frame',
name: 'Meta',
role: 'attachment-meta',
width: 'fill_container',
height: 'fit_content',
layout: 'vertical',
gap: 4,
children: metaChildren,
},
];
if (removable) {
rowChildren.push({
type: 'icon_font',
name: 'Remove',
role: 'attachment-remove',
iconFontName: 'x',
iconFontFamily: 'lucide',
width: 16,
height: 16,
fill: [{ type: 'solid', color: '#94A3B8' }],
});
}
return {
type: 'frame',
name: 'Attachment',
role: 'attachment-row',
width: 'fill_container',
height: 'fit_content',
cornerRadius: 8,
layout: 'horizontal',
alignItems: 'center',
gap: 10,
paddingTop: 10,
paddingBottom: 10,
paddingLeft: 12,
paddingRight: 12,
fill: [{ type: 'solid', color: '#F8FAFC' }],
children: rowChildren,
};
}

View file

@ -81,3 +81,4 @@ export {
} from './modal-shell-v1.js';
export { buildUploadDropzone, type UploadDropzoneParams } from './upload-dropzone.js';
export { buildOtpInput, type OtpInputParams } from './otp-input.js';
export { buildAttachmentRow, type AttachmentRowParams } from './attachment-row.js';

View file

@ -252,6 +252,7 @@ export {
buildModalShellV1,
buildUploadDropzone,
buildOtpInput,
buildAttachmentRow,
cjkFontFamily,
detectCjkScript,
type ElementTree,
@ -337,4 +338,5 @@ export {
type ModalShellV1Theme,
type UploadDropzoneParams,
type OtpInputParams,
type AttachmentRowParams,
} from './element-builders/index.js';

View file

@ -0,0 +1,120 @@
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 { handleAddAttachmentRowV0 } from '../tools/add-attachment-row-v0';
import { invalidateCache } from '../document-manager';
const TMP = join(tmpdir(), 'openpencil-add-attachment-row-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;
}
beforeEach(async () => {
await mkdir(TMP, { recursive: true });
});
afterEach(async () => {
for (const f of ['a.op']) {
try {
const fp = join(TMP, f);
invalidateCache(fp);
await unlink(fp);
} catch {}
}
});
describe('add_attachment_row_v0', () => {
it('registered; required=[filename]', () => {
expect(DESIGN_TOOL_NAMES.has('add_attachment_row_v0')).toBe(true);
const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_attachment_row_v0');
expect(def?.inputSchema.required).toEqual(['filename']);
});
it('minimal: filename only → icon + filename + remove × (no size)', async () => {
const fp = await fresh('a.op');
await handleAddAttachmentRowV0({ filePath: fp, filename: 'notes.txt' });
const root = getRoot(await readDoc(fp));
expect(root.role).toBe('attachment-row');
// Icon present + meta + remove
expect(findByRole(root, 'attachment-icon')).toBeDefined();
expect(findByRole(root, 'attachment-filename')!.content).toBe('notes.txt');
expect(findByRole(root, 'attachment-size')).toBeUndefined();
expect(findByRole(root, 'attachment-remove')).toBeDefined();
});
it('with size → muted text below filename', async () => {
const fp = await fresh('a.op');
await handleAddAttachmentRowV0({
filePath: fp,
filename: 'report.pdf',
size: '1.2 MB',
});
const root = getRoot(await readDoc(fp));
const sizeNode = findByRole(root, 'attachment-size')!;
expect(sizeNode).toBeDefined();
expect(sizeNode.content).toBe('1.2 MB');
});
it('custom icon shows up verbatim on the type-icon node', async () => {
const fp = await fresh('a.op');
await handleAddAttachmentRowV0({
filePath: fp,
filename: 'pic.jpg',
icon: 'file-image',
});
const root = getRoot(await readDoc(fp));
const icon = findByRole(root, 'attachment-icon')!;
expect(icon.iconFontName).toBe('file-image');
});
it('removable=false drops the × icon', async () => {
const fp = await fresh('a.op');
await handleAddAttachmentRowV0({
filePath: fp,
filename: 'sealed.zip',
removable: false,
});
const root = getRoot(await readDoc(fp));
expect(findByRole(root, 'attachment-remove')).toBeUndefined();
});
it('default icon is "file"', async () => {
const fp = await fresh('a.op');
await handleAddAttachmentRowV0({ filePath: fp, filename: 'x' });
const root = getRoot(await readDoc(fp));
expect(findByRole(root, 'attachment-icon')!.iconFontName).toBe('file');
});
it('throws on bogus parent_id AND leaves file untouched', async () => {
const fp = await fresh('a.op');
const before = await readFile(fp, 'utf-8');
await expect(
handleAddAttachmentRowV0({ filePath: fp, filename: 'x', parent_id: 'nope' }),
).rejects.toThrow(/parent_id.*not found/);
expect(await readFile(fp, 'utf-8')).toBe(before);
});
});

View file

@ -74,6 +74,7 @@ const ELEMENT_TOOL_NAMES = [
'add_modal_shell_v1',
'add_upload_dropzone_v0',
'add_otp_input_v0',
'add_attachment_row_v0',
];
describe('element tools — v0-MUST contract', () => {

View file

@ -628,4 +628,41 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT = [
required: [],
},
},
{
name: 'add_attachment_row_v0',
description:
'File attachment row — compact list unit: type-icon + filename (bold) + optional size (muted) + ' +
'optional removable × affordance. Pattern from email composers, chat attachments, form upload ' +
"summaries. Caller picks the `icon` from lucide's file-* set (file / file-text / file-image / " +
'file-video / file-audio / file-archive / file-spreadsheet / file-code) so the row reads ' +
'semantically. For upload-in-progress state, compose `add_progress_bar_v0` directly below this ' +
'row — the attachment builder intentionally stays focused on the 95% static case. Use for ' +
'"attached file", "uploaded file", "file item", "message attachment", "附件", "已上传文件". ' +
'schemaVersion 1.0',
inputSchema: {
type: 'object' as const,
properties: {
schemaVersion: schemaVersionProp,
filePath: filePathProp,
filename: { type: 'string', description: 'File name (bold, left-aligned)' },
size: {
type: 'string',
description:
'Optional size label (e.g. "1.2 MB", "340 KB"); rendered muted below the filename',
},
icon: {
type: 'string',
description:
'Lucide file-* icon name (default "file"; try file-text / file-image / file-video / file-code)',
},
removable: {
type: 'boolean',
description: 'When true (default), appends a right-side × icon for the remove affordance',
},
parent_id: parentIdProp,
pageId: pageIdProp,
},
required: ['filename'],
},
},
];

View file

@ -80,6 +80,7 @@ import { handleAddDatePickerV0 } from '../tools/add-date-picker-v0';
import { handleAddModalShellV1 } from '../tools/add-modal-shell-v1';
import { handleAddUploadDropzoneV0 } from '../tools/add-upload-dropzone-v0';
import { handleAddOtpInputV0 } from '../tools/add-otp-input-v0';
import { handleAddAttachmentRowV0 } from '../tools/add-attachment-row-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';
@ -240,6 +241,8 @@ async function dispatchElementToolCall(name: string, a: any): Promise<string> {
return JSON.stringify(await handleAddUploadDropzoneV0(a), null, 2);
case 'add_otp_input_v0':
return JSON.stringify(await handleAddOtpInputV0(a), null, 2);
case 'add_attachment_row_v0':
return JSON.stringify(await handleAddAttachmentRowV0(a), null, 2);
default:
return '';
}

View file

@ -0,0 +1,23 @@
import {
assignIdsRecursively,
buildAttachmentRow,
type AttachmentRowParams,
} from '@zseven-w/pen-core';
import type { handleBatchDesign } from './batch-design';
import { ensureParentExists, insertElementTree } from './element-tool-helpers';
export interface AddAttachmentRowV0Params extends AttachmentRowParams {
parent_id?: string;
filePath?: string;
pageId?: string;
}
/** File attachment row. Tree build delegated to `buildAttachmentRow`. */
export async function handleAddAttachmentRowV0(
params: AddAttachmentRowV0Params,
): Promise<Awaited<ReturnType<typeof handleBatchDesign>>> {
await ensureParentExists(params);
const r = buildAttachmentRow(params);
assignIdsRecursively(r);
return insertElementTree({ binding: 'attachment', tree: r, ...params });
}