diff --git a/docs/audits/block-api-coverage.md b/docs/audits/block-api-coverage.md index 615fa00b..7a19438c 100644 --- a/docs/audits/block-api-coverage.md +++ b/docs/audits/block-api-coverage.md @@ -46,6 +46,7 @@ | tooltipcard | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ | | dbview | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ | | dbform | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ | +| meeting | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ | | form | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ | | openbook.ledger/journal-entry | ✅ | ✅ | ➖ | ✅ | ➖ | ✅ | | openbook.ledger/trial-balance | ✅ | ✅ | ➖ | ✅ | ➖ | ✅ | diff --git a/docs/meeting-block.md b/docs/meeting-block.md new file mode 100644 index 00000000..4e52a824 --- /dev/null +++ b/docs/meeting-block.md @@ -0,0 +1,44 @@ +# Meeting block — THE REPRESENTATION CONTRACT (MEET-4) + +`meeting` is a `kit` block with `nature: 'container'`, no required parent, and +`kitValue: false`. It publishes no reactive value. Its `children` are ordinary +manual-note blocks (paragraphs, todos, headings, groups, etc.), not transcript +segments. No dedicated notes slot or child-only type is required. + +All top-level props are optional. Missing status means `idle`; missing arrays +mean empty lists; missing summary/title mean empty text; missing startedAt means +not started. These are consumer defaults, not schema-inserted persisted values. +As with other block props, patches shallow-merge; `null` removes a top-level key. +Unknown top-level props remain allowed for forward compatibility. + +| Prop | Stored shape and meaning | +| --- | --- | +| `status` | `'idle' \| 'recording' \| 'processing' \| 'done'`. Validation checks the enum, not state transitions. | +| `audioChunks` | Ordered array of `{assetId: string, durationMs: number, startedAtMs?: number}`. `assetId` is a nonempty asset reference (max 512 characters), never inline audio. `durationMs` is the chunk duration; optional `startedAtMs` is an offset from meeting start, **not** Unix time. Array order is capture/playback order. | +| `transcript` | Ordered array of `{startMs: number, endMs: number, text: string}`. Offsets are relative to meeting start; `endMs >= startMs`. Text is plain text. Array order is display order; overlapping segments are allowed. | +| `summary` | Plain string; no rich-text runs or Markdown interpretation is required. | +| `startedAt` | Unix epoch milliseconds, a finite nonnegative number. | +| `title` | Optional plain string. | + +All durations and offsets are finite nonnegative numbers in milliseconds; +fractional milliseconds are accepted. Every listed nested field is required +except `startedAtMs`; nested objects reject extra keys and null fields. Empty +arrays and empty transcript text are valid. Cross-field `endMs >= startMs` is +checked at runtime and described in the JSON schema (standard JSON Schema cannot +express a comparison to a sibling field). Asset existence, timeline sorting, +status transitions, and transcription size limits are producer responsibilities. + +Audio chunks and transcript segments use structured props, following existing +kit option/rich-run arrays and the form's structured schema prop. They are +machine-produced snapshots, while manual notes need independently editable CRDT +children. Each array is one prop value: updates replace the whole array, with +no per-segment merge guarantee. Recording/transcription producers should batch +updates and serialize writes to avoid concurrent array replacement losing data. +This keeps the first representation small and consistent; large-transcript +storage migration, if needed later, must explicitly version this contract. + +MEET-4 registers a minimal meeting shell through `registerArtifactKit` in both +editor and viewer hosts. It displays title, status, and saved note-block count; +notes remain stored but are not yet rendered/editable inside the shell. It has +no slash-menu entry or recording controls. MEET-5 replaces this renderer and +must render the existing child blocks rather than migrate them into props. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index ce54205f..9d0002ff 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -93,6 +93,7 @@ Call `list_block_types` for the machine-readable source of truth: every entry ha | `tooltipcard` | `term:string`, `tip:string` | | `dbview` | `pageId:string` | | `dbform` | `databaseId:string`, `viewId:string` | +| `meeting` | `status:"idle"/"recording"/"processing"/"done"`, `audioChunks:[{assetId:string,durationMs:number,startedAtMs?:number}]`, `transcript:[{startMs:number,endMs:number,text:string}]`, `summary:string`, `startedAt:number`, `title:string`; container children are manual notes; [representation contract](../../docs/meeting-block.md) | | `form` | `formId:string`, `submissionKey:string`, `enabled:boolean`, `databaseId:string`, `schema:object`, `label:string`, `description:string` | Shared input/frame props are `name`, `label`, and `description` strings plus `compact` and `interactive` booleans. A structured option's `value` defaults to a slug of its `label`; `selected` contains those string values. diff --git a/packages/mcp/scripts/blockTypes.test.mts b/packages/mcp/scripts/blockTypes.test.mts index 85f78bba..230b5b1f 100644 --- a/packages/mcp/scripts/blockTypes.test.mts +++ b/packages/mcp/scripts/blockTypes.test.mts @@ -157,6 +157,22 @@ async function main(): Promise { check('an invalid structured option is refused with a typed error naming opts', isError(badOpts) && /"opts"/.test(resultText(badOpts)) && /object/i.test(resultText(badOpts))); + const meetingProps = {status: 'done', audioChunks: [{assetId: 'audio-1', durationMs: 500, startedAtMs: 0}], transcript: [{startMs: 0, endMs: 500, text: 'Hello'}], summary: 'Agreed', startedAt: 1234, title: 'Review'}; + const meetingPage = await seed.savePage({name: 'Meeting target', data: {editor: 'blocks', blockdoc: {blocks: [{id: 'meeting1', type: 'meeting', children: [{id: 'note1', type: 'paragraph', text: [{t: 'Notes'}]}]}]}, editorjs: {blocks: []}, values: [], names: []}}); + const meetingUpdate = await mcp.client.callTool({name: 'update_block_props', arguments: {pageId: meetingPage.id, blockId: 'meeting1', props: meetingProps}}); + check('meeting structured props update is accepted', !isError(meetingUpdate)); + const meetingCreate = await mcp.client.callTool({name: 'append_blocks', arguments: {pageId: meetingPage.id, blocks: [{type: 'meeting', props: meetingProps, children: [{type: 'paragraph', text: 'Manual notes'}]}]}}); + check('meeting creation with manual note children is accepted', !isError(meetingCreate)); + for (const props of [{status: 'paused'}, {audioChunks: [{assetId: 'a', durationMs: -1}]}, {transcript: [{startMs: 2, endMs: 1, text: 'Reversed'}]}]) { + const badCreate = await mcp.client.callTool({name: 'append_blocks', arguments: {pageId: meetingPage.id, blocks: [{type: 'meeting', props}]}}); + const badUpdate = await mcp.client.callTool({name: 'update_block_props', arguments: {pageId: meetingPage.id, blockId: 'meeting1', props}}); + check(`meeting rejects invalid creation and update: ${JSON.stringify(props)}`, isError(badCreate) && isError(badUpdate)); + } + const meetingListing = JSON.parse(resultText(await mcp.client.callTool({name: 'list_block_types', arguments: {types: ['meeting']}}))); + assert.equal(meetingListing.blocks.length, 1); + assert.deepEqual(meetingListing.blocks[0].propsSchema.properties.transcript.items.required, ['startMs', 'endMs', 'text']); + check('meeting listing publishes container nature and structured propsSchema', meetingListing.blocks[0].nature === 'container'); + console.log('\nAPI-2: plugin block types — rejected while uninstalled, accepted once installed'); const uninstalled = await mcp.client.callTool({ name: 'create_artifact_page', diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index f5fdb6a1..1942e7cd 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -25,6 +25,7 @@ import { findUnknownBlockType, FORM_FIELD_KINDS, invalidBlockProps, + invalidBlockTreeProps, insertBlocks, isHttpUrl, KIT_VALUE_BLOCK_TYPES, @@ -609,7 +610,7 @@ function nestedBlockSchema(typeDesc: string, propsDesc: string): z.ZodType - blockTreeError(blocks, {maxDepth: MAX_BLOCK_DEPTH, maxNodes: MAX_BLOCK_NODES}); + blockTreeError(blocks, {maxDepth: MAX_BLOCK_DEPTH, maxNodes: MAX_BLOCK_NODES}) ?? invalidBlockTreeProps(blocks); /** * The write-tool kind an MCP mutation maps to (the same identifiers the in-app diff --git a/packages/sdk/src/blockCatalogue.test.ts b/packages/sdk/src/blockCatalogue.test.ts index 0ef41ead..4310c0e1 100644 --- a/packages/sdk/src/blockCatalogue.test.ts +++ b/packages/sdk/src/blockCatalogue.test.ts @@ -17,6 +17,7 @@ import { CONTAINER_BLOCK_TYPES, findUnknownBlockType, invalidBlockProps, + invalidBlockTreeProps, isPluginBlockType, KNOWN_BLOCK_TYPE_IDS, MAX_BLOCK_DEPTH, @@ -242,3 +243,48 @@ describe('generated tool text', () => { for (const type of KNOWN_BLOCK_TYPE_IDS) expect(guidance).toContain(type); }); }); + + +describe('meeting representation contract (MEET-4)', () => { + it('is a kit container with typed structured props and no reactive value', () => { + expect(blockTypeInfo('meeting')).toMatchObject({category: 'kit', nature: 'container', kitValue: false}); + expect(blockTreeError([{type: 'meeting', children: [{type: 'paragraph', text: 'Manual notes'}]}])).toBeNull(); + for (const status of ['idle', 'recording', 'processing', 'done']) { + expect(invalidBlockProps('meeting', {status, startedAt: 1234, title: 'Review', summary: 'Agreed.', + audioChunks: [{assetId: 'audio-1', durationMs: 500, startedAtMs: 0}, {assetId: 'audio-2', durationMs: 250}], + transcript: [{startMs: 0, endMs: 500, text: 'Hello'}], + })).toBeNull(); + } + expect(invalidBlockProps('meeting', {})).toBeNull(); + expect(invalidBlockProps('meeting', {status: null, audioChunks: null, transcript: null, summary: null, startedAt: null, title: null})).toBeNull(); + }); + + it.each([ + {status: 'paused'}, {startedAt: -1}, {startedAt: '2026-01-01'}, {summary: []}, {title: 1}, + {audioChunks: ['asset']}, {audioChunks: [{assetId: 'a'}]}, {audioChunks: [{assetId: '', durationMs: 1}]}, + {audioChunks: [{assetId: 'a', durationMs: -1}]}, {audioChunks: [{assetId: 'a', durationMs: 1, startedAtMs: -1}]}, + {audioChunks: [{assetId: 'a', durationMs: Infinity}]}, {audioChunks: [{assetId: 'a', durationMs: 1, extra: true}]}, + {transcript: [{startMs: 0, text: 'Missing end'}]}, {transcript: [{startMs: 2, endMs: 1, text: 'Reversed'}]}, + {transcript: [{startMs: -1, endMs: 1, text: 'Negative'}]}, {transcript: [{startMs: 0, endMs: 1, text: 1}]}, + ])('rejects malformed props: %j', (props) => { + expect(invalidBlockProps('meeting', props)).toContain('Invalid prop'); + }); + + it('publishes nested item schemas and required fields to agents', () => { + const {blocks} = JSON.parse(blockCatalogueText([], ['meeting'])); + expect(blocks).toHaveLength(1); + expect(blocks[0].propsSchema.properties.status.enum).toEqual(['idle', 'recording', 'processing', 'done']); + expect(blocks[0].propsSchema.properties.audioChunks.items.required).toEqual(['assetId', 'durationMs']); + expect(blocks[0].propsSchema.properties.transcript.items.required).toEqual(['startMs', 'endMs', 'text']); + }); +}); + + +describe('creation prop validation', () => { + it('validates nested blocks with the same schemas as prop updates', () => { + expect(invalidBlockTreeProps([{type: 'group', children: [{type: 'meeting', props: {status: 'paused'}}]}])).toContain('Invalid prop "status"'); + expect(invalidBlockTreeProps([{type: 'heading', props: {level: 'two'}}])).toContain('Invalid prop "level"'); + expect(invalidBlockTreeProps([{type: 'meeting', props: []}])).toContain('expected an object'); + expect(invalidBlockTreeProps([{type: 'meeting'}, {type: 'plugin/widget', props: {anything: true}}, {type: 'meeting', props: {title: null, future: {x: 1}}}])).toBeNull(); + }); +}); diff --git a/packages/sdk/src/blockCatalogue.ts b/packages/sdk/src/blockCatalogue.ts index ae2dc957..2c86bf60 100644 --- a/packages/sdk/src/blockCatalogue.ts +++ b/packages/sdk/src/blockCatalogue.ts @@ -31,7 +31,7 @@ export {BLOCK_PROP_JSON_SCHEMAS, BLOCK_PROP_SCHEMAS} from './blockPropSchemas'; /** How a block stores content: `container` blocks carry child blocks in * `children`, `text` blocks carry rich text in `text`, `void` blocks carry - * only `props` (all kit widgets are void). */ + * only `props`. Kit blocks may also be containers. */ export type BlockNature = 'container' | 'text' | 'void'; /** The value shapes per-type prop validation understands. Deliberately coarse @@ -109,6 +109,7 @@ const CATALOGUE_LITERAL = [ {type: 'tooltipcard', category: 'kit', nature: 'void', props: {term: 'string', tip: 'string'}, hint: '{term,tip}'}, {type: 'dbview', category: 'kit', nature: 'void', props: {pageId: 'string'}, hint: 'embedded live database view {pageId} — the page hosting the database'}, {type: 'dbform', category: 'kit', nature: 'void', props: {databaseId: 'string', viewId: 'string'}, hint: 'embedded database form {databaseId,viewId} — a live reference, never a copied schema or capability'}, + {type: 'meeting', category: 'kit', nature: 'container', kitValue: false, props: {status: 'string', audioChunks: 'array', transcript: 'array', summary: 'string', startedAt: 'number', title: 'string'}, hint: 'meeting recording {status?,audioChunks?:[{assetId,durationMs,startedAtMs?}],transcript?:[{startMs,endMs,text}],summary?,startedAt?,title?}; times in ms, startedAt is Unix epoch; children hold manual notes; see docs/meeting-block.md'}, {type: 'form', category: 'kit', nature: 'void', kitValue: false, props: {formId: 'string', submissionKey: 'string', enabled: 'boolean', databaseId: 'string', schema: 'object', label: 'string', description: 'string'}, hint: 'public form definition {formId,submissionKey,enabled,databaseId?,schema}'}, ] as const satisfies readonly BlockTypeInfo[]; @@ -134,9 +135,9 @@ export const KIT_VALUE_BLOCK_TYPES: ReadonlySet = new Set( BLOCK_TYPE_CATALOGUE.filter((entry) => entry.kitValue === true).map((entry) => entry.type), ); -/** Core types whose `children` hold ordinary blocks. */ -export const CONTAINER_BLOCK_TYPES: ReadonlySet = new Set( - BLOCK_TYPE_CATALOGUE.filter((e) => e.nature === 'container').map((e) => e.type as CoreBlockType), +/** Catalogued types whose `children` hold ordinary blocks (including kit containers). */ +export const CONTAINER_BLOCK_TYPES: ReadonlySet = new Set( + BLOCK_TYPE_CATALOGUE.filter((e) => e.nature === 'container').map((e) => e.type), ); /** Core types that carry editable rich text. */ @@ -284,7 +285,7 @@ export function blockTreeError( structural = parentType ? `A "${type}" block must be a direct child of a "${needs}" block, not a "${parentType}" block (at "${here}").` : `A "${type}" block can't be top-level — it belongs directly inside a "${needs}" block (at "${here}").`; - } else if (hasChildren && !CONTAINER_BLOCK_TYPES.has(type as CoreBlockType)) { + } else if (hasChildren && !CONTAINER_BLOCK_TYPES.has(type)) { structural = `A "${type}" block can't hold children — only container blocks (${[...CONTAINER_BLOCK_TYPES].join(', ')}) do (at "${here}"). The nested blocks would be dropped.`; } else if (type === 'table' && hasChildren) { structural = raggedTableError(children, here); @@ -339,6 +340,30 @@ export function invalidBlockProps(type: string, props: Record): return `Invalid prop "${prop}" of a "${type}" block: ${issue.message} (got ${clipJson(props[prop])}).`; } +/** Validate declared props throughout a creation payload. Call blockTreeError + * first to enforce structure/size limits. Plugin and unknown props retain the + * same permissive behavior as update_block_props. */ +export function invalidBlockTreeProps(blocks: readonly unknown[], depth = 1): string | null { + if (depth > TYPE_WALK_MAX_DEPTH) return null; // structure validation owns depth errors + for (const raw of blocks) { + if (!raw || typeof raw !== 'object') continue; + const block = raw as {type?: unknown; props?: unknown; children?: unknown}; + const type = String(block.type ?? ''); + if (block.props !== undefined) { + if (!block.props || typeof block.props !== 'object' || Array.isArray(block.props)) { + return `Invalid props of a "${type}" block: expected an object.`; + } + const error = invalidBlockProps(type, block.props as Record); + if (error) return error; + } + if (Array.isArray(block.children)) { + const error = invalidBlockTreeProps(block.children, depth + 1); + if (error) return error; + } + } + return null; +} + const clipJson = (v: unknown): string => { const s = JSON.stringify(v) ?? String(v); return s.length > 40 ? `${s.slice(0, 40)}…` : s; @@ -388,10 +413,10 @@ export function addBlocksGuidance(): string { 'Append rich blocks to a page — text, layouts, tables, media, interactive inputs, and charts. User approves before they are added.', 'Each block is {type, text?, props?, children?}. `text` is a plain string (or rich runs [{"t","a":{b,i,u,s,c,a}}]); `children` nests blocks inside containers. Call list_block_types for the full catalogue including installed plugin blocks.', `TEXT: ${core.filter((e) => e.nature === 'text' && !e.parent).map(hinted).join('; ')}.`, - `CONTAINERS (use children): ${core.filter((e) => e.nature === 'container' || e.parent).map(hinted).join('; ')}. Give every table row the same number of cells.`, + `CONTAINERS (use children): ${BLOCK_TYPE_CATALOGUE.filter((e) => e.nature === 'container' || e.parent).map(hinted).join('; ')}. Give every table row the same number of cells.`, `MEDIA/OTHER: ${core.filter((e) => e.nature === 'void').map(hinted).join('; ')}.`, `INPUTS (each publishes props.name into the reactive scope): ${kit.filter((e) => e.kitValue).map(hinted).join('; ')}.`, - `REACTIVE DISPLAY/ACTIONS (props.source is a JS expression over input names): ${kit.filter((e) => !e.kitValue).map(hinted).join('; ')}.`, + `REACTIVE DISPLAY/ACTIONS (props.source is a JS expression over input names): ${kit.filter((e) => !e.kitValue && e.nature !== 'container').map(hinted).join('; ')}.`, 'Example: a budget widget → [{"type":"heading","text":"Budget","props":{"level":2}},{"type":"columns","children":[{"type":"column","props":{"span":5},"children":[{"type":"slider","props":{"name":"spent","label":"Spent","value":80,"min":0,"max":200}},{"type":"number","props":{"name":"budget","label":"Budget","value":120}}]},{"type":"column","props":{"span":7},"children":[{"type":"kitchart","props":{"kind":"bar","title":"Spent vs budget","labels":"Spent, Budget","source":"[spent, budget]"}},{"type":"statuslight","props":{"label":"On track","source":"budget - spent","okAt":0,"warnAt":-20}}]}]}].', ].join('\n'); } diff --git a/packages/sdk/src/blockPropSchemas.ts b/packages/sdk/src/blockPropSchemas.ts index 0daf2dcf..56e07684 100644 --- a/packages/sdk/src/blockPropSchemas.ts +++ b/packages/sdk/src/blockPropSchemas.ts @@ -43,6 +43,22 @@ const opts = array(option, 'Structured selectable options.'); const attrs = object({b: boolean(), i: boolean(), u: boolean(), s: boolean(), c: boolean(), a: string('Safe http, https, or mailto link.')}); const run = object({t: string('Run text.'), a: attrs}, 'One rich-text run.', ['t']); const runs = array(run, 'Rich-text runs.'); +// MEET-4 representation contract: docs/meeting-block.md. Nested objects are +// strict; top-level props retain the catalogue's nullable patch semantics. +const audioChunk = object({ + assetId: {schema: z.string().min(1).max(512), json: {type: 'string', minLength: 1, maxLength: 512}}, + durationMs: number('Chunk duration in milliseconds.', 0), + startedAtMs: number('Offset from the meeting start in milliseconds.', 0), +}, 'One audio asset in capture order.', ['assetId', 'durationMs']); +const transcriptSegment = object({ + startMs: number('Inclusive offset from the meeting start in milliseconds.', 0), + endMs: number('Exclusive offset from the meeting start in milliseconds; must be >= startMs.', 0), + text: string('Plain transcript text.'), +}, 'One transcript segment in display order.', ['startMs', 'endMs', 'text']); +transcriptSegment.schema = transcriptSegment.schema.refine( + (segment) => segment.endMs >= segment.startMs, + {message: 'endMs must be greater than or equal to startMs', path: ['endMs']}, +); const common = {bg: text}; const frame = {name: text, label: text, description: text, compact: boolean(), interactive: boolean()}; const inputText = {...frame, value: text, placeholder: text}; @@ -71,6 +87,14 @@ const fields = { formula: {...frame, source: expression('Expression evaluated over the reactive scope.')}, linkcard: {title: text, url: text, description: text}, tooltipcard: {term: text, tip: text}, dbview: {pageId: id}, dbform: {databaseId: id, viewId: id}, + meeting: { + status: enumeration(['idle', 'recording', 'processing', 'done'], 'Absent means idle.'), + audioChunks: array(audioChunk, 'Audio chunks in capture order; replace the entire array when updating.'), + transcript: array(transcriptSegment, 'Plain-text segments in display order; replace the entire array when updating.'), + summary: string('Plain-text summary.'), + startedAt: number('Meeting start as Unix epoch milliseconds.', 0), + title: text, + }, form: {formId: id, submissionKey: text, enabled: boolean(), databaseId: id, schema: freeObject, label: text, description: text}, } satisfies Record>; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index c6d9c29b..1a7a8227 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -554,6 +554,7 @@ export { unknownBlockTypeMessage, blockTreeError, invalidBlockProps, + invalidBlockTreeProps, blockCatalogueText, addBlocksGuidance, type BlockNature, diff --git a/packages/server/src/ai/agent.ts b/packages/server/src/ai/agent.ts index d63033e7..a3f57675 100644 --- a/packages/server/src/ai/agent.ts +++ b/packages/server/src/ai/agent.ts @@ -19,6 +19,7 @@ import { CONTAINER_BLOCK_TYPES, findUnknownBlockType, invalidBlockProps, + invalidBlockTreeProps, KIT_VALUE_BLOCK_TYPES, providerSettings, movePageTool, @@ -1060,6 +1061,8 @@ export class AgentRunner { if (structural) return structural; const bad = unknownBlockTypeMessage(findUnknownBlockType(blocks, {installedPluginIds: await this.installedPluginIds()})); if (bad) return bad; + const propError = invalidBlockTreeProps(blocks); + if (propError) return propError; const normalizeBlockText = (value: unknown): unknown => { if (!value || typeof value !== 'object' || Array.isArray(value)) return value; const block = value as Record; diff --git a/packages/server/src/ai/agentBlockTypes.test.ts b/packages/server/src/ai/agentBlockTypes.test.ts index eef33a35..c337fa50 100644 --- a/packages/server/src/ai/agentBlockTypes.test.ts +++ b/packages/server/src/ai/agentBlockTypes.test.ts @@ -261,3 +261,28 @@ describe('list_block_types', () => { expect(afterCatalogue.pluginBlocks.every((entry: {category: string}) => entry.category === 'plugin')).toBe(true); }); }); + + +describe('meeting agent surface (MEET-4)', () => { + it('accepts notes and structured props, rejects malformed creation and updates', async () => { + const page = await store.upsertPage({name: `meeting-${seq}`, data: { + editor: 'blocks', blockdoc: {blocks: [{id: 'meeting1', type: 'meeting'}]}, editorjs: {blocks: []}, values: [], names: [], + }}); + const props = {status: 'done', audioChunks: [{assetId: 'audio-1', durationMs: 500}], transcript: [{startMs: 0, endMs: 500, text: 'Hello'}]}; + const added = await runTool('add_blocks', {pageId: page.id, blocks: [{type: 'meeting', props, children: [{type: 'paragraph', text: 'Notes'}]}]}); + expect(added.result).toContain('SUGGESTED for review'); + expect((await runTool('update_block_props', {pageId: page.id, blockId: 'meeting1', props})).result).toContain('SUGGESTED for review'); + for (const invalid of [{status: 'paused'}, {audioChunks: [{assetId: 'a', durationMs: -1}]}, {transcript: [{startMs: 2, endMs: 1, text: 'Reversed'}]}]) { + for (const [tool, args] of [ + ['add_blocks', {pageId: page.id, blocks: [{type: 'meeting', props: invalid}]}], + ['update_block_props', {pageId: page.id, blockId: 'meeting1', props: invalid}], + ] as const) { + const response = await runTool(tool, args); + expect(response.result).toContain('Invalid prop'); + expect(response.events.some((event) => event.type === 'suggestions')).toBe(false); + } + } + const listed = JSON.parse((await runTool('list_block_types', {types: ['meeting']})).result); + expect(listed.blocks.find((block: {type: string}) => block.type === 'meeting').propsSchema.properties.transcript.items.required).toEqual(['startMs', 'endMs', 'text']); + }); +}); diff --git a/packages/ui/src/blockeditor/MeetingBlockPlaceholder.tsx b/packages/ui/src/blockeditor/MeetingBlockPlaceholder.tsx new file mode 100644 index 00000000..c2ee56a1 --- /dev/null +++ b/packages/ui/src/blockeditor/MeetingBlockPlaceholder.tsx @@ -0,0 +1,14 @@ +import {blockChildren, blockProp} from './model'; +import type {CustomBlockDef, CustomBlockProps} from './registry'; + +/** MEET-5 replaces this shell. The container model already preserves notes; + * this placeholder deliberately offers no recording controls or slash entry. */ +const MeetingBlockPlaceholder = ({block}: CustomBlockProps) => ( +
+ {blockProp(block, 'title') || 'Meeting'} +

