From 49d7d1baf23c42c16ff2ee96defccbd090ca9abe Mon Sep 17 00:00:00 2001 From: Fini Date: Wed, 22 Apr 2026 21:53:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20add=5Fattachment=5Frow=5Fv0=20?= =?UTF-8?q?=E2=80=94=20file=20attachment=20list=20unit=20(66th=20tool)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/server/api/mcp/exec-tool.post.ts | 2 + .../ai/__tests__/shim-server-parity.test.ts | 6 + .../services/ai/element-tool-shims/index.ts | 3 + .../skills/phases/generation/elements.md | 10 +- .../src/element-builders/attachment-row.ts | 122 ++++++++++++++++++ .../pen-core/src/element-builders/index.ts | 1 + packages/pen-core/src/index.ts | 2 + .../__tests__/add-attachment-row-v0.test.ts | 120 +++++++++++++++++ .../__tests__/element-tools-contract.test.ts | 1 + .../src/routes/element-tool-defs-ext.ts | 37 ++++++ .../pen-mcp/src/routes/element-tool-defs.ts | 3 + .../src/tools/add-attachment-row-v0.ts | 23 ++++ 12 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 packages/pen-core/src/element-builders/attachment-row.ts create mode 100644 packages/pen-mcp/src/__tests__/add-attachment-row-v0.test.ts create mode 100644 packages/pen-mcp/src/tools/add-attachment-row-v0.ts diff --git a/apps/web/server/api/mcp/exec-tool.post.ts b/apps/web/server/api/mcp/exec-tool.post.ts index 0c5aa1c29..226dc1fc0 100644 --- a/apps/web/server/api/mcp/exec-tool.post.ts +++ b/apps/web/server/api/mcp/exec-tool.post.ts @@ -36,6 +36,7 @@ import { buildModalShellV1, buildUploadDropzone, buildOtpInput, + buildAttachmentRow, buildKbd, buildLink, buildListRow, @@ -193,6 +194,7 @@ const SERVER_BUILDERS: Record = { add_upload_dropzone_v0: (a) => buildUploadDropzone(a as Parameters[0]), add_otp_input_v0: (a) => buildOtpInput(a as Parameters[0]), + add_attachment_row_v0: (a) => buildAttachmentRow(a as Parameters[0]), }; interface ExecToolBody { diff --git a/apps/web/src/services/ai/__tests__/shim-server-parity.test.ts b/apps/web/src/services/ai/__tests__/shim-server-parity.test.ts index 7534a2066..c6eadaa83 100644 --- a/apps/web/src/services/ai/__tests__/shim-server-parity.test.ts +++ b/apps/web/src/services/ai/__tests__/shim-server-parity.test.ts @@ -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[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[0]), + }, ]; /** diff --git a/apps/web/src/services/ai/element-tool-shims/index.ts b/apps/web/src/services/ai/element-tool-shims/index.ts index c5b8265ad..69ce4831e 100644 --- a/apps/web/src/services/ai/element-tool-shims/index.ts +++ b/apps/web/src/services/ai/element-tool-shims/index.ts @@ -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 = { add_modal_shell_v1: wrap(buildModalShellV1), add_upload_dropzone_v0: wrap(buildUploadDropzone), add_otp_input_v0: wrap(buildOtpInput), + add_attachment_row_v0: wrap(buildAttachmentRow), }; export function getElementShim(name: string): ElementShim | undefined { diff --git a/packages/pen-ai-skills/skills/phases/generation/elements.md b/packages/pen-ai-skills/skills/phases/generation/elements.md index 99e1f6b3a..48e030648 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -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 diff --git a/packages/pen-core/src/element-builders/attachment-row.ts b/packages/pen-core/src/element-builders/attachment-row.ts new file mode 100644 index 000000000..f342d667c --- /dev/null +++ b/packages/pen-core/src/element-builders/attachment-row.ts @@ -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, + }; +} diff --git a/packages/pen-core/src/element-builders/index.ts b/packages/pen-core/src/element-builders/index.ts index 83a3153f6..3da19208f 100644 --- a/packages/pen-core/src/element-builders/index.ts +++ b/packages/pen-core/src/element-builders/index.ts @@ -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'; diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index f96e115de..b8d6fb74c 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -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'; diff --git a/packages/pen-mcp/src/__tests__/add-attachment-row-v0.test.ts b/packages/pen-mcp/src/__tests__/add-attachment-row-v0.test.ts new file mode 100644 index 000000000..04d7ed86f --- /dev/null +++ b/packages/pen-mcp/src/__tests__/add-attachment-row-v0.test.ts @@ -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 { + const fp = join(TMP, name); + await writeFile(fp, EMPTY, 'utf-8'); + return fp; +} +async function readDoc(fp: string): Promise> { + return JSON.parse(await readFile(fp, 'utf-8')); +} +function getRoot(doc: Record): Record { + const pages = doc['pages'] as Array<{ children?: Record[] }> | undefined; + const top = doc['children'] as Record[] | undefined; + const root = (top ?? pages?.[0]?.children)?.[0]; + if (!root) throw new Error('no root'); + return root; +} +function findByRole(n: Record, role: string): Record | undefined { + if (n.role === role) return n; + const kids = (n.children ?? []) as Record[]; + 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); + }); +}); diff --git a/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts b/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts index 11c982234..42d054bca 100644 --- a/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts +++ b/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts @@ -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', () => { diff --git a/packages/pen-mcp/src/routes/element-tool-defs-ext.ts b/packages/pen-mcp/src/routes/element-tool-defs-ext.ts index d9f7ca386..d98f105e7 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs-ext.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs-ext.ts @@ -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'], + }, + }, ]; diff --git a/packages/pen-mcp/src/routes/element-tool-defs.ts b/packages/pen-mcp/src/routes/element-tool-defs.ts index 8c9103543..432ba0e2f 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs.ts @@ -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 { 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 ''; } diff --git a/packages/pen-mcp/src/tools/add-attachment-row-v0.ts b/packages/pen-mcp/src/tools/add-attachment-row-v0.ts new file mode 100644 index 000000000..492494835 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-attachment-row-v0.ts @@ -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>> { + await ensureParentExists(params); + const r = buildAttachmentRow(params); + assignIdsRecursively(r); + return insertElementTree({ binding: 'attachment', tree: r, ...params }); +}