Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions packages/devtools/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @openuidev/devtools

Development-only UI widget for OpenUI apps. Renders a floating button that opens a left side drawer listing the events captured by [`@openuidev/observability`](../observability) — level, a one-line summary, and a drill-in stack trace per entry.
Development-only UI widget for OpenUI apps. Renders a floating button that opens a side drawer listing the events captured by [`@openuidev/observability`](../observability) — a severity icon, a one-line summary, and an expandable stack trace with copy on the same card. When errors come in, the button itself turns red and shows the count.

## Usage

Expand All @@ -21,6 +21,12 @@ The widget renders nothing in production builds (`NODE_ENV === "production"`) un

`@openuidev/react-lang` ships with this package and auto-mounts the widget in development — no manual `<OpenUIDevtools />` needed. Mounting it manually still works (e.g. to customize props): only one instance ever renders, and a manually mounted instance takes precedence over the auto-mounted one.

In development, `createLibrary()` registers the live library with the widget. The **OpenUI Paste** banner at the bottom of the drawer widens the drawer into an editor against that library (host CSS included), with Render / Validation / Tree / JSON / Stream panels and simulated stream playback. A stream event's **Debug** button opens its response the same way. Eject moves the view into a separate window. The first visit opens a short step-by-step guide (also on **Help**); dismissing it is remembered.

Paste renders through the host's own `Renderer`. Its previews stay off the event bus so a Stream replay does not append cards to the drawer you are reading.

Display filters ("auto-open on error", "errors only") and the theme live behind the gear in the drawer header. The theme is Light or Dark, chosen manually and remembered across reloads: nothing is auto-detected from the host page or the OS, and it styles the devtools chrome only — never your app. The floating Shiro toggle stays dark so the branded mark stays readable.

## Props

| Prop | Default | Description |
Expand All @@ -29,5 +35,6 @@ The widget renders nothing in production builds (`NODE_ENV === "production"`) un
| `position` | `"bottom-right"` | Corner for the toggle button: `top-left`/`top-right`/`bottom-*`. |
| `maxEvents` | `50` | How many events to keep; oldest are dropped first. |
| `errorsOnly` | `true` | Capture only error/warning events, or all. |
| `autoOpenOnError` | `true` | Initial state of the drawer's "auto-open on error" checkbox. |
| `autoOpenOnError` | `true` | Initial state of the "auto-open on error" setting. |
| `theme` | `"light"` | Initial widget chrome theme: `"light"` or `"dark"` (Settings overrides). |
| `bus` | shared singleton | An `Observability` instance to listen to. |
9 changes: 8 additions & 1 deletion packages/devtools/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openuidev/devtools",
"version": "0.0.6",
"version": "0.0.7",
"description": "Development-only UI widget for OpenUI apps: surfaces errors captured by @openuidev/observability",
"license": "MIT",
"type": "module",
Expand Down Expand Up @@ -41,9 +41,15 @@
},
"peerDependencies": {
"@openuidev/observability": "workspace:^",
"@openuidev/react-lang": "workspace:^",
"react": "catalog:",
"react-dom": "catalog:"
},
"peerDependenciesMeta": {
"@openuidev/react-lang": {
"optional": true
}
},
"keywords": [
"openui",
"devtools",
Expand All @@ -64,6 +70,7 @@
"author": "engineering@thesys.dev",
"devDependencies": {
"@openuidev/observability": "workspace:^",
"@openuidev/react-lang": "workspace:^",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
Expand Down
246 changes: 246 additions & 0 deletions packages/devtools/src/EventRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import { type ObservabilityErrorInfo, type ObservabilityEvent } from "@openuidev/observability";
import { Check, ChevronDown, ChevronRight, Copy } from "lucide-react";
import { useState, type CSSProperties } from "react";
import { LevelIcon } from "./LevelIcon";

export function EventRow({ event }: { event: ObservabilityEvent }) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const error = getErrorInfo(event);
const detail = asRecord(event.detail);
const kind = asString(detail["kind"]);
const status = typeof detail["status"] === "number" ? String(detail["status"]) : undefined;
const message = error?.message ?? asString(detail["message"]);
const summary = message ? null : kind ? null : summarize(event);
const stack = error?.stack;
const expandable = Boolean(stack);

const copyStack = () => {
if (!stack || typeof navigator === "undefined" || !navigator.clipboard) return;
navigator.clipboard
.writeText(stack)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
})
.catch(() => {});
};

const header = (
<>
<div style={styles.rowHeader}>
<div style={styles.badgeGroup}>
<LevelIcon level={event.level} />
{kind ? <span style={styles.kind}>{kind}</span> : null}
{status ? (
<span style={{ ...styles.badge, ...styles.badgeNeutral }}>{status}</span>
) : null}
</div>
<div style={styles.rowHeaderRight}>
<span style={styles.time}>{new Date(event.timestamp).toLocaleTimeString()}</span>
<span style={styles.chevron} aria-hidden>
{expandable ? expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
</span>
</div>
</div>
{message ? <div style={styles.summary}>{message}</div> : null}
{summary ? <div style={styles.summary}>{summary}</div> : null}
</>
);

return (
<div style={styles.row}>
{expandable ? (
<button
type="button"
style={styles.toggle}
onClick={() => setExpanded((current) => !current)}
aria-expanded={expanded}
aria-label="Toggle stack trace"
>
{header}
</button>
) : (
header
)}

{expanded && stack ? (
<div style={styles.expanded}>
<pre style={styles.stack}>{stack}</pre>
<div style={styles.actions}>
<button type="button" style={styles.action} onClick={copyStack}>
{copied ? <Check size={12} /> : <Copy size={12} />}
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
) : null}
</div>
);
}

