Skip to content
Draft
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
11 changes: 11 additions & 0 deletions .changeset/inspect-run-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@openuidev/devtools": patch
"@openuidev/observability-cloud": patch
"@openuidev/react-headless": patch
"@openuidev/react-lang": patch
"@openuidev/react-ui": patch
---

Group OpenUI Inspect events by LLM run, with the user's prompt as the group title and request, response, and stream details shown together. Expand groups and settled stream details when errors arrive.

Carry the run ID and title through assistant messages. Standalone users can wrap their renderers in `RendererDevtoolsProvider` and supply `run={{ id, title }}` once to group rendered responses in Inspect without additional instrumentation. AgentInterface provides the run around each turn, including custom message and tool renderers. A Renderer can override the inherited run with `devtools={{ run: { id, title } }}`. Keep distinct renderer streams in the same group, preserve the run metadata across assistant message segments, and avoid duplicate stream identities when unchanged historical content is marked streaming again.
27 changes: 27 additions & 0 deletions docs/content/docs/api-reference/react-lang.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,36 @@ interface RendererProps {
| null;
queryLoader?: React.ReactNode;
onError?: (errors: OpenUIError[]) => void;
devtools?: {
run?: {
id: string;
title?: string;
};
};
}
```

### Inspect grouping

Wrap the renderers for one generation in `RendererDevtoolsProvider` to group their responses in OpenUI Inspect. Supply the run once: its `id` is required and the optional `title` labels the group, for example with the user's prompt. Every nested Renderer inherits the run.

```tsx
import { Renderer, RendererDevtoolsProvider } from "@openuidev/react-lang";

