diff --git a/CHANGELOG.md b/CHANGELOG.md
index d8e574c..0618d8e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Global live update pause: disable automatic query refreshes on browser focus and reconnect, preventing paused workflow detail pages from re-fetching wait data outside the configured refresh interval. [PR #584](https://github.com/riverqueue/riverui/pull/584).
+- Job detail: preserve line breaks in attempt logs while keeping structured JSON viewer content outside preformatted code markup. [PR #592](https://github.com/riverqueue/riverui/pull/592).
## [v0.16.0] - 2026-05-19
diff --git a/src/components/CopyablePanel.tsx b/src/components/CopyablePanel.tsx
new file mode 100644
index 0000000..c659d30
--- /dev/null
+++ b/src/components/CopyablePanel.tsx
@@ -0,0 +1,111 @@
+import type { ReactNode } from "react";
+
+import { CheckIcon } from "@heroicons/react/16/solid";
+import { ClipboardIcon } from "@heroicons/react/24/outline";
+import { useState } from "react";
+import toast from "react-hot-toast";
+
+import { ToastContentSuccess } from "@/components/Toast";
+
+type CopyablePanelProps = {
+ children: ReactNode;
+ /**
+ * Additional class names to apply to the component.
+ */
+ className?: string;
+ /**
+ * Raw text to be copied to clipboard.
+ */
+ copyText: string;
+ /**
+ * The title to show in the copy confirmation toast.
+ * @default "Text"
+ */
+ copyTitle?: string;
+};
+
+const styleConfig = {
+ container: {
+ base: "relative overflow-auto rounded-md bg-slate-50 dark:bg-slate-800",
+ font: { fontFamily: "var(--font-family-monospace, monospace)" },
+ },
+ content: {
+ base: "relative overflow-x-auto overscroll-y-auto p-1 pl-2 text-xs",
+ },
+ header: {
+ base: "flex items-center justify-end bg-slate-100 px-2 py-1 text-xs dark:bg-slate-700",
+ textAlign: { textAlign: "right" as const },
+ },
+ icon: {
+ base: "h-3 w-3",
+ check: "text-green-500",
+ clipboard:
+ "text-slate-500 dark:text-slate-400 hover:text-brand-primary dark:hover:text-brand-primary",
+ },
+};
+
+/**
+ * A panel that wraps copyable content with a copy button.
+ */
+export default function CopyablePanel({
+ children,
+ className,
+ copyText,
+ copyTitle = "Text",
+}: CopyablePanelProps) {
+ const [isCopied, setIsCopied] = useState(false);
+
+ const copyToClipboard = () => {
+ navigator.clipboard.writeText(copyText).then(
+ () => {
+ setIsCopied(true);
+ toast.custom((t) => (
+
+ ));
+ setTimeout(() => {
+ setIsCopied(false);
+ }, 2000);
+ },
+ (err) => {
+ console.error("Failed to copy text: ", err);
+ },
+ );
+ };
+
+ return (
+
+
+
+
+
{children}
+
+ );
+}
diff --git a/src/components/JSONView.test.tsx b/src/components/JSONView.test.tsx
index 7b6e242..eedf24d 100644
--- a/src/components/JSONView.test.tsx
+++ b/src/components/JSONView.test.tsx
@@ -47,7 +47,7 @@ describe("JSONView Component", () => {
};
it("renders simple JSON data", () => {
- render();
+ const { container } = render();
// Check that key elements are in the document
expect(screen.getByText(/"name"/)).toBeInTheDocument();
@@ -56,6 +56,7 @@ describe("JSONView Component", () => {
expect(screen.getByText(/30/)).toBeInTheDocument();
expect(screen.getByText(/"isActive"/)).toBeInTheDocument();
expect(screen.getByText(/true/)).toBeInTheDocument();
+ expect(container.querySelector("pre")).toBeNull();
});
it("renders object keys alphabetically at root and nested levels", () => {
diff --git a/src/components/JSONView.tsx b/src/components/JSONView.tsx
index c5400dc..e66ebfc 100644
--- a/src/components/JSONView.tsx
+++ b/src/components/JSONView.tsx
@@ -6,7 +6,7 @@ import {
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
import React from "react";
-import PlaintextPanel from "@/components/PlaintextPanel";
+import CopyablePanel from "@/components/CopyablePanel";
interface JSONNodeRendererProps {
data: unknown;
@@ -60,13 +60,15 @@ export default function JSONView({
);
return (
-
+ >
+
+ {jsonContent}
+
+
);
}
diff --git a/src/components/JobAttempts.tsx b/src/components/JobAttempts.tsx
index af5b503..753549c 100644
--- a/src/components/JobAttempts.tsx
+++ b/src/components/JobAttempts.tsx
@@ -365,9 +365,9 @@ function AttemptRow({ attemptInfo }: { attemptInfo: AttemptInfo }) {
{attemptInfo.logs.map((log, idx) => (
))}
diff --git a/src/components/PlaintextPanel.stories.tsx b/src/components/PlaintextPanel.stories.tsx
new file mode 100644
index 0000000..2154277
--- /dev/null
+++ b/src/components/PlaintextPanel.stories.tsx
@@ -0,0 +1,52 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import PlaintextPanel from "./PlaintextPanel";
+
+const meta: Meta = {
+ component: PlaintextPanel,
+ parameters: {
+ layout: "centered",
+ },
+ title: "Components/PlaintextPanel",
+};
+
+export default meta;
+type Story = StoryObj;
+
+const multilineLog = [
+ 'time=2026-06-11T19:03:14Z level=info msg="starting job" job_id=123',
+ 'time=2026-06-11T19:03:15Z level=info msg="processing batch" batch=1 records=100',
+ '{"attempt":1,"event":"finished","metadata":{"customerID":"cus_123","queue":"default"}}',
+ " indented continuation line",
+ 'time=2026-06-11T19:03:16Z level=error msg="this is a very long log line that should demonstrate horizontal scrolling on narrow screens" details=abcdefghijklmnopqrstuvwxyz0123456789',
+].join("\n");
+
+export const MultilineLog: Story = {
+ args: {
+ copyTitle: "Log Entry",
+ text: multilineLog,
+ },
+ render: (args) => (
+
+ ),
+};
+
+export const WrappedExpression: Story = {
+ args: {
+ codeClassName: "whitespace-pre-wrap break-all",
+ copyTitle: "Wait expression",
+ text: [
+ "all([",
+ ' task.completed("prepare-customer-state"),',
+ ' signal.received("manual-approval")',
+ "])",
+ ].join("\n"),
+ },
+ render: (args) => (
+
+ ),
+};
diff --git a/src/components/PlaintextPanel.test.tsx b/src/components/PlaintextPanel.test.tsx
new file mode 100644
index 0000000..d2f6f96
--- /dev/null
+++ b/src/components/PlaintextPanel.test.tsx
@@ -0,0 +1,53 @@
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import toast from "react-hot-toast";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import PlaintextPanel from "./PlaintextPanel";
+
+Object.assign(navigator, {
+ clipboard: {
+ writeText: vi.fn().mockImplementation(() => Promise.resolve()),
+ },
+});
+
+vi.mock("react-hot-toast", () => ({
+ default: {
+ custom: vi.fn(),
+ },
+}));
+
+describe("PlaintextPanel", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders text in preformatted code markup", () => {
+ const text = [
+ "time=2026-06-11T19:03:14Z level=info msg=starting",
+ " indented continuation",
+ "time=2026-06-11T19:03:15Z level=info msg=finished",
+ ].join("\n");
+
+ const { container } = render();
+
+ const pre = container.querySelector("pre");
+ const code = pre?.querySelector("code");
+
+ expect(pre).toBeInTheDocument();
+ expect(pre).toHaveClass("whitespace-pre");
+ expect(code?.textContent).toBe(text);
+ });
+
+ it("copies the original text", async () => {
+ const text = "first line\nsecond line";
+
+ render();
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId("text-copy-button"));
+ });
+
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(text);
+ expect(toast.custom).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/PlaintextPanel.tsx b/src/components/PlaintextPanel.tsx
index 90815d4..dab51cb 100644
--- a/src/components/PlaintextPanel.tsx
+++ b/src/components/PlaintextPanel.tsx
@@ -1,10 +1,4 @@
-import { CheckIcon } from "@heroicons/react/16/solid";
-import { ClipboardIcon } from "@heroicons/react/24/outline";
-import { useState } from "react";
-import React from "react";
-import toast from "react-hot-toast";
-
-import { ToastContentSuccess } from "@/components/Toast";
+import CopyablePanel from "@/components/CopyablePanel";
type PlaintextPanelProps = {
/**
@@ -15,44 +9,20 @@ type PlaintextPanelProps = {
* Additional class names to apply to the code element.
*/
codeClassName?: string;
- /**
- * The content to be displayed in the panel.
- */
- content: React.ReactNode;
/**
* The title to show in the copy confirmation toast.
* @default "Text"
*/
copyTitle?: string;
/**
- * Raw text to be copied to clipboard instead of extracting from content.
+ * The text to be displayed and copied.
*/
- rawText?: string;
+ text: string;
};
const styleConfig = {
- container: {
- base: "relative overflow-auto rounded-md bg-slate-50 dark:bg-slate-800",
- font: { fontFamily: "var(--font-family-monospace, monospace)" },
- },
- content: {
- base: "relative text-xs p-1 pl-2",
- code: "block text-slate-800 dark:text-slate-200",
- layout: {
- overflowX: "auto" as const,
- overscrollBehaviorY: "auto" as const,
- },
- },
- header: {
- base: "flex items-center justify-end bg-slate-100 px-2 py-1 text-xs dark:bg-slate-700",
- textAlign: { textAlign: "right" as const },
- },
- icon: {
- base: "h-3 w-3",
- check: "text-green-500",
- clipboard:
- "text-slate-500 dark:text-slate-400 hover:text-brand-primary dark:hover:text-brand-primary",
- },
+ code: "block text-slate-800 dark:text-slate-200",
+ pre: "m-0 whitespace-pre",
};
/**
@@ -61,85 +31,16 @@ const styleConfig = {
export default function PlaintextPanel({
className,
codeClassName,
- content,
copyTitle = "Text",
- rawText,
+ text,
}: PlaintextPanelProps) {
- const [isCopied, setIsCopied] = useState(false);
-
- const copyToClipboard = () => {
- const textContent = rawText || extractTextFromNode(content);
- navigator.clipboard.writeText(textContent).then(
- () => {
- setIsCopied(true);
- toast.custom((t) => (
-
- ));
- setTimeout(() => {
- setIsCopied(false);
- }, 2000);
- },
- (err) => {
- console.error("Failed to copy text: ", err);
- },
- );
- };
-
return (
-
- {/* Header with copy button */}
-
-
-
- {/* Content block */}
-
-
- {content}
+
+
+
+ {text}
-
-
+
+
);
}
-
-// Helper function to extract text from React nodes
-function extractTextFromNode(node: React.ReactNode): string {
- if (typeof node === "string") return node;
- if (typeof node === "number") return node.toString();
- if (Array.isArray(node)) return node.map(extractTextFromNode).join(" ");
- if (React.isValidElement<{ children?: React.ReactNode }>(node)) {
- const children = node.props.children;
- if (children) return extractTextFromNode(children);
- }
- return "";
-}
diff --git a/src/components/WorkflowGateInspector.tsx b/src/components/WorkflowGateInspector.tsx
index e09ad20..97f672c 100644
--- a/src/components/WorkflowGateInspector.tsx
+++ b/src/components/WorkflowGateInspector.tsx
@@ -435,9 +435,8 @@ export default function WorkflowWaitInspector({