diff --git a/src/app/api/agent/action/route.ts b/src/app/api/agent/action/route.ts index 08eb748..8c80521 100755 --- a/src/app/api/agent/action/route.ts +++ b/src/app/api/agent/action/route.ts @@ -15,9 +15,17 @@ export async function POST( ) } - const body = await req.json() as { - action?: string - params?: Record + let body: { action?: string; params?: Record } + try { + body = (await req.json()) as { + action?: string + params?: Record + } + } catch { + return Response.json( + { success: false, error: "Invalid JSON body" }, + { status: 400 }, + ) } const { action, params } = body diff --git a/src/app/api/agent/render/route.ts b/src/app/api/agent/render/route.ts index e328109..55d0f55 100755 --- a/src/app/api/agent/render/route.ts +++ b/src/app/api/agent/render/route.ts @@ -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 + let body: { prompt?: string; context?: Record } + try { + body = (await req.json()) as { + prompt?: string + context?: Record + } + } 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 } | undefined diff --git a/src/app/api/agent/route.ts b/src/app/api/agent/route.ts index a73aa8a..c1ae7ca 100755 --- a/src/app/api/agent/route.ts +++ b/src/app/api/agent/route.ts @@ -58,8 +58,14 @@ export async function POST(req: Request): Promise { 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 = diff --git a/src/components/agent/component-loading-wrapper.tsx b/src/components/agent/component-loading-wrapper.tsx new file mode 100644 index 0000000..2709673 --- /dev/null +++ b/src/components/agent/component-loading-wrapper.tsx @@ -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 + readonly propConfigs: PropConfig[] + readonly children: ReactNode + readonly loading?: boolean + readonly showSkeletonsForMissing?: boolean +} + +// Maps prop types to skeleton renderers +function renderSkeleton(type: PropSkeletonType): ReactNode { + return +} + +// 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 ( +
+ {propConfigs.map((config) => ( +
{renderSkeleton(config.type)}
+ ))} +
+ ) + } + + // If missing required props, show skeleton placeholders + if (!hasAllRequired && showSkeletonsForMissing) { + return ( +
+ {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
{String(value)}
+ } + + return
{renderSkeleton(config.type)}
+ })} +
+ ) + } + + // 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 }, + ], +} diff --git a/src/components/agent/prop-skeleton.tsx b/src/components/agent/prop-skeleton.tsx new file mode 100644 index 0000000..b81f849 --- /dev/null +++ b/src/components/agent/prop-skeleton.tsx @@ -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 = { + 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 ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ) + } + + return +} + +// Convenience components for common patterns +export function TextSkeleton({ className }: { className?: string }) { + return +} + +export function BadgeSkeleton({ className }: { className?: string }) { + return +} + +export function TableRowSkeleton({ + columns = 4, + className, +}: { + columns?: number + className?: string +}) { + return ( +
+ {Array.from({ length: columns }).map((_, i) => ( + + ))} +
+ ) +} + +export function CardSkeleton({ className }: { className?: string }) { + return ( +
+ + + +
+ ) +} diff --git a/src/hooks/use-streaming-prop.ts b/src/hooks/use-streaming-prop.ts new file mode 100644 index 0000000..2c8cae0 --- /dev/null +++ b/src/hooks/use-streaming-prop.ts @@ -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 + +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, + options: UseStreamingPropsOptions = {} +): UseStreamingPropsResult { + const { propNames, onPropArrived, onAllArrived } = options + const [props, setProps] = useState({}) + const prevPropsRef = useRef>({}) + + 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({ + isStreaming: true, + hasValue: false, + arrivedAt: null, + }) + const prevValueRef = useRef(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 +} diff --git a/src/lib/agent/render/catalog.ts b/src/lib/agent/render/catalog.ts index 6e39e19..0950d88 100755 --- a/src/lib/agent/render/catalog.ts +++ b/src/lib/agent/render/catalog.ts @@ -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: { diff --git a/src/lib/agent/render/compass-renderer.tsx b/src/lib/agent/render/compass-renderer.tsx index 1a32985..8e4ca8d 100755 --- a/src/lib/agent/render/compass-renderer.tsx +++ b/src/lib/agent/render/compass-renderer.tsx @@ -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 readonly loading?: boolean + readonly streamingState?: StreamingState + readonly propConfigs?: Record + readonly enablePropSkeletons?: boolean } function buildRegistry( - loading?: boolean + loading?: boolean, + streamingState?: StreamingState, + propConfigs?: Record, + 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 type: string } children?: ReactNode - }) => ( - - }) => executeAction(a.name, a.params)} - loading={loading} - > - {renderProps.children} - - ) + }) => { + 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 ( + + + }) => executeAction(a.name, a.params)} + loading={loading} + streamingState={streamingState} + > + {renderProps.children} + + + ) + } + + // Standard rendering without prop-level skeletons + return ( + + }) => executeAction(a.name, a.params)} + loading={loading} + streamingState={streamingState} + > + {renderProps.children} + + ) + } } 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 diff --git a/src/lib/agent/render/registry.tsx b/src/lib/agent/render/registry.tsx index b47e146..6d467a9 100755 --- a/src/lib/agent/render/registry.tsx +++ b/src/lib/agent/render/registry.tsx @@ -47,6 +47,11 @@ type InferProps = ? P : never +export type StreamingState = + | "started" + | "streaming" + | "done" + interface ComponentContext { readonly props: InferProps readonly children?: ReactNode @@ -55,6 +60,7 @@ interface ComponentContext { params?: Record }) => void readonly loading?: boolean + readonly streamingState?: StreamingState } type ComponentFn = ( @@ -1666,6 +1672,131 @@ export const components: { ) }, + + // --- Loading Skeletons --- + + CardSkeleton: ({ props }) => { + const hasTitle = props.hasTitle ?? true + const hasDescription = props.hasDescription ?? false + const lines = props.lines ?? 3 + + return ( +
+ {hasTitle && ( +
+ )} + {hasDescription && ( +
+ )} +
+ {Array.from({ length: lines }).map((_, i) => ( +
+ ))} +
+
+ ) + }, + + StatCardSkeleton: ({ props }) => { + const hasChange = props.hasChange ?? true + + return ( +
+
+
+ {hasChange && ( +
+ )} +
+ ) + }, + + DataTableSkeleton: ({ props }) => { + const rows = props.rows ?? 5 + const columns = props.columns ?? 4 + + return ( +
+ + + + {Array.from({ length: columns }).map( + (_, i) => ( + + ) + )} + + + + {Array.from({ length: rows }).map((_, i) => ( + + {Array.from({ length: columns }).map( + (_, j) => ( + + ) + )} + + ))} + +
+
+
+
+
+
+ ) + }, + + SchedulePreviewSkeleton: ({ props }) => { + const tasks = props.tasks ?? 5 + + return ( +
+
+
+
+
+
+ {Array.from({ length: tasks }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+ ) + }, } export function Fallback({ type }: { readonly type: string }) {