From fb786eab082780c66b455011aeacb29724eff16b Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Sun, 23 Aug 2026 15:09:39 +0530 Subject: [PATCH 1/8] feat: add inspect groups --- packages/devtools/src/OpenUIDevtools.tsx | 57 ++++--- packages/devtools/src/inspect/EventRow.tsx | 35 ++++- .../devtools/src/inspect/InspectEvent.tsx | 35 +++++ .../src/inspect/ReactLangStreamEventRow.tsx | 23 ++- packages/devtools/src/inspect/RunGroup.tsx | 103 +++++++++++++ .../devtools/src/inspect/groupEvents.test.ts | 97 ++++++++++++ packages/devtools/src/inspect/groupEvents.ts | 145 ++++++++++++++++++ packages/devtools/src/inspect/index.ts | 3 + .../observability-cloud/src/events/stream.ts | 2 + .../src/store/createChatStore.ts | 1 + .../src/stream/processStreamedMessage.ts | 27 ++-- packages/react-headless/src/types/message.ts | 8 +- packages/react-lang/src/Renderer.tsx | 4 + packages/react-lang/src/hooks/streamEvent.ts | 2 + .../react-lang/src/hooks/useOpenUIState.ts | 4 + .../hooks/useStreamingObservability.test.ts | 11 ++ .../src/hooks/useStreamingObservability.ts | 11 +- .../OpenUIChat/GenUIAssistantMessage.tsx | 1 + 18 files changed, 527 insertions(+), 42 deletions(-) create mode 100644 packages/devtools/src/inspect/InspectEvent.tsx create mode 100644 packages/devtools/src/inspect/RunGroup.tsx create mode 100644 packages/devtools/src/inspect/groupEvents.test.ts create mode 100644 packages/devtools/src/inspect/groupEvents.ts diff --git a/packages/devtools/src/OpenUIDevtools.tsx b/packages/devtools/src/OpenUIDevtools.tsx index 6f7e490e0..0a40792c1 100644 --- a/packages/devtools/src/OpenUIDevtools.tsx +++ b/packages/devtools/src/OpenUIDevtools.tsx @@ -4,13 +4,7 @@ import { observability, type ObservabilityEvent } from "@openuidev/observability 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 { InspectEvent, RunGroup, groupEventsByRunId } from "./inspect"; import { addOrReplaceEvent, isLibraryEvent, @@ -240,26 +234,43 @@ export function OpenUIDevtools({ No events captured yet. ) : ( - 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 ( + + {item.events.map((event, eventIndex) => ( + + ))} + + ); + } + + const event = item.event; const key = typeof event.detail["id"] === "string" ? event.detail["id"] : `${event.timestamp}-${index}`; - const quotaError = getQuotaError(event); - if (quotaError) return ; - const stream = getReactLangStreamDetail(event); - if (stream) { - return ( - - ); - } - return ; + return ( + + ); }) )} diff --git a/packages/devtools/src/inspect/EventRow.tsx b/packages/devtools/src/inspect/EventRow.tsx index 731f56eaf..bda27008a 100644 --- a/packages/devtools/src/inspect/EventRow.tsx +++ b/packages/devtools/src/inspect/EventRow.tsx @@ -2,9 +2,18 @@ import { type ObservabilityErrorInfo, type ObservabilityEvent } from "@openuidev import { Check, ChevronDown, ChevronRight, Copy } from "lucide-react"; import { useState, type CSSProperties } from "react"; import { FONT, MONO, useStyles, type ThemeTokens } from "../theme"; +import { displayEventKind } from "./groupEvents"; import { LevelIcon } from "./LevelIcon"; -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); @@ -34,7 +43,7 @@ export function EventRow({ event }: { event: ObservabilityEvent }) {
- {kind ? {kind} : null} + {kind ? {displayEventKind(kind)} : null} {status ? ( {status} ) : null} @@ -53,7 +62,12 @@ export function EventRow({ event }: { event: ObservabilityEvent }) { return (
setHovered(true)} onMouseLeave={() => setHovered(false)} > @@ -119,7 +133,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)"; } @@ -143,6 +158,18 @@ function eventRowStyles(t: ThemeTokens) { borderColor: t.borderStrong, boxShadow: t.shadowSubtle, }, + rowEmbedded: { + borderWidth: 0, + borderBottomWidth: 1, + borderRadius: 0, + boxShadow: "none", + background: "transparent", + padding: "10px 12px 12px", + }, + rowEmbeddedLast: { + borderBottomWidth: 0, + paddingBottom: 14, + }, toggle: { width: "100%", border: "none", diff --git a/packages/devtools/src/inspect/InspectEvent.tsx b/packages/devtools/src/inspect/InspectEvent.tsx new file mode 100644 index 000000000..cfa7c8ef4 --- /dev/null +++ b/packages/devtools/src/inspect/InspectEvent.tsx @@ -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 ; + const stream = getReactLangStreamDetail(event); + if (stream) { + return ( + + ); + } + return ; +} diff --git a/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx b/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx index 34294f176..200bb4b9a 100644 --- a/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx +++ b/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx @@ -62,11 +62,15 @@ export function ReactLangStreamEventRow({ stream, onOpenInDebug, canOpenInDebug = false, + embedded = false, + last = false, }: { event: ObservabilityEvent; stream: ReactLangStreamDetail; onOpenInDebug?: (response: string, libraryId?: string) => void; canOpenInDebug?: boolean; + embedded?: boolean; + last?: boolean; }) { const [expanded, setExpanded] = useState(false); const [hovered, setHovered] = useState(false); @@ -107,7 +111,12 @@ export function ReactLangStreamEventRow({ return (
setHovered(true)} onMouseLeave={() => setHovered(false)} > @@ -359,6 +368,18 @@ function streamRowStyles(t: ThemeTokens) { borderColor: t.borderStrong, boxShadow: t.shadowSubtle, }, + rowEmbedded: { + borderWidth: 0, + borderBottomWidth: 1, + borderRadius: 0, + boxShadow: "none", + background: "transparent", + padding: "10px 12px 12px", + }, + rowEmbeddedLast: { + borderBottomWidth: 0, + paddingBottom: 14, + }, rowHeader: { display: "flex", justifyContent: "space-between", diff --git a/packages/devtools/src/inspect/RunGroup.tsx b/packages/devtools/src/inspect/RunGroup.tsx new file mode 100644 index 000000000..44df49e95 --- /dev/null +++ b/packages/devtools/src/inspect/RunGroup.tsx @@ -0,0 +1,103 @@ +import type { ObservabilityEvent } from "@openuidev/observability"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { useState, type CSSProperties, type ReactNode } from "react"; +import { FONT, useStyles, type ThemeTokens } from "../theme"; +import { LevelIcon } from "./LevelIcon"; +import { runGroupLevel, runGroupTitle } from "./groupEvents"; + +export function RunGroup({ + events, + defaultOpen, + children, +}: { + events: ObservabilityEvent[]; + defaultOpen: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + const styles = useStyles(runGroupStyles); + const level = runGroupLevel(events); + const title = runGroupTitle(events); + const newest = events[0]; + + return ( +
+ + {open ?
{children}
: null} +
+ ); +} + +function runGroupStyles(t: ThemeTokens) { + return { + group: { + borderWidth: 1, + borderStyle: "solid", + borderColor: t.border, + borderRadius: 12, + background: t.card, + display: "flex", + flexDirection: "column", + overflow: "hidden", + }, + header: { + display: "flex", + alignItems: "center", + gap: 8, + width: "100%", + border: "none", + borderBottom: `1px solid ${t.border}`, + background: t.bgMuted, + color: "inherit", + cursor: "pointer", + fontFamily: FONT, + padding: "10px 12px", + textAlign: "left" as const, + }, + title: { + fontSize: 12, + fontWeight: 600, + color: t.fg, + minWidth: 0, + flex: 1, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + headerRight: { + display: "flex", + alignItems: "center", + gap: 6, + marginLeft: "auto", + flexShrink: 0, + }, + time: { + color: t.fgFaint, + fontSize: 11, + }, + chevron: { + display: "inline-flex", + color: t.fgMuted, + }, + body: { + display: "flex", + flexDirection: "column", + }, + } satisfies Record; +} diff --git a/packages/devtools/src/inspect/groupEvents.test.ts b/packages/devtools/src/inspect/groupEvents.test.ts new file mode 100644 index 000000000..2af46f97a --- /dev/null +++ b/packages/devtools/src/inspect/groupEvents.test.ts @@ -0,0 +1,97 @@ +import type { ObservabilityEvent } from "@openuidev/observability"; +import { describe, expect, it } from "vitest"; +import { + displayEventKind, + groupEventsByRunId, + runGroupLevel, + runGroupTitle, +} from "./groupEvents"; + +function event( + detail: Record, + extra: Partial = {}, +): ObservabilityEvent { + return { + level: extra.level ?? "info", + timestamp: extra.timestamp ?? 1, + detail: { kind: "x", ...detail }, + }; +} + +describe("groupEventsByRunId", () => { + it("titles a run from the user message on LLM:request", () => { + const items = groupEventsByRunId([ + event( + { kind: "LLM:request", runId: "run-1", userMessage: { role: "user", content: "Who waited?" } }, + { timestamp: 1 }, + ), + event({ kind: "LLM:response", runId: "run-1", status: 200 }, { timestamp: 2 }), + ]); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ type: "run", runId: "run-1" }); + expect(runGroupTitle(items[0]!.type === "run" ? items[0].events : [])).toBe("Who waited?"); + }); + + it("keeps one stream row when a run published two stream identities", () => { + const items = groupEventsByRunId([ + event({ kind: "LLM:request", runId: "run-1" }, { timestamp: 1 }), + event( + { kind: "react-lang:stream", runId: "run-1", id: "s-old", phase: "settled" }, + { timestamp: 2 }, + ), + event( + { kind: "react-lang:stream", runId: "run-1", id: "s-new", phase: "settled" }, + { timestamp: 3 }, + ), + ]); + const run = items[0]; + expect(run?.type).toBe("run"); + if (run?.type !== "run") return; + const streams = run.events.filter((item) => item.detail["kind"] === "react-lang:stream"); + expect(streams).toHaveLength(1); + expect(streams[0]?.detail["id"]).toBe("s-new"); + }); + + it("orders request, then response, then stream", () => { + const items = groupEventsByRunId([ + event({ kind: "react-lang:stream", runId: "run-1", id: "s1" }, { timestamp: 30 }), + event({ kind: "LLM:response", runId: "run-1" }, { timestamp: 20 }), + event({ kind: "LLM:request", runId: "run-1" }, { timestamp: 10 }), + ]); + const run = items[0]; + expect(run?.type).toBe("run"); + if (run?.type !== "run") return; + expect(run.events.map((item) => item.detail["kind"])).toEqual([ + "LLM:request", + "LLM:response", + "react-lang:stream", + ]); + }); +}); + +describe("runGroupLevel", () => { + it("surfaces request/response severity, not stream parse errors", () => { + expect( + runGroupLevel([ + event({ kind: "LLM:request", runId: "run-1" }), + event({ kind: "LLM:response", runId: "run-1", status: 200 }), + event({ kind: "react-lang:stream", runId: "run-1" }, { level: "error" }), + ]), + ).toBe("info"); + expect( + runGroupLevel([ + event({ kind: "LLM:request", runId: "run-1" }), + event({ kind: "LLM:error", runId: "run-1" }, { level: "error" }), + event({ kind: "react-lang:stream", runId: "run-1" }), + ]), + ).toBe("error"); + }); +}); + +describe("displayEventKind", () => { + it("uses plain-language labels for LLM rows", () => { + expect(displayEventKind("LLM:request")).toBe("Request sent"); + expect(displayEventKind("LLM:response")).toBe("Response received"); + expect(displayEventKind("LLM:error")).toBe("Request failed"); + }); +}); diff --git a/packages/devtools/src/inspect/groupEvents.ts b/packages/devtools/src/inspect/groupEvents.ts new file mode 100644 index 000000000..bab62caf9 --- /dev/null +++ b/packages/devtools/src/inspect/groupEvents.ts @@ -0,0 +1,145 @@ +import type { ObservabilityEvent } from "@openuidev/observability"; + +const STREAM_KIND = "react-lang:stream"; +const LLM_KINDS = new Set(["LLM:request", "LLM:response", "LLM:error"]); + +export type InspectListItem = + | { type: "event"; event: ObservabilityEvent } + | { type: "run"; runId: string; events: ObservabilityEvent[] }; + +export function eventRunId(event: ObservabilityEvent): string | null { + const runId = event.detail["runId"]; + return typeof runId === "string" && runId.length > 0 ? runId : null; +} + +export function eventKind(event: ObservabilityEvent): string | undefined { + return typeof event.detail["kind"] === "string" ? event.detail["kind"] : undefined; +} + +/** + * Collapse events that share a `runId` into one list item, parked at the + * newest event of that run. Lone runIds stay as ordinary rows so a single + * in-flight request does not grow a wrapper. + */ +export function groupEventsByRunId(events: ObservabilityEvent[]): InspectListItem[] { + const buckets = new Map(); + for (const event of events) { + const runId = eventRunId(event); + if (!runId) continue; + const bucket = buckets.get(runId); + if (bucket) bucket.push(event); + else buckets.set(runId, [event]); + } + + const emitted = new Set(); + const items: InspectListItem[] = []; + for (const event of events) { + const runId = eventRunId(event); + if (!runId) { + items.push({ type: "event", event }); + continue; + } + const bucket = buckets.get(runId)!; + if (bucket.length === 1) { + items.push({ type: "event", event }); + continue; + } + if (emitted.has(runId)) continue; + emitted.add(runId); + items.push({ type: "run", runId, events: presentRunEvents(bucket) }); + } + return items; +} + +/** One request, one response/error, one stream — chronological inside the card. */ +export function presentRunEvents(events: ObservabilityEvent[]): ObservabilityEvent[] { + return sortRunEvents(collapseStreams(events)); +} + +export function runGroupTitle(events: ObservabilityEvent[]): string { + for (const event of events) { + if (eventKind(event) !== "LLM:request") continue; + const text = userMessageText(event.detail["userMessage"]); + if (text) return text; + } + return "LLM run"; +} + +/** + * Header severity follows the request/response pair. Stream parse errors stay + * on the stream row instead of turning the whole run red. + */ +export function runGroupLevel(events: ObservabilityEvent[]): ObservabilityEvent["level"] { + const llm = events.filter((event) => LLM_KINDS.has(eventKind(event) ?? "")); + const source = llm.length > 0 ? llm : events; + if (source.some((event) => event.level === "error")) return "error"; + if (source.some((event) => event.level === "warning")) return "warning"; + return "info"; +} + +export function displayEventKind(kind: string): string { + switch (kind) { + case "LLM:request": + return "Request sent"; + case "LLM:response": + return "Response received"; + case "LLM:error": + return "Request failed"; + default: + return kind; + } +} + +function collapseStreams(events: ObservabilityEvent[]): ObservabilityEvent[] { + const streams = events.filter((event) => eventKind(event) === STREAM_KIND); + if (streams.length <= 1) return events; + const chosen = streams.reduce((best, event) => (preferStream(event, best) ? event : best)); + return [...events.filter((event) => eventKind(event) !== STREAM_KIND), chosen]; +} + +function preferStream(candidate: ObservabilityEvent, best: ObservabilityEvent): boolean { + const candidateSettled = candidate.detail["phase"] === "settled"; + const bestSettled = best.detail["phase"] === "settled"; + if (candidateSettled !== bestSettled) return candidateSettled; + return candidate.timestamp >= best.timestamp; +} + +function sortRunEvents(events: ObservabilityEvent[]): ObservabilityEvent[] { + return [...events].sort((a, b) => { + const rank = kindRank(eventKind(a)) - kindRank(eventKind(b)); + if (rank !== 0) return rank; + return a.timestamp - b.timestamp; + }); +} + +function kindRank(kind: string | undefined): number { + if (kind === "LLM:request") return 0; + if (kind === "LLM:response" || kind === "LLM:error") return 1; + if (kind === STREAM_KIND) return 2; + return 3; +} + +function userMessageText(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + if (!value || typeof value !== "object") return undefined; + const content = (value as { content?: unknown }).content; + if (typeof content === "string") { + const trimmed = content.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + if (!Array.isArray(content)) return undefined; + const text = content + .map((part) => { + if (typeof part === "string") return part; + if (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string") { + return (part as { text: string }).text; + } + return ""; + }) + .join(""); + const trimmed = text.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} diff --git a/packages/devtools/src/inspect/index.ts b/packages/devtools/src/inspect/index.ts index 970c1b2ae..822b1f1e5 100644 --- a/packages/devtools/src/inspect/index.ts +++ b/packages/devtools/src/inspect/index.ts @@ -1,4 +1,6 @@ export { EventRow } from "./EventRow"; +export { groupEventsByRunId, eventRunId, runGroupTitle, runGroupLevel } from "./groupEvents"; +export { InspectEvent } from "./InspectEvent"; export { LevelIcon } from "./LevelIcon"; export { QuotaErrorRow, getQuotaError, type QuotaErrorInfo } from "./QuotaErrorRow"; export { @@ -6,3 +8,4 @@ export { getReactLangStreamDetail, type ReactLangStreamDetail, } from "./ReactLangStreamEventRow"; +export { RunGroup } from "./RunGroup"; diff --git a/packages/observability-cloud/src/events/stream.ts b/packages/observability-cloud/src/events/stream.ts index 1c14df65e..a4f4079f4 100644 --- a/packages/observability-cloud/src/events/stream.ts +++ b/packages/observability-cloud/src/events/stream.ts @@ -53,6 +53,8 @@ export interface SettledStreamEventDetail { message: string; /** `createLibrary()` instance id for the Renderer that produced this stream. */ __libraryId?: string; + /** LLM run that produced this stream. Matches `LLM:*` events from the chat store. */ + runId?: string; } /** Wire shape for settled stream events sent to cloud ingest. */ diff --git a/packages/react-headless/src/store/createChatStore.ts b/packages/react-headless/src/store/createChatStore.ts index eed9fd96c..d027c6d7c 100644 --- a/packages/react-headless/src/store/createChatStore.ts +++ b/packages/react-headless/src/store/createChatStore.ts @@ -216,6 +216,7 @@ export const createChatStore = (configRef: React.RefObject set((s) => ({ messages: [...s.messages, msg] })), updateMessage: (msg) => set((s) => ({ diff --git a/packages/react-headless/src/stream/processStreamedMessage.ts b/packages/react-headless/src/stream/processStreamedMessage.ts index e22f46291..484636498 100644 --- a/packages/react-headless/src/stream/processStreamedMessage.ts +++ b/packages/react-headless/src/stream/processStreamedMessage.ts @@ -20,6 +20,8 @@ interface Parameters { clearToolExecuting?: (toolCallId: string) => void; /** The adapter to use for parsing the stream */ adapter?: StreamProtocolAdapter; + /** LLM run that produced this stream. Copied onto each assistant message. */ + runId?: string; } /** @@ -32,13 +34,9 @@ export const processStreamedMessage = async ({ markToolExecuting = () => {}, clearToolExecuting = () => {}, adapter = agUIAdapter(), + runId, }: Parameters): Promise => { - let currentMessage: AssistantMessage = { - id: crypto.randomUUID(), - role: "assistant", - content: "", - toolCalls: [], - }; + let currentMessage: AssistantMessage = newAssistantMessage(runId); let isFirst = true; @@ -173,12 +171,7 @@ export const processStreamedMessage = async ({ rafId = null; if (!isFirst) updateMessage(currentMessage); } - currentMessage = { - id: crypto.randomUUID(), - role: "assistant", - content: "", - toolCalls: [], - }; + currentMessage = newAssistantMessage(runId); isFirst = true; } currentTextItemId = startId; @@ -265,3 +258,13 @@ export const processStreamedMessage = async ({ return currentMessage; }; + +function newAssistantMessage(runId?: string): AssistantMessage { + return { + id: crypto.randomUUID(), + role: "assistant", + content: "", + toolCalls: [], + ...(runId ? { runId } : {}), + }; +} diff --git a/packages/react-headless/src/types/message.ts b/packages/react-headless/src/types/message.ts index 2458172cf..070dcf928 100644 --- a/packages/react-headless/src/types/message.ts +++ b/packages/react-headless/src/types/message.ts @@ -1,6 +1,7 @@ +import type { AssistantMessage as AGUIAssistantMessage } from "@ag-ui/core"; + export type { ActivityMessage, - AssistantMessage, BinaryInputContent, DeveloperMessage, FunctionCall, @@ -13,3 +14,8 @@ export type { ToolMessage, UserMessage, } from "@ag-ui/core"; + +export type AssistantMessage = AGUIAssistantMessage & { + /** LLM run that produced this message. Set by `processStreamedMessage`. */ + runId?: string; +}; diff --git a/packages/react-lang/src/Renderer.tsx b/packages/react-lang/src/Renderer.tsx index 669ddb5b5..872e0245e 100644 --- a/packages/react-lang/src/Renderer.tsx +++ b/packages/react-lang/src/Renderer.tsx @@ -51,6 +51,8 @@ export interface RendererProps { */ onError?: (errors: OpenUIError[]) => void; publishObservability?: boolean; + /** LLM run that produced `response`. Echoed on stream observability events. */ + runId?: string; } // ─── Error boundary ─── @@ -209,6 +211,7 @@ export function Renderer({ queryLoader, onError, publishObservability, + runId, }: RendererProps) { useInsertionEffect(() => { ensureLoadingStyle(); @@ -256,6 +259,7 @@ export function Renderer({ toolProvider: resolvedToolProvider, onError, publishObservability, + runId, }, renderDeep, ); diff --git a/packages/react-lang/src/hooks/streamEvent.ts b/packages/react-lang/src/hooks/streamEvent.ts index 547bd0c54..7ebe75049 100644 --- a/packages/react-lang/src/hooks/streamEvent.ts +++ b/packages/react-lang/src/hooks/streamEvent.ts @@ -33,4 +33,6 @@ export interface SettledStreamEventDetail { message: string; /** `createLibrary()` instance id for the Renderer that produced this stream. */ __libraryId?: string; + /** LLM run that produced this stream. Matches `LLM:*` events from the chat store. */ + runId?: string; } diff --git a/packages/react-lang/src/hooks/useOpenUIState.ts b/packages/react-lang/src/hooks/useOpenUIState.ts index 13df4cde6..bd7e1ae44 100644 --- a/packages/react-lang/src/hooks/useOpenUIState.ts +++ b/packages/react-lang/src/hooks/useOpenUIState.ts @@ -49,6 +49,8 @@ export interface UseOpenUIStateOptions { /** Callback for structured, LLM-friendly errors. See OpenUIError type. */ onError?: (errors: OpenUIError[]) => void; publishObservability?: boolean; + /** LLM run that produced `response`. Echoed on stream observability events. */ + runId?: string; } export interface OpenUIState { @@ -79,6 +81,7 @@ export function useOpenUIState( toolProvider, onError, publishObservability, + runId, }: UseOpenUIStateOptions, renderDeep: (value: unknown) => React.ReactNode, ): OpenUIState { @@ -462,6 +465,7 @@ export function useOpenUIState( errorRevision, publish: publishObservability, __libraryId: library.__libraryId, + runId, }); return { result: evaluatedResult, parseResult: result, contextValue, isQueryLoading }; diff --git a/packages/react-lang/src/hooks/useStreamingObservability.test.ts b/packages/react-lang/src/hooks/useStreamingObservability.test.ts index 10a982358..6de577aae 100644 --- a/packages/react-lang/src/hooks/useStreamingObservability.test.ts +++ b/packages/react-lang/src/hooks/useStreamingObservability.test.ts @@ -85,6 +85,17 @@ describe("streaming observability lifecycle", () => { expect(idFactory).toHaveBeenCalledTimes(2); }); + it("does not start a new id when a settled Renderer is marked streaming with the same response", () => { + const state = createStreamingObservabilityState(); + const idFactory = vi.fn(() => "stream-1"); + + advanceStreamingObservability(state, true, "first", null, idFactory); + advanceStreamingObservability(state, false, "first", "[]", idFactory); + + expect(advanceStreamingObservability(state, true, "first", null, idFactory)).toBeNull(); + expect(idFactory).toHaveBeenCalledOnce(); + }); + it("republishes settled with a new updateIndex when the error snapshot changes", () => { const state = createStreamingObservabilityState(); const idFactory = () => "stream-1"; diff --git a/packages/react-lang/src/hooks/useStreamingObservability.ts b/packages/react-lang/src/hooks/useStreamingObservability.ts index b06c2e109..b40f5f52a 100644 --- a/packages/react-lang/src/hooks/useStreamingObservability.ts +++ b/packages/react-lang/src/hooks/useStreamingObservability.ts @@ -20,6 +20,8 @@ export interface UseStreamingObservabilityOptions { publish?: boolean; /** `createLibrary()` instance id, echoed on stream events for Debug matching. */ __libraryId?: string; + /** LLM run that produced this stream. Groups Inspect events with the request/response pair. */ + runId?: string; } export interface StreamingObservabilityState { @@ -86,6 +88,9 @@ export function advanceStreamingObservability( idFactory: () => string = createStreamId, ): StreamingObservabilityUpdate | null { if (isStreaming) { + // Same content flipping back to streaming is not a new run — that happens + // when a historical assistant is marked live because a new user turn started. + if (state.settled && state.lastResponse === response) return null; // A mounted Renderer can be reused for another message. Once the previous // stream has settled, the next streaming transition starts a new identity. if (state.settled) Object.assign(state, createStreamingObservabilityState()); @@ -136,6 +141,7 @@ export function useStreamingObservability({ errorRevision, publish = true, __libraryId, + runId, }: UseStreamingObservabilityOptions): void { const streamRef = useRef(createStreamingObservabilityState()); @@ -150,6 +156,7 @@ export function useStreamingObservability({ settledErrorKey, ); const libraryIdFields = __libraryId !== undefined ? { __libraryId } : {}; + const runIdFields = runId !== undefined ? { runId } : {}; if (isStreaming) { if (update) { @@ -163,6 +170,7 @@ export function useStreamingObservability({ parser: parserMetadata(result), ...captureStreamTiming(streamRef.current), ...libraryIdFields, + ...runIdFields, message: "OpenUI Lang is streaming", }); } @@ -182,11 +190,12 @@ export function useStreamingObservability({ errorCount: errors.length, ...captureStreamTiming(streamRef.current), ...libraryIdFields, + ...runIdFields, message: errors.length > 0 ? `OpenUI Lang settled with ${errors.length} error${errors.length === 1 ? "" : "s"}` : "OpenUI Lang settled", } satisfies SettledStreamEventDetail); } - }, [publish, isStreaming, response, result, errorsRef, errorRevision, __libraryId]); + }, [publish, isStreaming, response, result, errorsRef, errorRevision, __libraryId, runId]); } diff --git a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx index f36907ac6..cc9427784 100644 --- a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx +++ b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx @@ -104,6 +104,7 @@ export const GenUIAssistantMessage = ({ onAction={handleAction} onStateUpdate={handleStateUpdate} initialState={initialState} + runId={message.runId} /> )} From 3ea220d3d6b77e12ab2d21ef12d94b7c4ffd402e Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Sun, 23 Aug 2026 15:38:05 +0530 Subject: [PATCH 2/8] fix: add grouping --- packages/devtools/src/OpenUIDevtools.test.ts | 70 +++++++++++++++++-- packages/devtools/src/inspect/EventRow.tsx | 11 ++- .../devtools/src/inspect/InspectEvent.tsx | 2 +- .../devtools/src/inspect/QuotaErrorRow.tsx | 42 ++++++++++- .../src/inspect/ReactLangStreamEventRow.tsx | 28 ++++++-- packages/devtools/src/inspect/RunGroup.tsx | 10 ++- .../devtools/src/inspect/groupEvents.test.ts | 4 +- packages/devtools/src/inspect/groupEvents.ts | 12 +--- 8 files changed, 150 insertions(+), 29 deletions(-) diff --git a/packages/devtools/src/OpenUIDevtools.test.ts b/packages/devtools/src/OpenUIDevtools.test.ts index f2492ec26..e56ec406d 100644 --- a/packages/devtools/src/OpenUIDevtools.test.ts +++ b/packages/devtools/src/OpenUIDevtools.test.ts @@ -220,6 +220,68 @@ 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('[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(); @@ -385,14 +447,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( '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"); @@ -468,7 +528,7 @@ describe("OpenUIDevtools", () => { const expand = container.querySelector( 'button[aria-label="Toggle OpenUI Lang stream details"]', ); - click(expand!); + expect(expand?.getAttribute("aria-expanded")).toBe("true"); expect(container.textContent).toContain("First error"); act(() => diff --git a/packages/devtools/src/inspect/EventRow.tsx b/packages/devtools/src/inspect/EventRow.tsx index bda27008a..fc3b52977 100644 --- a/packages/devtools/src/inspect/EventRow.tsx +++ b/packages/devtools/src/inspect/EventRow.tsx @@ -143,7 +143,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, @@ -159,8 +164,10 @@ function eventRowStyles(t: ThemeTokens) { boxShadow: t.shadowSubtle, }, rowEmbedded: { - borderWidth: 0, + borderTopWidth: 0, + borderRightWidth: 0, borderBottomWidth: 1, + borderLeftWidth: 0, borderRadius: 0, boxShadow: "none", background: "transparent", diff --git a/packages/devtools/src/inspect/InspectEvent.tsx b/packages/devtools/src/inspect/InspectEvent.tsx index cfa7c8ef4..206924a84 100644 --- a/packages/devtools/src/inspect/InspectEvent.tsx +++ b/packages/devtools/src/inspect/InspectEvent.tsx @@ -17,7 +17,7 @@ export function InspectEvent({ last?: boolean; }) { const quotaError = getQuotaError(event); - if (quotaError) return ; + if (quotaError) return ; const stream = getReactLangStreamDetail(event); if (stream) { return ( diff --git a/packages/devtools/src/inspect/QuotaErrorRow.tsx b/packages/devtools/src/inspect/QuotaErrorRow.tsx index 15735119f..eefeb3c52 100644 --- a/packages/devtools/src/inspect/QuotaErrorRow.tsx +++ b/packages/devtools/src/inspect/QuotaErrorRow.tsx @@ -36,12 +36,26 @@ export function getQuotaError(event: ObservabilityEvent): QuotaErrorInfo | undef } /** Billing/rate-limit list entry — the highlighted card a known 429 code renders as. */ -export function QuotaErrorRow({ info }: { info: QuotaErrorInfo }) { +export function QuotaErrorRow({ + info, + embedded = false, + last = false, +}: { + info: QuotaErrorInfo; + embedded?: boolean; + last?: boolean; +}) { const styles = useStyles(quotaRowStyles); const [hoveredCta, setHoveredCta] = useState(null); return ( -
+
@@ -102,8 +116,17 @@ function asString(value: unknown): string | undefined { function quotaRowStyles(t: ThemeTokens) { return { + // Longhands, not the `border` shorthand: rowEmbedded overrides width/radius, + // and React blanks a shorthand's longhands when a later style touches one. row: { - border: `1px solid ${t.border}`, + // 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, padding: 12, display: "flex", @@ -111,6 +134,19 @@ function quotaRowStyles(t: ThemeTokens) { gap: 6, background: t.card, }, + rowEmbedded: { + borderTopWidth: 0, + borderRightWidth: 0, + borderBottomWidth: 1, + borderLeftWidth: 0, + borderRadius: 0, + background: "transparent", + padding: "10px 12px 12px", + }, + rowEmbeddedLast: { + borderBottomWidth: 0, + paddingBottom: 14, + }, // The card matches every other row; the chip and title colour carry the // billing/rate-limit signal. creditsNote: { diff --git a/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx b/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx index 200bb4b9a..9a808edd8 100644 --- a/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx +++ b/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx @@ -1,6 +1,6 @@ import { type ObservabilityEvent } from "@openuidev/observability"; import { Bug, Check, ChevronDown, ChevronRight, Copy } from "lucide-react"; -import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { tokenColor, tokenizeLang } from "../lib"; import { FONT, MONO, useStyles, type ThemeTokens } from "../theme"; import { LevelIcon } from "./LevelIcon"; @@ -72,20 +72,29 @@ export function ReactLangStreamEventRow({ embedded?: boolean; last?: boolean; }) { - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(() => stream.phase === "settled" && stream.errors.length > 0); const [hovered, setHovered] = useState(false); const [hoveredAction, setHoveredAction] = useState(null); const [responseCopied, setResponseCopied] = useState(false); const styles = useStyles(streamRowStyles); const colors = useStyles(tokenColor); + const isStreaming = stream.phase === "streaming"; + const visibleErrors = isStreaming ? [] : stream.errors; + const hasErrors = visibleErrors.length > 0; + const openedForError = useRef(hasErrors); // Collapsed rows skip tokenizing: a live stream re-renders this on every chunk. const responseTokens = useMemo( () => (expanded && stream.response ? tokenizeLang(stream.response) : []), [expanded, stream.response], ); - const isStreaming = stream.phase === "streaming"; const elapsedMs = useStreamElapsedMs(stream, isStreaming); - const visibleErrors = isStreaming ? [] : stream.errors; + + useEffect(() => { + if (!hasErrors || openedForError.current) return; + openedForError.current = true; + setExpanded(true); + }, [hasErrors]); + const statementCount = stream.parser?.statementCount; const orphaned = stream.parser?.orphaned ?? []; const parserIssues = [ @@ -352,7 +361,12 @@ function streamRowStyles(t: ThemeTokens) { // Longhands, not the `border` shorthand: rowHover overrides borderColor, and // React blanks a shorthand's longhands when a later style touches one of them. 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, @@ -369,8 +383,10 @@ function streamRowStyles(t: ThemeTokens) { boxShadow: t.shadowSubtle, }, rowEmbedded: { - borderWidth: 0, + borderTopWidth: 0, + borderRightWidth: 0, borderBottomWidth: 1, + borderLeftWidth: 0, borderRadius: 0, boxShadow: "none", background: "transparent", diff --git a/packages/devtools/src/inspect/RunGroup.tsx b/packages/devtools/src/inspect/RunGroup.tsx index 44df49e95..56775547c 100644 --- a/packages/devtools/src/inspect/RunGroup.tsx +++ b/packages/devtools/src/inspect/RunGroup.tsx @@ -1,6 +1,6 @@ import type { ObservabilityEvent } from "@openuidev/observability"; import { ChevronDown, ChevronRight } from "lucide-react"; -import { useState, type CSSProperties, type ReactNode } from "react"; +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { FONT, useStyles, type ThemeTokens } from "../theme"; import { LevelIcon } from "./LevelIcon"; import { runGroupLevel, runGroupTitle } from "./groupEvents"; @@ -15,10 +15,18 @@ export function RunGroup({ children: ReactNode; }) { const [open, setOpen] = useState(defaultOpen); + const openedForError = useRef(defaultOpen && events.some((event) => event.level === "error")); const styles = useStyles(runGroupStyles); const level = runGroupLevel(events); const title = runGroupTitle(events); const newest = events[0]; + const hasError = events.some((event) => event.level === "error"); + + useEffect(() => { + if (!hasError || openedForError.current) return; + openedForError.current = true; + setOpen(true); + }, [hasError]); return (
diff --git a/packages/devtools/src/inspect/groupEvents.test.ts b/packages/devtools/src/inspect/groupEvents.test.ts index 2af46f97a..1eb22438a 100644 --- a/packages/devtools/src/inspect/groupEvents.test.ts +++ b/packages/devtools/src/inspect/groupEvents.test.ts @@ -70,14 +70,14 @@ describe("groupEventsByRunId", () => { }); describe("runGroupLevel", () => { - it("surfaces request/response severity, not stream parse errors", () => { + it("surfaces the worst level in the group, including stream parse errors", () => { expect( runGroupLevel([ event({ kind: "LLM:request", runId: "run-1" }), event({ kind: "LLM:response", runId: "run-1", status: 200 }), event({ kind: "react-lang:stream", runId: "run-1" }, { level: "error" }), ]), - ).toBe("info"); + ).toBe("error"); expect( runGroupLevel([ event({ kind: "LLM:request", runId: "run-1" }), diff --git a/packages/devtools/src/inspect/groupEvents.ts b/packages/devtools/src/inspect/groupEvents.ts index bab62caf9..0c9e35d11 100644 --- a/packages/devtools/src/inspect/groupEvents.ts +++ b/packages/devtools/src/inspect/groupEvents.ts @@ -1,7 +1,6 @@ import type { ObservabilityEvent } from "@openuidev/observability"; const STREAM_KIND = "react-lang:stream"; -const LLM_KINDS = new Set(["LLM:request", "LLM:response", "LLM:error"]); export type InspectListItem = | { type: "event"; event: ObservabilityEvent } @@ -65,15 +64,10 @@ export function runGroupTitle(events: ObservabilityEvent[]): string { return "LLM run"; } -/** - * Header severity follows the request/response pair. Stream parse errors stay - * on the stream row instead of turning the whole run red. - */ +/** Worst level in the group, including stream parse errors and 429s. */ export function runGroupLevel(events: ObservabilityEvent[]): ObservabilityEvent["level"] { - const llm = events.filter((event) => LLM_KINDS.has(eventKind(event) ?? "")); - const source = llm.length > 0 ? llm : events; - if (source.some((event) => event.level === "error")) return "error"; - if (source.some((event) => event.level === "warning")) return "warning"; + if (events.some((event) => event.level === "error")) return "error"; + if (events.some((event) => event.level === "warning")) return "warning"; return "info"; } From d72417df29c50f5f3d27285b40e2d7fbbeb5ae94 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Sun, 23 Aug 2026 15:57:23 +0530 Subject: [PATCH 3/8] fix: ui --- packages/devtools/src/inspect/EventRow.tsx | 43 ++++++++----------- .../devtools/src/inspect/QuotaErrorRow.tsx | 24 ++--------- .../src/inspect/ReactLangStreamEventRow.tsx | 21 ++------- packages/devtools/src/inspect/RunGroup.tsx | 7 ++- packages/devtools/src/inspect/rowBox.ts | 22 ++++++++++ 5 files changed, 54 insertions(+), 63 deletions(-) create mode 100644 packages/devtools/src/inspect/rowBox.ts diff --git a/packages/devtools/src/inspect/EventRow.tsx b/packages/devtools/src/inspect/EventRow.tsx index fc3b52977..cfaba0092 100644 --- a/packages/devtools/src/inspect/EventRow.tsx +++ b/packages/devtools/src/inspect/EventRow.tsx @@ -1,9 +1,10 @@ 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, @@ -15,9 +16,7 @@ export function EventRow({ 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"]); @@ -26,11 +25,15 @@ export function EventRow({ 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); @@ -64,8 +67,7 @@ export function EventRow({
setHovered(true)} @@ -89,7 +91,7 @@ export function EventRow({
{stack}
- @@ -163,20 +165,6 @@ function eventRowStyles(t: ThemeTokens) { borderColor: t.borderStrong, boxShadow: t.shadowSubtle, }, - rowEmbedded: { - borderTopWidth: 0, - borderRightWidth: 0, - borderBottomWidth: 1, - borderLeftWidth: 0, - borderRadius: 0, - boxShadow: "none", - background: "transparent", - padding: "10px 12px 12px", - }, - rowEmbeddedLast: { - borderBottomWidth: 0, - paddingBottom: 14, - }, toggle: { width: "100%", border: "none", @@ -192,6 +180,7 @@ function eventRowStyles(t: ThemeTokens) { justifyContent: "space-between", alignItems: "center", gap: 8, + minHeight: 22, }, rowHeaderRight: { display: "flex", @@ -201,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, }, diff --git a/packages/devtools/src/inspect/QuotaErrorRow.tsx b/packages/devtools/src/inspect/QuotaErrorRow.tsx index eefeb3c52..a039ce9f3 100644 --- a/packages/devtools/src/inspect/QuotaErrorRow.tsx +++ b/packages/devtools/src/inspect/QuotaErrorRow.tsx @@ -1,8 +1,9 @@ import { type ObservabilityEvent } from "@openuidev/observability"; import { CreditCard, KeyRound } from "lucide-react"; import { useState, type CSSProperties } from "react"; -import { FONT, useStyles, type ThemeTokens } from "../theme"; +import { FONT, useStyles, useTheme, type ThemeTokens } from "../theme"; import { LevelIcon } from "./LevelIcon"; +import { nestedRowBox } from "./rowBox"; export interface QuotaErrorInfo { title: string; @@ -46,14 +47,14 @@ export function QuotaErrorRow({ last?: boolean; }) { const styles = useStyles(quotaRowStyles); + const theme = useTheme(); const [hoveredCta, setHoveredCta] = useState(null); return (
@@ -116,11 +117,7 @@ function asString(value: unknown): string | undefined { function quotaRowStyles(t: ThemeTokens) { return { - // Longhands, not the `border` shorthand: rowEmbedded overrides width/radius, - // and React blanks a shorthand's longhands when a later style touches one. row: { - // 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, @@ -134,19 +131,6 @@ function quotaRowStyles(t: ThemeTokens) { gap: 6, background: t.card, }, - rowEmbedded: { - borderTopWidth: 0, - borderRightWidth: 0, - borderBottomWidth: 1, - borderLeftWidth: 0, - borderRadius: 0, - background: "transparent", - padding: "10px 12px 12px", - }, - rowEmbeddedLast: { - borderBottomWidth: 0, - paddingBottom: 14, - }, // The card matches every other row; the chip and title colour carry the // billing/rate-limit signal. creditsNote: { diff --git a/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx b/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx index 9a808edd8..6e39661b3 100644 --- a/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx +++ b/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx @@ -2,8 +2,9 @@ import { type ObservabilityEvent } from "@openuidev/observability"; import { Bug, Check, ChevronDown, ChevronRight, Copy } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { tokenColor, tokenizeLang } from "../lib"; -import { FONT, MONO, useStyles, type ThemeTokens } from "../theme"; +import { FONT, MONO, useStyles, useTheme, type ThemeTokens } from "../theme"; import { LevelIcon } from "./LevelIcon"; +import { nestedRowBox } from "./rowBox"; export interface ReactLangStreamDetail { phase: "streaming" | "settled"; @@ -77,6 +78,7 @@ export function ReactLangStreamEventRow({ const [hoveredAction, setHoveredAction] = useState(null); const [responseCopied, setResponseCopied] = useState(false); const styles = useStyles(streamRowStyles); + const theme = useTheme(); const colors = useStyles(tokenColor); const isStreaming = stream.phase === "streaming"; const visibleErrors = isStreaming ? [] : stream.errors; @@ -122,8 +124,7 @@ export function ReactLangStreamEventRow({
setHovered(true)} @@ -382,20 +383,6 @@ function streamRowStyles(t: ThemeTokens) { borderColor: t.borderStrong, boxShadow: t.shadowSubtle, }, - rowEmbedded: { - borderTopWidth: 0, - borderRightWidth: 0, - borderBottomWidth: 1, - borderLeftWidth: 0, - borderRadius: 0, - boxShadow: "none", - background: "transparent", - padding: "10px 12px 12px", - }, - rowEmbeddedLast: { - borderBottomWidth: 0, - paddingBottom: 14, - }, rowHeader: { display: "flex", justifyContent: "space-between", diff --git a/packages/devtools/src/inspect/RunGroup.tsx b/packages/devtools/src/inspect/RunGroup.tsx index 56775547c..d7ccf1e9b 100644 --- a/packages/devtools/src/inspect/RunGroup.tsx +++ b/packages/devtools/src/inspect/RunGroup.tsx @@ -75,7 +75,7 @@ function runGroupStyles(t: ThemeTokens) { color: "inherit", cursor: "pointer", fontFamily: FONT, - padding: "10px 12px", + padding: 12, textAlign: "left" as const, }, title: { @@ -101,6 +101,11 @@ function runGroupStyles(t: ThemeTokens) { }, chevron: { display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 22, + height: 22, + flexShrink: 0, color: t.fgMuted, }, body: { diff --git a/packages/devtools/src/inspect/rowBox.ts b/packages/devtools/src/inspect/rowBox.ts new file mode 100644 index 000000000..ab444c9ae --- /dev/null +++ b/packages/devtools/src/inspect/rowBox.ts @@ -0,0 +1,22 @@ +import type { CSSProperties } from "react"; +import type { ThemeTokens } from "../theme"; + +/** + * Nested inspect rows must overwrite the same border longhands as the + * standalone card. Mixing `borderWidth` / `borderTop` with `borderBottomWidth` + * leaves a leftover box stroke in React (double border inside the group). + */ +export function nestedRowBox(t: ThemeTokens, last: boolean): CSSProperties { + return { + borderTopWidth: 0, + borderRightWidth: 0, + borderLeftWidth: 0, + borderBottomWidth: last ? 0 : 1, + borderStyle: "solid", + borderColor: t.border, + borderRadius: 0, + boxShadow: "none", + background: "transparent", + padding: last ? "12px 12px 14px" : 12, + }; +} From 1303bf0736561fe7c23d54cc6720937ff5ccf5d4 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Sun, 23 Aug 2026 16:09:38 +0530 Subject: [PATCH 4/8] fix: format --- packages/devtools/src/OpenUIDevtools.test.ts | 4 +++- .../src/inspect/ReactLangStreamEventRow.tsx | 4 +++- packages/devtools/src/inspect/groupEvents.test.ts | 13 ++++++------- packages/devtools/src/inspect/groupEvents.ts | 6 +++++- packages/devtools/src/inspect/index.ts | 2 +- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/devtools/src/OpenUIDevtools.test.ts b/packages/devtools/src/OpenUIDevtools.test.ts index e56ec406d..2c7e3ef08 100644 --- a/packages/devtools/src/OpenUIDevtools.test.ts +++ b/packages/devtools/src/OpenUIDevtools.test.ts @@ -240,7 +240,9 @@ describe("OpenUIDevtools", () => { id: "stream-1", phase: "settled", runId: "run-1", - errors: [{ source: "parser", code: "unknown-component", message: "Unknown component Ghost" }], + errors: [ + { source: "parser", code: "unknown-component", message: "Unknown component Ghost" }, + ], }), ); expect(toggle().getAttribute("aria-expanded")).toBe("true"); diff --git a/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx b/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx index 6e39661b3..9d2f63aed 100644 --- a/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx +++ b/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx @@ -73,7 +73,9 @@ export function ReactLangStreamEventRow({ embedded?: boolean; last?: boolean; }) { - const [expanded, setExpanded] = useState(() => stream.phase === "settled" && stream.errors.length > 0); + const [expanded, setExpanded] = useState( + () => stream.phase === "settled" && stream.errors.length > 0, + ); const [hovered, setHovered] = useState(false); const [hoveredAction, setHoveredAction] = useState(null); const [responseCopied, setResponseCopied] = useState(false); diff --git a/packages/devtools/src/inspect/groupEvents.test.ts b/packages/devtools/src/inspect/groupEvents.test.ts index 1eb22438a..d32400a3a 100644 --- a/packages/devtools/src/inspect/groupEvents.test.ts +++ b/packages/devtools/src/inspect/groupEvents.test.ts @@ -1,11 +1,6 @@ import type { ObservabilityEvent } from "@openuidev/observability"; import { describe, expect, it } from "vitest"; -import { - displayEventKind, - groupEventsByRunId, - runGroupLevel, - runGroupTitle, -} from "./groupEvents"; +import { displayEventKind, groupEventsByRunId, runGroupLevel, runGroupTitle } from "./groupEvents"; function event( detail: Record, @@ -22,7 +17,11 @@ describe("groupEventsByRunId", () => { it("titles a run from the user message on LLM:request", () => { const items = groupEventsByRunId([ event( - { kind: "LLM:request", runId: "run-1", userMessage: { role: "user", content: "Who waited?" } }, + { + kind: "LLM:request", + runId: "run-1", + userMessage: { role: "user", content: "Who waited?" }, + }, { timestamp: 1 }, ), event({ kind: "LLM:response", runId: "run-1", status: 200 }, { timestamp: 2 }), diff --git a/packages/devtools/src/inspect/groupEvents.ts b/packages/devtools/src/inspect/groupEvents.ts index 0c9e35d11..ec0bd5da5 100644 --- a/packages/devtools/src/inspect/groupEvents.ts +++ b/packages/devtools/src/inspect/groupEvents.ts @@ -128,7 +128,11 @@ function userMessageText(value: unknown): string | undefined { const text = content .map((part) => { if (typeof part === "string") return part; - if (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string") { + if ( + part && + typeof part === "object" && + typeof (part as { text?: unknown }).text === "string" + ) { return (part as { text: string }).text; } return ""; diff --git a/packages/devtools/src/inspect/index.ts b/packages/devtools/src/inspect/index.ts index 822b1f1e5..c8ae2fcde 100644 --- a/packages/devtools/src/inspect/index.ts +++ b/packages/devtools/src/inspect/index.ts @@ -1,5 +1,5 @@ export { EventRow } from "./EventRow"; -export { groupEventsByRunId, eventRunId, runGroupTitle, runGroupLevel } from "./groupEvents"; +export { eventRunId, groupEventsByRunId, runGroupLevel, runGroupTitle } from "./groupEvents"; export { InspectEvent } from "./InspectEvent"; export { LevelIcon } from "./LevelIcon"; export { QuotaErrorRow, getQuotaError, type QuotaErrorInfo } from "./QuotaErrorRow"; From 2a5b9a1f7c1a206121f9964eded9dce47285138a Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Mon, 5 Oct 2026 15:07:07 +0530 Subject: [PATCH 5/8] chore: add changeset for Inspect run grouping --- .changeset/inspect-run-groups.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/inspect-run-groups.md diff --git a/.changeset/inspect-run-groups.md b/.changeset/inspect-run-groups.md new file mode 100644 index 000000000..865d3f52c --- /dev/null +++ b/.changeset/inspect-run-groups.md @@ -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 `runId` through assistant messages and renderer observability events. Standalone `Renderer` users can pass `runId` and emit matching request and response events to enable grouping. Preserve the ID across assistant message segments and avoid duplicate stream identities when unchanged historical content is marked streaming again. From 9ed713bf093a0afe32ba3103a2fd44cbea50c946 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Mon, 5 Oct 2026 15:19:12 +0530 Subject: [PATCH 6/8] feat: group standalone Renderer streams through devtools options --- .changeset/inspect-run-groups.md | 2 +- .../content/docs/api-reference/react-lang.mdx | 19 ++++++++++++ .../devtools/src/inspect/groupEvents.test.ts | 5 ++-- packages/devtools/src/inspect/groupEvents.ts | 28 +++++++++++++----- .../observability-cloud/src/events/stream.ts | 2 ++ packages/react-lang/README.md | 14 +++++++++ packages/react-lang/src/Renderer.tsx | 14 ++++++--- packages/react-lang/src/hooks/streamEvent.ts | 2 ++ .../react-lang/src/hooks/useOpenUIState.ts | 4 +++ .../src/hooks/useStreamingObservability.ts | 29 ++++++++++++++++--- .../OpenUIChat/GenUIAssistantMessage.tsx | 2 +- 11 files changed, 100 insertions(+), 21 deletions(-) diff --git a/.changeset/inspect-run-groups.md b/.changeset/inspect-run-groups.md index 865d3f52c..ae1ce7fba 100644 --- a/.changeset/inspect-run-groups.md +++ b/.changeset/inspect-run-groups.md @@ -8,4 +8,4 @@ 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 `runId` through assistant messages and renderer observability events. Standalone `Renderer` users can pass `runId` and emit matching request and response events to enable grouping. Preserve the ID across assistant message segments and avoid duplicate stream identities when unchanged historical content is marked streaming again. +Carry the run identity through assistant messages. Standalone `Renderer` users can pass `devtools={{ runId, runTitle }}` to group rendered responses in Inspect without additional instrumentation. Keep distinct renderer streams in the same group, preserve the run identity across assistant message segments, and avoid duplicate stream identities when unchanged historical content is marked streaming again. diff --git a/docs/content/docs/api-reference/react-lang.mdx b/docs/content/docs/api-reference/react-lang.mdx index 1a329b7d5..95b4cef9c 100644 --- a/docs/content/docs/api-reference/react-lang.mdx +++ b/docs/content/docs/api-reference/react-lang.mdx @@ -108,9 +108,28 @@ interface RendererProps { | null; queryLoader?: React.ReactNode; onError?: (errors: OpenUIError[]) => void; + devtools?: { + runId?: string; + runTitle?: string; + }; } ``` +### Inspect grouping + +Pass `devtools.runId` to group rendered responses from one generation in OpenUI Inspect. Use the same ID for every message from that generation and a new ID for the next generation. The optional `devtools.runTitle` labels the group, for example with the user's prompt. + +```tsx + +``` + +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 automatically. + ## Tool Provider Handles `Query()` and `Mutation()` tool calls at runtime. The `toolProvider` prop accepts two forms: diff --git a/packages/devtools/src/inspect/groupEvents.test.ts b/packages/devtools/src/inspect/groupEvents.test.ts index d32400a3a..8cd4bae58 100644 --- a/packages/devtools/src/inspect/groupEvents.test.ts +++ b/packages/devtools/src/inspect/groupEvents.test.ts @@ -31,7 +31,7 @@ describe("groupEventsByRunId", () => { expect(runGroupTitle(items[0]!.type === "run" ? items[0].events : [])).toBe("Who waited?"); }); - it("keeps one stream row when a run published two stream identities", () => { + it("keeps distinct Renderer streams within the same run", () => { const items = groupEventsByRunId([ event({ kind: "LLM:request", runId: "run-1" }, { timestamp: 1 }), event( @@ -47,8 +47,7 @@ describe("groupEventsByRunId", () => { expect(run?.type).toBe("run"); if (run?.type !== "run") return; const streams = run.events.filter((item) => item.detail["kind"] === "react-lang:stream"); - expect(streams).toHaveLength(1); - expect(streams[0]?.detail["id"]).toBe("s-new"); + expect(streams.map((stream) => stream.detail["id"])).toEqual(["s-old", "s-new"]); }); it("orders request, then response, then stream", () => { diff --git a/packages/devtools/src/inspect/groupEvents.ts b/packages/devtools/src/inspect/groupEvents.ts index ec0bd5da5..511228f04 100644 --- a/packages/devtools/src/inspect/groupEvents.ts +++ b/packages/devtools/src/inspect/groupEvents.ts @@ -17,8 +17,8 @@ export function eventKind(event: ObservabilityEvent): string | undefined { /** * Collapse events that share a `runId` into one list item, parked at the - * newest event of that run. Lone runIds stay as ordinary rows so a single - * in-flight request does not grow a wrapper. + * newest event of that run. A standalone Renderer stream can form a group + * on its own; a lone in-flight request stays an ordinary row. */ export function groupEventsByRunId(events: ObservabilityEvent[]): InspectListItem[] { const buckets = new Map(); @@ -39,7 +39,7 @@ export function groupEventsByRunId(events: ObservabilityEvent[]): InspectListIte continue; } const bucket = buckets.get(runId)!; - if (bucket.length === 1) { + if (bucket.length === 1 && eventKind(event) !== STREAM_KIND) { items.push({ type: "event", event }); continue; } @@ -50,12 +50,16 @@ export function groupEventsByRunId(events: ObservabilityEvent[]): InspectListIte return items; } -/** One request, one response/error, one stream — chronological inside the card. */ +/** Request, response/error, then each Renderer stream inside the card. */ export function presentRunEvents(events: ObservabilityEvent[]): ObservabilityEvent[] { return sortRunEvents(collapseStreams(events)); } export function runGroupTitle(events: ObservabilityEvent[]): string { + for (const event of events) { + const title = event.detail["runTitle"]; + if (typeof title === "string" && title.trim()) return title.trim(); + } for (const event of events) { if (eventKind(event) !== "LLM:request") continue; const text = userMessageText(event.detail["userMessage"]); @@ -85,10 +89,18 @@ export function displayEventKind(kind: string): string { } function collapseStreams(events: ObservabilityEvent[]): ObservabilityEvent[] { - const streams = events.filter((event) => eventKind(event) === STREAM_KIND); - if (streams.length <= 1) return events; - const chosen = streams.reduce((best, event) => (preferStream(event, best) ? event : best)); - return [...events.filter((event) => eventKind(event) !== STREAM_KIND), chosen]; + const streams = new Map(); + const remaining: ObservabilityEvent[] = []; + for (const event of events) { + const id = event.detail["id"]; + if (eventKind(event) !== STREAM_KIND || typeof id !== "string") { + remaining.push(event); + continue; + } + const previous = streams.get(id); + if (!previous || preferStream(event, previous)) streams.set(id, event); + } + return [...remaining, ...streams.values()]; } function preferStream(candidate: ObservabilityEvent, best: ObservabilityEvent): boolean { diff --git a/packages/observability-cloud/src/events/stream.ts b/packages/observability-cloud/src/events/stream.ts index a4f4079f4..596cba77e 100644 --- a/packages/observability-cloud/src/events/stream.ts +++ b/packages/observability-cloud/src/events/stream.ts @@ -55,6 +55,8 @@ export interface SettledStreamEventDetail { __libraryId?: string; /** LLM run that produced this stream. Matches `LLM:*` events from the chat store. */ runId?: string; + /** Display title for the Inspect run group. */ + runTitle?: string; } /** Wire shape for settled stream events sent to cloud ingest. */ diff --git a/packages/react-lang/README.md b/packages/react-lang/README.md index 6a1b94f9b..ff9c8c64c 100644 --- a/packages/react-lang/README.md +++ b/packages/react-lang/README.md @@ -113,6 +113,20 @@ function AssistantMessage({ response, isStreaming }) { | `onStateUpdate` | `(state: Record) => void` | Callback when form field values change | | `initialState` | `Record` | Initial form state for hydration | | `onParseResult` | `(result: ParseResult \| null) => void` | Callback when the parse result changes | +| `devtools` | `{ runId?: string; runTitle?: string }` | Optional metadata for grouping rendered responses in OpenUI Inspect | + +To group responses from the same generation in Inspect, pass a stable `devtools.runId` to each Renderer and optionally set `devtools.runTitle` to the user's prompt: + +```tsx + +``` + +Create a new run ID for each generation and reuse it across all messages from that generation. 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 automatically. ### Parser (Server-Side) diff --git a/packages/react-lang/src/Renderer.tsx b/packages/react-lang/src/Renderer.tsx index 872e0245e..a5e1e27f6 100644 --- a/packages/react-lang/src/Renderer.tsx +++ b/packages/react-lang/src/Renderer.tsx @@ -51,8 +51,13 @@ export interface RendererProps { */ onError?: (errors: OpenUIError[]) => void; publishObservability?: boolean; - /** LLM run that produced `response`. Echoed on stream observability events. */ - runId?: string; + /** Optional metadata for grouping rendered responses in OpenUI Inspect. */ + devtools?: { + /** Renderers with the same ID appear in one run group. Keep it stable during a generation. */ + runId?: string; + /** Display title for the run group, such as the user's prompt. */ + runTitle?: string; + }; } // ─── Error boundary ─── @@ -211,7 +216,7 @@ export function Renderer({ queryLoader, onError, publishObservability, - runId, + devtools, }: RendererProps) { useInsertionEffect(() => { ensureLoadingStyle(); @@ -259,7 +264,8 @@ export function Renderer({ toolProvider: resolvedToolProvider, onError, publishObservability, - runId, + runId: devtools?.runId, + runTitle: devtools?.runTitle, }, renderDeep, ); diff --git a/packages/react-lang/src/hooks/streamEvent.ts b/packages/react-lang/src/hooks/streamEvent.ts index 7ebe75049..8f1b94caa 100644 --- a/packages/react-lang/src/hooks/streamEvent.ts +++ b/packages/react-lang/src/hooks/streamEvent.ts @@ -35,4 +35,6 @@ export interface SettledStreamEventDetail { __libraryId?: string; /** LLM run that produced this stream. Matches `LLM:*` events from the chat store. */ runId?: string; + /** Display title for the Inspect run group. */ + runTitle?: string; } diff --git a/packages/react-lang/src/hooks/useOpenUIState.ts b/packages/react-lang/src/hooks/useOpenUIState.ts index bd7e1ae44..f0261e992 100644 --- a/packages/react-lang/src/hooks/useOpenUIState.ts +++ b/packages/react-lang/src/hooks/useOpenUIState.ts @@ -51,6 +51,8 @@ export interface UseOpenUIStateOptions { publishObservability?: boolean; /** LLM run that produced `response`. Echoed on stream observability events. */ runId?: string; + /** Display title for the Inspect run group. */ + runTitle?: string; } export interface OpenUIState { @@ -82,6 +84,7 @@ export function useOpenUIState( onError, publishObservability, runId, + runTitle, }: UseOpenUIStateOptions, renderDeep: (value: unknown) => React.ReactNode, ): OpenUIState { @@ -466,6 +469,7 @@ export function useOpenUIState( publish: publishObservability, __libraryId: library.__libraryId, runId, + runTitle, }); return { result: evaluatedResult, parseResult: result, contextValue, isQueryLoading }; diff --git a/packages/react-lang/src/hooks/useStreamingObservability.ts b/packages/react-lang/src/hooks/useStreamingObservability.ts index b40f5f52a..e6132979a 100644 --- a/packages/react-lang/src/hooks/useStreamingObservability.ts +++ b/packages/react-lang/src/hooks/useStreamingObservability.ts @@ -22,6 +22,8 @@ export interface UseStreamingObservabilityOptions { __libraryId?: string; /** LLM run that produced this stream. Groups Inspect events with the request/response pair. */ runId?: string; + /** Display title for the Inspect run group. */ + runTitle?: string; } export interface StreamingObservabilityState { @@ -142,11 +144,17 @@ export function useStreamingObservability({ publish = true, __libraryId, runId, + runTitle, }: UseStreamingObservabilityOptions): void { const streamRef = useRef(createStreamingObservabilityState()); + const runIdRef = useRef(runId); useEffect(() => { if (!publish) return; + if (runIdRef.current !== runId) { + Object.assign(streamRef.current, createStreamingObservabilityState()); + runIdRef.current = runId; + } const errors = errorsRef.current; const settledErrorKey = isStreaming ? null : JSON.stringify(errors); const update = advanceStreamingObservability( @@ -156,7 +164,10 @@ export function useStreamingObservability({ settledErrorKey, ); const libraryIdFields = __libraryId !== undefined ? { __libraryId } : {}; - const runIdFields = runId !== undefined ? { runId } : {}; + const runFields = { + ...(runId !== undefined ? { runId } : {}), + ...(runTitle !== undefined ? { runTitle } : {}), + }; if (isStreaming) { if (update) { @@ -170,7 +181,7 @@ export function useStreamingObservability({ parser: parserMetadata(result), ...captureStreamTiming(streamRef.current), ...libraryIdFields, - ...runIdFields, + ...runFields, message: "OpenUI Lang is streaming", }); } @@ -190,12 +201,22 @@ export function useStreamingObservability({ errorCount: errors.length, ...captureStreamTiming(streamRef.current), ...libraryIdFields, - ...runIdFields, + ...runFields, message: errors.length > 0 ? `OpenUI Lang settled with ${errors.length} error${errors.length === 1 ? "" : "s"}` : "OpenUI Lang settled", } satisfies SettledStreamEventDetail); } - }, [publish, isStreaming, response, result, errorsRef, errorRevision, __libraryId, runId]); + }, [ + publish, + isStreaming, + response, + result, + errorsRef, + errorRevision, + __libraryId, + runId, + runTitle, + ]); } diff --git a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx index cc9427784..3a78a77bb 100644 --- a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx +++ b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx @@ -104,7 +104,7 @@ export const GenUIAssistantMessage = ({ onAction={handleAction} onStateUpdate={handleStateUpdate} initialState={initialState} - runId={message.runId} + devtools={{ runId: message.runId }} /> )} From 8d4cb2fac02c168b7a7317ca6461d97f0effb464 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Mon, 5 Oct 2026 15:22:20 +0530 Subject: [PATCH 7/8] refactor: nest Renderer devtools metadata in a run object --- .changeset/inspect-run-groups.md | 2 +- docs/content/docs/api-reference/react-lang.mdx | 10 ++++++---- packages/react-lang/README.md | 6 +++--- packages/react-lang/src/Renderer.tsx | 14 ++++++++------ .../OpenUIChat/GenUIAssistantMessage.tsx | 2 +- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.changeset/inspect-run-groups.md b/.changeset/inspect-run-groups.md index ae1ce7fba..5a8421a30 100644 --- a/.changeset/inspect-run-groups.md +++ b/.changeset/inspect-run-groups.md @@ -8,4 +8,4 @@ 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 identity through assistant messages. Standalone `Renderer` users can pass `devtools={{ runId, runTitle }}` to group rendered responses in Inspect without additional instrumentation. Keep distinct renderer streams in the same group, preserve the run identity across assistant message segments, and avoid duplicate stream identities when unchanged historical content is marked streaming again. +Carry the run identity through assistant messages. Standalone `Renderer` users can pass `devtools={{ run: { id, title } }}` to group rendered responses in Inspect without additional instrumentation. Keep distinct renderer streams in the same group, preserve the run identity across assistant message segments, and avoid duplicate stream identities when unchanged historical content is marked streaming again. diff --git a/docs/content/docs/api-reference/react-lang.mdx b/docs/content/docs/api-reference/react-lang.mdx index 95b4cef9c..f2c489a66 100644 --- a/docs/content/docs/api-reference/react-lang.mdx +++ b/docs/content/docs/api-reference/react-lang.mdx @@ -109,22 +109,24 @@ interface RendererProps { queryLoader?: React.ReactNode; onError?: (errors: OpenUIError[]) => void; devtools?: { - runId?: string; - runTitle?: string; + run?: { + id: string; + title?: string; + }; }; } ``` ### Inspect grouping -Pass `devtools.runId` to group rendered responses from one generation in OpenUI Inspect. Use the same ID for every message from that generation and a new ID for the next generation. The optional `devtools.runTitle` labels the group, for example with the user's prompt. +Pass `devtools.run` to group rendered responses from one generation in OpenUI Inspect. Its `id` is required: use the same ID for every message from that generation and a new ID for the next generation. The optional `title` labels the group, for example with the user's prompt. ```tsx ``` diff --git a/packages/react-lang/README.md b/packages/react-lang/README.md index ff9c8c64c..044c32c28 100644 --- a/packages/react-lang/README.md +++ b/packages/react-lang/README.md @@ -113,16 +113,16 @@ function AssistantMessage({ response, isStreaming }) { | `onStateUpdate` | `(state: Record) => void` | Callback when form field values change | | `initialState` | `Record` | Initial form state for hydration | | `onParseResult` | `(result: ParseResult \| null) => void` | Callback when the parse result changes | -| `devtools` | `{ runId?: string; runTitle?: string }` | Optional metadata for grouping rendered responses in OpenUI Inspect | +| `devtools` | `{ run?: { id: string; title?: string } }` | Optional metadata for grouping rendered responses in OpenUI Inspect | -To group responses from the same generation in Inspect, pass a stable `devtools.runId` to each Renderer and optionally set `devtools.runTitle` to the user's prompt: +To group responses from the same generation in Inspect, pass `devtools.run` to each Renderer with a stable `id` and an optional `title`, such as the user's prompt: ```tsx ``` diff --git a/packages/react-lang/src/Renderer.tsx b/packages/react-lang/src/Renderer.tsx index a5e1e27f6..e8b18ae70 100644 --- a/packages/react-lang/src/Renderer.tsx +++ b/packages/react-lang/src/Renderer.tsx @@ -53,10 +53,12 @@ export interface RendererProps { publishObservability?: boolean; /** Optional metadata for grouping rendered responses in OpenUI Inspect. */ devtools?: { - /** Renderers with the same ID appear in one run group. Keep it stable during a generation. */ - runId?: string; - /** Display title for the run group, such as the user's prompt. */ - runTitle?: string; + run?: { + /** Renderers with the same ID appear in one run group. Keep it stable during a generation. */ + id: string; + /** Display title for the run group, such as the user's prompt. */ + title?: string; + }; }; } @@ -264,8 +266,8 @@ export function Renderer({ toolProvider: resolvedToolProvider, onError, publishObservability, - runId: devtools?.runId, - runTitle: devtools?.runTitle, + runId: devtools?.run?.id, + runTitle: devtools?.run?.title, }, renderDeep, ); diff --git a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx index 3a78a77bb..67a7100d1 100644 --- a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx +++ b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx @@ -104,7 +104,7 @@ export const GenUIAssistantMessage = ({ onAction={handleAction} onStateUpdate={handleStateUpdate} initialState={initialState} - devtools={{ runId: message.runId }} + devtools={message.runId ? { run: { id: message.runId } } : undefined} /> )} From 41e5373fa65fb123a90a59f9760ad4209430e1f3 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Mon, 5 Oct 2026 15:50:21 +0530 Subject: [PATCH 8/8] feat: inherit Inspect run metadata through Renderer context --- .changeset/inspect-run-groups.md | 2 +- .../content/docs/api-reference/react-lang.mdx | 22 +++++++++------ .../src/store/createChatStore.ts | 14 ++++++++++ .../src/stream/processStreamedMessage.ts | 10 ++++--- packages/react-headless/src/types/message.ts | 2 ++ packages/react-lang/README.md | 22 +++++++++------ packages/react-lang/src/Renderer.tsx | 15 +++++------ .../src/RendererDevtoolsProvider.tsx | 27 +++++++++++++++++++ packages/react-lang/src/exports.ts | 2 ++ .../src/components/AgentInterface/Thread.tsx | 9 +++++-- .../OpenUIChat/GenUIAssistantMessage.tsx | 1 - 11 files changed, 95 insertions(+), 31 deletions(-) create mode 100644 packages/react-lang/src/RendererDevtoolsProvider.tsx diff --git a/.changeset/inspect-run-groups.md b/.changeset/inspect-run-groups.md index 5a8421a30..3348a6927 100644 --- a/.changeset/inspect-run-groups.md +++ b/.changeset/inspect-run-groups.md @@ -8,4 +8,4 @@ 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 identity through assistant messages. Standalone `Renderer` users can pass `devtools={{ run: { id, title } }}` to group rendered responses in Inspect without additional instrumentation. Keep distinct renderer streams in the same group, preserve the run identity across assistant message segments, and avoid duplicate stream identities when unchanged historical content is marked streaming again. +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. diff --git a/docs/content/docs/api-reference/react-lang.mdx b/docs/content/docs/api-reference/react-lang.mdx index f2c489a66..98445f24e 100644 --- a/docs/content/docs/api-reference/react-lang.mdx +++ b/docs/content/docs/api-reference/react-lang.mdx @@ -119,18 +119,24 @@ interface RendererProps { ### Inspect grouping -Pass `devtools.run` to group rendered responses from one generation in OpenUI Inspect. Its `id` is required: use the same ID for every message from that generation and a new ID for the next generation. The optional `title` labels the group, for example with the user's prompt. +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"; + + + {messages.map((message) => ( + + ))} + ``` -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 automatically. +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 diff --git a/packages/react-headless/src/store/createChatStore.ts b/packages/react-headless/src/store/createChatStore.ts index d027c6d7c..85555d2eb 100644 --- a/packages/react-headless/src/store/createChatStore.ts +++ b/packages/react-headless/src/store/createChatStore.ts @@ -175,11 +175,13 @@ export const createChatStore = (configRef: React.RefObject set((s) => ({ messages: [...s.messages, msg] })), updateMessage: (msg) => set((s) => ({ @@ -282,3 +287,12 @@ export const createChatStore = (configRef: React.RefObject (part.type === "text" ? part.text : "")).join(""); + return text.trim() || undefined; +} diff --git a/packages/react-headless/src/stream/processStreamedMessage.ts b/packages/react-headless/src/stream/processStreamedMessage.ts index 7a7549d88..661d89702 100644 --- a/packages/react-headless/src/stream/processStreamedMessage.ts +++ b/packages/react-headless/src/stream/processStreamedMessage.ts @@ -22,6 +22,8 @@ interface Parameters { adapter?: StreamProtocolAdapter; /** LLM run that produced this stream. Copied onto each assistant message. */ runId?: string; + /** Inspect run title. Copied onto each assistant message. */ + runTitle?: string; } /** @@ -35,8 +37,9 @@ export const processStreamedMessage = async ({ clearToolExecuting = () => {}, adapter = agUIAdapter(), runId, + runTitle, }: Parameters): Promise => { - let currentMessage: AssistantMessage = newAssistantMessage(runId); + let currentMessage: AssistantMessage = newAssistantMessage(runId, runTitle); let isFirst = true; @@ -67,7 +70,7 @@ export const processStreamedMessage = async ({ rafId = null; if (!isFirst) updateMessage(currentMessage); } - currentMessage = newAssistantMessage(runId); + currentMessage = newAssistantMessage(runId, runTitle); isFirst = true; currentTextItemId = null; }; @@ -286,12 +289,13 @@ export const processStreamedMessage = async ({ return currentMessage; }; -function newAssistantMessage(runId?: string): AssistantMessage { +function newAssistantMessage(runId?: string, runTitle?: string): AssistantMessage { return { id: crypto.randomUUID(), role: "assistant", content: "", toolCalls: [], ...(runId ? { runId } : {}), + ...(runTitle ? { runTitle } : {}), }; } diff --git a/packages/react-headless/src/types/message.ts b/packages/react-headless/src/types/message.ts index 070dcf928..654f957e0 100644 --- a/packages/react-headless/src/types/message.ts +++ b/packages/react-headless/src/types/message.ts @@ -18,4 +18,6 @@ export type { export type AssistantMessage = AGUIAssistantMessage & { /** LLM run that produced this message. Set by `processStreamedMessage`. */ runId?: string; + /** Inspect run title derived from the user prompt. Set by `processStreamedMessage`. */ + runTitle?: string; }; diff --git a/packages/react-lang/README.md b/packages/react-lang/README.md index 044c32c28..83db9a98f 100644 --- a/packages/react-lang/README.md +++ b/packages/react-lang/README.md @@ -115,18 +115,24 @@ function AssistantMessage({ response, isStreaming }) { | `onParseResult` | `(result: ParseResult \| null) => void` | Callback when the parse result changes | | `devtools` | `{ run?: { id: string; title?: string } }` | Optional metadata for grouping rendered responses in OpenUI Inspect | -To group responses from the same generation in Inspect, pass `devtools.run` to each Renderer with a stable `id` and an optional `title`, such as the user's prompt: +To group responses from the same generation in Inspect, wrap them in `RendererDevtoolsProvider`. Supply the run once with a stable `id` and an optional `title`, such as the user's prompt. Every nested Renderer inherits it: ```tsx - +import { Renderer, RendererDevtoolsProvider } from "@openuidev/react-lang"; + + + {messages.map((message) => ( + + ))} + ``` -Create a new run ID for each generation and reuse it across all messages from that generation. 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 automatically. +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. ### Parser (Server-Side) diff --git a/packages/react-lang/src/Renderer.tsx b/packages/react-lang/src/Renderer.tsx index e8b18ae70..fc30324ec 100644 --- a/packages/react-lang/src/Renderer.tsx +++ b/packages/react-lang/src/Renderer.tsx @@ -11,6 +11,7 @@ import React, { Component, Fragment, useEffect, useInsertionEffect, useRef } fro import { OpenUIContext, useOpenUI, useRenderNode } from "./context"; import { useOpenUIState } from "./hooks/useOpenUIState"; import type { ComponentRenderer, Library } from "./library"; +import { useRendererDevtoolsRun, type RendererDevtoolsRun } from "./RendererDevtoolsProvider"; export interface RendererProps { /** Raw response text (openui-lang code). */ @@ -53,12 +54,8 @@ export interface RendererProps { publishObservability?: boolean; /** Optional metadata for grouping rendered responses in OpenUI Inspect. */ devtools?: { - run?: { - /** Renderers with the same ID appear in one run group. Keep it stable during a generation. */ - id: string; - /** Display title for the run group, such as the user's prompt. */ - title?: string; - }; + /** Overrides the run supplied by RendererDevtoolsProvider for this Renderer. */ + run?: RendererDevtoolsRun; }; } @@ -220,6 +217,8 @@ export function Renderer({ publishObservability, devtools, }: RendererProps) { + const inheritedRun = useRendererDevtoolsRun(); + const run = devtools?.run ?? inheritedRun; useInsertionEffect(() => { ensureLoadingStyle(); }, []); @@ -266,8 +265,8 @@ export function Renderer({ toolProvider: resolvedToolProvider, onError, publishObservability, - runId: devtools?.run?.id, - runTitle: devtools?.run?.title, + runId: run?.id, + runTitle: run?.title, }, renderDeep, ); diff --git a/packages/react-lang/src/RendererDevtoolsProvider.tsx b/packages/react-lang/src/RendererDevtoolsProvider.tsx new file mode 100644 index 000000000..fc48a91de --- /dev/null +++ b/packages/react-lang/src/RendererDevtoolsProvider.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext, type ReactNode } from "react"; + +export interface RendererDevtoolsRun { + /** Renderers with the same ID appear in one Inspect group. Keep it stable during a generation. */ + id: string; + /** Display title for the run group, such as the user's prompt. */ + title?: string; +} + +const RendererDevtoolsContext = createContext(undefined); + +/** Supplies Inspect run metadata to all nested Renderers. */ +export function RendererDevtoolsProvider({ + run, + children, +}: { + run?: RendererDevtoolsRun; + children: ReactNode; +}) { + return ( + {children} + ); +} + +export function useRendererDevtoolsRun(): RendererDevtoolsRun | undefined { + return useContext(RendererDevtoolsContext); +} diff --git a/packages/react-lang/src/exports.ts b/packages/react-lang/src/exports.ts index 586c5ef99..e8abdcbec 100644 --- a/packages/react-lang/src/exports.ts +++ b/packages/react-lang/src/exports.ts @@ -16,6 +16,8 @@ export type { // openui-lang renderer export { Renderer } from "./Renderer"; export type { RendererProps } from "./Renderer"; +export { RendererDevtoolsProvider } from "./RendererDevtoolsProvider"; +export type { RendererDevtoolsRun } from "./RendererDevtoolsProvider"; // openui-lang action types export { ACTION_STEPS, BuiltinActionType } from "@openuidev/lang-core"; diff --git a/packages/react-ui/src/components/AgentInterface/Thread.tsx b/packages/react-ui/src/components/AgentInterface/Thread.tsx index 943e5f5be..d5a6e3430 100644 --- a/packages/react-ui/src/components/AgentInterface/Thread.tsx +++ b/packages/react-ui/src/components/AgentInterface/Thread.tsx @@ -7,6 +7,7 @@ import { useThread, useToolActivities, } from "@openuidev/react-headless"; +import { RendererDevtoolsProvider } from "@openuidev/react-lang"; import clsx from "clsx"; import React, { memo, useId, useMemo, useRef } from "react"; import { useLayoutContext } from "../../context/LayoutContext"; @@ -385,6 +386,10 @@ const InterleavedTurn = ({ const last = activeSegments[activeSegments.length - 1]!; const turnLive = isRunning && lastAssistantId === last.id; + const devtoolsRun = useMemo( + () => (last.runId ? { id: last.runId, title: last.runTitle } : undefined), + [last.runId, last.runTitle], + ); // One id-keyed pairing across every segment's tool calls (synthetic message). const turnMessage = useMemo( @@ -438,7 +443,7 @@ const InterleavedTurn = ({ const answerStarted = !!answer && lastContent.length > 0; return ( - <> + {turnActivities.length > 0 && (CustomToolCallTimeline ? ( )} - + ); }; diff --git a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx index 67a7100d1..f36907ac6 100644 --- a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx +++ b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx @@ -104,7 +104,6 @@ export const GenUIAssistantMessage = ({ onAction={handleAction} onStateUpdate={handleStateUpdate} initialState={initialState} - devtools={message.runId ? { run: { id: message.runId } } : undefined} /> )}