<RendererDevtoolsProvider run={{ id: generationId, title: userPrompt }}>
{messages.map((message) => (
<Renderer
key={message.id}
response={message.content}
library={library}
isStreaming={message.isStreaming}
/>
))}
</RendererDevtoolsProvider>
```

Use a separate provider and a new run ID for each generation. A Renderer can override the inherited run with `devtools={{ run: { id, title } }}`. Renderers without a run ID appear as individual stream rows. Inspect captures renderers while they stream; mounting historical content with `isStreaming={false}` does not create a new stream entry. `AgentInterface` supplies the run ID and title through a provider around each turn, including custom message and tool renderers.

## Tool Provider

Handles `Query()` and `Mutation()` tool calls at runtime. The `toolProvider` prop accepts two forms:
Expand Down
72 changes: 67 additions & 5 deletions packages/devtools/src/OpenUIDevtools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,70 @@ describe("OpenUIDevtools", () => {
expect(toggle().getAttribute("aria-expanded")).toBe("false");
});

it("auto-opens the drawer when a stream settles with errors", () => {
render({ enabled: true, autoOpenOnError: true, errorsOnly: false });
expect(toggle().getAttribute("aria-expanded")).toBe("false");

act(() =>
observability.info({
kind: "react-lang:stream",
id: "stream-1",
phase: "streaming",
runId: "run-1",
}),
);
expect(toggle().getAttribute("aria-expanded")).toBe("false");

act(() =>
observability.error({
kind: "react-lang:stream",
id: "stream-1",
phase: "settled",
runId: "run-1",
errors: [
{ source: "parser", code: "unknown-component", message: "Unknown component Ghost" },
],
}),
);
expect(toggle().getAttribute("aria-expanded")).toBe("true");
});

it("opens a collapsed run group when an error arrives later", () => {
render({ enabled: true, autoOpenOnError: false, errorsOnly: false });

act(() => {
observability.info({
kind: "LLM:request",
runId: "run-old",
userMessage: { role: "user", content: "older prompt" },
});
observability.info({ kind: "LLM:response", runId: "run-old", status: 200 });
observability.info({
kind: "LLM:request",
runId: "run-new",
userMessage: { role: "user", content: "newer prompt" },
});
observability.info({ kind: "LLM:response", runId: "run-new", status: 200 });
});

const older = container.querySelector<HTMLElement>('[role="group"][aria-label="older prompt"]');
expect(older?.querySelector("button")?.getAttribute("aria-expanded")).toBe("false");

act(() =>
observability.error({
kind: "react-lang:stream",
id: "stream-old",
phase: "settled",
runId: "run-old",
errors: [{ message: "late parse error" }],
}),
);

expect(older?.querySelector("button")?.getAttribute("aria-expanded")).toBe("true");
expect(older?.querySelector('[aria-label="error"]')).not.toBeNull();
expect(container.textContent).toContain("late parse error");
});

it("restores auto-open on error from a previous session", () => {
render({ enabled: true, autoOpenOnError: true });
openSettings();
Expand Down Expand Up @@ -385,14 +449,12 @@ describe("OpenUIDevtools", () => {
expect(container.textContent).not.toContain("Streaming");
expect(overviewStats()).toContain("1 statement");
expect(overviewStats()).toContain("1 error");
expect(container.textContent).not.toContain("Unknown component Ghost");
expect(toggle().getAttribute("aria-expanded")).toBe("true");

const expand = container.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle OpenUI Lang stream details"]',
);
expect(expand).not.toBeNull();
click(expand!);

expect(expand?.getAttribute("aria-expanded")).toBe("true");
expect(container.textContent).toContain("parser / unknown-component");
expect(container.textContent).toContain("Unknown component Ghost");
expect(container.textContent).toContain("Use a component registered in the library");
Expand Down Expand Up @@ -468,7 +530,7 @@ describe("OpenUIDevtools", () => {
const expand = container.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle OpenUI Lang stream details"]',
);
click(expand!);
expect(expand?.getAttribute("aria-expanded")).toBe("true");
expect(container.textContent).toContain("First error");

act(() =>
Expand Down
57 changes: 34 additions & 23 deletions packages/devtools/src/OpenUIDevtoolsWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,7 @@
import { Inbox, RotateCcw, Settings, X } from "lucide-react";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { DEFAULT_EDITOR_PCT, useDebug } from "./debug";
import {
EventRow,
getQuotaError,
getReactLangStreamDetail,
QuotaErrorRow,
ReactLangStreamEventRow,
} from "./inspect";
import { groupEventsByRunId, InspectEvent, RunGroup } from "./inspect";
import {
addOrReplaceEvent,
isLibraryEvent,
Expand Down Expand Up @@ -112,7 +106,7 @@
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open, debug.trayOpen, debug.retract]);

Check warning on line 109 in packages/devtools/src/OpenUIDevtoolsWidget.tsx

View workflow job for this annotation

GitHub Actions / build

React Hook useEffect has a missing dependency: 'debug'. Either include it or remove the dependency array

if (!isEnabled || !isSingleton) return null;

Expand Down Expand Up @@ -216,26 +210,43 @@
No events captured yet.
</div>
) : (
visibleEvents.map((event, index) => {
groupEventsByRunId(visibleEvents).map((item, index) => {
if (item.type === "run") {
const defaultOpen =
index === 0 || item.events.some((event) => event.level === "error");
return (
<RunGroup key={item.runId} events={item.events} defaultOpen={defaultOpen}>
{item.events.map((event, eventIndex) => (
<InspectEvent
key={
typeof event.detail["id"] === "string"
? event.detail["id"]
: `${event.timestamp}-${eventIndex}`
}
event={event}
embedded
last={eventIndex === item.events.length - 1}
canOpenInDebug={debug.canOpen}
onOpenInDebug={debug.openWith}
/>
))}
</RunGroup>
);
}

const event = item.event;
const key =
typeof event.detail["id"] === "string"
? event.detail["id"]
: `${event.timestamp}-${index}`;
const quotaError = getQuotaError(event);
if (quotaError) return <QuotaErrorRow key={key} info={quotaError} />;
const stream = getReactLangStreamDetail(event);
if (stream) {
return (
<ReactLangStreamEventRow
key={key}
event={event}
stream={stream}
canOpenInDebug={debug.canOpen}
onOpenInDebug={debug.openWith}
/>
);
}
return <EventRow key={key} event={event} />;
return (
<InspectEvent
key={key}
event={event}
canOpenInDebug={debug.canOpen}
onOpenInDebug={debug.openWith}
/>
);
})
)}
</div>
Expand Down
55 changes: 41 additions & 14 deletions packages/devtools/src/inspect/EventRow.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { type ObservabilityErrorInfo, type ObservabilityEvent } from "@openuidev/observability";
import { Check, ChevronDown, ChevronRight, Copy } from "lucide-react";
import { useState, type CSSProperties } from "react";
import { FONT, MONO, useStyles, type ThemeTokens } from "../theme";
import { FONT, MONO, useStyles, useTheme, type ThemeTokens } from "../theme";
import { displayEventKind } from "./groupEvents";
import { LevelIcon } from "./LevelIcon";
import { nestedRowBox } from "./rowBox";

export function EventRow({ event }: { event: ObservabilityEvent }) {
export function EventRow({
event,
embedded = false,
last = false,
}: {
event: ObservabilityEvent;
embedded?: boolean;
last?: boolean;
}) {
const styles = useStyles(eventRowStyles);
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const [hovered, setHovered] = useState(false);
const theme = useTheme();
const error = getErrorInfo(event);
const detail = asRecord(event.detail);
const kind = asString(detail["kind"]);
Expand All @@ -17,11 +25,15 @@ export function EventRow({ event }: { event: ObservabilityEvent }) {
const summary = message ? null : kind ? null : summarize(event);
const stack = error?.stack;
const expandable = Boolean(stack);
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const [hovered, setHovered] = useState(false);

const copyStack = () => {
if (!stack || typeof navigator === "undefined" || !navigator.clipboard) return;
const copyText = stack ?? "";
const copyDetail = () => {
if (!copyText || typeof navigator === "undefined" || !navigator.clipboard) return;
navigator.clipboard
.writeText(stack)
.writeText(copyText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
Expand All @@ -34,7 +46,7 @@ export function EventRow({ event }: { event: ObservabilityEvent }) {
<div style={styles.rowHeader}>
<div style={styles.badgeGroup}>
<LevelIcon level={event.level} />
{kind ? <span style={styles.kind}>{kind}</span> : null}
{kind ? <span style={styles.kind}>{displayEventKind(kind)}</span> : null}
{status ? (
<span style={{ ...styles.badge, ...styles.badgeNeutral }}>{status}</span>
) : null}
Expand All @@ -53,7 +65,11 @@ export function EventRow({ event }: { event: ObservabilityEvent }) {

return (
<div
style={{ ...styles.row, ...(expandable && hovered ? styles.rowHover : null) }}
style={{
...styles.row,
...(embedded ? nestedRowBox(theme, last) : null),
...(expandable && hovered && !embedded ? styles.rowHover : null),
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
Expand All @@ -75,7 +91,7 @@ export function EventRow({ event }: { event: ObservabilityEvent }) {
<div style={styles.expanded}>
<pre style={styles.stack}>{stack}</pre>
<div style={styles.actions}>
<button type="button" style={styles.action} onClick={copyStack}>
<button type="button" style={styles.action} onClick={copyDetail}>
{copied ? <Check size={12} /> : <Copy size={12} />}
{copied ? "Copied" : "Copy"}
</button>
Expand Down Expand Up @@ -119,7 +135,8 @@ function summarize(event: ObservabilityEvent): string {
const parts = [subject, status, message].filter(Boolean);
if (parts.length > 0) return parts.join(" ");
try {
return JSON.stringify(event.detail) ?? "(no detail)";
const { runId: _runId, ...rest } = detail;
return JSON.stringify(rest) ?? "(no detail)";
} catch {
return "(no detail)";
}
Expand All @@ -128,7 +145,12 @@ function summarize(event: ObservabilityEvent): string {
function eventRowStyles(t: ThemeTokens) {
return {
row: {
borderWidth: 1,
// Four longhands: `borderWidth` is a shorthand, and mixing it with
// `borderBottomWidth` in the embedded override leaves a leftover box stroke.
borderTopWidth: 1,
borderRightWidth: 1,
borderBottomWidth: 1,
borderLeftWidth: 1,
borderStyle: "solid",
borderColor: t.border,
borderRadius: 12,
Expand Down Expand Up @@ -158,6 +180,7 @@ function eventRowStyles(t: ThemeTokens) {
justifyContent: "space-between",
alignItems: "center",
gap: 8,
minHeight: 22,
},
rowHeaderRight: {
display: "flex",
Expand All @@ -167,7 +190,11 @@ function eventRowStyles(t: ThemeTokens) {
},
chevron: {
display: "inline-flex",
width: 14,
alignItems: "center",
justifyContent: "center",
boxSizing: "border-box",
width: 22,
height: 22,
flexShrink: 0,
color: t.fgMuted,
},
Expand Down
35 changes: 35 additions & 0 deletions packages/devtools/src/inspect/InspectEvent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { ObservabilityEvent } from "@openuidev/observability";
import { EventRow } from "./EventRow";
import { QuotaErrorRow, getQuotaError } from "./QuotaErrorRow";
import { ReactLangStreamEventRow, getReactLangStreamDetail } from "./ReactLangStreamEventRow";

export function InspectEvent({
event,
canOpenInDebug,
onOpenInDebug,
embedded = false,
last = false,
}: {
event: ObservabilityEvent;
canOpenInDebug: boolean;
onOpenInDebug: (response: string, libraryId?: string) => void;
embedded?: boolean;
last?: boolean;
}) {
const quotaError = getQuotaError(event);
if (quotaError) return <QuotaErrorRow info={quotaError} embedded={embedded} last={last} />;
const stream = getReactLangStreamDetail(event);
if (stream) {
return (
<ReactLangStreamEventRow
event={event}
stream={stream}
canOpenInDebug={canOpenInDebug}
onOpenInDebug={onOpenInDebug}
embedded={embedded}
last={last}
/>
);
}
return <EventRow event={event} embedded={embedded} last={last} />;
}
Loading
Loading