diff --git a/lib/render/__tests__/llm-image-output.e2e.test.ts b/lib/render/__tests__/llm-image-output.e2e.test.ts index cced67595..7e01679d8 100644 --- a/lib/render/__tests__/llm-image-output.e2e.test.ts +++ b/lib/render/__tests__/llm-image-output.e2e.test.ts @@ -112,7 +112,7 @@ describe.skipIf(!RUN)('LLM inline image output (E2E)', () => { // src MUST be one of the fixture image URLs — no fabrication. expect(fixtureSrcs.has(src)).toBe(true) } - if (el.type === 'QuestionButton') { + if (el.type === 'Button') { hasRelatedQuestions = true } } diff --git a/lib/render/__tests__/migrations.test.ts b/lib/render/__tests__/migrations.test.ts new file mode 100644 index 000000000..ba4ca8b50 --- /dev/null +++ b/lib/render/__tests__/migrations.test.ts @@ -0,0 +1,148 @@ +import type { Spec } from '@json-render/core' +import { describe, expect, test } from 'vitest' + +import { migrateSpec, type MigrationMap } from '../migrations' + +// Synthetic fixture so the tests exercise the migration infrastructure +// without committing to any real catalog entries. +const fixtureMigrations: MigrationMap = { + LegacyHeading: { to: 'Heading' }, + LegacyButton: { + to: 'Button', + defaultProps: { variant: 'link', icon: 'arrow-right' } + }, + // Chained rename: IntermediateHeading was itself renamed to LegacyHeading + // at some point, and LegacyHeading is now Heading. + IntermediateHeading: { to: 'LegacyHeading' } +} + +function makeLegacySpec(): Spec { + return { + root: 'main', + elements: { + main: { + type: 'Stack', + props: { direction: 'vertical' }, + children: ['header', 'questions'] + }, + header: { + type: 'LegacyHeading', + props: { title: 'Related' }, + children: [] + }, + questions: { + type: 'Stack', + props: { direction: 'vertical' }, + children: ['q1'] + }, + q1: { + type: 'LegacyButton', + props: { text: 'First question' }, + on: { + press: { action: 'submitQuery', params: { query: 'First question' } } + }, + children: [] + } + } + } as unknown as Spec +} + +describe('migrateSpec', () => { + test('renames legacy type names to their current catalog names', () => { + const spec = makeLegacySpec() + const result = migrateSpec(spec, fixtureMigrations) + + const elements = result.elements as Record + expect(elements.header.type).toBe('Heading') + expect(elements.q1.type).toBe('Button') + // Untouched types stay the same. + expect(elements.main.type).toBe('Stack') + expect(elements.questions.type).toBe('Stack') + }) + + test('merges defaultProps without overriding existing props', () => { + const spec = makeLegacySpec() + const result = migrateSpec(spec, fixtureMigrations) + const q1 = ( + result.elements as Record }> + ).q1 + + // Existing prop preserved. + expect(q1.props.text).toBe('First question') + // Default props applied. + expect(q1.props.variant).toBe('link') + expect(q1.props.icon).toBe('arrow-right') + }) + + test('does not override an existing prop that happens to match a default', () => { + const spec: Spec = { + root: 'q1', + elements: { + q1: { + type: 'LegacyButton', + props: { text: 'X', variant: 'outline' }, + children: [] + } + } + } as unknown as Spec + + const result = migrateSpec(spec, fixtureMigrations) + const q1 = ( + result.elements as Record }> + ).q1 + // LegacyButton's default variant is "link" but the original spec + // already declared "outline" — that must win. + expect(q1.props.variant).toBe('outline') + // icon default still fills in because it was not provided. + expect(q1.props.icon).toBe('arrow-right') + }) + + test('is idempotent — running the migration twice yields the same result', () => { + const spec = makeLegacySpec() + const once = migrateSpec(spec, fixtureMigrations) + const twice = migrateSpec(once, fixtureMigrations) + expect(twice).toEqual(once) + }) + + test('resolves chained renames through to the final target', () => { + const spec: Spec = { + root: 'header', + elements: { + header: { + type: 'IntermediateHeading', + props: { title: 'Chained' }, + children: [] + } + } + } as unknown as Spec + + const result = migrateSpec(spec, fixtureMigrations) + const header = (result.elements as Record).header + expect(header.type).toBe('Heading') + }) + + test('returns the same object reference when no changes are needed', () => { + const spec: Spec = { + root: 'main', + elements: { + main: { type: 'Heading', props: { title: 'Up to date' }, children: [] } + } + } as unknown as Spec + + const result = migrateSpec(spec, fixtureMigrations) + expect(result).toBe(spec) + }) + + test('leaves unknown types untouched so the renderer can report them', () => { + const spec: Spec = { + root: 'x', + elements: { + x: { type: 'TotallyUnknown', props: {}, children: [] } + } + } as unknown as Spec + + const result = migrateSpec(spec, fixtureMigrations) + const x = (result.elements as Record).x + expect(x.type).toBe('TotallyUnknown') + }) +}) diff --git a/lib/render/__tests__/parse-spec-block.test.ts b/lib/render/__tests__/parse-spec-block.test.ts index 5d88d500d..631af1994 100644 --- a/lib/render/__tests__/parse-spec-block.test.ts +++ b/lib/render/__tests__/parse-spec-block.test.ts @@ -10,7 +10,7 @@ function buildRelatedQuestionsSource(questions: string[]): string { ] questions.forEach((q, i) => { lines.push( - `{"op":"add","path":"/elements/q${i + 1}","value":{"type":"QuestionButton","props":{"text":"${q}"},"on":{"press":{"action":"submitQuery","params":{"query":"${q}"}}},"children":[]}}` + `{"op":"add","path":"/elements/q${i + 1}","value":{"type":"Button","props":{"text":"${q}","variant":"link","icon":"arrow-right"},"on":{"press":{"action":"submitQuery","params":{"query":"${q}"}}},"children":[]}}` ) }) return lines.join('\n') @@ -93,8 +93,12 @@ describe('parseSpecBlock', () => { const spec = parseSpecBlock(source) expect(spec.root).toBe('main') expect(Object.keys(spec.elements)).toHaveLength(4) // main + q1 + q2 + q3 - expect(spec.elements['q1'].type).toBe('QuestionButton') - expect(spec.elements['q1'].props).toEqual({ text: 'Question 1' }) + expect(spec.elements['q1'].type).toBe('Button') + expect(spec.elements['q1'].props).toMatchObject({ + text: 'Question 1', + variant: 'link', + icon: 'arrow-right' + }) }) test('throws for invalid component types', () => { diff --git a/lib/render/catalog.ts b/lib/render/catalog.ts index 346fede64..4afdf0a4d 100644 --- a/lib/render/catalog.ts +++ b/lib/render/catalog.ts @@ -2,14 +2,16 @@ import { z } from 'zod' import { schema } from './schema' +const iconName = z.enum(['related', 'arrow-right']).optional() + export const catalog = schema.createCatalog({ components: { - SectionHeader: { + Heading: { props: z.object({ title: z.string(), - icon: z.enum(['related']).optional() + icon: iconName }), - description: 'A section heading label with optional icon' + description: 'A heading label with an optional icon' }, Stack: { props: z @@ -21,11 +23,16 @@ export const catalog = schema.createCatalog({ description: 'A layout container that stacks children vertically or horizontally' }, - QuestionButton: { + Button: { props: z.object({ - text: z.string() + text: z.string(), + icon: iconName, + variant: z + .enum(['default', 'outline', 'ghost', 'link', 'secondary']) + .optional() }), - description: 'A related follow-up question the user can click to ask' + description: + 'A clickable button that emits a press action. Use variant="link" with icon="arrow-right" for inline follow-up suggestions.' }, Grid: { props: z.object({ diff --git a/lib/render/components/button.tsx b/lib/render/components/button.tsx new file mode 100644 index 000000000..3a3a20180 --- /dev/null +++ b/lib/render/components/button.tsx @@ -0,0 +1,39 @@ +'use client' + +import type { ComponentFn } from '@json-render/react' + +import { cn } from '@/lib/utils' + +import { Button as UIButton } from '@/components/ui/button' + +import { type CatalogType, iconMap } from './shared' + +/** + * Generic button spec component. Renders the project's shadcn Button and + * emits a `press` action on click. Use variant="link" with an arrow icon + * for inline follow-up suggestions (the former QuestionButton look). + */ +export const Button: ComponentFn = ({ props, on }) => { + const { text, icon, variant = 'link' } = props + const Icon = icon ? iconMap[icon] : null + const handle = on('press') + + // For the link variant, tweak layout/color so it reads as an inline + // follow-up suggestion: muted color, left-aligned, wrappable text and + // zero padding. The shadcn link variant's hover underline is preserved. + const linkOverride = + variant === 'link' + ? 'h-auto w-fit justify-start whitespace-normal text-left px-0 py-0.5 font-semibold text-accent-foreground/50 hover:text-accent-foreground/70' + : '' + + return ( + + {Icon && } + {text} + + ) +} diff --git a/lib/render/components/section-header.tsx b/lib/render/components/heading.tsx similarity index 57% rename from lib/render/components/section-header.tsx rename to lib/render/components/heading.tsx index 84e4df6d6..47fe12907 100644 --- a/lib/render/components/section-header.tsx +++ b/lib/render/components/heading.tsx @@ -1,17 +1,10 @@ 'use client' import type { ComponentFn } from '@json-render/react' -import { Repeat2 } from 'lucide-react' -import type { CatalogType } from './shared' +import { type CatalogType, iconMap } from './shared' -const iconMap = { - related: Repeat2 -} as const - -export const SectionHeader: ComponentFn = ({ - props -}) => { +export const Heading: ComponentFn = ({ props }) => { const Icon = props.icon ? iconMap[props.icon] : null return (
diff --git a/lib/render/components/question-button.tsx b/lib/render/components/question-button.tsx deleted file mode 100644 index bc56c76b4..000000000 --- a/lib/render/components/question-button.tsx +++ /dev/null @@ -1,25 +0,0 @@ -'use client' - -import type { ComponentFn } from '@json-render/react' -import { ArrowRight } from 'lucide-react' - -import type { CatalogType } from './shared' - -export const QuestionButton: ComponentFn = ({ - props, - on -}) => { - const handle = on('press') - return ( -
- - -
- ) -} diff --git a/lib/render/components/shared.ts b/lib/render/components/shared.ts index 562699cef..e9319a344 100644 --- a/lib/render/components/shared.ts +++ b/lib/render/components/shared.ts @@ -1,3 +1,5 @@ +import { ArrowRight, Repeat2 } from 'lucide-react' + import { catalog } from '../catalog' export type CatalogType = typeof catalog @@ -8,3 +10,14 @@ export const stackGap = { md: 'gap-3', lg: 'gap-4' } as const + +/** + * Icons available to any spec component that accepts an `icon` prop + * (Heading, Button, etc). Keep this small and curated. + */ +export const iconMap = { + related: Repeat2, + 'arrow-right': ArrowRight +} as const + +export type IconName = keyof typeof iconMap diff --git a/lib/render/migrations.ts b/lib/render/migrations.ts new file mode 100644 index 000000000..74859b71b --- /dev/null +++ b/lib/render/migrations.ts @@ -0,0 +1,106 @@ +import type { Spec } from '@json-render/core' + +/** + * Describes how a legacy spec element `type` should be rewritten when the + * catalog changes. Only type renames and additive prop defaults are + * supported. For value transformations or structural changes, add a proper + * migration function pathway when an actual case demands it. + */ +export type TypeMigration = { + /** New catalog type name. */ + to: string + /** + * Props merged into the migrated element when the corresponding key is + * absent from the original element's props. Never overrides existing + * values, so the migration remains safe to run against specs that were + * authored against the new catalog. + */ + defaultProps?: Record +} + +export type MigrationMap = Record + +/** + * Parse-time migration table for renamed or removed spec component types. + * + * Keep this minimal. Only add an entry when a real incompatibility must be + * preserved for historical messages — this map is effectively a forever + * compatibility promise. Purely additive catalog changes (new components, + * new optional props) do NOT require an entry. + */ +export const typeMigrations: MigrationMap = {} + +type SpecElement = { + type: string + props?: Record + children?: string[] + on?: Record +} + +/** + * Resolves a migration chain (`A -> B -> C`) to its final target so callers + * always see the latest catalog name, even when a type has been renamed + * multiple times. Default props accumulated along the chain are merged with + * later entries taking precedence over earlier ones. + */ +function resolveMigration( + type: string, + migrations: MigrationMap +): TypeMigration | null { + const first = migrations[type] + if (!first) return null + + let current: TypeMigration = first + const seen = new Set([type]) + while (migrations[current.to]) { + if (seen.has(current.to)) break // cycle guard + seen.add(current.to) + const next = migrations[current.to] + current = { + to: next.to, + defaultProps: { ...current.defaultProps, ...next.defaultProps } + } + } + return current +} + +/** + * Rewrites legacy element `type`s in a spec to their current catalog names + * using the migration table. Pure and idempotent: running it multiple times + * on the same spec yields the same result, so it is safe to apply on every + * incremental parse of a streaming spec block. + * + * Unknown types (not in catalog and not in migrations) are left untouched; + * the downstream renderer is responsible for logging/skipping them. + */ +export function migrateSpec( + spec: Spec, + migrations: MigrationMap = typeMigrations +): Spec { + const elements = spec.elements as Record | undefined + if (!elements) return spec + + let changed = false + const migrated: Record = {} + + for (const [key, element] of Object.entries(elements)) { + const migration = resolveMigration(element.type, migrations) + if (!migration) { + migrated[key] = element + continue + } + + changed = true + migrated[key] = { + ...element, + type: migration.to, + props: { + ...migration.defaultProps, + ...(element.props ?? {}) + } + } + } + + if (!changed) return spec + return { ...spec, elements: migrated } as Spec +} diff --git a/lib/render/parse-spec-block.ts b/lib/render/parse-spec-block.ts index ade014128..88a740a2f 100644 --- a/lib/render/parse-spec-block.ts +++ b/lib/render/parse-spec-block.ts @@ -7,6 +7,7 @@ import { } from '@json-render/core' import { catalog } from './catalog' +import { migrateSpec } from './migrations' function compileSource(source: string): Spec { return compileSpecStream(source, { @@ -62,21 +63,25 @@ export function createPartialSpecParser(): PartialSpecParser { } if (source === lastSource) { - const result = prunePartialSpec(compiler.getResult() as Spec) + const result = migrateSpec( + prunePartialSpec(compiler.getResult() as Spec) + ) return result.root ? result : null } if (source.startsWith(lastSource)) { compiler.push(source.slice(lastSource.length)) lastSource = source - const result = prunePartialSpec(compiler.getResult() as Spec) + const result = migrateSpec( + prunePartialSpec(compiler.getResult() as Spec) + ) return result.root ? result : null } compiler = createCompiler() compiler.push(source) lastSource = source - const result = prunePartialSpec(compiler.getResult() as Spec) + const result = migrateSpec(prunePartialSpec(compiler.getResult() as Spec)) return result.root ? result : null }, reset() { @@ -87,7 +92,11 @@ export function createPartialSpecParser(): PartialSpecParser { } export function parseSpecBlock(source: string): Spec { - const validation = catalog.validate(compileSource(source)) + // Apply legacy type migrations before catalog validation so that old + // specs persisted in chat history can still validate against the current + // catalog. + const compiled = migrateSpec(compileSource(source)) + const validation = catalog.validate(compiled) if (!validation.success || !validation.data) { const issues = validation.error?.issues diff --git a/lib/render/prompt.ts b/lib/render/prompt.ts index 4dcdd57b7..6923422ab 100644 --- a/lib/render/prompt.ts +++ b/lib/render/prompt.ts @@ -10,24 +10,24 @@ Each question should explore a different aspect of the topic not yet covered. Questions must be concise (max 10-12 words) and in the user's language. The spec block uses JSONL (one JSON object per line) with RFC 6902 JSON Patch operations. -Always include a SectionHeader with title "Related" as the first child element. +Always include a Heading with title "Related" as the first child element. Example output (always at the very end of your response): \`\`\`spec {"op":"add","path":"/root","value":"main"} {"op":"add","path":"/elements/main","value":{"type":"Stack","props":{"direction":"vertical","gap":"sm"},"children":["header","questions"]}} -{"op":"add","path":"/elements/header","value":{"type":"SectionHeader","props":{"title":"Related","icon":"related"},"children":[]}} +{"op":"add","path":"/elements/header","value":{"type":"Heading","props":{"title":"Related","icon":"related"},"children":[]}} {"op":"add","path":"/elements/questions","value":{"type":"Stack","props":{"direction":"vertical","gap":"xs"},"children":["q1","q2","q3"]}} -{"op":"add","path":"/elements/q1","value":{"type":"QuestionButton","props":{"text":"First follow-up question here"},"on":{"press":{"action":"submitQuery","params":{"query":"First follow-up question here"}}},"children":[]}} -{"op":"add","path":"/elements/q2","value":{"type":"QuestionButton","props":{"text":"Second follow-up question here"},"on":{"press":{"action":"submitQuery","params":{"query":"Second follow-up question here"}}},"children":[]}} -{"op":"add","path":"/elements/q3","value":{"type":"QuestionButton","props":{"text":"Third follow-up question here"},"on":{"press":{"action":"submitQuery","params":{"query":"Third follow-up question here"}}},"children":[]}} +{"op":"add","path":"/elements/q1","value":{"type":"Button","props":{"text":"First follow-up question here","variant":"link","icon":"arrow-right"},"on":{"press":{"action":"submitQuery","params":{"query":"First follow-up question here"}}},"children":[]}} +{"op":"add","path":"/elements/q2","value":{"type":"Button","props":{"text":"Second follow-up question here","variant":"link","icon":"arrow-right"},"on":{"press":{"action":"submitQuery","params":{"query":"Second follow-up question here"}}},"children":[]}} +{"op":"add","path":"/elements/q3","value":{"type":"Button","props":{"text":"Third follow-up question here","variant":"link","icon":"arrow-right"},"on":{"press":{"action":"submitQuery","params":{"query":"Third follow-up question here"}}},"children":[]}} \`\`\` AVAILABLE COMPONENTS: -- SectionHeader: { title: string, icon?: "related" } - A section heading label with optional icon +- Heading: { title: string, icon?: "related" | "arrow-right" } - A heading label with optional icon - Stack: { direction?: "vertical" | "horizontal", gap?: "xs" | "sm" | "md" | "lg" } - Layout container -- QuestionButton: { text: string } - A clickable follow-up question button +- Button: { text: string, icon?: "related" | "arrow-right", variant?: "default" | "outline" | "ghost" | "link" | "secondary" } - A clickable button that emits a press action. Use variant="link" with icon="arrow-right" for inline follow-up suggestions. AVAILABLE ACTIONS: - submitQuery: { query: string } - Submit a follow-up query diff --git a/lib/render/registry.tsx b/lib/render/registry.tsx index c8233528f..79c3f680d 100644 --- a/lib/render/registry.tsx +++ b/lib/render/registry.tsx @@ -2,18 +2,18 @@ import { defineRegistry } from '@json-render/react' +import { Button } from './components/button' import { Grid } from './components/grid' +import { Heading } from './components/heading' import { Image } from './components/image' -import { QuestionButton } from './components/question-button' -import { SectionHeader } from './components/section-header' import { Stack } from './components/stack' import { catalog } from './catalog' export const { registry } = defineRegistry(catalog, { components: { - SectionHeader, + Heading, Stack, - QuestionButton, + Button, Grid, Image },