diff --git a/changelog.d/fixes/7845-log-detail-structured-error.md b/changelog.d/fixes/7845-log-detail-structured-error.md
new file mode 100644
index 0000000000..909c9ff960
--- /dev/null
+++ b/changelog.d/fixes/7845-log-detail-structured-error.md
@@ -0,0 +1 @@
+- fix(dashboard): Request Logs detail modal no longer crashes when the persisted error is a structured object (#7845)
diff --git a/src/shared/components/RequestLoggerDetail.tsx b/src/shared/components/RequestLoggerDetail.tsx
index 30dc8b5091..01647eb252 100644
--- a/src/shared/components/RequestLoggerDetail.tsx
+++ b/src/shared/components/RequestLoggerDetail.tsx
@@ -7,6 +7,7 @@ import {
getProtocolColor,
} from "@/shared/constants/colors";
import { formatDuration, formatApiKeyLabel, maskAccount } from "@/shared/utils/formatting";
+import { formatErrorForDisplay } from "@/shared/utils/formatting";
// ─── Payload Code Block ─────────────────────────────────────────────────────
@@ -617,8 +618,8 @@ export default function RequestLoggerDetail({
Error
-
- {detail?.error || log.error}
+
+ {formatErrorForDisplay(detail?.error || log.error)}
)}
diff --git a/src/shared/utils/formatting.ts b/src/shared/utils/formatting.ts
index 37957d4833..100321d897 100644
--- a/src/shared/utils/formatting.ts
+++ b/src/shared/utils/formatting.ts
@@ -181,3 +181,20 @@ export function formatResetCountdown(resetAt: string | number | null | undefined
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
+
+/**
+ * Coerces a persisted log `error` field into safe display text. It is
+ * legitimate for this field to be a structured object (e.g. `{ code,
+ * message }`) — only the RENDER needs to be a string, never the persisted
+ * artifact itself. Used by RequestLoggerDetail to avoid React error #31
+ * ("Objects are not valid as a React child") when rendering it. See #7845.
+ */
+export function formatErrorForDisplay(err: unknown): string | null {
+ if (err == null) return null;
+ if (typeof err === "string") return err;
+ try {
+ return JSON.stringify(err, null, 2);
+ } catch {
+ return String(err);
+ }
+}
diff --git a/tests/unit/ui/issue-7845-log-detail-structured-error.test.tsx b/tests/unit/ui/issue-7845-log-detail-structured-error.test.tsx
new file mode 100644
index 0000000000..15405507d4
--- /dev/null
+++ b/tests/unit/ui/issue-7845-log-detail-structured-error.test.tsx
@@ -0,0 +1,117 @@
+// @vitest-environment jsdom
+/**
+ * TDD regression for #7845: opening the detail modal for a failed
+ * /dashboard/logs entry crashes the dashboard (React error #31 —
+ * "Objects are not valid as a React child") when the persisted artifact's
+ * `error` field is a structured object (e.g. `{ code, message }`) instead of
+ * a plain string.
+ *
+ * Root cause: `RequestLoggerDetail` rendered `{detail?.error || log.error}`
+ * directly as a React child. The list summary keeps `error_summary` as a
+ * string, but `/api/logs/{id}` returns the persisted structured `error`
+ * object for detail view — React throws when that object hits the child
+ * position (error #31).
+ *
+ * Fix: coerce any non-string `error` payload to a formatted JSON string
+ * before rendering, while leaving plain string errors rendered verbatim
+ * (no added JSON quoting).
+ */
+import React from "react";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+const RequestLoggerDetail = (
+ await import("../../../src/shared/components/RequestLoggerDetail.tsx")
+).default;
+
+let container: HTMLElement;
+let root: Root;
+
+function baseLog(overrides: Record = {}) {
+ return {
+ id: "log-1",
+ status: 500,
+ method: "POST",
+ path: "/v1/chat/completions",
+ model: "gpt-test",
+ provider: "openai",
+ timestamp: new Date().toISOString(),
+ duration: 42,
+ tokens: { in: 1, out: 2 },
+ ...overrides,
+ };
+}
+
+const noop = () => {};
+
+beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(async () => {
+ if (root) {
+ await act(async () => {
+ root.unmount();
+ });
+ }
+ container?.remove();
+});
+
+describe("RequestLoggerDetail structured error rendering (#7845)", () => {
+ it("renders a structured object error without throwing, showing its content in the DOM", async () => {
+ const structuredError = {
+ code: "codex_ws_provider_required",
+ message: "Responses WebSocket bridge only supports Codex models, got proxy",
+ };
+ const log = baseLog({ error: structuredError });
+ const detail = { ...log, error: structuredError };
+
+ let renderError: unknown = null;
+ await act(async () => {
+ try {
+ root.render(
+ true}
+ />
+ );
+ } catch (err) {
+ renderError = err;
+ }
+ });
+
+ expect(renderError).toBeNull();
+ expect(container.textContent).toContain("codex_ws_provider_required");
+ expect(container.textContent).toContain(
+ "Responses WebSocket bridge only supports Codex models, got proxy"
+ );
+ });
+
+ it("keeps rendering a plain string error verbatim, with no added JSON quoting", async () => {
+ const log = baseLog({ error: "upstream timeout" });
+ const detail = { ...log, error: "upstream timeout" };
+
+ await act(async () => {
+ root.render(
+ true}
+ />
+ );
+ });
+
+ expect(container.textContent).toContain("upstream timeout");
+ expect(container.textContent).not.toContain('"upstream timeout"');
+ });
+});