Skip to content
Open
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
32 changes: 30 additions & 2 deletions apps/web/content/docs/api/chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ The root element is a `Card`. Every component maps to a Chat SDK card element.
<tr><td><code>Chart</code></td><td><code>chart</code>, as a pie, bar, line, or area chart</td></tr>
<tr><td><code>Divider</code></td><td><code>divider</code></td></tr>
<tr><td><code>Image</code></td><td><code>image</code></td></tr>
<tr><td><code>Actions</code>, <code>Button</code>, and <code>LinkButton</code></td><td><code>actions</code>, <code>button</code>, and <code>link-button</code></td></tr>
<tr><td><code>Actions</code>, <code>Button</code>, <code>LinkButton</code>, <code>Select</code>, and <code>RadioSelect</code></td><td>Action controls using each adapter's interaction and fallback behavior.</td></tr>
<tr><td><code>Columns</code> and <code>Carousel</code></td><td>Sections carrying an optional layout hint.</td></tr>
<tr><td><code>TaskList</code> and <code>Task</code></td><td>Task snapshots composed from sections and text.</td></tr>
<tr><td><code>Feedback</code></td><td>Text and two action buttons.</td></tr>
<tr><td><code>Link</code></td><td>An inline link.</td></tr>
</tbody>
</table>

Expand Down Expand Up @@ -96,11 +100,35 @@ await thread.post(toCard(spec));

`toPartialCard(spec)` returns a best-effort card for a spec that is still streaming. It keeps the elements whose props validate and returns `null` until the root `Card` exists.

## Portable composition