function asRecord(detail: unknown): Record<string, unknown> {
return typeof detail === "object" && detail !== null ? (detail as Record<string, unknown>) : {};
}

function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}

function getErrorInfo(event: ObservabilityEvent): ObservabilityErrorInfo | undefined {
const error = asRecord(event.detail)["error"];
if (typeof error === "object" && error !== null && "message" in error) {
return error as ObservabilityErrorInfo;
}
return undefined;
}

function summarize(event: ObservabilityEvent): string {
const detail = asRecord(event.detail);
const error = getErrorInfo(event);
const method = asString(detail["method"]);
const url = asString(detail["url"]);
const subject =
asString(detail["kind"]) ??
asString(detail["component"]) ??
asString(detail["toolName"]) ??
asString(detail["target"]) ??
(url ? [method, url].filter(Boolean).join(" ") : undefined);
const status = typeof detail["status"] === "number" ? `→ ${detail["status"]}` : undefined;
const message = error ? `— ${error.message}` : asString(detail["message"]);

const parts = [subject, status, message].filter(Boolean);
if (parts.length > 0) return parts.join(" ");
try {
return JSON.stringify(event.detail) ?? "(no detail)";
} catch {
return "(no detail)";
}
}

const FONT = '"Inter", system-ui, sans-serif';
const MONO = "ui-monospace, SFMono-Regular, Menlo, monospace";

const styles = {
row: {
border: "1px solid var(--oui-dt-border)",
borderRadius: 12,
padding: 12,
display: "flex",
flexDirection: "column",
gap: 6,
background: "var(--oui-dt-bg)",
},
toggle: {
width: "100%",
border: "none",
background: "transparent",
color: "inherit",
cursor: "pointer",
fontFamily: FONT,
padding: 0,
textAlign: "left",
},
rowHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 8,
},
rowHeaderRight: {
display: "flex",
alignItems: "center",
gap: 6,
flexShrink: 0,
},
chevron: {
display: "inline-flex",
width: 14,
flexShrink: 0,
color: "var(--oui-dt-fg-muted)",
},
badgeGroup: {
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 6,
minWidth: 0,
},
kind: {
color: "var(--oui-dt-fg)",
fontSize: 12,
fontWeight: 700,
wordBreak: "break-word",
},
badge: {
display: "inline-flex",
alignItems: "center",
borderRadius: 999,
borderWidth: 1,
borderStyle: "solid",
borderColor: "transparent",
padding: "1px 8px",
fontSize: 11,
fontWeight: 500,
fontFamily: FONT,
},
badgeNeutral: {
background: "var(--oui-dt-bg-subtle)",
color: "var(--oui-dt-fg-secondary)",
borderColor: "var(--oui-dt-border)",
fontFamily: MONO,
},
time: {
color: "var(--oui-dt-fg-faint)",
fontSize: 11,
},
summary: {
wordBreak: "break-word",
color: "var(--oui-dt-fg-tertiary)",
fontSize: 12,
lineHeight: 1.5,
marginTop: 7,
paddingLeft: 24,
},
expanded: {
display: "flex",
flexDirection: "column",
gap: 6,
marginTop: 4,
},
stack: {
maxHeight: 260,
overflow: "auto",
margin: 0,
border: "1px solid var(--oui-dt-border)",
borderRadius: 8,
background: "var(--oui-dt-bg-muted)",
color: "var(--oui-dt-fg-tertiary)",
fontFamily: MONO,
fontSize: 11,
lineHeight: 1.5,
padding: 10,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
},
actions: {
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 2,
},
action: {
display: "inline-flex",
alignItems: "center",
gap: 4,
border: "1px solid var(--oui-dt-border)",
borderRadius: 8,
background: "var(--oui-dt-bg)",
color: "var(--oui-dt-fg-secondary)",
cursor: "pointer",
fontFamily: FONT,
fontSize: 11,
fontWeight: 500,
padding: "4px 9px",
},
} satisfies Record<string, CSSProperties>;
53 changes: 53 additions & 0 deletions packages/devtools/src/LevelIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { type ObservabilityEvent } from "@openuidev/observability";
import { Info, TriangleAlert, X } from "lucide-react";
import type { CSSProperties } from "react";

const BY_LEVEL = {
info: {
Icon: Info,
style: {
background: "var(--oui-dt-bg-subtle)",
color: "var(--oui-dt-fg-muted)",
},
},
warning: {
Icon: TriangleAlert,
style: {
background: "var(--oui-dt-warning-bg)",
color: "var(--oui-dt-warning)",
},
},
error: {
Icon: X,
style: {
background: "var(--oui-dt-danger-bg)",
color: "var(--oui-dt-danger)",
},
},
} satisfies Record<ObservabilityEvent["level"], { Icon: typeof Info; style: CSSProperties }>;

/**
* Severity as a colored glyph instead of a word, so the row header has room for
* the fields that actually differ between events. The level stays the accessible
* name, which is also what tests and screen readers read.
*/
export function LevelIcon({ level }: { level: ObservabilityEvent["level"] }) {
const { Icon, style } = BY_LEVEL[level];
return (
<span style={{ ...styles.chip, ...style }} role="img" aria-label={level} title={level}>
<Icon size={11} strokeWidth={2.75} />
</span>
);
}

const styles = {
chip: {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
width: 18,
height: 18,
borderRadius: 999,
},
} satisfies Record<string, CSSProperties>;
Loading
Loading