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
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/OpenUIDevtools.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 { InspectEvent, RunGroup, groupEventsByRunId } from "./inspect";
import {
addOrReplaceEvent,
isLibraryEvent,
Expand Down Expand Up @@ -141,7 +135,7 @@
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open, debug.trayOpen, debug.retract]);

Check warning on line 138 in packages/devtools/src/OpenUIDevtools.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 @@ -240,26 +234,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} />;
}
28 changes: 24 additions & 4 deletions packages/devtools/src/inspect/QuotaErrorRow.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -36,12 +37,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 theme = useTheme();
const [hoveredCta, setHoveredCta] = useState<string | null>(null);

return (
<div style={styles.row}>
<div
style={{
...styles.row,
...(embedded ? nestedRowBox(theme, last) : null),
}}
>
<div style={styles.creditsNote}>
<div style={styles.creditsHeader}>
<LevelIcon level="warning" />
Expand Down Expand Up @@ -103,7 +118,12 @@ function asString(value: unknown): string | undefined {
function quotaRowStyles(t: ThemeTokens) {
return {
row: {
border: `1px solid ${t.border}`,
borderTopWidth: 1,
borderRightWidth: 1,
borderBottomWidth: 1,
borderLeftWidth: 1,
borderStyle: "solid",
borderColor: t.border,
borderRadius: 12,
padding: 12,
display: "flex",
Expand Down
Loading
Loading