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:
parent
33b427ed33
commit
337117f895
@ -15,9 +15,17 @@ export async function POST(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json() as {
|
let body: { action?: string; params?: Record<string, unknown> }
|
||||||
action?: string
|
try {
|
||||||
params?: Record<string, unknown>
|
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
|
const { action, params } = body
|
||||||
|
|||||||
@ -61,11 +61,21 @@ export async function POST(
|
|||||||
return new Response("Unauthorized", { status: 401 })
|
return new Response("Unauthorized", { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { prompt, context } = (await req.json()) as {
|
let body: { prompt?: string; context?: Record<string, unknown> }
|
||||||
prompt: string
|
try {
|
||||||
context?: Record<string, unknown>
|
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
|
const previousSpec = context?.previousSpec as
|
||||||
| { root?: string; elements?: Record<string, unknown> }
|
| { root?: string; elements?: Record<string, unknown> }
|
||||||
| undefined
|
| undefined
|
||||||
|
|||||||
@ -58,8 +58,14 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
const pluginSections = registry.getPromptSections()
|
const pluginSections = registry.getPromptSections()
|
||||||
const pluginTools = registry.getTools()
|
const pluginTools = registry.getTools()
|
||||||
|
|
||||||
const body = (await req.json()) as {
|
let body: { messages: UIMessage[] }
|
||||||
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 =
|
const currentPage =
|
||||||
|
|||||||
111
src/components/agent/component-loading-wrapper.tsx
Normal file
111
src/components/agent/component-loading-wrapper.tsx
Normal 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 },
|
||||||
|
],
|
||||||
|
}
|
||||||
88
src/components/agent/prop-skeleton.tsx
Normal file
88
src/components/agent/prop-skeleton.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
src/hooks/use-streaming-prop.ts
Normal file
157
src/hooks/use-streaming-prop.ts
Normal 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
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ export const compassCatalog = defineCatalog(schema, {
|
|||||||
centered: z.boolean().nullable(),
|
centered: z.boolean().nullable(),
|
||||||
}),
|
}),
|
||||||
slots: ["default"],
|
slots: ["default"],
|
||||||
|
loadingComponent: "CardSkeleton",
|
||||||
description:
|
description:
|
||||||
"Container card for content sections. " +
|
"Container card for content sections. " +
|
||||||
"Use as root for dashboards.",
|
"Use as root for dashboards.",
|
||||||
@ -340,6 +341,7 @@ export const compassCatalog = defineCatalog(schema, {
|
|||||||
change: z.number().nullable(),
|
change: z.number().nullable(),
|
||||||
changeLabel: z.string().nullable(),
|
changeLabel: z.string().nullable(),
|
||||||
}),
|
}),
|
||||||
|
loadingComponent: "StatCardSkeleton",
|
||||||
description:
|
description:
|
||||||
"Single metric with optional trend indicator",
|
"Single metric with optional trend indicator",
|
||||||
},
|
},
|
||||||
@ -371,6 +373,7 @@ export const compassCatalog = defineCatalog(schema, {
|
|||||||
)
|
)
|
||||||
.nullable(),
|
.nullable(),
|
||||||
}),
|
}),
|
||||||
|
loadingComponent: "DataTableSkeleton",
|
||||||
description:
|
description:
|
||||||
"Tabular data display with columns. " +
|
"Tabular data display with columns. " +
|
||||||
"Best for lists of records. Use rowActions " +
|
"Best for lists of records. Use rowActions " +
|
||||||
@ -429,11 +432,44 @@ export const compassCatalog = defineCatalog(schema, {
|
|||||||
maxTasks: z.number().nullable(),
|
maxTasks: z.number().nullable(),
|
||||||
groupByPhase: z.boolean().nullable(),
|
groupByPhase: z.boolean().nullable(),
|
||||||
}),
|
}),
|
||||||
|
loadingComponent: "SchedulePreviewSkeleton",
|
||||||
description:
|
description:
|
||||||
"Schedule/timeline display with phase grouping. " +
|
"Schedule/timeline display with phase grouping. " +
|
||||||
"ALWAYS prefer this over composing schedule " +
|
"ALWAYS prefer this over composing schedule " +
|
||||||
"displays from Heading+Text+Progress+Badge primitives.",
|
"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: {
|
actions: {
|
||||||
|
|||||||
@ -10,41 +10,87 @@ import {
|
|||||||
ActionProvider,
|
ActionProvider,
|
||||||
} from "@json-render/react"
|
} from "@json-render/react"
|
||||||
|
|
||||||
import { components, Fallback } from "./registry"
|
import {
|
||||||
|
components,
|
||||||
|
Fallback,
|
||||||
|
type StreamingState,
|
||||||
|
} from "./registry"
|
||||||
import { executeAction, actionHandlers } from "./actions"
|
import { executeAction, actionHandlers } from "./actions"
|
||||||
|
import {
|
||||||
|
ComponentLoadingWrapper,
|
||||||
|
type PropConfig,
|
||||||
|
} from "@/components/agent/component-loading-wrapper"
|
||||||
|
|
||||||
interface CompassRendererProps {
|
interface CompassRendererProps {
|
||||||
readonly spec: Spec | null
|
readonly spec: Spec | null
|
||||||
readonly data?: Record<string, unknown>
|
readonly data?: Record<string, unknown>
|
||||||
readonly loading?: boolean
|
readonly loading?: boolean
|
||||||
|
readonly streamingState?: StreamingState
|
||||||
|
readonly propConfigs?: Record<string, PropConfig[]>
|
||||||
|
readonly enablePropSkeletons?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRegistry(
|
function buildRegistry(
|
||||||
loading?: boolean
|
loading?: boolean,
|
||||||
|
streamingState?: StreamingState,
|
||||||
|
propConfigs?: Record<string, PropConfig[]>,
|
||||||
|
enablePropSkeletons?: boolean
|
||||||
): ComponentRegistry {
|
): ComponentRegistry {
|
||||||
const registry: ComponentRegistry = {}
|
const registry: ComponentRegistry = {}
|
||||||
|
|
||||||
for (const [name, Component] of Object.entries(
|
for (const [name, Component] of Object.entries(components)) {
|
||||||
components
|
|
||||||
)) {
|
|
||||||
registry[name] = (renderProps: {
|
registry[name] = (renderProps: {
|
||||||
element: {
|
element: {
|
||||||
props: Record<string, unknown>
|
props: Record<string, unknown>
|
||||||
type: string
|
type: string
|
||||||
}
|
}
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
}) => (
|
}) => {
|
||||||
<Component
|
const componentProps = renderProps.element.props
|
||||||
props={renderProps.element.props as never}
|
const componentPropConfigs = propConfigs?.[name]
|
||||||
onAction={(a: {
|
|
||||||
name: string
|
// If prop-level skeletons are enabled and configs exist, wrap component
|
||||||
params?: Record<string, unknown>
|
if (
|
||||||
}) => executeAction(a.name, a.params)}
|
enablePropSkeletons &&
|
||||||
loading={loading}
|
componentPropConfigs &&
|
||||||
>
|
componentPropConfigs.length > 0
|
||||||
{renderProps.children}
|
) {
|
||||||
</Component>
|
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
|
return registry
|
||||||
@ -58,10 +104,19 @@ export function CompassRenderer({
|
|||||||
spec,
|
spec,
|
||||||
data,
|
data,
|
||||||
loading,
|
loading,
|
||||||
|
streamingState,
|
||||||
|
propConfigs,
|
||||||
|
enablePropSkeletons = false,
|
||||||
}: CompassRendererProps): ReactNode {
|
}: CompassRendererProps): ReactNode {
|
||||||
const registry = useMemo(
|
const registry = useMemo(
|
||||||
() => buildRegistry(loading),
|
() =>
|
||||||
[loading]
|
buildRegistry(
|
||||||
|
loading,
|
||||||
|
streamingState,
|
||||||
|
propConfigs,
|
||||||
|
enablePropSkeletons
|
||||||
|
),
|
||||||
|
[loading, streamingState, propConfigs, enablePropSkeletons]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!spec) return null
|
if (!spec) return null
|
||||||
|
|||||||
@ -47,6 +47,11 @@ type InferProps<K extends keyof CatalogComponents> =
|
|||||||
? P
|
? P
|
||||||
: never
|
: never
|
||||||
|
|
||||||
|
export type StreamingState =
|
||||||
|
| "started"
|
||||||
|
| "streaming"
|
||||||
|
| "done"
|
||||||
|
|
||||||
interface ComponentContext<K extends keyof CatalogComponents> {
|
interface ComponentContext<K extends keyof CatalogComponents> {
|
||||||
readonly props: InferProps<K>
|
readonly props: InferProps<K>
|
||||||
readonly children?: ReactNode
|
readonly children?: ReactNode
|
||||||
@ -55,6 +60,7 @@ interface ComponentContext<K extends keyof CatalogComponents> {
|
|||||||
params?: Record<string, unknown>
|
params?: Record<string, unknown>
|
||||||
}) => void
|
}) => void
|
||||||
readonly loading?: boolean
|
readonly loading?: boolean
|
||||||
|
readonly streamingState?: StreamingState
|
||||||
}
|
}
|
||||||
|
|
||||||
type ComponentFn<K extends keyof CatalogComponents> = (
|
type ComponentFn<K extends keyof CatalogComponents> = (
|
||||||
@ -1666,6 +1672,131 @@ export const components: {
|
|||||||
</div>
|
</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 }) {
|
export function Fallback({ type }: { readonly type: string }) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user