Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/content/docs/api-reference/react-lang.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ function useTriggerAction(): (
action?: { type?: string; params?: Record<string, any> },
) => void;
function useIsStreaming(): boolean;
function useIsQueryLoading(): boolean;
function useGetFieldValue(): (formName: string | undefined, name: string) => any;
function useSetFieldValue(): (
formName: string | undefined,
Expand Down
30 changes: 30 additions & 0 deletions docs/content/docs/api-reference/react-ui.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,33 @@ npx @openuidev/cli@latest generate ./src/library.ts --out src/generated/system-p
// Standalone renderer
<Renderer response={message} library={openuiLibrary} />
```

## Skeleton components

`Skeleton` and `TableSkeleton` are loading-state placeholders that render while data-driven genui components wait for `Query()` results. They use theme-aware CSS variables and a pulsing opacity animation.

```tsx
import { Skeleton, TableSkeleton } from "@openuidev/react-ui";
```

### `Skeleton`

A generic skeleton bar. Use standalone or with `count` for stacked bars.

| Prop | Type | Default | Description |
|------------|----------|--------------|-------------|
| `count` | `number` | `1` | Number of bars to stack |
| `height` | `string` | `"16px"` | Bar height |
| `width` | `string` | `"100%"` | Bar width |
| `borderRadius` | `string` | `undefined` | Bar border radius (defaults to `--openui-radius-xs`) |

### `TableSkeleton`

A table-shaped placeholder used by the genui `Table` component while queries load.

| Prop | Type | Default | Description |
|---------|----------|---------|-------------|
| `rows` | `number` | `5` | Number of skeleton rows |
| `columns` | `number` | `4` | Number of skeleton columns |

Built-in genui components like `Table` automatically render `TableSkeleton` when `useIsQueryLoading()` is `true` and no data rows exist yet.
1 change: 1 addition & 0 deletions docs/content/docs/openui-lang/renderer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ Use these inside components defined with `defineComponent(...)`. See [Reactive S

- `useStateField(name, value?)` - reactive `$variable` binding (preferred for inputs)
- `useIsStreaming()`
- `useIsQueryLoading()`
- `useTriggerAction()`
- `useRenderNode()`
- `useFormValidation()`
Expand Down
2 changes: 2 additions & 0 deletions examples/openui-dashboard/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Simulated network delay for mock tool calls (ms). Increase to test loading states.
NEXT_PUBLIC_TOOL_DELAY_MS=
1 change: 1 addition & 0 deletions examples/openui-dashboard/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ yarn-error.log*

# env files (can opt-in for committing if needed)
.env*
!.env.example

# vercel
.vercel
Expand Down
26 changes: 21 additions & 5 deletions examples/openui-dashboard/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import type { ChatCompletionMessageParam } from "openai/resources/chat/completio
import { generatePrompt } from "@openuidev/lang-core";
import { promptSpec } from "@/prompt-config";
import { tools as toolDefs } from "@/tools";
import { sseResponseFromRunner } from "@/lib/sse-stream";

const tools = toolDefs.map((t) => t.toOpenAITool());
const SSE_HEADERS = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
} as const;

function buildSystemPrompt(): string {
return generatePrompt({
Expand All @@ -30,13 +33,26 @@ export async function POST(req: NextRequest) {
}

const client = new OpenAI({ apiKey, baseURL });
const runner = client.chat.completions.runTools({
const stream = await client.chat.completions.create({
model,
messages: [{ role: "system" as const, content: buildSystemPrompt() }, ...messages],
tools,
stream: true,
// reasoning: { effort: "low" },
});

return sseResponseFromRunner(runner);
const encoder = new TextEncoder();
const readable = new ReadableStream({
async start(controller) {
try {
for await (const chunk of stream) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
} finally {
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
}
},
});

return new Response(readable, { headers: SSE_HEADERS });
}
115 changes: 0 additions & 115 deletions examples/openui-dashboard/src/lib/sse-stream.ts

This file was deleted.

23 changes: 15 additions & 8 deletions examples/openui-dashboard/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import { ToolDef } from "./lib/tool-def";

// ── Helpers ──────────────────────────────────────────────────────────────────

/** Simulated network delay to surface intermediate loading states (ms). */
const TOOL_DELAY_MS = Number(process.env.NEXT_PUBLIC_TOOL_DELAY_MS) || 0;

function delay(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}

function dateOffset(daysAgo: number): string {
const d = new Date();
d.setDate(d.getDate() + daysAgo);
Expand Down Expand Up @@ -152,7 +159,7 @@ export const tools: ToolDef[] = [
}),
),
}),
execute: async (args) => getUsageMetrics(args),
execute: async (args) => { await delay(TOOL_DELAY_MS); return getUsageMetrics(args); },
}),
new ToolDef({
name: "get_top_endpoints",
Expand All @@ -168,7 +175,7 @@ export const tools: ToolDef[] = [
}),
),
}),
execute: async (args) => getTopEndpoints(args),
execute: async (args) => { await delay(TOOL_DELAY_MS); return getTopEndpoints(args); },
}),
new ToolDef({
name: "get_resource_breakdown",
Expand All @@ -179,7 +186,7 @@ export const tools: ToolDef[] = [
z.object({ name: z.string(), events: z.number(), users: z.number(), cost: z.number() }),
),
}),
execute: async () => getResourceBreakdown(),
execute: async () => { await delay(TOOL_DELAY_MS); return getResourceBreakdown(); },
}),
new ToolDef({
name: "get_error_breakdown",
Expand All @@ -188,7 +195,7 @@ export const tools: ToolDef[] = [
outputSchema: z.object({
errors: z.array(z.object({ category: z.string(), count: z.number() })),
}),
execute: async () => getErrorBreakdown(),
execute: async () => { await delay(TOOL_DELAY_MS * 2); return getErrorBreakdown(); },
}),
new ToolDef({
name: "get_server_health",
Expand All @@ -203,7 +210,7 @@ export const tools: ToolDef[] = [
z.object({ time: z.string(), cpu: z.number(), memory: z.number(), latencyP95: z.number() }),
),
}),
execute: async () => getServerHealth(),
execute: async () => { await delay(TOOL_DELAY_MS); return getServerHealth(); },
}),
new ToolDef({
name: "get_experiment_results",
Expand All @@ -214,7 +221,7 @@ export const tools: ToolDef[] = [
z.object({ variant: z.string(), conversionRate: z.number(), users: z.number() }),
),
}),
execute: async () => getExperimentResults(),
execute: async () => { await delay(TOOL_DELAY_MS); return getExperimentResults(); },
}),
new ToolDef({
name: "get_geo_usage",
Expand All @@ -223,13 +230,13 @@ export const tools: ToolDef[] = [
outputSchema: z.object({
regions: z.array(z.object({ region: z.string(), users: z.number(), events: z.number() })),
}),
execute: async () => getGeoUsage(),
execute: async () => { await delay(TOOL_DELAY_MS); return getGeoUsage(); },
}),
new ToolDef({
name: "get_funnel_metrics",
description: "Get conversion funnel metrics",
inputSchema: z.object({}),
outputSchema: z.object({ steps: z.array(z.object({ step: z.string(), users: z.number() })) }),
execute: async () => getFunnelMetrics(),
execute: async () => { await delay(TOOL_DELAY_MS); return getFunnelMetrics(); },
}),
];
11 changes: 11 additions & 0 deletions packages/react-lang/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export interface OpenUIContextValue {
/** Whether the LLM is currently streaming content. */
isStreaming: boolean;

/** Whether any Query is currently fetching data (e.g., MCP tool calls in-flight). */
isQueryLoading: boolean;

/** Get a field value. Top-level for $bindings, nested under formName for form fields. */
getFieldValue: (formName: string | undefined, name: string) => any;

Expand Down Expand Up @@ -99,6 +102,14 @@ export function useIsStreaming() {
return useOpenUI().isStreaming;
}

/**
* Whether any Query is currently fetching data.
* Useful for showing skeleton/loading states in data-driven components.
*/
export function useIsQueryLoading() {
return useOpenUI().isQueryLoading;
}

/**
* Get a form field value from the form state context.
*
Expand Down
6 changes: 4 additions & 2 deletions packages/react-lang/src/hooks/useOpenUIState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,8 @@ export function useOpenUIState(
renderErrorsRef.current.push(error);
}, []);

const isQueryLoading = querySnapshot.__openui_loading.length > 0;

// ─── Context value ───
const contextValue = useMemo<OpenUIContextValue>(
() => ({
Expand All @@ -393,11 +395,13 @@ export function useOpenUIState(
store,
evaluationContext,
reportError,
isQueryLoading,
}),
[
library,
renderDeep,
isStreaming,
isQueryLoading,
triggerAction,
getFieldValue,
setFieldValue,
Expand Down Expand Up @@ -432,8 +436,6 @@ export function useOpenUIState(
}
}, [result, evaluationContext, library, store, storeSnapshot, querySnapshot]);

const isQueryLoading = querySnapshot.__openui_loading.length > 0;

// ─── Collect and fire onError ───
const lastErrorKeyRef = useRef<string>("");
useEffect(() => {
Expand Down
1 change: 1 addition & 0 deletions packages/react-lang/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export {
FormNameContext,
useFormName,
useGetFieldValue,
useIsQueryLoading,
useIsStreaming,
useRenderNode,
useSetDefaultValue,
Expand Down
Loading
Loading