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:
parent
dc73885015
commit
49d7d1baf2
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
122
packages/pen-core/src/element-builders/attachment-row.ts
Normal file
122
packages/pen-core/src/element-builders/attachment-row.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
120
packages/pen-mcp/src/__tests__/add-attachment-row-v0.test.ts
Normal file
120
packages/pen-mcp/src/__tests__/add-attachment-row-v0.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
}
|
||||
|
|
|
|||
23
packages/pen-mcp/src/tools/add-attachment-row-v0.ts
Normal file
23
packages/pen-mcp/src/tools/add-attachment-row-v0.ts
Normal 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 });
|
||||
}
|
||||
Loading…
Reference in a new issue