diff --git a/.changeset/7655-chatbot-registration-authoring-faces.md b/.changeset/7655-chatbot-registration-authoring-faces.md new file mode 100644 index 0000000000..c5a8e2fbff --- /dev/null +++ b/.changeset/7655-chatbot-registration-authoring-faces.md @@ -0,0 +1,108 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-chatbot': patch +--- + +One named, importable authoring-face type per `plugin-chatbot` registration: +`ChatbotEnhancedSchema` and `ChatbotFloatingSchema` join `ChatbotSchema` +(objectui#7655, under the objectui#6169 / #6172 family ruling — every component +node has exactly one named, importable authoring-face type). + +`packages/plugin-chatbot` registers three components — `chatbot`, +`chatbot-enhanced`, `chatbot-floating` — and `@object-ui/types` published ONE +face for the family with `type` pinned to `'chatbot'`. An author annotating a +`chatbot-enhanced` or `chatbot-floating` node either dropped to untyped JSON or +annotated with `ChatbotSchema` and lied about `type`; the docs' floating example +had to be a `json` fence because no `tsx` fence could compile. The two +registrations' real key sets lived in anonymous `ChatbotSchema & { ... }` +intersections local to the renderer, referenceable by nothing outside that file. + +## The shape, and why not the smaller diff + +One interface per registration, not `ChatbotSchema['type']` widened to the union +of the three keys. The union would give three nodes ONE type and re-open what +#6169 closed — a single interface declaring keys only some of its own `type` +values read — and this card exists because the family's declarations had already +drifted from its reads. Each face declares what ITS registration reads, censused +per key on the PR's base (one `schema.KEY` read per registration body in +`renderer.tsx`, lit by keys that are NOT shared: `processVisibility` 0 / 1 / 0, +`floatingConfig` 0 / 0 / 1), and the twenty keys all three read are picked off +`ChatbotSchema` by name (`ChatbotSharedKey`) so they stay one declaration: + +- **`ChatbotEnhancedSchema`** (`type: 'chatbot-enhanced'`): the shared twenty, + plus `maxHeight` and `processVisibility` (read here, not by the floating + panel), plus `enableMarkdown`, `enableFileUpload`, `surface` (`'card' | + 'plain'`, objectui#6687) and the `onClear` runtime slot — four keys + `ChatbotSchema` never declared. +- **`ChatbotFloatingSchema`** (`type: 'chatbot-floating'`): the shared twenty, + plus `enableMarkdown`, `enableFileUpload`, `onClear`, and the two keys it + declares alongside `ChatbotSchema` — `floatingConfig` (`FloatingChatbotConfig`) + and `displayMode`. No `maxHeight`, `processVisibility` or `surface`: the + floating registration has no named read for any of them. (Its trailing raw + props spread does carry authored keys into the panel today — `processVisibility`, + `surface` and `showAvatars` are live there, measured through the real host; + that accidental channel is tracked as objectui#7708, and this face neither + declares nor promises it.) +- Neither face declares `ChatbotSchema`'s six legacy members (`loading`, + `showAvatars`, `userAvatar`, `assistantAvatar`, `markdown`, `height`) — no + registration reads them by name — and neither redeclares `disabled`, which + stays `BaseSchema`'s `boolean | string` (objectui#7087). + +**`ChatbotSchema` is unchanged.** It keeps `displayMode` and `floatingConfig` +(declarations verbatim), and the floating face declares the same two, so +`ChatbotSchema['displayMode']` and `ChatbotSchema['floatingConfig']` stay the +typed members they were — the objectui#7669 `triggerIcon` tombstone keeps its +reach on `chatbot` nodes, now pinned on the node. `floatingConfig`'s doc comment +is rewritten on both faces: the old text said it was "only used when +`displayMode` is `'floating'`", which was false — it is read by `chatbot-floating` +alone and forwarded to the panel. `displayMode` is RULED RETIRED — objectui#7654, +maintainer ruling B (2026-09-05): `?: never` tombstone, designer control and +`defaultProps` seed removed, in that card's own change. This change carries the +key untouched on both faces (still unmirrored, still read by nothing) so that PR +finds the member exactly as ruled, and a tripwire test pins that any value still +parses green until that PR flips it. + +**New published symbol:** `ChatbotSharedKey`, the string-literal union of the +twenty keys all three registrations read. It is exported from `complex.ts` +because an exported interface may not extend a `Pick` over a private name +(TS4022), so it is emitted into `dist/complex.d.ts` and is reachable through the +published `@object-ui/types/complex` subpath (it is not re-exported from the +package entry). It is a census, not an authoring face. + +## Zod twins, in lockstep + +`@object-ui/types/zod` gains `ChatbotEnhancedSchema` and `ChatbotFloatingSchema` +(and `ComplexSchema` routes the two new discriminants). Every declared key is an +arm except: the three runtime slots (`onError`, `onSend`, `onClear`), refused by +name per objectui#6124; and, on the floating twin only, `floatingConfig` (no +`FloatingChatbotConfig` mirror exists — minting one is objectui#6152's axis) and +`displayMode` (unmirrored on `ChatbotSchema`'s twin too; retired by ruling on +objectui#7654 and executed there). The twins mirror the API body params under the +key the renderer reads, `requestBody`, and inherit `body` as the children slot — +they do not copy `ChatbotSchema`'s `body` naming collision. + +**Accept-set change, stated plainly:** a `chatbot-enhanced` or `chatbot-floating` +document parsed through the family's only twin used to fail on `type`; through +its own twin it now parses, and the keys the twin declares are VALIDATED where +they rode through `.passthrough()` unexamined before (`surface: 'frameless'`, +`enableMarkdown: 'yes'` and `requestBody: 'x'` are refused). A `chatbot` node's +parse outcome is unchanged: `ChatbotSchema`'s twin did not move. + +## `@object-ui/plugin-chatbot` + +The `chatbot-enhanced` and `chatbot-floating` registrations type `schema` as the +published faces and drop the anonymous intersections. One consequence: +`chatbot-floating` used to write `disabled={schema.disabled}` and then spread +`{...props}` AFTER it — and `SchemaRenderer` always includes `disabled: verdict +|| undefined` in those props, so the raw read was overridden on every render. +With `disabled` honestly typed as `boolean | string` the raw union cannot be +forwarded into the panel's `boolean` prop, so the registration now names the +host verdict (`disabled: hostDisabled`) the way its two siblings have since +objectui#4431. No render outcome moves; the pin renders through the real host +both ways. + +This ships as `minor` for `@object-ui/types` because it widens the published +surface with two new node types, two new Zod twins and one new type alias; +`ChatbotSchema`'s own accept set does not move: objectui's major is pinned to `@objectstack`'s +(`scripts/check-changeset-no-major.mjs`), and objectui's own contract changes +ship as `minor` with the semantics spelled out — as above. diff --git a/content/docs/plugins/plugin-chatbot.mdx b/content/docs/plugins/plugin-chatbot.mdx index 9856f6c825..7a1e8ca2df 100644 --- a/content/docs/plugins/plugin-chatbot.mdx +++ b/content/docs/plugins/plugin-chatbot.mdx @@ -139,10 +139,25 @@ This page documents **three** registrations - `chatbot`, `chatbot-enhanced` and `chatbot-floating` - and they do not all read the same keys. **A row whose description carries no bolded scope note is read by all three.** The rows only some of them read say so in bold at the start of the description, and name what -to author instead on the registrations that ignore the key. Every key below is -declared on `ChatbotSchema`, so a key a registration ignores still type-checks -and still parses - it is dropped silently at render time, which is why the scope -is spelled out here rather than left to the type to express. +to author instead on the registrations that ignore the key. + +Each registration has its own importable authoring-face type in +`@object-ui/types` (objectui#7655): `ChatbotSchema` for `chatbot`, +`ChatbotEnhancedSchema` for `chatbot-enhanced` and `ChatbotFloatingSchema` for +`chatbot-floating`. Each declares exactly the keys its registration reads - the +twenty shared rows below are one declaration the two newer faces pick off +`ChatbotSchema` by name, and a scoped row is declared only on the face(s) whose +registration reads it. A key a registration ignores is therefore not a declared +member of its type. It still type-checks (`BaseSchema` ends in an index +signature, so an unlisted key is `any` rather than an error) and still parses +(the Zod twins are `.passthrough()`). On `chatbot` and `chatbot-enhanced` it is +then dropped silently at render time. `chatbot-floating` is different today: its +registration forwards the whole authored node to the panel through an +unfiltered props spread, so some keys its type does not declare +(`processVisibility`, `surface`, `showAvatars`) do reach the panel - an +accidental channel, measured and tracked as objectui#7708, not a contract to +author against. That is why the scope is spelled out here as well as in the +types. The table below is that shared chat surface. `chatbot-floating` declares seven more keys of its own - `displayMode` and six `floatingConfig` entries - @@ -160,7 +175,7 @@ one, under **`chatbot-floating` panel and trigger keys**. | `userAvatarFallback` | string | `'You'` | Fallback text for user avatar | | `assistantAvatarUrl` | string | - | URL for assistant avatar image | | `assistantAvatarFallback` | string | `'AI'` | Fallback text for assistant avatar | -| `maxHeight` | string | `'500px'` | **`chatbot` and `chatbot-enhanced` only.** Maximum height of the chat message container, as a CSS length. `chatbot-floating` does not read it: its panel is sized by `floatingConfig.panelHeight` (a **number** of pixels, default `520`), and the panel pins its inner chat to `maxHeight: '100%'` so it fills that panel - a `maxHeight` authored on a floating node would be overridden even if it were forwarded. Size a floating chatbot with `floatingConfig.panelHeight` instead | +| `maxHeight` | string | `'500px'` | **`chatbot` and `chatbot-enhanced` only** (declared on `ChatbotSchema` and `ChatbotEnhancedSchema`; `ChatbotFloatingSchema` does not declare it). Maximum height of the chat message container, as a CSS length. `chatbot-floating` does not read it: its panel is sized by `floatingConfig.panelHeight` (a **number** of pixels, default `520`), and the panel pins its inner chat to `maxHeight: '100%'` so it fills that panel - a `maxHeight` authored on a floating node would be overridden even if it were forwarded. Size a floating chatbot with `floatingConfig.panelHeight` instead | | `autoResponse` | boolean | `false` | Enable auto-response (demo mode, ignored when `api` is set) | | `autoResponseText` | string | - | Text for auto-response | | `autoResponseDelay` | number | `1000` | Delay before auto-response (ms) | @@ -174,8 +189,8 @@ one, under **`chatbot-floating` panel and trigger keys**. | `headers` | object | - | Additional headers for API requests | | `requestBody` | object | - | Additional body parameters sent with each API request. Authored on the node as `requestBody`; the renderer forwards it to the chat runtime under its own `body` option. Writing `body` on the node instead sets the base schema's children container and never reaches the API | | `maxToolRoundtrips` | number | - | **Deprecated - has no effect.** Nothing reads this value, so it never capped anything. Cap tool-calling loops on the agent instead (`planning.maxIterations`). Still accepted so existing documents keep parsing; slated for removal in a future major | -| `surface` | `'card' \| 'plain'` | `'card'` | **`chatbot-enhanced` only.** Controls whether the chat renders as a bordered panel (`'card'`) or a frameless full-page workspace (`'plain'`). The `chatbot` and `chatbot-floating` registrations render different components, which have no such chrome to switch, and do not read this key | -| `processVisibility` | `'hidden' \| 'summary' \| 'debug'` | `'summary'` | **`chatbot-enhanced` only.** Controls how much agent reasoning and tool detail is shown. `chatbot` renders the plain chat component, which has no agent-process display to configure at all - switch the node to `chatbot-enhanced` if you need one. `chatbot-floating` does not forward the key either, so its panel always renders at the `'summary'` default; there is no floating-side substitute to author | +| `surface` | `'card' \| 'plain'` | `'card'` | **`chatbot-enhanced` only** (declared on `ChatbotEnhancedSchema`). Controls whether the chat renders as a bordered panel (`'card'`) or a frameless full-page workspace (`'plain'`). `chatbot` renders the plain chat component, which has no such chrome to switch, and does not read this key. `chatbot-floating` has no named read for it and `ChatbotFloatingSchema` does not declare it; its panel is a `ChatbotEnhanced`, and an authored value currently reaches that panel only through the registration's unfiltered props spread (objectui#7708) - not a contract to author against | +| `processVisibility` | `'hidden' \| 'summary' \| 'debug'` | `'summary'` | **`chatbot-enhanced` only** (declared on `ChatbotEnhancedSchema`; `ChatbotSchema` still declares it too, though the `chatbot` registration has no read for it). Controls how much agent reasoning and tool detail is shown. `chatbot` renders the plain chat component, which has no agent-process display to configure at all - switch the node to `chatbot-enhanced` if you need one. `chatbot-floating` has no named read for it and `ChatbotFloatingSchema` does not declare it; an authored value currently reaches its panel only through the registration's unfiltered props spread (objectui#7708), which is not a contract - there is no floating-side substitute to author | | `onError` | function | - | Error callback for streaming/API errors | ### `chatbot-floating` panel and trigger keys @@ -184,13 +199,15 @@ The seven keys below are declared in the `chatbot-floating` registration's own `inputs` (`packages/plugin-chatbot/src/renderer.tsx`). They configure the floating action button and the panel it opens; the `chatbot` and `chatbot-enhanced` registrations render neither and ignore them. `displayMode` -and `floatingConfig` are declared on `ChatbotSchema` like every key above, so -authoring them on an inline node still type-checks and still parses - it is -dropped at render time. +and `floatingConfig` are declared on `ChatbotSchema` and on +`ChatbotFloatingSchema` alike (objectui#7655 declared the floating face with the +same two members; `ChatbotSchema` kept its own), so authoring them on an inline +node type-checks and parses - and is dropped at render time, because the +`chatbot` node never read either. | Property | Type | Default | Description | |----------|------|---------|-------------| -| `displayMode` | `'inline' \| 'floating'` | `'floating'` | **Declared and offered in the designer, but read by nothing.** The node's own `type` selects the presentation: a `chatbot-floating` node renders the trigger and panel unconditionally, and authoring `'inline'` here does not make it inline - author a `chatbot` node for that. The registration declares it with `defaultValue: 'floating'` and writes the same value into its `defaultProps`, so nodes created in the designer carry it | +| `displayMode` | `'inline' \| 'floating'` | `'floating'` | **Declared and offered in the designer, but read by nothing.** The node's own `type` selects the presentation: a `chatbot-floating` node renders the trigger and panel unconditionally, and authoring `'inline'` here does not make it inline - author a `chatbot` node for that. The registration declares it with `defaultValue: 'floating'` and writes the same value into its `defaultProps`, so nodes created in the designer carry it. objectui#7654 ruled it retired (maintainer, 2026-09-05): the declaration becomes a `never` tombstone and the designer control and default are removed in that card's own change; until that lands the key is carried exactly as described here | | `floatingConfig.position` | `'bottom-right' \| 'bottom-left'` | `'bottom-right'` | Corner the trigger sits in; the panel is anchored to the same side | | `floatingConfig.defaultOpen` | boolean | `false` | Whether the panel is already open when the node mounts | | `floatingConfig.panelWidth` | number | `400` | Panel width in pixels, applied from the `sm` breakpoint up - below it the panel is full-bleed. Snapped to a step, see below | @@ -211,25 +228,31 @@ On small screens `panelHeight` is additionally capped to the viewport (`min(step, 100svh - 6rem - safe-area-inset-bottom)`), and while the panel is fullscreen it ignores both size keys and fills the screen. -Authored on the node: +Authored on the node, with the node's own type - `ChatbotFloatingSchema` pins +`type` to `'chatbot-floating'` and declares `floatingConfig`, so this fence +compiles against the published types. (Until objectui#7655 no type could +annotate a floating node - `ChatbotSchema` pins `type` to `'chatbot'` - and +this example had to be untyped JSON.) -```json -{ - "type": "chatbot-floating", - "floatingConfig": { - "position": "bottom-left", - "defaultOpen": false, - "panelWidth": 400, - "panelHeight": 520, - "title": "Support", - "triggerSize": 56 +```tsx +import type { ChatbotFloatingSchema } from '@object-ui/types'; + +const supportChat: ChatbotFloatingSchema = { + type: 'chatbot-floating', + messages: [], // seed with your own ChatMessage values + floatingConfig: { + position: 'bottom-left', + defaultOpen: false, + panelWidth: 400, + panelHeight: 520, + title: 'Support', + triggerSize: 56, }, - "placeholder": "Ask us anything..." -} + placeholder: 'Ask us anything...', +}; ``` -`ChatbotSchema` pins `type` to `'chatbot'`, so it cannot annotate a floating -node, but the config object has its own exported type: +The config object also has its own exported type: ```tsx import type { FloatingChatbotConfig } from '@object-ui/types'; @@ -336,6 +359,22 @@ Use `surface="plain"` for full-page chat workspaces where the surrounding app already provides navigation chrome. The default `surface="card"` remains a better fit for embedded dashboards, side panels, and floating chat windows. +Both keys are authorable as metadata on a `chatbot-enhanced` node, typed with +that node's own face (objectui#7655): + +```tsx +import type { ChatbotEnhancedSchema } from '@object-ui/types'; + +const workspace: ChatbotEnhancedSchema = { + type: 'chatbot-enhanced', + messages: [], // seed with your own ChatMessage values + api: '/api/v1/ai/chat', + surface: 'plain', + processVisibility: 'debug', + enableFileUpload: true, +}; +``` + Console chat surfaces also keep a sanitized browser-side display cache for the current conversation. When a conversation can be reopened but the server returns no message rows, the UI restores user/assistant text and grouped tool names plus @@ -573,8 +612,13 @@ const schema: ChatbotSchema = { ## TypeScript Support +Each of the three registrations has its own authoring-face type: +`ChatbotSchema` (`chatbot`), `ChatbotEnhancedSchema` (`chatbot-enhanced`) and +`ChatbotFloatingSchema` (`chatbot-floating`) - see the typed examples under +**Tool Messages** and **`chatbot-floating` panel and trigger keys** above. + ```plaintext -import type { ChatbotSchema, ChatMessage, ChatToolInvocation } from '@object-ui/types' +import type { ChatbotSchema, ChatbotEnhancedSchema, ChatbotFloatingSchema, ChatMessage, ChatToolInvocation } from '@object-ui/types' import { useObjectChat } from '@object-ui/plugin-chatbot' // Basic messages diff --git a/packages/plugin-chatbot/src/__tests__/renderer.authoring-faces-7655.test.tsx b/packages/plugin-chatbot/src/__tests__/renderer.authoring-faces-7655.test.tsx new file mode 100644 index 0000000000..3145758e29 --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/renderer.authoring-faces-7655.test.tsx @@ -0,0 +1,261 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The `chatbot-enhanced` and `chatbot-floating` registrations type their + * `schema` against the PUBLISHED faces (objectui#7655) — and the one runtime + * consequence of doing so is pinned. + * + * ## The four things this file pins + * + * 1. **The registrations consume the published faces.** Read off the + * renderer's own source: each registration's parameter annotation names + * `ChatbotEnhancedSchema` / `ChatbotFloatingSchema`, and no anonymous + * `ChatbotSchema & { ... }` intersection is left in the file. A source pin + * rather than a type pin because `ComponentRegistry` is untyped — the + * registered component's props type is not recoverable from the registry. + * 2. **One vocabulary, two spellings, pinned equal.** `ChatbotEnhanced.tsx` + * owns `ChatbotSurface` and `ChatbotProcessVisibility` as component props; + * `@object-ui/types` declares the same unions on the face. Neither can + * widen without the other or this goes red at `tsc -p tsconfig.test.json` + * (compile-time — erased before vitest runs). + * 3. **`chatbot-floating` consumes the host's evaluated `disabled` verdict.** + * Before #7655 it wrote `disabled={schema.disabled}` and then spread + * `{...props}` AFTER it — and `SchemaRenderer` always includes + * `disabled: verdict || undefined` in those props, so the raw read was + * overridden on every render. Typing the face honestly (`disabled` stays + * `BaseSchema`'s `boolean | string`, objectui#7087) means the raw union + * cannot be forwarded into the panel's `boolean` prop, so the registration + * now names the verdict the way its two siblings do. The render cases + * below measure the OUTCOME through the real SDUI host — the composer is + * disabled when the node says so and enabled when it does not — which is + * what must not move. + * 4. **`chatbot-floating` has a second channel the named-read census cannot + * see — TRIPWIRE, not contract.** The registration ends its + * `FloatingChatbot` element with a raw `{...props}` spread, LAST, where + * its two siblings spread `toDomProps(props)` FIRST. So three keys + * `ChatbotFloatingSchema` does NOT declare — and the named-read census in + * `@object-ui/types`' `chatbot-registration-authoring-faces-7655.test.ts` + * correctly reads 0 for — still reach the panel's `ChatbotEnhanced`: + * `showAvatars`, `surface`, `processVisibility`. The cases below pin that + * MEASUREMENT (lit/dark pairs on a floating node, `chatbot-enhanced` as the + * control) so the face's docblock cannot rot silently. They do not make the + * channel a contract: fencing the spread like the siblings, or declaring + * the keys, is objectui#7708's ruling, and whichever lands flips these + * pins with it — deliberately, never silently. + * + * The runtime cases render through `SchemaRenderer`, not the bare component, + * for the reason `renderer.surface.test.tsx` gives: what is measured is what + * an AUTHOR gets. + */ + +import '@testing-library/jest-dom/vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { toDomProps } from '@object-ui/core'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import type { ChatbotEnhancedSchema } from '@object-ui/types'; +import type { ChatbotProcessVisibility, ChatbotSurface } from '../ChatbotEnhanced'; +// Side-effect import: this is what registers the chat components. +import '../renderer'; + +/* ── 2. One vocabulary, two spellings (the `tsc` channel) ────────────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +export type assertionSurfaceIsOneContract = Expect< + Equal> +>; +export type assertionProcessVisibilityIsOneContract = Expect< + Equal> +>; + +/* ── 1. The registrations consume the published faces ────────────────────── */ + +const RENDERER = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'renderer.tsx'), 'utf8'); + +/** The body of one `ComponentRegistry.register('', ...)` call, up to the next registration. */ +function registration(key: string): string { + const start = RENDERER.indexOf(`ComponentRegistry.register('${key}',`); + if (start < 0) throw new Error(`no registration for ${key}`); + const next = RENDERER.indexOf('ComponentRegistry.register(', start + 1); + return RENDERER.slice(start, next < 0 ? undefined : next); +} + +describe('the registrations type `schema` as the published faces (objectui#7655)', () => { + it("`chatbot-enhanced` names `ChatbotEnhancedSchema`; `chatbot-floating` names `ChatbotFloatingSchema`", () => { + expect(registration('chatbot-enhanced')).toContain('schema: ChatbotEnhancedSchema;'); + expect(registration('chatbot-floating')).toContain('schema: ChatbotFloatingSchema;'); + // Lit control: the `chatbot` registration still names its own face. + expect(registration('chatbot')).toContain('schema: ChatbotSchema;'); + }); + + it('no anonymous `ChatbotSchema & { ... }` intersection is left in the renderer', () => { + // Code only: strip line and block comments before counting, since the + // registrations' own comments recount the history in those exact words. + const code = RENDERER.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + expect(code.match(/ChatbotSchema\s*&\s*\{/g) ?? []).toEqual([]); + }); + + it('`chatbot-floating` consumes the host verdict, not the raw `schema.disabled`', () => { + const floating = registration('chatbot-floating').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + expect(floating).toContain('disabled: hostDisabled'); + expect(floating).toContain('disabled={hostDisabled}'); + expect(floating).not.toContain('schema.disabled'); + }); +}); + +/* ── 3. The `disabled` outcome, through the real host ────────────────────── */ + +const FAKE_ADAPTER = { + find: async () => [], + findOne: async () => null, + aggregate: async () => [], + count: async () => 0, + getObject: async () => null, +}; + +beforeAll(() => { + (globalThis as Record).ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} + }; + if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) { + (Element.prototype as unknown as { scrollIntoView: () => void }).scrollIntoView = () => {}; + } +}); + +afterEach(() => { + cleanup(); +}); + +/** Renders a `chatbot-floating` node with the panel open and returns its composer. */ +async function renderFloatingComposer(extra: Record): Promise { + render( + + + , + ); + // The panel mounts through a portal onto `document.body`, so query the body. + await waitFor(() => { + if (!document.body.querySelector('textarea')) { + throw new Error(`the floating panel never reached its composer. Body was:\n${document.body.innerHTML.slice(0, 600)}`); + } + }); + return document.body.querySelector('textarea') as HTMLTextAreaElement; +} + +describe('chatbot-floating: `disabled` is the host-evaluated verdict (objectui#7655)', () => { + it('an authored `disabled: true` disables the composer', async () => { + const composer = await renderFloatingComposer({ disabled: true }); + expect(composer).toBeDisabled(); + }); + + it('an UNAUTHORED `disabled` leaves the composer enabled — the absent case is unchanged', async () => { + const composer = await renderFloatingComposer({}); + expect(composer).toBeEnabled(); + }); + + it('an authored `disabled: false` leaves the composer enabled', async () => { + const composer = await renderFloatingComposer({ disabled: false }); + expect(composer).toBeEnabled(); + }); +}); + +/* ── 4. The raw spread is a second channel — TRIPWIRE for objectui#7708 ── */ + +/** An assistant turn with a tool result: `processVisibility: 'debug'` shows the raw tool name; `'summary'` (the default) does not. */ +const ASSISTANT_WITH_TOOL = [ + { + id: 'a1', + role: 'assistant', + content: 'seed reply', + toolInvocations: [{ toolCallId: 't1', toolName: 'search_records', state: 'result', args: { q: 'x' }, result: { rows: 3 } }], + }, +]; + +/** Renders one node through the real host and returns the root to query — `document.body` (the floating panel portals there). */ +async function renderNode(kind: 'chatbot-floating' | 'chatbot-enhanced', extra: Record): Promise { + render( + + + , + ); + await waitFor(() => { + if (!document.body.querySelector('textarea')) { + throw new Error(`the ${kind} node never reached its composer. Body was:\n${document.body.innerHTML.slice(0, 600)}`); + } + }); + return document.body; +} + +/** The per-message avatar `MessageAvatar` renders only when `showAvatars` is on. */ +const AVATAR = 'div.size-7.rounded-full[aria-hidden="true"]'; + +describe('chatbot-floating: three undeclared keys are LIVE through the raw spread — tripwire for objectui#7708', () => { + it('the spread really is the difference: `showAvatars` survives `props` and not `toDomProps(props)`', () => { + // The mechanism, pinned on the whitelist itself so the render readings + // below have a stated cause and not just a correlation. + const props = { showAvatars: true, surface: 'plain', processVisibility: 'debug', className: 'x' }; + expect(toDomProps(props)).not.toHaveProperty('showAvatars'); + expect(toDomProps(props)).not.toHaveProperty('surface'); + expect(toDomProps(props)).not.toHaveProperty('processVisibility'); + expect(toDomProps(props)).toHaveProperty('className', 'x'); // lit control + }); + + it('`showAvatars: true` renders the message avatar on a floating node (lit 1 / dark 0); dark on `chatbot-enhanced` either way', async () => { + expect((await renderNode('chatbot-floating', { showAvatars: true })).querySelectorAll(AVATAR)).toHaveLength(1); + cleanup(); + expect((await renderNode('chatbot-floating', {})).querySelectorAll(AVATAR)).toHaveLength(0); + cleanup(); + expect((await renderNode('chatbot-enhanced', { showAvatars: true })).querySelectorAll(AVATAR)).toHaveLength(0); + }); + + it("`surface: 'plain'` reaches the floating panel (two `.max-w-2xl` wrappers lit / 0 dark) — `chatbot-enhanced` reads it by name, so it lights there too", async () => { + expect((await renderNode('chatbot-floating', { surface: 'plain' })).querySelectorAll('.max-w-2xl')).toHaveLength(2); + cleanup(); + expect((await renderNode('chatbot-floating', {})).querySelectorAll('.max-w-2xl')).toHaveLength(0); + cleanup(); + expect((await renderNode('chatbot-enhanced', { surface: 'plain' })).querySelectorAll('.max-w-2xl')).toHaveLength(2); + }); + + it("`processVisibility: 'debug'` reaches the floating panel (raw tool name shown / hidden at the default) — named read lights `chatbot-enhanced` the same way", async () => { + expect((await renderNode('chatbot-floating', { processVisibility: 'debug' })).textContent).toContain('search_records'); + cleanup(); + expect((await renderNode('chatbot-floating', {})).textContent).not.toContain('search_records'); + cleanup(); + expect((await renderNode('chatbot-enhanced', { processVisibility: 'debug' })).textContent).toContain('search_records'); + cleanup(); + expect((await renderNode('chatbot-enhanced', {})).textContent).not.toContain('search_records'); + }); +}); diff --git a/packages/plugin-chatbot/src/renderer.tsx b/packages/plugin-chatbot/src/renderer.tsx index 16ca319867..3dbd2634c5 100644 --- a/packages/plugin-chatbot/src/renderer.tsx +++ b/packages/plugin-chatbot/src/renderer.tsx @@ -8,13 +8,11 @@ import { useMemo } from 'react'; import { ComponentRegistry, toDomProps } from '@object-ui/core'; -import type { ChatbotSchema } from '@object-ui/types'; +import type { ChatbotSchema, ChatbotEnhancedSchema, ChatbotFloatingSchema } from '@object-ui/types'; import { Chatbot } from './index'; import { ChatbotEnhanced } from './ChatbotEnhanced'; -import type { ChatbotSurface } from './ChatbotEnhanced'; import { FloatingChatbot } from './FloatingChatbot'; import { useObjectChat } from './useObjectChat'; -import type { ObjectChatMessage } from './useObjectChat'; import { toRuntimeMessages } from './chatMessageAdapter'; /** @@ -269,33 +267,19 @@ ComponentRegistry.register('chatbot', // Register Enhanced Chatbot ComponentRegistry.register('chatbot-enhanced', - ({ schema, className, disabled: hostDisabled, ...props }: { schema: ChatbotSchema & { - enableMarkdown?: boolean; - enableFileUpload?: boolean; - /** - * Visual chrome for the chat surface (objectui#6687, maintainer ruling - * 2026-08-29). `card` keeps the embeddable bordered panel; `plain` removes - * the panel chrome for a full-page chat workspace. Declared here, on the - * registration that actually renders ``, because that is - * the only one with a `surface` prop to forward it to — `chatbot` and - * `chatbot-floating` render different components and do not gain the key. - * Typed by importing `ChatbotSurface` rather than re-spelling the union: - * one contract, not two dialects (AGENTS.md #0.1). - */ - surface?: ChatbotSurface; - showTimestamp?: boolean; - disabled?: boolean; - userAvatarUrl?: string; - userAvatarFallback?: string; - assistantAvatarUrl?: string; - assistantAvatarFallback?: string; - maxHeight?: string; - autoResponse?: boolean; - autoResponseText?: string; - autoResponseDelay?: number; - onSend?: (content: string, messages: ObjectChatMessage[]) => void; - onClear?: () => void; - }; className?: string; disabled?: boolean; [key: string]: any }) => { + // `schema` is the published authoring face of THIS registration + // (objectui#7655, the #6169 / #6172 family ruling: every component node has + // exactly one named, importable authoring-face type). It used to be an + // anonymous `ChatbotSchema & { ... }` intersection local to this file; + // every key that intersection carried was read-site-censused before being + // declared on `ChatbotEnhancedSchema`, and the two `ChatbotSchema` keys this + // registration never read (`displayMode`, `floatingConfig`) are not on it. + // `surface` (objectui#6687, maintainer ruling 2026-08-29) is declared there + // too; the plugin's own `ChatbotSurface` alias is pinned equal to it in + // `__tests__`, so the union has one contract, not two dialects + // (AGENTS.md #0.1). `disabled` is the host-EVALUATED verdict, as on + // `chatbot` above. + ({ schema, className, disabled: hostDisabled, ...props }: { schema: ChatbotEnhancedSchema; className?: string; disabled?: boolean; [key: string]: any }) => { const { messages, isLoading, @@ -419,21 +403,21 @@ ComponentRegistry.register('chatbot-enhanced', // Register Floating Chatbot (FAB widget) ComponentRegistry.register('chatbot-floating', - ({ schema, className, ...props }: { schema: ChatbotSchema & { - enableMarkdown?: boolean; - enableFileUpload?: boolean; - showTimestamp?: boolean; - disabled?: boolean; - userAvatarUrl?: string; - userAvatarFallback?: string; - assistantAvatarUrl?: string; - assistantAvatarFallback?: string; - autoResponse?: boolean; - autoResponseText?: string; - autoResponseDelay?: number; - onSend?: (content: string, messages: ObjectChatMessage[]) => void; - onClear?: () => void; - }; className?: string; [key: string]: any }) => { + // `schema` is the published authoring face of THIS registration + // (objectui#7655) — see the `chatbot-enhanced` note above. `disabled` is + // the host-EVALUATED verdict `SchemaRenderer` forwards for every node type, + // destructured under the name its two sibling registrations use. The raw + // `schema.disabled` read that used to sit on the `disabled` prop below was + // already dead: `SchemaRenderer` spreads `disabled: verdict || undefined` + // LAST into the props it hands a registration, and `{...props}` below is + // spread after that prop, so the verdict overrode the raw value on every + // render. Naming it changes no outcome; it keeps one carrier for one + // question (AGENTS.md #0.1) and lets the published face inherit + // `BaseSchema.disabled` (`boolean | string`) unnarrowed (objectui#7087) — + // a raw forward of that union into the panel's `boolean` prop would not + // type-check, and narrowing the face to make it fit is the shape #7087 + // retired. + ({ schema, className, disabled: hostDisabled, ...props }: { schema: ChatbotFloatingSchema; className?: string; disabled?: boolean; [key: string]: any }) => { const { messages, isLoading, @@ -482,7 +466,8 @@ ComponentRegistry.register('chatbot-floating', onClear={handleClear} onStop={isApiMode && isLoading ? stop : undefined} onReload={isApiMode ? reload : undefined} - disabled={schema.disabled} + // The evaluated verdict — see the head of this registration. + disabled={hostDisabled} isLoading={isLoading} error={error} showTimestamp={schema.showTimestamp} @@ -493,6 +478,15 @@ ComponentRegistry.register('chatbot-floating', enableMarkdown={schema.enableMarkdown ?? true} enableFileUpload={schema.enableFileUpload ?? false} className={className} + // ⚠️ Raw and LAST — the two sibling registrations spread + // `toDomProps(props)` FIRST. Every authored key `SchemaRenderer` + // forwards reaches the panel's `ChatbotEnhanced` unfiltered (so + // `processVisibility`, `surface` and `showAvatars` are live here + // although `ChatbotFloatingSchema` declares none of them), and the + // authored `messages` seed overrides the runtime `messages` prop + // written above. Measured through the real host and carded as objectui#7708 + // — fence vs declare is that card's ruling. Deliberately NOT changed + // by objectui#7655, which declared faces and moved no render outcome. {...props} /> ); diff --git a/packages/types/src/__tests__/chatbot-registration-authoring-faces-7655.test.ts b/packages/types/src/__tests__/chatbot-registration-authoring-faces-7655.test.ts new file mode 100644 index 0000000000..3448d2205b --- /dev/null +++ b/packages/types/src/__tests__/chatbot-registration-authoring-faces-7655.test.ts @@ -0,0 +1,459 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * One named, importable authoring-face type per `plugin-chatbot` registration + * (objectui#7655, under the objectui#6169 / #6172 family ruling: every + * component node has exactly one named, importable authoring-face type). + * + * ## What was wrong + * + * `packages/plugin-chatbot/src/renderer.tsx` registers three components — + * `chatbot`, `chatbot-enhanced`, `chatbot-floating` — and `@object-ui/types` + * published ONE face for the family, `ChatbotSchema`, with `type` pinned to + * `'chatbot'`. An author annotating a `chatbot-floating` node either dropped to + * untyped JSON or annotated with `ChatbotSchema` and lied about `type`; the + * docs' floating example had to be a `json` fence because no `tsx` fence could + * compile. The two registrations' real key sets lived in anonymous + * `ChatbotSchema & { ... }` intersections local to the renderer. + * + * ## The shape, and why not the smaller diff + * + * One interface per registration — `ChatbotEnhancedSchema`, + * `ChatbotFloatingSchema` — not `ChatbotSchema['type']` widened to the union + * of the three keys. The union would give three nodes ONE type and re-open what + * #6169 closed: a single interface declaring keys only some of its own `type` + * values read. Each new face declares what ITS registration reads, censused per + * key on the PR's base with lit controls, and the twenty keys all three read + * are picked off `ChatbotSchema` by name (`ChatbotSharedKey`) so they stay one + * declaration. + * + * ## Two channels, stated so nobody reads the wrong one + * + * The `export type assertion…` blocks below are COMPILE-TIME: they are checked + * by `tsc -p tsconfig.test.json` (this package's `type-check` script chains it) + * and are erased before vitest runs. A green vitest run says nothing about + * them — the same instrument split `zod-mirror-parity.test.ts` documents, and + * the same one `chatbot-authoring-face-keys.test.ts` (objectui#6169) uses for + * its `@ts-expect-error` pins. The `it` blocks are the RUNTIME channel: the Zod + * twins' accept sets. + * + * ## `displayMode` and `floatingConfig` live on BOTH faces; neither is decided here + * + * `ChatbotSchema` keeps both members exactly as it had them (declarations + * verbatim), and `ChatbotFloatingSchema` declares the same two — the designer + * control and the `defaultProps` seed for `displayMode` are the + * `chatbot-floating` registration's. `displayMode` is RULED RETIRED: + * objectui#7654, maintainer ruling B (2026-09-05) — `?: never` tombstone on + * `ChatbotSchema`, control and seed removed — and that retirement executes in + * #7654's own PR, which must find the member on both faces as ruled. So this + * card carries the key untouched, and the runtime pin below asserts it STILL + * parses green with any value, as a tripwire: that PR flips the pin + * deliberately, with the ruling in hand, rather than have it change under it. + * + * ## The census counts NAMED reads; the floating registration has a second channel + * + * `assertionFloatingDeclaresWhatItReads` pins the named-read instrument — + * `schema.KEY` inside the `chatbot-floating` registration body — and nothing + * else. That registration also ends its `FloatingChatbot` element with a raw + * `{...props}` spread, so every authored key `SchemaRenderer` forwards reaches + * the panel's `ChatbotEnhanced` unfiltered: `processVisibility`, `surface` and + * `showAvatars` are LIVE on a `chatbot-floating` node today (measured through + * the real host with lit/dark pairs, `chatbot-enhanced`'s `toDomProps`-filtered + * spread as the control — pinned as a tripwire in `plugin-chatbot`'s + * `renderer.authoring-faces-7655.test.tsx`). The face does not declare them: + * that channel is accidental and is carded for a declare-vs-fence ruling + * (objectui#7708). + */ + +import { describe, it, expect } from 'vitest'; +import type { BaseSchema } from '../base'; +import type { + ChatMessage, + ChatbotEnhancedSchema, + ChatbotFloatingSchema, + ChatbotSchema, + ChatbotSharedKey, + FloatingChatbotConfig, +} from '../complex'; +import { + ChatbotEnhancedSchema as ChatbotEnhancedZod, + ChatbotFloatingSchema as ChatbotFloatingZod, + ChatbotSchema as ChatbotZod, + ComplexSchema as ComplexZod, +} from '../zod/complex.zod'; + +/* ── Type-level helpers (the `tsc` channel) ──────────────────────────────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** Declared keys, read past `BaseSchema`'s `[key: string]: any` index signature. */ +type WithoutIndexSignature = { + [K in keyof D as string extends K ? never : number extends K ? never : K]: D[K]; +}; +type DeclaredKeys = Extract, string>; +/** + * Every face's declared keys are `BaseSchema`'s plus exactly its own census, so + * each pin below is spelled `BaseKeys | `. A union rather than a + * subtraction on purpose: `placeholder` is declared on the base AND redeclared + * on `ChatbotSchema` (hence picked onto both new faces), and a subtraction + * would silently drop it from the census it belongs to — measured, not + * assumed, with a compiler probe before this file was written. + */ +type BaseKeys = DeclaredKeys; + +/** The twenty keys every registration reads, spelled out so the alias is pinned to a list and not to itself. */ +type SharedKeys = + | 'messages' | 'placeholder' | 'api' | 'conversationId' | 'systemPrompt' | 'model' + | 'streamingEnabled' | 'headers' | 'requestBody' | 'maxToolRoundtrips' | 'onError' + | 'showTimestamp' | 'userAvatarUrl' | 'userAvatarFallback' | 'assistantAvatarUrl' + | 'assistantAvatarFallback' | 'autoResponse' | 'autoResponseText' | 'autoResponseDelay' + | 'onSend'; + +/* ── The discriminants: one node, one type ───────────────────────────────── */ + +export type assertionOneDiscriminantPerFace = [ + Expect>, + Expect>, + Expect>, +]; + +/* ── The census, as EXACT key sets (both directions) ─────────────────────── */ + +export type assertionSharedKeyAliasIsTheCensus = Expect>; + +/** `chatbot-enhanced`: the shared twenty + `maxHeight`, `processVisibility`, and its four own keys. */ +export type assertionEnhancedDeclaresWhatItReads = Expect< + Equal< + DeclaredKeys, + BaseKeys | SharedKeys | 'maxHeight' | 'processVisibility' | 'enableMarkdown' | 'enableFileUpload' | 'surface' | 'onClear' + > +>; + +/** + * `chatbot-floating`, NAMED reads only: the shared twenty + its three own keys + + * the two keys it declares alongside `ChatbotSchema`. NO `maxHeight`, + * `processVisibility` or `surface` — no named read; the raw-spread channel is NOT + * what this pin measures (see the header). + */ +export type assertionFloatingDeclaresWhatItReads = Expect< + Equal< + DeclaredKeys, + BaseKeys | SharedKeys | 'enableMarkdown' | 'enableFileUpload' | 'onClear' | 'displayMode' | 'floatingConfig' + > +>; + +/** + * `chatbot` keeps its WHOLE face — the six legacy keys (`loading` … `height`), + * the `onSendMessage` tombstone, and `displayMode` / `floatingConfig` — exactly + * where they were. This card declared faces; it retired and moved nothing. + */ +export type assertionChatbotKeepsItsWholeFace = Expect< + Equal< + DeclaredKeys, + | BaseKeys | SharedKeys | 'loading' | 'onSendMessage' | 'showAvatars' | 'userAvatar' | 'assistantAvatar' + | 'markdown' | 'processVisibility' | 'height' | 'maxHeight' | 'displayMode' | 'floatingConfig' + > +>; + +/** + * The two floating keys stay TYPED on `ChatbotSchema`. Read off the member, + * not the key set: a member that fell off the declaration would not go missing + * here — it would read as `any` through `BaseSchema`'s index signature, wrong + * values would compile, and the objectui#7669 `triggerIcon` tombstone would lose + * its reach on `chatbot` nodes (all three measured on #7655's first cut, which + * moved the keys). `Equal` is what catches the `any`. + */ +export type assertionFloatingKeysStayTypedOnChatbot = [ + Expect>, + Expect>, +]; + +/* ── One declaration per shared key: a pick, not a copy ──────────────────── */ + +export type assertionSharedKeysAreOneDeclaration = [ + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, +]; + +/* ── `displayMode` / `floatingConfig`: one type, both faces ─────────────── */ + +export type assertionFloatingKeysHaveOneTypeOnBothFaces = [ + Expect>, + Expect>, + Expect>, + Expect>, +]; + +/* ── `surface` has one vocabulary; `disabled` stays the inherited union ──── */ + +export type assertionSurfaceVocabulary = Expect< + Equal +>; + +/** + * Neither face redeclares `disabled` (objectui#6169, #7087): the host evaluates + * it for every node type, and a `boolean` redeclaration would narrow away the + * expression-string half. `visible` beside it is the twin control, so a face + * that dropped both keys cannot pass vacuously. + */ +export type assertionDisabledStaysTheBaseUnion = [ + Expect>, + Expect>, + Expect>, + Expect>, +]; + +/* ── Runtime: the TypeScript face accepts what it declares, refuses the lie ─ */ + +const baseMessages: ChatMessage[] = [{ id: '1', role: 'user', content: 'hi' }]; + +describe('the two faces annotate the nodes their registrations render (objectui#7655)', () => { + it('a fully authored `chatbot-enhanced` node type-checks against `ChatbotEnhancedSchema`', () => { + const node: ChatbotEnhancedSchema = { + type: 'chatbot-enhanced', + messages: baseMessages, + api: '/api/v1/ai/chat', + requestBody: { tenant: 'acme' }, + maxHeight: '600px', + processVisibility: 'debug', + enableMarkdown: true, + enableFileUpload: true, + surface: 'plain', + onClear: () => undefined, + onSend: (content, messages) => { + expect(typeof content).toBe('string'); + expect(Array.isArray(messages)).toBe(true); + }, + }; + expect(node.type).toBe('chatbot-enhanced'); + expect(node.surface).toBe('plain'); + node.onSend?.('hello', baseMessages); + }); + + it('a fully authored `chatbot-floating` node type-checks against `ChatbotFloatingSchema`', () => { + const node: ChatbotFloatingSchema = { + type: 'chatbot-floating', + messages: baseMessages, + floatingConfig: { position: 'bottom-left', defaultOpen: true, panelHeight: 520, title: 'Support' }, + displayMode: 'floating', + enableMarkdown: false, + onClear: () => undefined, + }; + expect(node.floatingConfig?.title).toBe('Support'); + }); + + it('the lie the card was filed on is now a `tsc` error: `ChatbotSchema` cannot annotate the other two nodes, and vice versa', () => { + const wrongOnChatbot: ChatbotSchema = { + // @ts-expect-error `ChatbotSchema` pins `type` to `'chatbot'` — annotate with `ChatbotFloatingSchema` + type: 'chatbot-floating', + messages: baseMessages, + }; + const wrongOnEnhanced: ChatbotEnhancedSchema = { + // @ts-expect-error `ChatbotEnhancedSchema` pins `type` to `'chatbot-enhanced'` + type: 'chatbot', + messages: baseMessages, + }; + const wrongOnFloating: ChatbotFloatingSchema = { + // @ts-expect-error `ChatbotFloatingSchema` pins `type` to `'chatbot-floating'` + type: 'chatbot-enhanced', + messages: baseMessages, + }; + expect([wrongOnChatbot, wrongOnEnhanced, wrongOnFloating]).toHaveLength(3); + }); + + it('a wrong-typed value on a DECLARED key is refused — the field is no longer `any` through the index signature', () => { + const enhanced: ChatbotEnhancedSchema = { + type: 'chatbot-enhanced', + messages: baseMessages, + // @ts-expect-error `enableFileUpload` is declared `boolean` + enableFileUpload: 'yes', + }; + const floating: ChatbotFloatingSchema = { + type: 'chatbot-floating', + messages: baseMessages, + // @ts-expect-error `panelHeight` is a number of pixels, not a CSS length + floatingConfig: { panelHeight: '520px' }, + }; + // …and on `ChatbotSchema` too, which still declares both keys: a wrong + // `displayMode` is refused there, not swallowed as `any`. + const chatbot: ChatbotSchema = { + type: 'chatbot', + messages: baseMessages, + // @ts-expect-error `displayMode` is the typed union on `ChatbotSchema`, unchanged + displayMode: 'bogus', + }; + expect(enhanced.type).toBe('chatbot-enhanced'); + expect(floating.type).toBe('chatbot-floating'); + expect(chatbot.type).toBe('chatbot'); + }); +}); + +/* ── Runtime: the Zod twins, in lockstep with the declarations ───────────── */ + +describe('`ChatbotEnhancedSchema` (zod) validates what the face declares', () => { + const node = { + type: 'chatbot-enhanced', + messages: [{ id: '1', role: 'user', content: 'hi' }], + }; + + it('parses a fully authored node green', () => { + const result = ChatbotEnhancedZod.safeParse({ + ...node, + api: '/api/v1/ai/chat', + requestBody: { tenant: 'acme' }, + maxHeight: '600px', + processVisibility: 'summary', + enableMarkdown: true, + enableFileUpload: false, + surface: 'plain', + }); + expect(result.success).toBe(true); + }); + + it('refuses the other two discriminants — the twin is for THIS node', () => { + for (const type of ['chatbot', 'chatbot-floating']) { + const result = ChatbotEnhancedZod.safeParse({ ...node, type }); + expect(result.success, type).toBe(false); + expect(result.error?.issues.some((i) => i.path.join('.') === 'type'), type).toBe(true); + } + }); + + it("refuses a `surface` outside 'card' | 'plain', and a non-boolean `enableMarkdown` — mirrored, not passed through", () => { + const surface = ChatbotEnhancedZod.safeParse({ ...node, surface: 'frameless' }); + expect(surface.success).toBe(false); + expect(surface.error?.issues.some((i) => i.path.join('.') === 'surface')).toBe(true); + + const markdown = ChatbotEnhancedZod.safeParse({ ...node, enableMarkdown: 'yes' }); + expect(markdown.success).toBe(false); + expect( + markdown.error?.issues.some((i) => i.path.join('.') === 'enableMarkdown' && i.code === 'invalid_type'), + ).toBe(true); + }); + + it('mirrors `requestBody` under the key the renderer reads, and refuses a non-object', () => { + // `ChatbotSchema`'s twin mirrors this under `body`, colliding with the base + // children slot; the two new twins do not copy that collision. + expect(ChatbotEnhancedZod.shape.requestBody).toBeDefined(); + const result = ChatbotEnhancedZod.safeParse({ ...node, requestBody: 'tenant=acme' }); + expect(result.success).toBe(false); + expect(result.error?.issues.some((i) => i.path.join('.') === 'requestBody')).toBe(true); + }); + + it('`onClear` is a RUNTIME SLOT the twin refuses by name (objectui#6124), like `onError` and `onSend`', () => { + for (const key of ['onClear', 'onError', 'onSend']) { + const result = ChatbotEnhancedZod.safeParse({ ...node, [key]: () => undefined }); + expect(result.success, key).toBe(false); + const issue = result.error?.issues.find((i) => String(i.path[0]) === key); + expect(issue?.code, key).toBe('custom'); + expect(issue?.message, key).toContain(`\`${key}\` is a RUNTIME SLOT`); + } + }); +}); + +describe('`ChatbotFloatingSchema` (zod) validates what the face declares, and leaves the two shared floating keys unmirrored', () => { + const node = { + type: 'chatbot-floating', + messages: [{ id: '1', role: 'user', content: 'hi' }], + }; + + it('parses a fully authored floating node green', () => { + const result = ChatbotFloatingZod.safeParse({ + ...node, + floatingConfig: { position: 'bottom-left', defaultOpen: true, panelWidth: 400, panelHeight: 520, title: 'Support', triggerSize: 56 }, + displayMode: 'floating', + enableMarkdown: true, + enableFileUpload: true, + requestBody: { tenant: 'acme' }, + }); + expect(result.success).toBe(true); + }); + + it('refuses the other two discriminants', () => { + for (const type of ['chatbot', 'chatbot-enhanced']) { + const result = ChatbotFloatingZod.safeParse({ ...node, type }); + expect(result.success, type).toBe(false); + } + }); + + it('TRIPWIRE — `displayMode` is unmirrored here as on `ChatbotSchema`: any value still parses green (objectui#7654 ruled it retired; its own PR flips this)', () => { + // Declared on the face with the same `'inline' | 'floating'` type + // `ChatbotSchema` carries, deliberately NOT given a mirror arm: it is + // declared-but-unmirrored on both faces. A value the declaration would + // refuse rides through `.passthrough()` here, as it does on `ChatbotSchema`'s + // twin. objectui#7654 RULED the key retired (maintainer ruling B, + // 2026-09-05); that card's own PR lands the tombstone and flips this pin + // with the ruling in hand — if it goes red any other way, someone mirrored, + // retired or wired the key silently. + expect((ChatbotFloatingZod.shape as Record).displayMode).toBeUndefined(); + expect(ChatbotFloatingZod.safeParse({ ...node, displayMode: 'anything-at-all' }).success).toBe(true); + // Lit control on the same instrument: a key the twin DOES declare is in its + // shape and DOES refuse a wrong value, so "undefined" above is a reading. + expect(ChatbotFloatingZod.shape.enableMarkdown).toBeDefined(); + expect(ChatbotFloatingZod.safeParse({ ...node, enableMarkdown: 'anything-at-all' }).success).toBe(false); + }); + + it('`floatingConfig` has no mirror here either — the objectui#6152 axis is not widened into', () => { + expect((ChatbotFloatingZod.shape as Record).floatingConfig).toBeUndefined(); + // Rides through unvalidated, wrong shape and all — byte for byte the + // outcome on `ChatbotSchema`'s twin, which has no arm for it either. + expect(ChatbotFloatingZod.safeParse({ ...node, floatingConfig: { panelHeight: '520px' } }).success).toBe(true); + }); + + it('`onClear` / `onError` / `onSend` are refused by name here too', () => { + for (const key of ['onClear', 'onError', 'onSend']) { + const result = ChatbotFloatingZod.safeParse({ ...node, [key]: () => undefined }); + expect(result.success, key).toBe(false); + expect(result.error?.issues.find((i) => String(i.path[0]) === key)?.code, key).toBe('custom'); + } + }); +}); + +describe('the census is structural: picked off `ChatbotSchema`, never copied', () => { + const shared: readonly ChatbotSharedKey[] = [ + 'messages', 'placeholder', 'api', 'conversationId', 'systemPrompt', 'model', 'streamingEnabled', + 'headers', 'maxToolRoundtrips', 'onError', 'showTimestamp', 'userAvatarUrl', 'userAvatarFallback', + 'assistantAvatarUrl', 'assistantAvatarFallback', 'autoResponse', 'autoResponseText', + 'autoResponseDelay', 'onSend', + ]; + + it('every shared arm on the two twins carries `ChatbotSchema`\'s own description — one spelling', () => { + const describe_ = (shape: Record, key: string) => shape[key]?.description; + for (const key of shared) { + expect(describe_(ChatbotEnhancedZod.shape, key), key).toBe(describe_(ChatbotZod.shape, key)); + expect(describe_(ChatbotFloatingZod.shape, key), key).toBe(describe_(ChatbotZod.shape, key)); + expect(describe_(ChatbotZod.shape, key), key).toBeDefined(); + } + }); + + it('the keys a registration never reads are NOT on its twin — with `ChatbotSchema` as the lit control', () => { + const enhanced = ChatbotEnhancedZod.shape as Record; + const floating = ChatbotFloatingZod.shape as Record; + const chatbot = ChatbotZod.shape as Record; + for (const legacy of ['loading', 'showAvatars', 'userAvatar', 'assistantAvatar', 'markdown', 'height', 'onSendMessage']) { + expect(enhanced[legacy], legacy).toBeUndefined(); + expect(floating[legacy], legacy).toBeUndefined(); + expect(chatbot[legacy], legacy).toBeDefined(); + } + for (const enhancedOnly of ['maxHeight', 'processVisibility', 'surface']) { + expect(enhanced[enhancedOnly], enhancedOnly).toBeDefined(); + expect(floating[enhancedOnly], enhancedOnly).toBeUndefined(); + } + }); + + it('`ComplexSchema` routes each discriminant to its own arm', () => { + const messages = [{ id: '1', role: 'user', content: 'hi' }]; + expect(ComplexZod.safeParse({ type: 'chatbot-floating', messages }).success).toBe(true); + expect(ComplexZod.safeParse({ type: 'chatbot-enhanced', messages }).success).toBe(true); + // Routed to the ENHANCED arm — a `surface` refusal can only come from there. + const routed = ComplexZod.safeParse({ type: 'chatbot-enhanced', messages, surface: 'frameless' }); + expect(routed.success).toBe(false); + expect(routed.error?.issues.some((i) => i.path.join('.') === 'surface')).toBe(true); + }); +}); diff --git a/packages/types/src/__tests__/floating-chatbot-trigger-icon-retired.test.ts b/packages/types/src/__tests__/floating-chatbot-trigger-icon-retired.test.ts index 59279daebc..369e41b52c 100644 --- a/packages/types/src/__tests__/floating-chatbot-trigger-icon-retired.test.ts +++ b/packages/types/src/__tests__/floating-chatbot-trigger-icon-retired.test.ts @@ -52,8 +52,12 @@ */ import { describe, it, expect } from 'vitest'; -import type { FloatingChatbotConfig } from '../complex'; -import { ChatbotSchema } from '../zod/complex.zod'; +import type { + ChatbotFloatingSchema as TsChatbotFloatingSchema, + ChatbotSchema as TsChatbotSchema, + FloatingChatbotConfig, +} from '../complex'; +import { ChatbotFloatingSchema, ChatbotSchema } from '../zod/complex.zod'; /* ── type-level pins: the `tsc` channel ──────────────────────────────────── */ @@ -89,6 +93,30 @@ describe('the `triggerIcon` tombstone makes authoring a `tsc` error', () => { expect(config.triggerSize).toBe(56); }); + it('reaches a `chatbot` node AND a `chatbot-floating` node through their faces — the tombstone bites wherever `floatingConfig` is declared', () => { + // Both faces declare `floatingConfig` (`ChatbotSchema` always did; + // objectui#7655 declared it on `ChatbotFloatingSchema` too). The pins above + // sit on `FloatingChatbotConfig` directly, so a face that LOST the member + // would not turn them red — the member would read as `any` through + // `BaseSchema`'s index signature and this literal would compile clean. That + // is exactly what #7655's contract review measured on its first cut, which + // moved the key off `ChatbotSchema`; pinned on the nodes since. + const onChatbot: TsChatbotSchema = { + type: 'chatbot', + messages: [], + // @ts-expect-error `triggerIcon` is a retirement tombstone (objectui#7654), reached through `ChatbotSchema.floatingConfig` + floatingConfig: { title: 'Chat', triggerIcon: 'Sparkles' }, + }; + const onFloating: TsChatbotFloatingSchema = { + type: 'chatbot-floating', + messages: [], + // @ts-expect-error `triggerIcon` is a retirement tombstone (objectui#7654), reached through `ChatbotFloatingSchema.floatingConfig` + floatingConfig: { title: 'Chat', triggerIcon: 'Sparkles' }, + }; + expect(onChatbot.type).toBe('chatbot'); + expect(onFloating.type).toBe('chatbot-floating'); + }); + it('a key the interface never declared still rides the widened path — the DELETED row', () => { // This carries NO directive on purpose. It is the measured contrast that // justifies `?: never` over deletion: an undeclared key IS refused in a @@ -102,26 +130,34 @@ describe('the `triggerIcon` tombstone makes authoring a `tsc` error', () => { /* ── the runtime channel: DELIBERATELY unchanged, and a tripwire if that ends ─ */ -describe('there is NO zod refusal, and that is deliberate (objectui#7654)', () => { +// Both faces declare `floatingConfig` — `ChatbotSchema` always did, and +// objectui#7655 declared it on `ChatbotFloatingSchema`, the face of the one +// registration that reads it — and NEITHER twin has an arm for it, so the +// tripwire parses both nodes, each through its own twin. +describe.each([ + ['chatbot', ChatbotSchema], + ['chatbot-floating', ChatbotFloatingSchema], +] as const)('there is NO zod refusal on a `%s` node, and that is deliberate (objectui#7654)', (type, twin) => { const node = { - type: 'chatbot' as const, + type, messages: [{ id: 'm1', role: 'user' as const, content: 'hi' }], }; - it('a chatbot node carrying `floatingConfig.triggerIcon` still parses GREEN', () => { + it(`a ${type} node carrying \`floatingConfig.triggerIcon\` still parses GREEN`, () => { // `FloatingChatbotConfig` has NO zod mirror: `floatingConfig` sits in the - // `UnmirroredDeclared` ledger (`zod-mirror-parity.test.ts`, - // `complex.zod.ts#ChatbotSchema`), and `BaseSchema` is `.passthrough()`, so - // the whole object rides through unvalidated. This was green before the - // tombstone and is green after it — the retirement changed the TypeScript - // face only, and this pins that it changed no parse outcome. + // `UnmirroredDeclared` ledger (`zod-mirror-parity.test.ts`, under both + // `complex.zod.ts#ChatbotSchema` and, since objectui#7655, + // `complex.zod.ts#ChatbotFloatingSchema`), and `BaseSchema` is + // `.passthrough()`, so the whole object rides through unvalidated. This was + // green before the tombstone and is green after it — the retirement changed + // the TypeScript face only, and this pins that it changed no parse outcome. // // ⚠️ TRIPWIRE: if objectui#6152 ever mints a `FloatingChatbotConfigSchema`, // this goes RED. That is the intended signal, not a nuisance — whoever // lands the mirror must add the `retirementTombstone()` half for // `triggerIcon` at the same time, and flip this control rather than delete // it into a vacuum. - const result = ChatbotSchema.safeParse({ + const result = twin.safeParse({ ...node, floatingConfig: { title: 'Chat', triggerIcon: 'Sparkles' }, }); @@ -129,7 +165,7 @@ describe('there is NO zod refusal, and that is deliberate (objectui#7654)', () = }); it('a live `floatingConfig` parses green too — the non-vacuity control', () => { - const result = ChatbotSchema.safeParse({ + const result = twin.safeParse({ ...node, floatingConfig: { title: 'Chat', triggerSize: 56 }, }); @@ -139,7 +175,7 @@ describe('there is NO zod refusal, and that is deliberate (objectui#7654)', () = it('the mirror really has no `floatingConfig` key at all', () => { // The load-bearing fact behind everything above, asserted rather than // assumed: a key the mirror declares would appear in its shape. - const shape = (ChatbotSchema as unknown as { shape: Record }).shape; + const shape = (twin as unknown as { shape: Record }).shape; expect(shape.floatingConfig).toBeUndefined(); // Lit control: a key the mirror DOES declare is present, so the reading // above is a measurement and not an empty object. diff --git a/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts b/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts index 07f07cc050..ba1aaafa74 100644 --- a/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts +++ b/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts @@ -84,6 +84,8 @@ import { CalendarViewSchema as CalendarViewZod, CarouselSchema as CarouselZod, ChatbotSchema as ChatbotZod, + ChatbotEnhancedSchema as ChatbotEnhancedZod, + ChatbotFloatingSchema as ChatbotFloatingZod, FilterBuilderSchema as FilterBuilderZod, DeclarativeKanbanSchema as KanbanZod, } from '../zod/complex.zod'; @@ -140,6 +142,8 @@ import type { CalendarViewSchema, CarouselSchema, ChatbotSchema, + ChatbotEnhancedSchema, + ChatbotFloatingSchema, FilterBuilderSchema, DeclarativeKanbanSchema, } from '../complex'; @@ -205,10 +209,12 @@ const objectOf = (mirror: z.ZodType, key: string): z.ZodObject => }; /** - * 36 keys whose function value REACHES a renderer at runtime — the TypeScript - * interface keeps the function type (38 since: `ObjectDataTableSchema.onRowClick`, - * objectui#6576, and `AlertDialogSchema.onAction`, objectui#7104, joined after - * this census). Channel measured per key on this tree: + * 44 keys whose function value REACHES a renderer at runtime — the TypeScript + * interface keeps the function type (36 at the objectui#6124 census; 38 since + * `ObjectDataTableSchema.onRowClick`, objectui#6576, and `AlertDialogSchema.onAction`, + * objectui#7104, joined; 44 since objectui#7655 gave `chatbot-enhanced` and + * `chatbot-floating` their own faces, each carrying the three slots its + * registration forwards). Channel measured per key on this tree: * `schema.onX` read/forwarded (kanban, chatbot, data-table, form, code-editor, * menu items), `props.onX` called after `SchemaRenderer`'s spread (input, * textarea, select, checkbox, file-upload, date-picker, input-otp, pagination, @@ -224,6 +230,14 @@ const RUNTIME_SLOT: readonly Site[] = [ ['complex.zod.ts', 'FilterBuilderSchema', 'onChange', FilterBuilderZod], ['complex.zod.ts', 'ChatbotSchema', 'onError', ChatbotZod], ['complex.zod.ts', 'ChatbotSchema', 'onSend', ChatbotZod], + // objectui#7655 — the two sibling faces forward the same two slots off `schema.*` + // into `useObjectChat`, and their `handleClear` calls `schema.onClear?.()`. + ['complex.zod.ts', 'ChatbotEnhancedSchema', 'onError', ChatbotEnhancedZod], + ['complex.zod.ts', 'ChatbotEnhancedSchema', 'onSend', ChatbotEnhancedZod], + ['complex.zod.ts', 'ChatbotEnhancedSchema', 'onClear', ChatbotEnhancedZod], + ['complex.zod.ts', 'ChatbotFloatingSchema', 'onError', ChatbotFloatingZod], + ['complex.zod.ts', 'ChatbotFloatingSchema', 'onSend', ChatbotFloatingZod], + ['complex.zod.ts', 'ChatbotFloatingSchema', 'onClear', ChatbotFloatingZod], ['data-display.zod.ts', 'DataTableSchema', 'onRowEdit', DataTableZod], ['data-display.zod.ts', 'DataTableSchema', 'onRowDelete', DataTableZod], ['data-display.zod.ts', 'DataTableSchema', 'onSelectionChange', DataTableZod], @@ -357,15 +371,17 @@ describe('census: no on* key in the eight mirrors is declared z.function() (obje ]); }); - it('60 sites are ledgered, 38 runtime slots + 22 retired, with no key filed twice', () => { + it('66 sites are ledgered, 44 runtime slots + 22 retired, with no key filed twice', () => { // 58 from objectui#6124; the 59th is `ObjectDataTableSchema.onRowClick`, // minted with its arm by objectui#6576 / #6914; the 60th is // `AlertDialogSchema.onAction`, declared by objectui#7104 for a key the - // renderer had been reading undeclared. - expect(RUNTIME_SLOT).toHaveLength(38); + // renderer had been reading undeclared; 61–66 are the six slots the + // `ChatbotEnhancedSchema` / `ChatbotFloatingSchema` twins were born with + // (objectui#7655). + expect(RUNTIME_SLOT).toHaveLength(44); expect(RETIRED).toHaveLength(22); const ids = ALL_SITES.map(([file, schema, key]) => `${file}#${schema}.${key}`); - expect(new Set(ids).size).toBe(60); + expect(new Set(ids).size).toBe(66); }); it.each(ALL_SITES)('%s %s.%s is DECLARED on the mirror shape, with the objectui#6124 guidance as its description', (_file, _schema, key, mirror) => { @@ -519,6 +535,12 @@ export type assertionRuntimeSlotsKeepTheirFunctionType = [ Expect>, Expect>, Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, Expect>, Expect>, Expect>, diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 5dada1a13b..2addbb0382 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -28,7 +28,7 @@ * * It reads `.shape` and not `keyof z.input` because that spelling is * vacuous — `.passthrough()` collapses the inferred key union to bare `string`. - * `assertionNoVacuousEntry` below pins that for all 155 entries at once. + * `assertionNoVacuousEntry` below pins that for all 157 entries at once. * * ## What is registered * @@ -53,23 +53,28 @@ * framing, and objectui#6058's own dispatch, both inherited "163"). On a file whose * entire subject is measurement that is worth stating explicitly: * - * - **155 pairs** — `Object.keys(MIRRORS).length`, which `assertionRegistryHalvesAgree` - * already pins equal to `keyof Declared`. 154 until objectui#7352 registered - * `data-display.zod.ts#DrillDownConfigSchema` — a nested config mirror paired with - * the local `DrillDownConfig`, the `ObjectMapConfigSchema` precedent — which - * carries no ledger entry. ⚠️ **This line rotted twice and was re-derived at - * objectui#7352 contract review.** Its history, measured by running the same walk over - * this file at each revision rather than by reading the prose: `4ca30d044` wrote - * "160" when `MIRRORS` held **163**; `d88e20f55` (objectui#7432) took the registry to - * **154** without touching the sentence; objectui#7352 then added its 1 to the stale - * baseline and wrote 161. Two independent derivations agree on 155 today — a - * TypeScript AST walk counting `PropertyAssignment` nodes in the `MIRRORS` - * initializer, and a line-oriented parse of the same block — and they agree on - * the three historical figures above. ⛔ Do not add a delta to this number; count - * the registry. Nothing asserts it against a written + * - **157 pairs** — `Object.keys(MIRRORS).length`, which `assertionRegistryHalvesAgree` + * already pins equal to `keyof Declared`. 155 until objectui#7655 registered the + * `ChatbotEnhancedSchema` and `ChatbotFloatingSchema` twins; 154 until objectui#7352 + * registered `data-display.zod.ts#DrillDownConfigSchema` — a nested config mirror + * paired with the local `DrillDownConfig`, the `ObjectMapConfigSchema` precedent — + * which carries no ledger entry. ⚠️ **This line rotted twice and was re-derived at + * objectui#7352 contract review** (objectui#7655's contract review measured the same + * rot independently: three instruments read 154 where the prose said 160). Its + * history, measured by running the same walk over this file at each revision + * rather than by reading the prose: `4ca30d044` wrote "160" when `MIRRORS` held + * **163**; `d88e20f55` (objectui#7432) took the registry to **154** without touching + * the sentence; objectui#7352 then added its 1 to the stale baseline and wrote 161. + * Two independent derivations agree on 157 today — a TypeScript AST walk counting + * `PropertyAssignment` nodes in the `MIRRORS` initializer, and a line-oriented parse + * of the same block — and they agree on the historical figures above. ⛔ Do not add + * a delta to this number; count the registry. Nothing asserts it against a written * one, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. - * - **40 entries** in `KnownDrift`, **57 keys** across them — 40 / 56 until + * - **42 entries** in `KnownDrift`, **63 keys** across them — 40 / 57 until + * objectui#7655 SEEDED the `ChatbotEnhancedSchema` and `ChatbotFloatingSchema` pairs + * with three runtime-slot refusals each (pairs born ledgered in the #6124 shape, + * not growth on an existing entry); 40 / 56 until * objectui#7104 declared `AlertDialogSchema.onAction`, the action button's * `onClick` the renderer had been reading UNDECLARED, as a RUNTIME SLOT on an * already-ledgered pair (growth on an existing entry, both faces measured); @@ -87,11 +92,14 @@ * and 37 / 53 until objectui#7344 swept the string / `z.any()` handler mirrors: * `DetailSchema` and `DetailViewSchema` entered (one `onBack` each) and * `CalendarViewSchema` grew by `onEventClick`. - * - **13 entries** in `UnmirroredDeclared`, **94 keys** across them — 15 / 96 until - * objectui#7352 MIRRORED both `drillDown` rows at once (`ChartSchema` and - * `ObjectDataTableSchema`, each the entry's whole content, so both entries went): - * the ledger's second and third shrink by REPAIR, on the route objectui#6639 opened. - * It read 17 / 98 + * - **14 entries** in `UnmirroredDeclared`, **96 keys** across them — 13 / 94 until + * objectui#7655 SEEDED a `ChatbotFloatingSchema` entry with `displayMode` and + * `floatingConfig`, the two keys that face declares alongside `ChatbotSchema` + * (whose own entry keeps all three of its keys — a pair born ledgered, not a + * move); 15 / 96 until objectui#7352 MIRRORED both `drillDown` rows at once + * (`ChartSchema` and `ObjectDataTableSchema`, each the entry's whole content, so + * both entries went): the ledger's second and third shrink by REPAIR, on the route + * objectui#6639 opened. It read 17 / 98 * between objectui#6576, which SEEDED the new `ObjectDataTableSchema` pair with its * one measured key `drillDown` (a pair born ledgered, not growth on an existing * one), and objectui#7129, which RETIRED `DetailViewSectionSchema.hideEmpty` — @@ -107,18 +115,18 @@ * meaning — the comparable figure is 95 + 1 mirrored + 2 retired + 23 * reclassified. The full statement is on that ledger. * - **7 entries** in `RuntimeOnlyDeclared`, **24 keys** across them. Six of the - * seven are a subset of the 13 pairs above; `TreeViewSchema` is NOT — it is + * seven are a subset of the 14 pairs above; `TreeViewSchema` is NOT — it is * the first pair whose ONLY ledger entry is a runtime-only one * (objectui#6150 declared `onNodeClick` on an otherwise clean pair), which is why - * the union of the two unmirrored ledgers is **14** pairs and not 13. - * - **141 pairs with no entry in either** unmirrored ledger — 155 − 14, measured, + * the union of the two unmirrored ledgers is **15** pairs and not 14. + * - **142 pairs with no entry in either** unmirrored ledger — 157 − 15, measured, * not stepped. ⚠️ This line used to carry a running chain of deltas (141 → 142 → * 143 → 144 → 147, one per card). Every one of those was computed against the * stale pair count above, so they were arithmetic on a wrong base and are NOT * re-derivable from this file; objectui#7352 contract review replaced the chain with - * the measurement. ⛔ Do not restart the chain — subtract the union from the - * registry count, both read from the file. - * - 155 − 40 = **115**, the "pairs with no entry" `LedgerMismatch` speaks of. + * the measurement (objectui#7655 re-measured: 142 = 157 − 15). ⛔ Do not restart + * the chain — subtract the union from the registry count, both read from the file. + * - 157 − 42 = **115**, the "pairs with no entry" `LedgerMismatch` speaks of. * * ## Two ratchets, because the forward comparison has two halves * @@ -139,7 +147,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 40 of the 155 pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 42 of the 157 pairs carry TYPE drift TODAY (measured, not assumed). Each is * pinned to its EXACT drifted key set, so the entry fails when new drift appears on * that mirror AND when the recorded drift is fixed — a stale entry cannot rot * quietly. Correcting them is not one change: the pairs below split into DISJOINT @@ -173,7 +181,7 @@ import type { z } from 'zod'; import { AppActionSchema, AppComponentSchema, NavigationAreaSchema } from '../zod/app.zod.js'; import { BaseSchema, ComponentConfigSchema, ComponentInputSchema, ComponentMetaSchema, KeyedI18nLabelSchema } from '../zod/base.zod.js'; -import { CalendarEventSchema, CalendarViewSchema, CarouselItemSchema, CarouselSchema, ChatbotSchema, ChatMessageSchema, ChatMessageSourceSchema, ChatToolInvocationSchema, DashboardComponentSchema, DashboardConfigSchema, DashboardWidgetConfigSchema, DashboardWidgetLayoutSchema, DashboardWidgetSchema, FilterBuilderSchema, FilterFieldSchema, DeclarativeKanbanCardSchema, DeclarativeKanbanColumnSchema, DeclarativeKanbanSchema } from '../zod/complex.zod.js'; +import { CalendarEventSchema, CalendarViewSchema, CarouselItemSchema, CarouselSchema, ChatbotSchema, ChatbotEnhancedSchema, ChatbotFloatingSchema, ChatMessageSchema, ChatMessageSourceSchema, ChatToolInvocationSchema, DashboardComponentSchema, DashboardConfigSchema, DashboardWidgetConfigSchema, DashboardWidgetLayoutSchema, DashboardWidgetSchema, FilterBuilderSchema, FilterFieldSchema, DeclarativeKanbanCardSchema, DeclarativeKanbanColumnSchema, DeclarativeKanbanSchema } from '../zod/complex.zod.js'; import { ActionCallbackSchema, CRUDDialogSchema, DetailSchema } from '../zod/crud.zod.js'; import { AlertSchema, AvatarSchema, BadgeSchema, BarChartSchema, ChartDataSeriesSchema, ChartSchema, DataTableSchema, DrillDownConfigSchema, HtmlSchema, KbdSchema, ListItemSchema, ListSchema, MarkdownSchema, StaticTableColumnSchema, StatisticSchema, TableColumnSchema, TableSchema, TimelineEventSchema, TimelineSchema, TreeViewSchema } from '../zod/data-display.zod.js'; import { AccordionItemSchema, AccordionSchema, CollapsibleSchema, ToggleGroupItemSchema, ToggleGroupSchema } from '../zod/disclosure.zod.js'; @@ -188,7 +196,7 @@ import { DetailViewFieldSchema, DetailViewSchema, DetailViewSectionSchema, Detai import type { AppAction as Ts_AppAction, AppComponentSchema as Ts_AppComponentSchema, NavigationArea as Ts_NavigationArea } from '../app'; import type { BaseSchema as Ts_BaseSchema, ComponentConfig as Ts_ComponentConfig, ComponentInput as Ts_ComponentInput, ComponentMeta as Ts_ComponentMeta, KeyedI18nLabel as Ts_KeyedI18nLabel } from '../base'; -import type { CalendarEvent as Ts_CalendarEvent, CalendarViewSchema as Ts_CalendarViewSchema, CarouselItem as Ts_CarouselItem, CarouselSchema as Ts_CarouselSchema, ChatbotSchema as Ts_ChatbotSchema, ChatMessage as Ts_ChatMessage, ChatMessageSource as Ts_ChatMessageSource, ChatToolInvocation as Ts_ChatToolInvocation, DashboardComponentSchema as Ts_DashboardComponentSchema, DashboardWidgetLayout as Ts_DashboardWidgetLayout, DashboardWidgetSchema as Ts_DashboardWidgetSchema, FilterBuilderSchema as Ts_FilterBuilderSchema, FilterField as Ts_FilterField, DeclarativeKanbanCard as Ts_KanbanCard, DeclarativeKanbanColumn as Ts_KanbanColumn, DeclarativeKanbanSchema as Ts_KanbanSchema } from '../complex'; +import type { CalendarEvent as Ts_CalendarEvent, CalendarViewSchema as Ts_CalendarViewSchema, CarouselItem as Ts_CarouselItem, CarouselSchema as Ts_CarouselSchema, ChatbotSchema as Ts_ChatbotSchema, ChatbotEnhancedSchema as Ts_ChatbotEnhancedSchema, ChatbotFloatingSchema as Ts_ChatbotFloatingSchema, ChatMessage as Ts_ChatMessage, ChatMessageSource as Ts_ChatMessageSource, ChatToolInvocation as Ts_ChatToolInvocation, DashboardComponentSchema as Ts_DashboardComponentSchema, DashboardWidgetLayout as Ts_DashboardWidgetLayout, DashboardWidgetSchema as Ts_DashboardWidgetSchema, FilterBuilderSchema as Ts_FilterBuilderSchema, FilterField as Ts_FilterField, DeclarativeKanbanCard as Ts_KanbanCard, DeclarativeKanbanColumn as Ts_KanbanColumn, DeclarativeKanbanSchema as Ts_KanbanSchema } from '../complex'; import type { DashboardConfig as Ts_DashboardConfig, DashboardWidgetConfig as Ts_DashboardWidgetConfig } from '../designer'; import type { ActionCallback as Ts_ActionCallback, CRUDDialogSchema as Ts_CRUDDialogSchema, DetailSchema as Ts_DetailSchema } from '../crud'; import type { AlertSchema as Ts_AlertSchema, AvatarSchema as Ts_AvatarSchema, BadgeSchema as Ts_BadgeSchema, BarChartSchema as Ts_BarChartSchema, ChartDataSeries as Ts_ChartDataSeries, ChartSchema as Ts_ChartSchema, DataTableSchema as Ts_DataTableSchema, DrillDownConfig as Ts_DrillDownConfig, HtmlSchema as Ts_HtmlSchema, KbdSchema as Ts_KbdSchema, ListItem as Ts_ListItem, ListSchema as Ts_ListSchema, MarkdownSchema as Ts_MarkdownSchema, StaticTableColumn as Ts_StaticTableColumn, StatisticSchema as Ts_StatisticSchema, TableColumn as Ts_TableColumn, TableSchema as Ts_TableSchema, TimelineEvent as Ts_TimelineEvent, TimelineSchema as Ts_TimelineSchema, TreeViewSchema as Ts_TreeViewSchema, BreadcrumbItem as Ts_BreadcrumbItem, BreadcrumbSchema as Ts_BreadcrumbSchema } from '../data-display'; @@ -304,7 +312,10 @@ export type ReconcileAgainstLedger< K, Measured, Recorded > = export type assertionRatchetAcceptsAgreement = Expect< Equal< ReconcileAgainstLedger< 'p', 'a', 'a' >, never > >; -/** …and so does a clean pair with no entry, which is the case for 141 of the 155. */ +/** + * …and so does a clean pair with no entry — 115 of the 157 on the `KnownDrift` half, + * 142 on the unmirrored half (both measured, not stepped). + */ export type assertionRatchetAcceptsCleanPair = Expect< Equal< ReconcileAgainstLedger< 'p', never, never >, never > >; @@ -399,6 +410,8 @@ const MIRRORS = { 'complex.zod.ts#CarouselItemSchema': CarouselItemSchema, 'complex.zod.ts#CarouselSchema': CarouselSchema, 'complex.zod.ts#ChatbotSchema': ChatbotSchema, + 'complex.zod.ts#ChatbotEnhancedSchema': ChatbotEnhancedSchema, + 'complex.zod.ts#ChatbotFloatingSchema': ChatbotFloatingSchema, 'complex.zod.ts#ChatMessageSchema': ChatMessageSchema, 'complex.zod.ts#ChatMessageSourceSchema': ChatMessageSourceSchema, 'complex.zod.ts#ChatToolInvocationSchema': ChatToolInvocationSchema, @@ -558,6 +571,8 @@ interface Declared { 'complex.zod.ts#CarouselItemSchema': Ts_CarouselItem; 'complex.zod.ts#CarouselSchema': Ts_CarouselSchema; 'complex.zod.ts#ChatbotSchema': Ts_ChatbotSchema; + 'complex.zod.ts#ChatbotEnhancedSchema': Ts_ChatbotEnhancedSchema; + 'complex.zod.ts#ChatbotFloatingSchema': Ts_ChatbotFloatingSchema; 'complex.zod.ts#ChatMessageSchema': Ts_ChatMessage; 'complex.zod.ts#ChatMessageSourceSchema': Ts_ChatMessageSource; 'complex.zod.ts#ChatToolInvocationSchema': Ts_ChatToolInvocation; @@ -765,6 +780,19 @@ interface KnownDrift { * refuses them by name (`handlerKeyRefusal`). See the class note above `ButtonSchema`. */ 'complex.zod.ts#ChatbotSchema': 'body' | 'onError' | 'onSend'; + /** + * RUNTIME SLOT (objectui#6124) — pairs born ledgered by objectui#7655, which gave + * the `chatbot-enhanced` and `chatbot-floating` registrations their own faces. + * Each face keeps the callables its registration forwards off `schema.*` — + * `onError` and `onSend` into `useObjectChat`, `onClear` from `handleClear` — + * and the mirror refuses all three by name (`handlerKeyRefusal`). No `body` + * here: these twins mirror the key the renderer reads, `requestBody`, and + * inherit `body` as the children slot, so `ChatbotSchema`'s naming collision + * was deliberately not copied across. + */ + 'complex.zod.ts#ChatbotEnhancedSchema': 'onClear' | 'onError' | 'onSend'; + /** The same three slots on the same channel — see `ChatbotEnhancedSchema` above. */ + 'complex.zod.ts#ChatbotFloatingSchema': 'onClear' | 'onError' | 'onSend'; /** * spec-derived shape (`SpecDashboardFields`) measured against a hand-written * local declaration. Needs the spec-unification triage of #2231 rather than a @@ -979,7 +1007,7 @@ interface KnownDrift { * is being let through, because there was no such thing. Seeding takes the count of * VISIBLE, RATCHETED facts from 0 to 121 and installs a floor: the problem cannot * grow while they are worked off, and a new declared-but-unmirrored key on any of - * the 155 pairs reddens immediately (`assertionRatchetRejectsFreshDrift`) — + * the 157 pairs reddens immediately (`assertionRatchetRejectsFreshDrift`) — * including a callback-shaped one, which reddens until it is filed in * `RuntimeOnlyDeclared` (`assertionSplitLedgerRejectsFreshCallback`). * @@ -1016,10 +1044,13 @@ interface KnownDrift { * spec schema does not model, which is objectui#2231's unification question and * NOT a local mirror edit. They are marked, not exempted: exempting them in the * instrument would re-blind exactly the pairs objectui#5927 leaned on hardest. - * - **LOCAL (11 entries, 82 keys)** — plain omissions from a hand-written mirror. + * - **LOCAL (12 entries, 84 keys)** — plain omissions from a hand-written mirror. * It was 13 / 84 until objectui#7129 RETIRED `DetailViewSectionSchema.hideEmpty`, - * a shrink by removing the DECLARATION rather than by mirroring it, and 12 / 83 - * until objectui#7352 MIRRORED `ChartSchema.drillDown` — its whole entry. + * a shrink by removing the DECLARATION rather than by mirroring it, 12 / 83 + * until objectui#7352 MIRRORED `ChartSchema.drillDown` — its whole entry — and + * 11 / 82 until objectui#7655 SEEDED a `ChatbotFloatingSchema` entry with the two + * keys that face declares alongside `ChatbotSchema` — an entry and two keys + * gained; `ChatbotSchema`'s own entry did not move. * * ⚠️ Both counts moved with the reclassification: the spec-derived side lost * `ObjectViewSchema.onNavigate` (14 → 13) and the local side lost the other 22 @@ -1029,10 +1060,11 @@ interface KnownDrift { * entries / 97 keys after it. Three later changes moved the entry count itself: * objectui#6576 SEEDED `ObjectDataTableSchema` (a 17th entry, in neither half * above), objectui#7129 RETIRED an entry from the LOCAL half, objectui#7623 - * RETIRED one from the SPEC-DERIVED half (13 → 12 keys there), and objectui#7352 + * RETIRED one from the SPEC-DERIVED half (13 → 12 keys there), objectui#7352 * MIRRORED two — the LOCAL `ChartSchema` entry and the seeded `ObjectDataTableSchema` - * one. The ledger now totals **13 entries / 94 keys** — 2 / 12 spec-derived, - * 11 / 82 local; the seeded pair is no longer among them. + * one — and objectui#7655 SEEDED the LOCAL `ChatbotFloatingSchema` entry, born with + * two keys. The ledger now totals **14 entries / 96 keys** — 2 / 12 spec-derived, + * 12 / 84 local; the seeded pair is no longer among them. * * ## How this was measured, and the trap that makes the number hard to get * @@ -1066,6 +1098,21 @@ interface UnmirroredDeclared { * heard of. */ 'complex.zod.ts#ChatbotSchema': 'displayMode' | 'floatingConfig' | 'requestBody'; + /** + * LOCAL — a pair born ledgered (objectui#7655) with the two keys the floating + * face declares alongside `ChatbotSchema`, in the same state the entry above + * records them. `floatingConfig` has no `FloatingChatbotConfig` mirror at all — + * minting one is objectui#6152's axis, and the `triggerIcon` tombstone's tripwire + * (objectui#7654, `floating-chatbot-trigger-icon-retired.test.ts`) watches for + * it. `displayMode` is RULED RETIRED (objectui#7654, maintainer ruling B, + * 2026-09-05) and the retirement executes in that card's own PR: the TypeScript + * half is the `?: never` tombstone, and the mirror half (`retirementTombstone()`) + * is owed when objectui#6152 mints the arm — until then the key stays unmirrored + * here and on `ChatbotSchema` alike, and what that PR does to these two entries + * is its own to record. ⛔ Not a waiver: every OTHER key this pair declares is + * mirrored, and a third key here reddens the pair like growth on any other entry. + */ + 'complex.zod.ts#ChatbotFloatingSchema': 'displayMode' | 'floatingConfig'; // `complex.zod.ts#DashboardComponentSchema` recorded `title` here (SPEC-DERIVED) // until objectui#7623 RETIRED the declaration — the objectui#7129 route, not a // mirror edit: the spec's strict `DashboardSchema` refuses a root `title` outright, @@ -1417,7 +1464,7 @@ export type assertionLedgerHalvesAreDisjoint = Expect< Equal< DoubleFiledKey, ne /** * Every pair's TYPE drift equals what `KnownDrift` records for it — `never` for the - * 115 pairs with no entry (155 − 40). + * 115 pairs with no entry (157 − 42). * * Routed through `ReconcileAgainstLedger` rather than spelling the conditional * inline. That is a semantics-preserving refactor and nothing else — the type is @@ -1465,17 +1512,18 @@ export const assertionDriftMatchesLedger: never = 0 as unknown as LedgerMismatch /** * The SECOND half of the forward comparison: every pair's declared-but-unmirrored - * key set equals what the two ledgers TOGETHER record for it — `never` for the 141 - * pairs with no entry in either (155 − 14). Six of `RuntimeOnlyDeclared`'s seven - * pairs are a measured subset of `UnmirroredDeclared`'s 13, so objectui#6152's + * key set equals what the two ledgers TOGETHER record for it — `never` for the 142 + * pairs with no entry in either (157 − 15). Six of `RuntimeOnlyDeclared`'s seven + * pairs are a measured subset of `UnmirroredDeclared`'s 14, so objectui#6152's * reclassification left the clean population unchanged; objectui#6150 then added * `TreeViewSchema`, whose only entry is runtime-only, which is why the union is one * pair larger than `UnmirroredDeclared` itself. (objectui#6576 took the union to 18; * objectui#7129 brought it back to 17 by retiring `DetailViewSectionSchema`'s only - * ledgered key, objectui#7623 to 16 by retiring `DashboardComponentSchema`'s, and + * ledgered key, objectui#7623 to 16 by retiring `DashboardComponentSchema`'s, * objectui#7352 to 14 by MIRRORING both `drillDown` entries — each leaving its - * pair with no entry in either half. ⚠️ Two of those pairs still carry a `KnownDrift` - * entry: "no entry in either" is about the two UNMIRRORED ledgers.) + * pair with no entry in either half — and objectui#7655 to 15 by registering + * `ChatbotFloatingSchema` born ledgered. ⚠️ Two of those pairs still carry a + * `KnownDrift` entry: "no entry in either" is about the two UNMIRRORED ledgers.) * * ⚠️ **The discriminating signal is the PER-PAIR set, not this file's exit code.** * The exit code is a whole-file verdict, so it moves only while the rest of the @@ -1525,7 +1573,7 @@ export const assertionUnmirroredMatchesLedger: never = 0 as unknown as { }[MirrorKey]; /** - * Non-vacuity for all 155 entries at once. + * Non-vacuity for all 157 entries at once. * * `NarrowerThanDeclared` is `never` — green — for an entry whose mirror exposes no * `.shape`, and also for one whose key union has degenerated to bare `string` (the diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 453512295b..fd59df102b 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -886,8 +886,214 @@ export interface ChatbotSchema extends BaseSchema { displayMode?: 'inline' | 'floating'; /** - * Configuration for floating display mode. - * Only used when `displayMode` is `'floating'`. + * Configuration for the floating action button and the panel it opens — + * read by `chatbot-floating` alone and forwarded to ``. + */ + floatingConfig?: FloatingChatbotConfig; +} + +/** + * The chat-surface keys that ALL THREE `plugin-chatbot` registrations read + * (objectui#7655) — the members {@link ChatbotEnhancedSchema} and + * {@link ChatbotFloatingSchema} pick off {@link ChatbotSchema} by name, so the + * three faces share ONE declaration and ONE doc comment per key. + * + * Every member was read-site-censused per registration on the PR's base: one + * NAMED `schema.KEY` read in each of the three `ComponentRegistry.register(...)` + * bodies of `packages/plugin-chatbot/src/renderer.tsx`, forwarded into + * `useObjectChat` or onto the rendered component. (Named reads are the + * instrument; the `chatbot-floating` registration also has an unfiltered + * props spread — see {@link ChatbotFloatingSchema}.) The instrument was lit by + * keys that are NOT shared — `processVisibility` read 0 / 1 / 0 across + * `chatbot` / `chatbot-enhanced` / `chatbot-floating` and `floatingConfig` + * 0 / 0 / 1 — so a zero in that census is a reading, not a blind grep. + * + * Exported only because an exported interface may not extend a `Pick` over a + * private name (TS4022). It is a census, not an authoring face, and it is not + * re-exported from the package entry: the node types are. + */ +export type ChatbotSharedKey = + | 'messages' + | 'placeholder' + | 'api' + | 'conversationId' + | 'systemPrompt' + | 'model' + | 'streamingEnabled' + | 'headers' + | 'requestBody' + | 'maxToolRoundtrips' + | 'onError' + | 'showTimestamp' + | 'userAvatarUrl' + | 'userAvatarFallback' + | 'assistantAvatarUrl' + | 'assistantAvatarFallback' + | 'autoResponse' + | 'autoResponseText' + | 'autoResponseDelay' + | 'onSend'; + +/** + * `chatbot-enhanced` component — the authoring face of the + * `ComponentRegistry.register('chatbot-enhanced', ...)` registration in + * `packages/plugin-chatbot/src/renderer.tsx` (objectui#7655, under the + * objectui#6169 / #6172 family ruling: every component node has exactly one + * named, importable authoring-face type). + * + * Until objectui#7655 this node had no importable type: {@link ChatbotSchema} + * pins `type` to `'chatbot'`, so an author either dropped to untyped JSON or + * annotated with `ChatbotSchema` and lied about `type`. The registration's + * parameter type was an anonymous `ChatbotSchema & { ... }` intersection + * local to the renderer, referenceable by nothing outside that file. + * + * What is declared here is what THIS registration reads — censused per key on + * the PR's base, not copied off `ChatbotSchema`: + * + * - the twenty {@link ChatbotSharedKey} members every registration reads; + * - `maxHeight` and `processVisibility`, which `chatbot-enhanced` forwards to + * `` by name and `chatbot-floating` has no named read for + * (its panel is sized by `floatingConfig.panelHeight`; for the second, + * unnamed channel on that registration see {@link ChatbotFloatingSchema}); + * - `enableMarkdown`, `enableFileUpload`, `surface` and the `onClear` + * runtime slot, which `ChatbotSchema` never declared. + * + * NOT declared, on purpose: `loading`, `showAvatars`, `userAvatar`, + * `assistantAvatar`, `markdown` and `height` — `ChatbotSchema` members this + * registration has no read for. `disabled` and `className` are inherited from + * {@link BaseSchema}: `SchemaRenderer` evaluates `disabled` / `disabledOn` + * for every node type and hands the verdict to the registration as a prop, so + * redeclaring `disabled` here as `boolean` would only narrow away the + * expression-string half of an inherited field (objectui#6169, #7087). + */ +export interface ChatbotEnhancedSchema + extends BaseSchema, + Pick { + type: 'chatbot-enhanced'; + /** + * Render assistant messages as markdown. Forwarded to ``'s + * `enableMarkdown` prop; an unauthored value falls back to `true` at the + * registration (`schema.enableMarkdown ?? true`). + * @default true + */ + enableMarkdown?: boolean; + /** + * Show the file-attachment control in the composer. Forwarded to + * ``'s `enableFileUpload` prop; an unauthored value falls + * back to `false` at the registration. + * @default false + */ + enableFileUpload?: boolean; + /** + * Visual chrome for the chat surface (objectui#6687, maintainer ruling + * 2026-08-29). `'card'` keeps the embeddable bordered panel; `'plain'` + * removes the panel chrome for a full-page chat workspace. Declared on this + * node only: `chatbot-enhanced` is the one registration that renders + * `` and has a `surface` prop to forward it to. Passed + * through `undefined` when unauthored, so the component's own `'card'` + * default keeps applying. + * + * The plugin's `ChatbotSurface` alias (`@object-ui/plugin-chatbot`) is the + * component-side spelling of this same union; the plugin's tests pin the + * two equal so they cannot drift into two dialects (AGENTS.md #0.1). + * @default 'card' + */ + surface?: 'card' | 'plain'; + /** + * Called after the conversation is cleared through the composer's clear + * control, once the chat runtime has dropped its messages. + * + * RUNTIME SLOT (objectui#6124) — a host-supplied function, NOT authorable + * metadata: JSON has no function value, so the zod twin refuses this key by + * name and points at the node-type spelling. Kept callable here because + * `plugin-chatbot`'s `handleClear` invokes `schema.onClear?.()`. + */ + onClear?: () => void; +} + +/** + * `chatbot-floating` component — the authoring face of the + * `ComponentRegistry.register('chatbot-floating', ...)` registration + * (objectui#7655; same ruling and same census discipline as + * {@link ChatbotEnhancedSchema}). The floating-action-button presentation: a + * trigger in a page corner that opens a chat panel overlay. + * + * Declared here is what THIS registration reads by name (`schema.KEY`), + * censused per key on the PR's base: + * + * - the twenty {@link ChatbotSharedKey} members; + * - `enableMarkdown`, `enableFileUpload` and the `onClear` runtime slot, + * forwarded into the panel's ``; + * - `floatingConfig`, the trigger and panel geometry + * ({@link FloatingChatbotConfig}), and `displayMode` — both ALSO declared + * on {@link ChatbotSchema}, unchanged there; see each member's comment. + * + * NOT declared, on purpose: `maxHeight` (the panel pins its inner chat to + * `100%` of `floatingConfig.panelHeight` AFTER any forwarded value, so an + * authored `maxHeight` is dead here), `processVisibility` and `surface` (no + * named read in this registration), and the six `ChatbotSchema` legacy + * members no registration reads by name. `disabled` / `className` are + * inherited from {@link BaseSchema}, as on the two sibling faces. + * + * ⚠️ The named-read census is not the only channel. This registration ends + * its `` element with a raw `{...props}` spread — every + * authored key `SchemaRenderer` forwards, unfiltered — and the panel is a + * ``, so an authored `processVisibility`, `surface` or + * `showAvatars` DOES reach it today (measured through the real host: each + * lights its marker on a `chatbot-floating` node and stays dark without the + * key, while `chatbot-enhanced`, whose spread is `toDomProps`-filtered, keeps + * `showAvatars` dark). That channel is accidental, not contract: declaring + * the three here would fossilise it (AGENTS.md #0.1), and fencing it is a + * behaviour change with its own review. Recorded on its own card, + * objectui#7708; this face neither declares nor promises it. + */ +export interface ChatbotFloatingSchema + extends BaseSchema, + Pick { + type: 'chatbot-floating'; + /** + * Render assistant messages as markdown inside the panel. Forwarded to the + * panel's `enableMarkdown` prop; an unauthored value falls back to `true` at + * the registration. + * @default true + */ + enableMarkdown?: boolean; + /** + * Show the file-attachment control in the panel's composer. Forwarded to + * the panel's `enableFileUpload` prop; an unauthored value falls back to + * `false` at the registration. + * @default false + */ + enableFileUpload?: boolean; + /** + * Called after the conversation is cleared through the panel's clear + * control, once the chat runtime has dropped its messages. + * + * RUNTIME SLOT (objectui#6124) — a host-supplied function, NOT authorable + * metadata; the zod twin refuses it by name. Kept callable here because + * `plugin-chatbot`'s `handleClear` invokes `schema.onClear?.()`. + */ + onClear?: () => void; + /** + * Display mode for the chatbot. + * - `'inline'` (default): Embedded in the page flow. + * - `'floating'`: Rendered as a floating action button (FAB) that opens a panel overlay. + * + * ⚠️ RULED RETIRED — objectui#7654, maintainer ruling B (2026-09-05): the + * node's own `type` is the one selector of presentation, and this key is a + * second spelling of that choice that no renderer has ever read (measured + * there and re-measured here: declared, offered as a designer control in the + * `chatbot-floating` registration's `inputs`, seeded by its `defaultProps`, + * read by nothing). The retirement — `?: never` tombstone, control and seed + * removed — executes in that card's own PR. objectui#7655 declared the key + * here with the same three lines {@link ChatbotSchema} still carries, so + * that PR finds the member on both faces exactly as ruled; nothing was + * retired, tombstoned, mirrored or made live here. + */ + displayMode?: 'inline' | 'floating'; + /** + * Configuration for the floating action button and the panel it opens — + * read by `chatbot-floating` alone and forwarded to ``. */ floatingConfig?: FloatingChatbotConfig; } @@ -1275,4 +1481,6 @@ export type ComplexSchema = | FilterBuilderSchema | CarouselSchema | ChatbotSchema + | ChatbotEnhancedSchema + | ChatbotFloatingSchema | DashboardComponentSchema; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e7b241ddc5..2e2d256dd0 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -312,6 +312,8 @@ export type { ChatMessageSource, ChatToolInvocation, ChatbotSchema, + ChatbotEnhancedSchema, + ChatbotFloatingSchema, FloatingChatbotConfig, ComplexSchema, } from './complex.js'; diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index ee25b16a1d..b1cd5ac009 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -413,6 +413,107 @@ export const ChatbotSchema = BaseSchema.extend({ onSend: handlerKeyRefusal('onSend', 'runtime-slot', 'Called after a message is sent, in both API and local auto-response mode'), }); +/** + * The chat-surface arms ALL THREE `plugin-chatbot` registrations read + * (objectui#7655) — the Zod side of `../complex.ts`'s `ChatbotSharedKey`, + * taken off `ChatbotSchema`'s own shape so every shared arm has ONE spelling. + * Not exported: it is a census, not a mirror, and the parity census in + * `__tests__/zod-mirror-parity.test.ts` registers `export const`s only. + * + * `requestBody` is deliberately NOT in this pick. `ChatbotSchema` above mirrors + * the API body params under the key `body`, which collides with `BaseSchema`'s + * `body` children slot — the naming collision the parity ledger records under + * `KnownDrift`. The two twins below mirror the key the renderer actually reads, + * `requestBody`, and inherit `body` as the children slot, so they are born + * without the collision. Ruling on `ChatbotSchema`'s own `body` arm is a + * separate question and is not decided here. + */ +const ChatbotSharedMirrorShape = ChatbotSchema.pick({ + messages: true, + placeholder: true, + api: true, + conversationId: true, + systemPrompt: true, + model: true, + streamingEnabled: true, + headers: true, + maxToolRoundtrips: true, + onError: true, + showTimestamp: true, + userAvatarUrl: true, + userAvatarFallback: true, + assistantAvatarUrl: true, + assistantAvatarFallback: true, + autoResponse: true, + autoResponseText: true, + autoResponseDelay: true, + onSend: true, +}).shape; + +/** The arms `chatbot-enhanced` and `chatbot-floating` share beyond the pick above. */ +const chatbotRequestBodyArm = () => + z.record(z.string(), z.unknown()).optional() + .describe('Additional body parameters sent with each API request (forwarded to the chat runtime as its `body` option)'); +const chatbotEnableMarkdownArm = () => + z.boolean().optional().describe('Render assistant messages as markdown (default true)'); +const chatbotEnableFileUploadArm = () => + z.boolean().optional().describe('Show the file-attachment control in the composer (default false)'); +const chatbotOnClearArm = () => + handlerKeyRefusal('onClear', 'runtime-slot', 'Called after the conversation is cleared'); + +/** + * Chatbot Enhanced Schema - `chatbot-enhanced` component (objectui#7655). + * + * Zod twin of `../complex.ts`'s `ChatbotEnhancedSchema`, in lockstep: every + * key that declaration lists is an arm here, and the three runtime slots + * (`onError`, `onSend`, `onClear`) are named refusals (objectui#6124). + */ +export const ChatbotEnhancedSchema = BaseSchema.extend({ + type: z.literal('chatbot-enhanced'), + ...ChatbotSharedMirrorShape, + requestBody: chatbotRequestBodyArm(), + maxHeight: ChatbotSchema.shape.maxHeight, + processVisibility: ChatbotSchema.shape.processVisibility, + enableMarkdown: chatbotEnableMarkdownArm(), + enableFileUpload: chatbotEnableFileUploadArm(), + surface: z.enum(['card', 'plain']).optional() + .describe("Visual chrome for the chat surface: 'card' bordered panel (default) or 'plain' frameless full-page workspace (objectui#6687)"), + onClear: chatbotOnClearArm(), +}); + +/** + * Chatbot Floating Schema - `chatbot-floating` component (objectui#7655). + * + * Zod twin of `../complex.ts`'s `ChatbotFloatingSchema`. Two of that + * declaration's keys are deliberately NOT mirrored, and the parity ledger + * records both under `UnmirroredDeclared` for this pair — exactly as it + * records the same two keys for `ChatbotSchema`, which declares them too: + * + * - `floatingConfig` — `FloatingChatbotConfig` has no Zod mirror at all; + * minting one is the declared-but-unmirrored axis (objectui#6152), a + * different defect from the one this pair closes, and the axis the + * `triggerIcon` tombstone's tripwire watches (objectui#7654). + * - `displayMode` — RULED RETIRED by objectui#7654 (maintainer ruling B, + * 2026-09-05): the node `type` is the one selector of presentation. The + * retirement executes in that card's own PR — `?: never` tombstone on the + * TypeScript faces, designer control and seed removed — and, per the + * ruling, the mirror half (`retirementTombstone()`) is owed at the moment + * objectui#6152 mints an arm for it, not before. Until then a mirror arm + * here would be a parse outcome that ruling did not ask for, so this twin + * has none. + * + * Both ride through `BaseSchema`'s `.passthrough()` unvalidated, byte for byte + * as they do on `ChatbotSchema`'s twin. + */ +export const ChatbotFloatingSchema = BaseSchema.extend({ + type: z.literal('chatbot-floating'), + ...ChatbotSharedMirrorShape, + requestBody: chatbotRequestBodyArm(), + enableMarkdown: chatbotEnableMarkdownArm(), + enableFileUpload: chatbotEnableFileUploadArm(), + onClear: chatbotOnClearArm(), +}); + /** * Dashboard Widget Layout Schema */ @@ -771,5 +872,7 @@ export const ComplexSchema = z.discriminatedUnion('type', [ FilterBuilderSchema, CarouselSchema, ChatbotSchema, + ChatbotEnhancedSchema, + ChatbotFloatingSchema, DashboardComponentSchema, ]); diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 94054257ca..d7d6a83065 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -235,6 +235,8 @@ export { ChatMessageSourceSchema, ChatMessageSchema, ChatbotSchema, + ChatbotEnhancedSchema, + ChatbotFloatingSchema, DashboardWidgetLayoutSchema, DashboardWidgetTypeSchema, DashboardWidgetSchema,