{blockProp(block, 'status') || 'idle'} — Meeting view coming soon.

+

{blockChildren(block)?.length ?? 0} note blocks saved.

+
+); + +export const MEETING_BLOCK: CustomBlockDef = {type: 'meeting', render: MeetingBlockPlaceholder}; diff --git a/packages/ui/src/blockeditor/kit/index.ts b/packages/ui/src/blockeditor/kit/index.ts index f18f6aa5..1c9dbda5 100644 --- a/packages/ui/src/blockeditor/kit/index.ts +++ b/packages/ui/src/blockeditor/kit/index.ts @@ -4,6 +4,7 @@ import {INPUT2_BLOCKS} from './inputs2'; import {PROGRESS_BLOCKS} from './progress'; import {CHART_BLOCKS} from './charts'; import {CARD_BLOCKS} from './cards'; +import {MEETING_BLOCK} from '../MeetingBlockPlaceholder'; export {evalExpr, formatValue, inputScope, INPUT_TYPES} from './scope'; export {CHART_KINDS} from './charts'; @@ -17,7 +18,7 @@ export {CHART_KINDS} from './charts'; * out of reusable, collaborative blocks. */ export function registerArtifactKit(): void { - for (const def of [...INPUT_BLOCKS, ...INPUT2_BLOCKS, ...PROGRESS_BLOCKS, ...CHART_BLOCKS, ...CARD_BLOCKS]) { + for (const def of [...INPUT_BLOCKS, ...INPUT2_BLOCKS, ...PROGRESS_BLOCKS, ...CHART_BLOCKS, ...CARD_BLOCKS, MEETING_BLOCK]) { const d = def as unknown as CustomBlockDef; // Tag the built-ins so the slash menu files them under "Interactive blocks" // (third-party plugin blocks fall through to "Extensions"). diff --git a/packages/ui/src/blockeditor/model.ts b/packages/ui/src/blockeditor/model.ts index 77191ff9..51e44df8 100644 --- a/packages/ui/src/blockeditor/model.ts +++ b/packages/ui/src/blockeditor/model.ts @@ -94,7 +94,7 @@ export const TEXT_BLOCKS: ReadonlySet = TEXT_BLOCK_TYPES; /** Block types whose `children` hold ordinary blocks — the catalogue's * `container` nature. */ -export const CONTAINER_BLOCKS: ReadonlySet = CONTAINER_BLOCK_TYPES; +export const CONTAINER_BLOCKS: ReadonlySet = CONTAINER_BLOCK_TYPES; export type BlockMap = Y.Map; diff --git a/packages/ui/src/blockeditor/registryCatalogue.test.tsx b/packages/ui/src/blockeditor/registryCatalogue.test.tsx index 24adc0b3..e0d85802 100644 --- a/packages/ui/src/blockeditor/registryCatalogue.test.tsx +++ b/packages/ui/src/blockeditor/registryCatalogue.test.tsx @@ -27,7 +27,7 @@ import {registeredBlockTypes} from './registry'; import {registerArtifactKit} from './kit'; import {registerReactiveBlocks} from './reactiveBlocks'; import {INPUT_TYPES, inputValue} from './kit/scope'; -import {CONTAINER_BLOCKS, createDoc, rootBlocks, TEXT_BLOCKS} from './model'; +import {blockToJSON, CONTAINER_BLOCKS, createDoc, rootBlocks, TEXT_BLOCKS} from './model'; import {registerDatabaseBlock} from '@/components/database/InlineDatabaseBlock'; import {registerDatabaseFormBlock} from '@/components/database/DatabaseFormBlock'; import {registerFormBlock} from './FormBlockView'; @@ -62,6 +62,15 @@ describe('registry ↔ catalogue drift guard', () => { expect(KNOWN_BLOCK_TYPE_IDS.size).toBe(BLOCK_TYPE_CATALOGUE.length); }); + it('meeting container notes and structured props survive the UI model round trip', () => { + const props = {status: 'done', audioChunks: [{assetId: 'audio-1', durationMs: 500}], transcript: [{startMs: 0, endMs: 500, text: 'Hello'}]}; + const doc = createDoc([{type: 'meeting', props, children: [{type: 'paragraph', text: 'Manual notes'}]}]); + const json = blockToJSON(rootBlocks(doc).get(0)); + expect(json.props).toEqual(props); + expect(json.children).toHaveLength(1); + expect(json.children?.[0]).toMatchObject({type: 'paragraph', text: [{t: 'Manual notes'}]}); + }); + it('the model\'s nature sets ARE the catalogue\'s', () => { expect(TEXT_BLOCKS).toBe(TEXT_BLOCK_TYPES); expect(CONTAINER_BLOCKS).toBe(CONTAINER_BLOCK_TYPES);