Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .changeset/6169-chatbot-authoring-face-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
'@object-ui/types': minor
'@object-ui/plugin-chatbot': minor
---

`ChatbotSchema` names the `chatbot` node's local-display and legacy
auto-response keys — a new, additive published surface (objectui#6169, the
#6172 family ruling: every component node has exactly one named, importable
authoring-face type).

`ChatbotSchema` (`@object-ui/types`) now declares ten keys that previously
existed ONLY inside an anonymous inline intersection local to
`packages/plugin-chatbot/src/renderer.tsx`'s `chatbot` registration, invisible
to anything outside that one file:

- `showTimestamp`, `userAvatarUrl`, `userAvatarFallback`, `assistantAvatarUrl`,
`assistantAvatarFallback`, `maxHeight` — display fields.
- `autoResponse`, `autoResponseText`, `autoResponseDelay` — the local
auto-response (demo/playground) fields, already live via a real consumer
(`packages/app-shell/src/console/ai/AiChatPage.tsx`).
- `onSend?: (content: string, messages: ChatMessage[]) => void` — the
send-callback, now typed against the published `ChatMessage` shape rather
than the plugin's internal runtime message type.

Each was read-site-censused before being declared (renderer.tsx and/or
`useObjectChat.ts` reads every one); none were dead, so none took the
ADR-0049 retirement route. `disabled` — also present in the original
intersection — is NOT redeclared: it is already `BaseSchema.disabled`
(`boolean | string`), read generically for every node type, and redeclaring
it here would have narrowed away the inherited expression-string case.

