feat(ui): add streaming state and loading skeleton support (#70)

implement short-term generative UI improvements:

- Add StreamingState type ("started" | "streaming" | "done") to ComponentContext
- Add loadingComponent field to catalog for Card, StatCard, DataTable, SchedulePreview
- Create skeleton components (CardSkeleton, StatCardSkeleton, etc.) with animate-pulse
- Add PropSkeleton component for different prop types (text, badge, table-row, etc.)
- Add useStreamingProps/useStreamingProp hooks to track prop arrival during streaming
- Add ComponentLoadingWrapper for prop-level skeleton display (opt-in via enablePropSkeletons)
- Fix JSON parse errors in API routes when request body is incomplete

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
This commit is contained in:
Nicholai 2026-02-12 06:36:21 -07:00 committed by GitHub
parent 33b427ed33
commit 337117f895
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 629 additions and 27 deletions

View File

@ -15,9 +15,17 @@ export async function POST(
)
}
const body = await req.json() as {
action?: string
params?: Record<string, unknown>
let body: { action?: string; params?: Record<string, unknown> }
try {
body = (await req.json()) as {
action?: string
params?: Record<string, unknown>
}
} catch {
return Response.json(
{ success: false, error: "Invalid JSON body" },
{ status: 400 },
)
}
const { action, params } = body

View File

@ -61,11 +61,21 @@ export async function POST(
return new Response("Unauthorized", { status: 401 })
}
const { prompt, context } = (await req.json()) as {
prompt: string
context?: Record<string, unknown>
let body: { prompt?: string; context?: Record<string, unknown> }
try {
body = (await req.json()) as {
prompt?: string
context?: Record<string, unknown>
}
} catch {
return new Response(
JSON.stringify({ error: "Invalid JSON body" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
)
}
const { prompt, context } = body
const previousSpec = context?.previousSpec as
| { root?: string; elements?: Record<string, unknown> }
| undefined

View File

@ -58,8 +58,14 @@ export async function POST(req: Request): Promise<Response> {
const pluginSections = registry.getPromptSections()
const pluginTools = registry.getTools()
const body = (await req.json()) as {
messages: UIMessage[]
let body: { messages: UIMessage[] }
try {
body = (await req.json()) as { messages: UIMessage[] }
} catch {
return new Response(
JSON.stringify({ error: "Invalid JSON body" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
)
}
const currentPage =

View File

@ -0,0 +1,111 @@
import { type ReactNode, useMemo } from "react"
import { PropSkeleton, type PropSkeletonType } from "./prop-skeleton"
export interface PropConfig {
readonly name: string
readonly type: PropSkeletonType
readonly required?: boolean
readonly fallback?: ReactNode
}
interface ComponentLoadingWrapperProps {
readonly props: Record<string, unknown>
readonly propConfigs: PropConfig[]
readonly children: ReactNode
readonly loading?: boolean
readonly showSkeletonsForMissing?: boolean
}
// Maps prop types to skeleton renderers
function renderSkeleton(type: PropSkeletonType): ReactNode {
return <PropSkeleton type={type} />
}
// Wraps components and shows skeletons for props that haven't arrived yet
export function ComponentLoadingWrapper({
props,
propConfigs,
children,
loading,
showSkeletonsForMissing = true,
}: ComponentLoadingWrapperProps): ReactNode {
const { hasAllRequired, missingProps } = useMemo(() => {
const missing: Array<{ name: string; type: PropSkeletonType }> = []
let hasAll = true
for (const config of propConfigs) {
const value = props[config.name]
const hasValue =
value !== undefined && value !== null && value !== ""
if (!hasValue) {
if (config.required !== false) {
hasAll = false
}
if (showSkeletonsForMissing) {
missing.push({ name: config.name, type: config.type })
}
}
}
return { hasAllRequired: hasAll, missingProps: missing }
}, [props, propConfigs, showSkeletonsForMissing])
// If component is in global loading state, show full skeleton
if (loading) {
return (
<div className="space-y-2">
{propConfigs.map((config) => (
<div key={config.name}>{renderSkeleton(config.type)}</div>
))}
</div>
)
}
// If missing required props, show skeleton placeholders
if (!hasAllRequired && showSkeletonsForMissing) {
return (
<div className="space-y-2">
{propConfigs.map((config) => {
const value = props[config.name]
const hasValue =
value !== undefined && value !== null && value !== ""
if (hasValue) {
// Show the actual value if present (partial rendering)
return <div key={config.name}>{String(value)}</div>
}
return <div key={config.name}>{renderSkeleton(config.type)}</div>
})}
</div>
)
}
// All props available, render the component
return children
}
// Predefined prop configs for common component patterns
export const commonPropConfigs = {
card: [
{ name: "title", type: "text-short" as const, required: true },
{ name: "description", type: "text" as const },
{ name: "badge", type: "badge" as const },
],
tableRow: [
{ name: "name", type: "text-short" as const, required: true },
{ name: "status", type: "badge" as const },
{ name: "date", type: "date" as const },
{ name: "value", type: "number" as const },
],
stat: [
{ name: "label", type: "text-short" as const, required: true },
{ name: "value", type: "number" as const, required: true },
],
userCard: [
{ name: "name", type: "text-short" as const, required: true },
{ name: "role", type: "badge" as const },
{ name: "avatar", type: "avatar" as const },
],
}

View File

@ -0,0 +1,88 @@
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
export type PropSkeletonType =
| "text"
| "text-short"
| "number"
| "date"
| "badge"
| "badge-lg"
| "avatar"
| "table-row"
| "card"
| "button"
interface PropSkeletonProps {
readonly type: PropSkeletonType
readonly className?: string
readonly count?: number
}
const skeletonVariants: Record<PropSkeletonType, string> = {
text: "h-4 w-full",
"text-short": "h-4 w-24",
number: "h-4 w-16",
date: "h-4 w-28",
badge: "h-5 w-16 rounded-full",
"badge-lg": "h-6 w-20 rounded-full",
avatar: "h-8 w-8 rounded-full",
"table-row": "h-12 w-full rounded-md",
card: "h-32 w-full rounded-lg",
button: "h-9 w-24 rounded-md",
}
export function PropSkeleton({
type,
className,
count = 1,
}: PropSkeletonProps): React.ReactNode {
const baseClasses = skeletonVariants[type]
if (count > 1) {
return (
<div className="space-y-2">
{Array.from({ length: count }).map((_, i) => (
<Skeleton key={i} className={cn(baseClasses, className)} />
))}
</div>
)
}
return <Skeleton className={cn(baseClasses, className)} />
}
// Convenience components for common patterns
export function TextSkeleton({ className }: { className?: string }) {
return <PropSkeleton type="text" className={className} />
}
export function BadgeSkeleton({ className }: { className?: string }) {
return <PropSkeleton type="badge" className={className} />
}
export function TableRowSkeleton({
columns = 4,
className,
}: {
columns?: number
className?: string
}) {
return (
<div className={cn("flex items-center gap-4 py-3", className)}>
{Array.from({ length: columns }).map((_, i) => (
<Skeleton key={i} className="h-4 flex-1" />
))}
</div>
)
}
export function CardSkeleton({ className }: { className?: string }) {
return (
<div className={cn("space-y-3 rounded-lg border p-4", className)}>
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-20 w-full" />
</div>
)
}

View File

@ -0,0 +1,157 @@
import { useRef, useEffect, useState } from "react"
interface StreamingPropState {
readonly isStreaming: boolean
readonly hasValue: boolean
readonly arrivedAt: number | null
}
type StreamingPropsState = Record<string, StreamingPropState>
interface UseStreamingPropsResult {
readonly props: StreamingPropsState
readonly isPropStreaming: (propName: string) => boolean
readonly hasPropValue: (propName: string) => boolean
readonly allPropsArrived: boolean
readonly pendingProps: string[]
}
interface UseStreamingPropsOptions {
readonly propNames?: string[]
readonly onPropArrived?: (propName: string) => void
readonly onAllArrived?: () => void
}
// Tracks streaming state for component props
// Useful for showing prop-level skeletons during incremental rendering
export function useStreamingProps(
currentProps: Record<string, unknown>,
options: UseStreamingPropsOptions = {}
): UseStreamingPropsResult {
const { propNames, onPropArrived, onAllArrived } = options
const [props, setProps] = useState<StreamingPropsState>({})
const prevPropsRef = useRef<Record<string, unknown>>({})
useEffect(() => {
const trackedProps = propNames ?? Object.keys(currentProps)
const updates: StreamingPropsState = {}
const arrivedCallbacks: string[] = []
for (const propName of trackedProps) {
const currentValue = currentProps[propName]
const prevValue = prevPropsRef.current[propName]
const hadValue =
prevValue !== undefined && prevValue !== null && prevValue !== ""
const nowHasValue =
currentValue !== undefined &&
currentValue !== null &&
currentValue !== ""
// Check if this prop just arrived
if (!hadValue && nowHasValue) {
updates[propName] = {
isStreaming: false,
hasValue: true,
arrivedAt: Date.now(),
}
arrivedCallbacks.push(propName)
} else if (hadValue && !nowHasValue) {
// Value was removed (shouldn't normally happen, but handle it)
updates[propName] = {
isStreaming: false,
hasValue: false,
arrivedAt: null,
}
} else {
// Keep existing state
updates[propName] = {
isStreaming: !nowHasValue,
hasValue: nowHasValue,
arrivedAt: props[propName]?.arrivedAt ?? null,
}
}
}
setProps((prev) => ({ ...prev, ...updates }))
prevPropsRef.current = { ...currentProps }
// Fire callbacks after state update
for (const propName of arrivedCallbacks) {
onPropArrived?.(propName)
}
// Check if all props arrived
const allArrived = trackedProps.every(
(name) => updates[name]?.hasValue ?? props[name]?.hasValue
)
if (allArrived && arrivedCallbacks.length > 0) {
onAllArrived?.()
}
}, [currentProps, propNames, onPropArrived, onAllArrived, props])
const isPropStreaming = (propName: string): boolean => {
return props[propName]?.isStreaming ?? true
}
const hasPropValue = (propName: string): boolean => {
return props[propName]?.hasValue ?? false
}
const pendingProps = Object.entries(props)
.filter(([, state]) => state.isStreaming)
.map(([name]) => name)
const allPropsArrived = pendingProps.length === 0
return {
props,
isPropStreaming,
hasPropValue,
allPropsArrived,
pendingProps,
}
}
// Simpler hook for tracking a single prop's streaming state
export function useStreamingProp(
value: unknown,
options: {
readonly onArrived?: () => void
} = {}
): StreamingPropState {
const { onArrived } = options
const [state, setState] = useState<StreamingPropState>({
isStreaming: true,
hasValue: false,
arrivedAt: null,
})
const prevValueRef = useRef<unknown>(undefined)
useEffect(() => {
const hasValue =
value !== undefined && value !== null && value !== ""
const hadValue =
prevValueRef.current !== undefined &&
prevValueRef.current !== null &&
prevValueRef.current !== ""
if (!hadValue && hasValue) {
setState({
isStreaming: false,
hasValue: true,
arrivedAt: Date.now(),
})
onArrived?.()
} else if (!hasValue) {
setState({
isStreaming: true,
hasValue: false,
arrivedAt: null,
})
}
prevValueRef.current = value
}, [value, onArrived])
return state
}

View File

@ -15,6 +15,7 @@ export const compassCatalog = defineCatalog(schema, {
centered: z.boolean().nullable(),
}),
slots: ["default"],
loadingComponent: "CardSkeleton",
description:
"Container card for content sections. " +
"Use as root for dashboards.",
@ -340,6 +341,7 @@ export const compassCatalog = defineCatalog(schema, {
change: z.number().nullable(),
changeLabel: z.string().nullable(),
}),
loadingComponent: "StatCardSkeleton",
description:
"Single metric with optional trend indicator",
},
@ -371,6 +373,7 @@ export const compassCatalog = defineCatalog(schema, {
)
.nullable(),
}),
loadingComponent: "DataTableSkeleton",
description:
"Tabular data display with columns. " +
"Best for lists of records. Use rowActions " +
@ -429,11 +432,44 @@ export const compassCatalog = defineCatalog(schema, {
maxTasks: z.number().nullable(),
groupByPhase: z.boolean().nullable(),
}),
loadingComponent: "SchedulePreviewSkeleton",
description:
"Schedule/timeline display with phase grouping. " +
"ALWAYS prefer this over composing schedule " +
"displays from Heading+Text+Progress+Badge primitives.",
},
// Loading Skeletons
CardSkeleton: {
props: z.object({
hasTitle: z.boolean().nullable(),
hasDescription: z.boolean().nullable(),
lines: z.number().nullable(),
}),
description: "Loading skeleton for Card component",
},
StatCardSkeleton: {
props: z.object({
hasChange: z.boolean().nullable(),
}),
description: "Loading skeleton for StatCard component",
},
DataTableSkeleton: {
props: z.object({
rows: z.number().nullable(),
columns: z.number().nullable(),
}),
description: "Loading skeleton for DataTable component",
},
SchedulePreviewSkeleton: {
props: z.object({
tasks: z.number().nullable(),
}),
description: "Loading skeleton for SchedulePreview component",
},
},
actions: {

View File

@ -10,41 +10,87 @@ import {
ActionProvider,
} from "@json-render/react"
import { components, Fallback } from "./registry"
import {
components,
Fallback,
type StreamingState,
} from "./registry"
import { executeAction, actionHandlers } from "./actions"
import {
ComponentLoadingWrapper,
type PropConfig,
} from "@/components/agent/component-loading-wrapper"
interface CompassRendererProps {
readonly spec: Spec | null
readonly data?: Record<string, unknown>
readonly loading?: boolean
readonly streamingState?: StreamingState
readonly propConfigs?: Record<string, PropConfig[]>
readonly enablePropSkeletons?: boolean
}
function buildRegistry(
loading?: boolean
loading?: boolean,
streamingState?: StreamingState,
propConfigs?: Record<string, PropConfig[]>,
enablePropSkeletons?: boolean
): ComponentRegistry {
const registry: ComponentRegistry = {}
for (const [name, Component] of Object.entries(
components
)) {
for (const [name, Component] of Object.entries(components)) {
registry[name] = (renderProps: {
element: {
props: Record<string, unknown>
type: string
}
children?: ReactNode
}) => (
<Component
props={renderProps.element.props as never}
onAction={(a: {
name: string
params?: Record<string, unknown>
}) => executeAction(a.name, a.params)}
loading={loading}
>
{renderProps.children}
</Component>
)
}) => {
const componentProps = renderProps.element.props
const componentPropConfigs = propConfigs?.[name]
// If prop-level skeletons are enabled and configs exist, wrap component
if (
enablePropSkeletons &&
componentPropConfigs &&
componentPropConfigs.length > 0
) {
return (
<ComponentLoadingWrapper
props={componentProps}
propConfigs={componentPropConfigs}
loading={loading}
>
<Component
props={componentProps as never}
onAction={(a: {
name: string
params?: Record<string, unknown>
}) => executeAction(a.name, a.params)}
loading={loading}
streamingState={streamingState}
>
{renderProps.children}
</Component>
</ComponentLoadingWrapper>
)
}
// Standard rendering without prop-level skeletons
return (
<Component
props={componentProps as never}
onAction={(a: {
name: string
params?: Record<string, unknown>
}) => executeAction(a.name, a.params)}
loading={loading}
streamingState={streamingState}
>
{renderProps.children}
</Component>
)
}
}
return registry
@ -58,10 +104,19 @@ export function CompassRenderer({
spec,
data,
loading,
streamingState,
propConfigs,
enablePropSkeletons = false,
}: CompassRendererProps): ReactNode {
const registry = useMemo(
() => buildRegistry(loading),
[loading]
() =>
buildRegistry(
loading,
streamingState,
propConfigs,
enablePropSkeletons
),
[loading, streamingState, propConfigs, enablePropSkeletons]
)
if (!spec) return null

View File

@ -47,6 +47,11 @@ type InferProps<K extends keyof CatalogComponents> =
? P
: never
export type StreamingState =
| "started"
| "streaming"
| "done"
interface ComponentContext<K extends keyof CatalogComponents> {
readonly props: InferProps<K>
readonly children?: ReactNode
@ -55,6 +60,7 @@ interface ComponentContext<K extends keyof CatalogComponents> {
params?: Record<string, unknown>
}) => void
readonly loading?: boolean
readonly streamingState?: StreamingState
}
type ComponentFn<K extends keyof CatalogComponents> = (
@ -1666,6 +1672,131 @@ export const components: {
</div>
)
},
// --- Loading Skeletons ---
CardSkeleton: ({ props }) => {
const hasTitle = props.hasTitle ?? true
const hasDescription = props.hasDescription ?? false
const lines = props.lines ?? 3
return (
<div className="border border-border rounded-xl shadow-sm p-5 bg-card overflow-hidden animate-pulse">
{hasTitle && (
<div className="h-4 bg-muted rounded w-1/3 mb-1" />
)}
{hasDescription && (
<div className="h-3 bg-muted rounded w-2/3 mb-3" />
)}
<div className="space-y-3">
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className="h-3 bg-muted rounded"
style={{
width: `${Math.random() * 40 + 60}%`,
}}
/>
))}
</div>
</div>
)
},
StatCardSkeleton: ({ props }) => {
const hasChange = props.hasChange ?? true
return (
<div className="border border-border border-l-4 border-l-primary/30 rounded-xl shadow-sm p-4 bg-card animate-pulse">
<div className="h-3 bg-muted rounded w-1/4 mb-2 uppercase" />
<div className="h-9 bg-muted rounded w-1/2 mb-1" />
{hasChange && (
<div className="h-3 bg-muted rounded w-1/3" />
)}
</div>
)
},
DataTableSkeleton: ({ props }) => {
const rows = props.rows ?? 5
const columns = props.columns ?? 4
return (
<div className="overflow-x-auto border border-border rounded-xl shadow-sm animate-pulse">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/70">
{Array.from({ length: columns }).map(
(_, i) => (
<th
key={i}
className="px-4 py-2.5"
>
<div className="h-3 bg-muted/50 rounded w-3/4" />
</th>
)
)}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<tr
key={i}
className="border-b last:border-0"
>
{Array.from({ length: columns }).map(
(_, j) => (
<td
key={j}
className="px-4 py-2.5"
>
<div
className="h-3 bg-muted rounded"
style={{
width: `${Math.random() * 40 + 40}%`,
}}
/>
</td>
)
)}
</tr>
))}
</tbody>
</table>
</div>
)
},
SchedulePreviewSkeleton: ({ props }) => {
const tasks = props.tasks ?? 5
return (
<div className="border border-border rounded-xl shadow-sm bg-card overflow-hidden animate-pulse">
<div className="px-4 py-3 border-b bg-muted/30 flex items-center justify-between">
<div className="h-4 bg-muted rounded w-1/3" />
<div className="h-3 bg-muted rounded w-16" />
</div>
<div className="divide-y">
{Array.from({ length: tasks }).map((_, i) => (
<div
key={i}
className="flex items-center gap-3 px-4 py-2.5"
>
<div className="size-2 rounded-full bg-muted shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<div className="h-3 bg-muted rounded w-2/3" />
<div className="h-2 bg-muted rounded w-1/2" />
</div>
<div className="w-20 shrink-0">
<div className="h-2 bg-muted rounded" />
</div>
<div className="h-3 bg-muted rounded w-8" />
</div>
))}
</div>
</div>
)
},
}
export function Fallback({ type }: { readonly type: string }) {