Use `Columns` and `Carousel` to group `Section` or nested `Card` items. They produce ordinary Chat SDK sections with a layout hint. Adapters that do not support the hint keep their existing vertical rendering; all items remain in source order. Native rendering of these hints is proposed in [Chat SDK PR #1126](https://github.com/vercel/chat/pull/1126). The released adapters currently used here stack the items; this package does not require an unreleased SDK dependency.

`Actions` also accepts `Select` and `RadioSelect`. Each takes `id`, `label`, `options` (objects with `label`, `value`, and optional `description`), optional `initialOption`, `optional`, and `dispatchAction`. Option values must be unique, and `initialOption` must match one of them. Interaction and submission behavior follow the adapter: Slack reports selections as actions, Teams uses its card submission flow, and support on other platforms follows Chat SDK and the adapter configuration. For example, Discord select controls require Components V2; the existing Embeds format does not render them.

`Feedback` produces ordinary buttons with action IDs `<id>:positive` and `<id>:negative`, and values `positive` and `negative`. Optional `label`, `positiveLabel`, and `negativeLabel` customize their text. Handle these through Chat SDK's normal action handlers.

`TaskList` holds `Task` children and accepts an optional `title`. Tasks take `title`, `status` (`pending`, `in_progress`, `complete`, or `error`), and optional `details` and `output`. They render as text sections representing a snapshot. For a live plan with lifecycle methods, use Chat SDK's `Plan` API directly. `Link` takes `label` and `url` and renders an inline link.

```json
{
"root": "report",
"elements": {
"report": { "type": "Card", "props": { "title": "Deployment" }, "children": ["tasks", "rating"] },
"tasks": { "type": "TaskList", "props": { "title": "Progress" }, "children": ["build"] },
"build": { "type": "Task", "props": { "title": "Build", "status": "complete", "output": "Build passed" }, "children": [] },
"rating": { "type": "Feedback", "props": { "id": "deployment-rating", "label": "Was this helpful?" }, "children": [] }
}
}
```

Post these specs with `thread.post(toCard(spec))`, or use `streamCard` to stream them. State expressions, visibility, repeats, validation, and expansion budgets apply as for the existing components. Nested cards retain their title, subtitle, image, and children. A `Columns`, `Carousel`, or untitled `TaskList` with no visible children is omitted.

## State and repeat

Specs can read from `spec.state`. `toCard` resolves `$state`, `$item`, `$index`, and `$template` expressions, drops elements whose `visible` condition is false, and renders the children of an element with `repeat` once per item. A `Table` with `repeat` uses its rows as a template, with one copy per item. A `Table` whose `rows` resolve to objects matches each header to the key with the same name.

A `Fields`, `Actions`, or `Section` left without children, for example after a `repeat` over an empty array, is omitted, since platforms reject empty groups. A posted message has no inputs, so `$bindState` and `$bindItem` have no effect.
A `Fields`, `Actions`, or `Section` left without children, for example after a `repeat` over an empty array, is omitted, since platforms reject empty groups. Selections report events to the bot; `$bindState` and `$bindItem` do not create local form state.

Expansion stops at `maxElements`, which defaults to 1000 elements and table rows, so nested repeats over state cannot grow without bound. `toCard` throws `CardSpecError` when a spec goes past it, and `toPartialCard` keeps what was expanded so far.

Expand Down
10 changes: 7 additions & 3 deletions apps/web/tests/fixtures/docs-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,9 @@
"title": "@json-render/chat",
"metadataTitle": "@json-render/chat API | json-render",
"prefix": "import { pageMetadata } from \"@/lib/page-metadata\"\nexport const metadata = pageMetadata(\"docs/api/chat\")\n\n# @json-render/chat\n",
"sourceSha256": "e4c727360fe598b2f8bb3435bc184d4936625045d4fffc39dce7950ebf75f917",
"bodySha256": "81250149f008c60473251a9b833c9bd6c90ac36b50fbbb03c17be6152ffdd161",
"markdownSha256": "0e1e1935eb8e952be1c1c14c2295523ef54d5ff5b6db63847ff4af5e55e7c8eb",
"sourceSha256": "3a9f899ef422b3ab9d455c487dd1fefd270ed2f3beacd8ed317760e76cfa96bf",
"bodySha256": "1cc4d6b0d457dfd9015006bbb3b634ba69339f295fec4083de36284f5cef0e3f",
"markdownSha256": "8b2a4804944a73d668f8a9ac77a523e955363ae90e079d1733d142f7b33be278",
"headings": [
{
"title": "Install",
Expand All @@ -211,6 +211,10 @@
"title": "toCard",
"id": "tocard"
},
{
"title": "Portable composition",
"id": "portable-composition"
},
{
"title": "State and repeat",
"id": "state-and-repeat"
Expand Down
28 changes: 26 additions & 2 deletions packages/chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,35 @@ chat.onNewMention(async (thread, message) => {

## Components

`Card` is the root. Inside it you can use `Section`, `Text`, `Fields` and `Field`, `Table`, `Chart` (pie, bar, line, area), `Divider`, `Image`, and `Actions` with `Button` and `LinkButton`. Each one maps to a Chat SDK card element.
`Card` is the root. Inside it you can use `Section`, `Text`, `Fields` and `Field`, `Table`, `Chart` (pie, bar, line, area), `Divider`, `Image`, and `Actions` with `Button` and `LinkButton`. Additional portable components include `Columns`, `Carousel`, `TaskList` with `Task`, `Feedback`, `Link`, and `Actions` with `Select` and `RadioSelect`. Each component maps to Chat SDK elements.

## Portable composition

Use `Columns` and `Carousel` to group `Section` or nested `Card` items. They produce ordinary Chat SDK sections with a layout hint. Adapters that do not support the hint keep their existing vertical rendering; all items remain in source order. Native rendering of these hints is proposed in [Chat SDK PR #1126](https://github.com/vercel/chat/pull/1126). The released adapters currently used here stack the items; this package does not require an unreleased SDK dependency.

`Actions` also accepts `Select` and `RadioSelect`. Each takes `id`, `label`, `options` (objects with `label`, `value`, and optional `description`), optional `initialOption`, `optional`, and `dispatchAction`. Option values must be unique, and `initialOption` must match one of them. Interaction and submission behavior follow the adapter: Slack reports selections as actions, Teams uses its card submission flow, and support on other platforms follows Chat SDK and the adapter configuration. For example, Discord select controls require Components V2; the existing Embeds format does not render them.

`Feedback` produces ordinary buttons with action IDs `<id>:positive` and `<id>:negative`, and values `positive` and `negative`. Optional `label`, `positiveLabel`, and `negativeLabel` customize their text. Handle these through Chat SDK's normal action handlers.

`TaskList` holds `Task` children and accepts an optional `title`. Tasks take `title`, `status` (`pending`, `in_progress`, `complete`, or `error`), and optional `details` and `output`. They render as text sections representing a snapshot. For a live plan with lifecycle methods, use Chat SDK's `Plan` API directly. `Link` takes `label` and `url` and renders an inline link.

```json
{
"root": "report",
"elements": {
"report": { "type": "Card", "props": { "title": "Deployment" }, "children": ["tasks", "rating"] },
"tasks": { "type": "TaskList", "props": { "title": "Progress" }, "children": ["build"] },
"build": { "type": "Task", "props": { "title": "Build", "status": "complete", "output": "Build passed" }, "children": [] },
"rating": { "type": "Feedback", "props": { "id": "deployment-rating", "label": "Was this helpful?" }, "children": [] }
}
}
```

Post these specs with `thread.post(toCard(spec))`, or use `streamCard` to stream them. State expressions, visibility, repeats, validation, and expansion budgets apply as for the existing components. Nested cards retain their title, subtitle, image, and children. A `Columns`, `Carousel`, or untitled `TaskList` with no visible children is omitted.

## State and repeat

`toCard` resolves `$state`, `$item`, `$index`, and `$template` expressions against `spec.state`, honors `visible` conditions, and expands `repeat`. A posted message has no inputs, so `$bindState` and `$bindItem` have no effect. Expansion stops at `maxElements` (1000 elements and table rows by default).
`toCard` resolves `$state`, `$item`, `$index`, and `$template` expressions against `spec.state`, honors `visible` conditions, and expands `repeat`. Selections report events to the bot; `$bindState` and `$bindItem` do not create local form state. Expansion stops at `maxElements` (1000 elements and table rows by default).

## Validation and limits

Expand Down
114 changes: 113 additions & 1 deletion packages/chat/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,39 @@ import { z } from "zod";
// Models often emit numbers for display values. Accept them and show them as text.
const displayText = z.union([z.string(), z.number()]).transform(String);

const choiceProps = z
.object({
id: z.string().min(1),
label: z.string().min(1),
options: z
.array(
z.object({
label: z.string().min(1),
value: z.string().min(1),
description: z.string().nullish(),
}),
)
.min(1),
initialOption: z.string().nullish(),
optional: z.boolean().nullish(),
dispatchAction: z.boolean().nullish(),
})
.superRefine((props, ctx) => {
const values = props.options.map((option) => option.value);
if (new Set(values).size !== values.length)
ctx.addIssue({
code: "custom",
path: ["options"],
message: "option values must be unique",
});
if (props.initialOption != null && !values.includes(props.initialOption))
ctx.addIssue({
code: "custom",
path: ["initialOption"],
message: "initialOption must match an option value",
});
});

const pieChart = z.object({
type: z.literal("pie"),
segments: z
Expand Down Expand Up @@ -45,6 +78,85 @@ export const standardComponentDefinitions = {
description: "Groups related elements.",
example: {},
},
Columns: {
props: z.object({}),
slots: ["default"],
description:
"Related Section or Card children arranged as columns when the adapter supports layout hints; otherwise stacked in order.",
example: {},
},
Carousel: {
props: z.object({}),
slots: ["default"],
description:
"Section or Card items presented as a carousel when supported; otherwise stacked with their content and controls.",
example: {},
},
TaskList: {
props: z.object({ title: z.string().nullish() }),
slots: ["default"],
description:
"A snapshot of related Task children, optionally headed by a title. Not a live Chat SDK Plan.",
example: { title: "Deploy" },
},
Task: {
props: z.object({
title: z.string().min(1),
status: z.enum(["pending", "in_progress", "complete", "error"]),
details: z.string().nullish(),
output: z.string().nullish(),
}),
slots: [],
description:
"Task title, status, optional details and output, rendered as portable text. Use inside TaskList.",
example: { title: "Build", status: "complete", output: "Build passed" },
},
Feedback: {
props: z.object({
id: z.string().min(1),
label: z.string().nullish(),
positiveLabel: z.string().min(1).nullish(),
negativeLabel: z.string().min(1).nullish(),
}),
slots: [],
description:
"Two ordinary action buttons using id:positive and id:negative action IDs, with positive and negative values. Supports the adapter's normal button behavior.",
example: { id: "answer-feedback", label: "Was this helpful?" },
},
Link: {
props: z.object({ label: z.string().min(1), url: z.string().url() }),
slots: [],
description: "An inline link rendered in each platform's text format.",
example: { label: "Build log", url: "https://example.com/build" },
},
Select: {
props: choiceProps,
slots: [],
description:
"Single-choice dropdown inside Actions. Control availability and submission behavior follow Chat SDK and the adapter configuration.",
example: {
id: "environment",
label: "Environment",
options: [
{ label: "Production", value: "production" },
{ label: "Preview", value: "preview" },
],
},
},
RadioSelect: {
props: choiceProps,
slots: [],
description:
"Single-choice radio group inside Actions. Adapter support and submission behavior follow Chat SDK.",
example: {
id: "priority",
label: "Priority",
options: [
{ label: "Normal", value: "normal" },
{ label: "Urgent", value: "urgent" },
],
},
},
Text: {
props: z.object({
content: z.string().min(1),
Expand Down Expand Up @@ -117,7 +229,7 @@ export const standardComponentDefinitions = {
Actions: {
props: z.object({}),
slots: ["default"],
description: "Row of Button and LinkButton elements.",
description: "Row of Button, LinkButton, Select, and RadioSelect elements.",
example: {},
},
Button: {
Expand Down
23 changes: 22 additions & 1 deletion packages/chat/src/elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import type {
FieldsElement,
ImageElement,
LinkButtonElement,
LinkElement,
SelectElement,
RadioSelectElement,
SectionElement,
TableElement,
TextElement,
Expand Down Expand Up @@ -37,11 +40,29 @@ export const image = (url: string, alt?: string): ImageElement => ({

export const divider = (): DividerElement => ({ type: "divider" });

export const section = (children: CardChild[]): SectionElement => ({
// Older Chat SDK adapters ignore layout hints and keep the section's content.
export type SectionLayout = "stack" | "columns" | "carousel";
export const section = (
children: CardChild[],
layout?: SectionLayout,
): SectionElement & { layout?: SectionLayout } => ({
type: "section",
children,
...(layout ? { layout } : {}),
});

export const link = (label: string, url: string): LinkElement => ({
type: "link",
label,
url,
});
export const select = (
options: Omit<SelectElement, "type">,
): SelectElement => ({ type: "select", ...options });
export const radioSelect = (
options: Omit<RadioSelectElement, "type">,
): RadioSelectElement => ({ type: "radio_select", ...options });

export const field = (label: string, value: string): FieldElement => ({
type: "field",
label,
Expand Down
Loading
Loading