**What an external consumer can now do that they could not before:** import
`ChatbotSchema` from `@object-ui/types` and get these ten keys with real,
checked types — previously any reference to them required either duplicating
the anonymous type by hand or falling back to `any`. The Zod mirror
(`@object-ui/types/zod`) gained the same ten keys in lockstep, so a `chatbot`
node parsed through it is now validated on these keys rather than silently
passed through unchecked (`BaseSchema`'s Zod mirror is `.passthrough()`).

`packages/plugin-chatbot`'s `chatbot` registration (`renderer.tsx`) now types
its `schema` prop as `ChatbotSchema` directly, dropping the anonymous
intersection. No behavior change: `renderer.tsx:87`'s
`body: schema.requestBody` forwarding — the subject of the already-merged
#6193 — is untouched, and the render function reads the exact same keys it
already read.

This is additive (new optional keys on an interface that already carried a
`[key: string]: any` index signature, and a new Zod-validated subset of
previously-passthrough keys), so it ships as `minor` even though it changes
published type surface: objectui's major is pinned to `@objectstack`'s
(`scripts/check-changeset-no-major.mjs`), and objectui's own breaking changes
ship as `minor` with the break spelled out — there is no break here to spell
out, only a widening from anonymous-and-unchecked to named-and-validated.

Out of scope, deliberately: the `chatbot-enhanced` and `chatbot-floating`
registrations' own anonymous intersections (different key sets, a decision
for a separate card in the same family), and the `surface` row on
`content/docs/plugins/plugin-chatbot.mdx`'s Properties table, which names a
key no registration in this package currently reads (filed separately).
5 changes: 5 additions & 0 deletions content/docs/plugins/plugin-chatbot.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ const schema = {

## Properties

`showTimestamp` through `onSend` below are declared on [`ChatbotSchema`](https://github.com/objectstack-ai/objectui/blob/main/packages/types/src/complex.ts)
(`@object-ui/types`) — previously they existed only in an anonymous type local
to the renderer, referenceable, validatable and documentable by nothing
outside that one file (objectui#6169).

| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `messages` | array | `[]` | Initial chat messages |
Expand Down
25 changes: 12 additions & 13 deletions packages/plugin-chatbot/src/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,19 +59,18 @@ import { toRuntimeMessages } from './chatMessageAdapter';
* element, which the sweep gate measured as clean.
*/
ComponentRegistry.register('chatbot',
({ schema, className, disabled: hostDisabled, ...props }: { schema: ChatbotSchema & {
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;
}; className?: string; disabled?: boolean; [key: string]: any }) => {
// The eleven keys this destructure's parameter type used to carry as an
// anonymous inline intersection now live on `ChatbotSchema` itself
// (objectui#6169, the #6172 family ruling: every component node has
// exactly one named, importable authoring-face type) — each was
// read-site-censused first; none were dead, so none took the ADR-0049
// route. `schema` is that one type, referenceable and documentable from
// outside this file for the first time. `disabled` here is the sibling,
// host-EVALUATED prop (`SchemaRenderer`'s verdict on `schema.disabled` /
// `schema.disabledOn`, forwarded as `hostDisabled`) — a different carrier
// from the authored `schema.disabled` it is derived from; see the comment
// on `disabled={hostDisabled || isLoading}` below.
({ schema, className, disabled: hostDisabled, ...props }: { schema: ChatbotSchema; className?: string; disabled?: boolean; [key: string]: any }) => {
const {
messages,
isLoading,
Expand Down
175 changes: 175 additions & 0 deletions packages/types/src/__tests__/chatbot-authoring-face-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* 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.
*/

/**
* `ChatbotSchema` declares the `chatbot` node's local-display and legacy
* auto-response keys by name (objectui#6169, the #6172 family ruling: every
* component node has exactly one named, importable authoring-face type).
*
* Before this file, `showTimestamp`, `userAvatarUrl`, `userAvatarFallback`,
* `assistantAvatarUrl`, `assistantAvatarFallback`, `maxHeight`,
* `autoResponse`, `autoResponseText`, `autoResponseDelay` and `onSend` lived
* ONLY in an anonymous inline intersection at
* `packages/plugin-chatbot/src/renderer.tsx`'s `chatbot` registration site —
* nothing outside that one file could reference, validate, or document them.
* Each was read-site-censused before being declared here (renderer.tsx and/or
* `useObjectChat.ts` read every one); none were dead, so none took the
* ADR-0049 route.
*
* `disabled` — also in the original intersection — is deliberately NOT
* redeclared: it is already `BaseSchema.disabled` (`boolean | string`), read
* generically by `SchemaRenderer` for every node type. Redeclaring it here as
* `boolean` only would have narrowed away the expression-string half of an
* inherited field for no reason; the last `it` below pins that it stays wide.
*
* ## Why the TS half is real enforcement, not decoration
*
* `BaseSchema` carries `[key: string]: any` (objectui#5155). Before this
* change, `autoResponseDelay: 'not-a-number'` on a `ChatbotSchema`-typed
* object type-checked FINE — the index signature swallowed the unlisted key
* as `any`. Declaring the field with its real type is what makes a wrong
* value refusable at the type level; the `@ts-expect-error` below is checked
* by `tsc -p tsconfig.test.json` (this package's `type-check` script chains
* it), so a regression that widens the field back toward `any` — or deletes
* it, letting the index signature re-absorb it — fails the build on the
* now-unused directive. A green test RUN cannot show this; only a green
* type-check can (AGENTS.md: "A green type-check is the proof a tombstone
* bites; a green test run is not" — the same instrument, applied to a
* declaration rather than a retirement).
*
* ## Why the Zod half is real enforcement, not decoration
*
* `BaseSchema`'s Zod mirror is `.passthrough()`, inherited through
* `.extend()`. Before this change `ChatbotSchema.safeParse({ ...,
* autoResponseDelay: 'not-a-number' })` returned `success: true` — an
* undeclared key rides through passthrough UNVALIDATED, wrong type and all.
* Mirroring the field is what turns that into a refusal.
*/

import { describe, it, expect } from 'vitest';
import type { ChatbotSchema, ChatMessage } from '../complex';
import { ChatbotSchema as ChatbotZodSchema } from '../zod/complex.zod';

const baseMessages: ChatMessage[] = [
{ id: '1', role: 'user', content: 'hi' },
];

describe('ChatbotSchema: the ten local-display/legacy keys are declared, not anonymous (objectui#6169)', () => {
it('accepts every key at its declared type on the TypeScript interface', () => {
const onSend: ChatbotSchema['onSend'] = (content, messages) => {
expect(typeof content).toBe('string');
expect(Array.isArray(messages)).toBe(true);
};

const node: ChatbotSchema = {
type: 'chatbot',
messages: baseMessages,
showTimestamp: true,
userAvatarUrl: 'https://example.com/user.png',
userAvatarFallback: 'You',
assistantAvatarUrl: 'https://example.com/assistant.png',
assistantAvatarFallback: 'AI',
maxHeight: '500px',
autoResponse: true,
autoResponseText: 'Thanks!',
autoResponseDelay: 1000,
onSend,
};

expect(node.showTimestamp).toBe(true);
expect(node.autoResponseDelay).toBe(1000);
node.onSend?.('hello', baseMessages);
});

it('refuses a wrong-typed value on a declared key — proof the field is no longer `any` via the index signature', () => {
const node: ChatbotSchema = {
type: 'chatbot',
messages: baseMessages,
// @ts-expect-error `autoResponseDelay` is declared `number`; before this
// field was named, `[key: string]: any` on `BaseSchema` swallowed any
// value at any unlisted key and this line type-checked.
autoResponseDelay: 'not-a-number',
};
// Runtime shape is unaffected by the type-level assertion above.
expect(node.type).toBe('chatbot');
});

it('keeps `disabled` inherited from BaseSchema (`boolean | string`), not redeclared narrower', () => {
// `BaseSchema.disabled` accepts an expression STRING (evaluated by
// `evaluator.evaluateCondition`), not just a boolean. If this key were
// redeclared inside the new group as `boolean` only, this line would be
// the one to catch it.
const node: ChatbotSchema = {
type: 'chatbot',
messages: baseMessages,
disabled: 'record.locked === true',
};
expect(node.disabled).toBe('record.locked === true');
});

it('accepts every key at its declared type through the Zod mirror', () => {
const result = ChatbotZodSchema.safeParse({
type: 'chatbot',
messages: [{ id: '1', role: 'user', content: 'hi' }],
showTimestamp: true,
userAvatarUrl: 'https://example.com/user.png',
userAvatarFallback: 'You',
assistantAvatarUrl: 'https://example.com/assistant.png',
assistantAvatarFallback: 'AI',
maxHeight: '500px',
autoResponse: true,
autoResponseText: 'Thanks!',
autoResponseDelay: 1000,
onSend: () => {},
});

expect(result.success).toBe(true);
});

it('refuses a wrong-typed value on a declared key through the Zod mirror (was silently passed through before)', () => {
const result = ChatbotZodSchema.safeParse({
type: 'chatbot',
messages: [],
autoResponseDelay: 'not-a-number',
});

expect(result.success).toBe(false);
if (!result.success) {
expect(
result.error.issues.some(
(issue) => issue.path.join('.') === 'autoResponseDelay' && issue.code === 'invalid_type',
),
).toBe(true);
}
});

it('lists all ten keys in the Zod mirror shape (mirrored, not merely passed through)', () => {
const shapeKeys = Object.keys(ChatbotZodSchema.shape);
for (const key of [
'showTimestamp',
'userAvatarUrl',
'userAvatarFallback',
'assistantAvatarUrl',
'assistantAvatarFallback',
'maxHeight',
'autoResponse',
'autoResponseText',
'autoResponseDelay',
'onSend',
]) {
expect(shapeKeys).toContain(key);
}
// `disabled` DOES appear here too — `.extend()`'s `.shape` merges the
// parent's fields into the child's, so BaseSchema's `disabled` shows up
// without this file's `.extend({...})` call ever naming it. That merge is
// exactly why it was correct not to re-declare `disabled` above: doing so
// would have SHADOWED the inherited `boolean | string` entry with a
// narrower one, rather than adding a new key.
expect(shapeKeys).toContain('disabled');
});
});
65 changes: 65 additions & 0 deletions packages/types/src/complex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,71 @@ export interface ChatbotSchema extends BaseSchema {
*/
onError?: (error: Error) => void;

// --- Local display + legacy auto-response fields (objectui#6169) ---
//
// Lifted from an anonymous inline intersection that used to live ONLY at
// `packages/plugin-chatbot/src/renderer.tsx`'s `chatbot` registration site
// (`ComponentRegistry.register('chatbot', ...)`), where nothing outside
// that one file could reference, validate, or document them. Each key
// below was read-site-censused before being declared here — every one has
// a live reader in `renderer.tsx` and/or `useObjectChat.ts`; none were
// dead. `disabled` is deliberately NOT redeclared in this group: it is
// already `BaseSchema.disabled` (`boolean | string`), read generically by
// `SchemaRenderer` for every node type, not specific to `chatbot`.

/**
* Display a timestamp on each message.
* @default false
*/
showTimestamp?: boolean;
/**
* URL of the user's avatar image.
*/
userAvatarUrl?: string;
/**
* Fallback text shown when `userAvatarUrl` is not set or fails to load.
* @default 'You'
*/
userAvatarFallback?: string;
/**
* URL of the assistant's avatar image.
*/
assistantAvatarUrl?: string;
/**
* Fallback text shown when `assistantAvatarUrl` is not set or fails to load.
* @default 'AI'
*/
assistantAvatarFallback?: string;
/**
* Maximum height of the chat message container (CSS value).
* @default '500px'
*/
maxHeight?: string;
/**
* Enable local auto-response (demo/playground) mode. Ignored once `api`
* is set — API mode replaces the local echo entirely.
* @default false
*/
autoResponse?: boolean;
/**
* The text of the local auto-response, used when `autoResponse` is true.
*/
autoResponseText?: string;
/**
* Delay in milliseconds before the local auto-response is sent.
* @default 1000
*/
autoResponseDelay?: number;
/**
* Called after a message is sent, in both API and local auto-response
* mode, with the trimmed content and the full message list at that
* point. `messages` here is the same authoring-side {@link ChatMessage}
* shape as the `messages` field above; the plugin's own runtime message
* type is a structural superset (objectui#4424) and still satisfies a
* handler typed against this narrower, published shape.
*/
onSend?: (content: string, messages: ChatMessage[]) => void;

// --- Floating / FAB display mode ---

/**
Expand Down
15 changes: 15 additions & 0 deletions packages/types/src/zod/complex.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,21 @@ export const ChatbotSchema = BaseSchema.extend({
maxToolRoundtrips: z.number().optional()
.describe('DEPRECATED (inert, slated for removal) — Max tool-calling round-trips. Nothing reads this; cap tool loops on the agent via planning.maxIterations'),
onError: z.function().optional().describe('Error callback'),
// --- Local display + legacy auto-response fields (objectui#6169) ---
// Mirrors the TS declaration added at ../complex.ts in lockstep, so these
// ten keys move from the pre-existing "declared but unmirrored, rides
// through .passthrough() unvalidated" state straight to mirrored — never
// through an interim unmirrored window.
showTimestamp: z.boolean().optional().describe('Display a timestamp on each message'),
userAvatarUrl: z.string().optional().describe("URL of the user's avatar image"),
userAvatarFallback: z.string().optional().describe('Fallback text for the user avatar'),
assistantAvatarUrl: z.string().optional().describe("URL of the assistant's avatar image"),
assistantAvatarFallback: z.string().optional().describe('Fallback text for the assistant avatar'),
maxHeight: z.string().optional().describe('Maximum height of the chat message container (CSS value)'),
autoResponse: z.boolean().optional().describe('Enable local auto-response (demo/playground) mode'),
autoResponseText: z.string().optional().describe('Text of the local auto-response'),
autoResponseDelay: z.number().optional().describe('Delay in milliseconds before the local auto-response is sent'),
onSend: z.function().optional().describe('Called after a message is sent, in both API and local auto-response mode'),
});

/**
Expand Down
Loading