diff --git a/.changeset/copyable-data-block-tests.md b/.changeset/copyable-data-block-tests.md
new file mode 100644
index 0000000000..cbf5db6fd0
--- /dev/null
+++ b/.changeset/copyable-data-block-tests.md
@@ -0,0 +1,5 @@
+---
+'@workflow/web-shared': patch
+---
+
+Export `serializeForClipboard` from the copyable data block and add unit tests covering its clipboard serialization and the `CopyableDataBlock`/`EncryptedDataBlock` rendering.
diff --git a/.changeset/data-inspector-expanded-brackets.md b/.changeset/data-inspector-expanded-brackets.md
new file mode 100644
index 0000000000..98128f03af
--- /dev/null
+++ b/.changeset/data-inspector-expanded-brackets.md
@@ -0,0 +1,5 @@
+---
+'@workflow/web-shared': patch
+---
+
+Rework the data inspector's JSON rendering: bracket notation (`{ … }` / `[ … ]`), colored keys, typed value colors, `▸`/`▾` disclosure icons, trailing commas, and a `...` collapsed indicator. Replaces the `react-inspector` engine with an in-house tree renderer while keeping the workflow-specific value handling (StreamRef/RunRef badges, encrypted markers, decoded byte streams, dates, class instances).
diff --git a/packages/web-shared/package.json b/packages/web-shared/package.json
index f7491208ce..c60c203ae2 100644
--- a/packages/web-shared/package.json
+++ b/packages/web-shared/package.json
@@ -60,7 +60,6 @@
"lucide-react": "0.575.0",
"react": "19.1.0",
"react-dom": "19.1.0",
- "react-inspector": "9.0.0",
"react-use-measure": "2.1.1",
"react-virtuoso": "4.18.1",
"shiki": "4.0.0",
diff --git a/packages/web-shared/src/components/sidebar/copyable-data-block.tsx b/packages/web-shared/src/components/sidebar/copyable-data-block.tsx
index 26abfb87ea..a99c09c8ad 100644
--- a/packages/web-shared/src/components/sidebar/copyable-data-block.tsx
+++ b/packages/web-shared/src/components/sidebar/copyable-data-block.tsx
@@ -44,7 +44,7 @@ export function EncryptedDataBlock() {
);
}
-const serializeForClipboard = (value: unknown): string => {
+export const serializeForClipboard = (value: unknown): string => {
if (typeof value === 'string') return value;
if (
typeof value === 'number' ||
diff --git a/packages/web-shared/src/components/ui/data-inspector.styles.ts b/packages/web-shared/src/components/ui/data-inspector.styles.ts
new file mode 100644
index 0000000000..5af1224455
--- /dev/null
+++ b/packages/web-shared/src/components/ui/data-inspector.styles.ts
@@ -0,0 +1,96 @@
+/**
+ * Class names and styles for the data inspector tree.
+ *
+ * Kept out of the component module for readability. The CSS is injected via a
+ * React 19 hoistable `
+
+ >
+ );
if (onStreamClick) {
- wrapped = (
+ content = (
- {wrapped}
+ {content}
);
}
if (onRunClick) {
- wrapped = (
+ content = (
- {wrapped}
+ {content}
);
}
if (onDecrypt) {
- wrapped = (
+ content = (
- {wrapped}
+ {content}
);
}
- return wrapped;
+ return content;
}
+// ---------------------------------------------------------------------------
+// Render stabilization (avoid re-renders when data is deeply equal)
+// ---------------------------------------------------------------------------
+
function useStableInspectorData(next: T): T {
const previousRef = useRef(next);
if (!isDeepEqual(previousRef.current, next)) {
diff --git a/packages/web-shared/src/components/ui/inspector-theme.ts b/packages/web-shared/src/components/ui/inspector-theme.ts
deleted file mode 100644
index 5d04188082..0000000000
--- a/packages/web-shared/src/components/ui/inspector-theme.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-/**
- * Shared theme configuration for react-inspector's ObjectInspector.
- *
- * Colors follow Geist's Shiki JSON palette so the inspector reads the same
- * as highlighted code blocks across the product:
- * - property names / punctuation: --ds-gray-1000 (default foreground)
- * - strings / numbers / booleans: --ds-green-900
- * - null / undefined: --ds-gray-900 (muted)
- * - regexp / function: --ds-purple-900
- * - date: --ds-pink-900
- *
- * Because the `--ds-*` tokens adapt to theme automatically, the light and
- * dark objects are intentionally identical.
- */
-
-// ---------------------------------------------------------------------------
-// Extended color tokens not supported by react-inspector's built-in theme
-// system, applied via our custom nodeRenderer in data-inspector.tsx.
-// ---------------------------------------------------------------------------
-
-export interface InspectorThemeExtended {
- /** Color for Date values (Node: 'magenta') */
- OBJECT_VALUE_DATE_COLOR: string;
-}
-
-export const inspectorThemeExtendedLight: InspectorThemeExtended = {
- OBJECT_VALUE_DATE_COLOR: 'var(--ds-pink-900)',
-};
-
-export const inspectorThemeExtendedDark: InspectorThemeExtended = {
- OBJECT_VALUE_DATE_COLOR: 'var(--ds-pink-900)',
-};
-
-// ---------------------------------------------------------------------------
-// Shared structural values (same in both themes)
-// ---------------------------------------------------------------------------
-
-const shared = {
- BASE_FONT_SIZE: '11px',
- BASE_LINE_HEIGHT: 1.4,
- BASE_BACKGROUND_COLOR: 'transparent',
- OBJECT_PREVIEW_ARRAY_MAX_PROPERTIES: 10,
- OBJECT_PREVIEW_OBJECT_MAX_PROPERTIES: 5,
- HTML_TAGNAME_TEXT_TRANSFORM: 'lowercase' as const,
- ARROW_MARGIN_RIGHT: 3,
- ARROW_FONT_SIZE: 12,
- TREENODE_FONT_FAMILY: 'var(--font-mono)',
- TREENODE_FONT_SIZE: '11px',
- TREENODE_LINE_HEIGHT: 1.4,
- TREENODE_PADDING_LEFT: 12,
- TABLE_DATA_BACKGROUND_IMAGE: 'none',
- TABLE_DATA_BACKGROUND_SIZE: '0',
-};
-
-// ---------------------------------------------------------------------------
-// Light theme
-// ---------------------------------------------------------------------------
-
-const geistTheme = {
- ...shared,
-
- // Base text
- BASE_COLOR: 'var(--ds-gray-1000)',
-
- // Property names — default foreground (matches JSON key color in Geist Shiki)
- OBJECT_NAME_COLOR: 'var(--ds-gray-1000)',
-
- // Strings & symbols — green
- OBJECT_VALUE_STRING_COLOR: 'var(--ds-green-900)',
- OBJECT_VALUE_SYMBOL_COLOR: 'var(--ds-green-900)',
-
- // Numbers & booleans — green (Geist JSON tokens)
- OBJECT_VALUE_NUMBER_COLOR: 'var(--ds-green-900)',
- OBJECT_VALUE_BOOLEAN_COLOR: 'var(--ds-green-900)',
-
- // null — muted foreground
- OBJECT_VALUE_NULL_COLOR: 'var(--ds-gray-900)',
-
- // undefined — muted foreground
- OBJECT_VALUE_UNDEFINED_COLOR: 'var(--ds-gray-900)',
-
- // RegExp — purple
- OBJECT_VALUE_REGEXP_COLOR: 'var(--ds-purple-900)',
-
- // Functions — purple
- OBJECT_VALUE_FUNCTION_PREFIX_COLOR: 'var(--ds-purple-900)',
-
- // HTML (rarely used here, kept consistent with the palette)
- HTML_TAG_COLOR: 'var(--ds-gray-900)',
- HTML_TAGNAME_COLOR: 'var(--ds-blue-900)',
- HTML_ATTRIBUTE_NAME_COLOR: 'var(--ds-amber-900)',
- HTML_ATTRIBUTE_VALUE_COLOR: 'var(--ds-green-900)',
- HTML_COMMENT_COLOR: 'var(--ds-gray-700)',
- HTML_DOCTYPE_COLOR: 'var(--ds-gray-700)',
-
- // Structural
- ARROW_COLOR: 'var(--ds-gray-700)',
- TABLE_BORDER_COLOR: 'var(--ds-gray-300)',
- TABLE_TH_BACKGROUND_COLOR: 'var(--ds-gray-100)',
- TABLE_TH_HOVER_COLOR: 'var(--ds-gray-200)',
- TABLE_SORT_ICON_COLOR: 'var(--ds-gray-700)',
-};
-
-export const inspectorThemeLight = geistTheme;
-
-// ---------------------------------------------------------------------------
-// Dark theme
-// ---------------------------------------------------------------------------
-
-export const inspectorThemeDark = geistTheme;
diff --git a/packages/web-shared/src/lib/hydration.ts b/packages/web-shared/src/lib/hydration.ts
index dbf1d34d6f..b7472021b9 100644
--- a/packages/web-shared/src/lib/hydration.ts
+++ b/packages/web-shared/src/lib/hydration.ts
@@ -298,7 +298,7 @@ export function getWebRevivers(): Revivers {
// Web-specific overrides for class instances.
// Create objects with a dynamically-named constructor so that
- // react-inspector shows the class name (it reads constructor.name).
+ // the data inspector shows the class name (it reads constructor.name).
Class: (value) => ``,
Instance: (value) => {
// Run instances are rendered as clickable RunRef badges
@@ -311,7 +311,7 @@ export function getWebRevivers(): Revivers {
const props =
data && typeof data === 'object' ? { ...data } : { value: data };
// Create a constructor with the right name using computed property
- // so react-inspector's `object.constructor.name` shows the class name.
+ // so the data inspector's `object.constructor.name` shows the class name.
// Must use `function` (not arrow) because arrow functions have no .prototype.
// biome-ignore lint/complexity/useArrowFunction: arrow functions have no .prototype
const ctor = { [className]: function () {} }[className]!;
diff --git a/packages/web-shared/test/copyable-data-block.test.ts b/packages/web-shared/test/copyable-data-block.test.ts
new file mode 100644
index 0000000000..20bfd6f237
--- /dev/null
+++ b/packages/web-shared/test/copyable-data-block.test.ts
@@ -0,0 +1,142 @@
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { describe, expect, it } from 'vitest';
+import {
+ CopyableDataBlock,
+ EncryptedDataBlock,
+ serializeForClipboard,
+} from '../src/components/sidebar/copyable-data-block.js';
+import {
+ DecryptClickContext,
+ type DecryptClickContextValue,
+} from '../src/components/ui/data-inspector.js';
+
+/**
+ * `serializeForClipboard` is the helper behind the copy button on the JSON-style
+ * data viewer (`CopyableDataBlock`). It decides what text lands on the clipboard
+ * when a user copies a step's input/output/error payload, so these tests pin the
+ * formatting contract: strings stay verbatim, primitives stringify, and objects
+ * become pretty-printed JSON with a defensive fallback for un-serializable values.
+ */
+describe('serializeForClipboard', () => {
+ it('returns strings verbatim without quoting them', () => {
+ expect(serializeForClipboard('hello world')).toBe('hello world');
+ // Strings that happen to be JSON must not be double-encoded.
+ expect(serializeForClipboard('{"a":1}')).toBe('{"a":1}');
+ expect(serializeForClipboard('')).toBe('');
+ });
+
+ it('stringifies numeric, boolean, and null primitives', () => {
+ expect(serializeForClipboard(42)).toBe('42');
+ expect(serializeForClipboard(0)).toBe('0');
+ expect(serializeForClipboard(-1.5)).toBe('-1.5');
+ expect(serializeForClipboard(Number.NaN)).toBe('NaN');
+ expect(serializeForClipboard(true)).toBe('true');
+ expect(serializeForClipboard(false)).toBe('false');
+ expect(serializeForClipboard(null)).toBe('null');
+ });
+
+ it('pretty-prints objects as two-space-indented JSON', () => {
+ const value = { input: 'x', count: 2, nested: { ok: true } };
+ expect(serializeForClipboard(value)).toBe(JSON.stringify(value, null, 2));
+ // Sanity-check the formatting is actually multi-line and indented.
+ expect(serializeForClipboard(value)).toContain('\n "input": "x"');
+ });
+
+ it('pretty-prints arrays as two-space-indented JSON', () => {
+ const value = [1, 'two', { three: 3 }];
+ expect(serializeForClipboard(value)).toBe(JSON.stringify(value, null, 2));
+ });
+
+ it('falls back to String() when JSON.stringify throws (circular refs)', () => {
+ const circular: Record = { name: 'loop' };
+ circular.self = circular;
+
+ expect(serializeForClipboard(circular)).toBe('[object Object]');
+ });
+
+ it('falls back to String() for BigInt values JSON cannot encode', () => {
+ expect(serializeForClipboard(10n)).toBe('10');
+ });
+
+ it('passes undefined through (JSON.stringify yields no string)', () => {
+ // Documents the current behavior: undefined is neither a handled primitive
+ // nor JSON-serializable, so the helper returns undefined as-is.
+ expect(serializeForClipboard(undefined)).toBeUndefined();
+ });
+});
+
+describe('CopyableDataBlock', () => {
+ it('renders a labelled copy button for the data viewer', () => {
+ const markup = renderToStaticMarkup(
+ createElement(CopyableDataBlock, { data: { input: 'value' } })
+ );
+
+ expect(markup).toContain('aria-label="Copy data"');
+ });
+
+ it('renders without throwing for a variety of payload shapes', () => {
+ const payloads: unknown[] = [
+ 'a plain string',
+ 1234,
+ null,
+ { nested: { deeply: [1, 2, 3] } },
+ [{ id: 'a' }, { id: 'b' }],
+ ];
+
+ for (const data of payloads) {
+ expect(() =>
+ renderToStaticMarkup(createElement(CopyableDataBlock, { data }))
+ ).not.toThrow();
+ }
+ });
+});
+
+describe('EncryptedDataBlock', () => {
+ it('shows a static Encrypted badge when no decrypt context is provided', () => {
+ const markup = renderToStaticMarkup(createElement(EncryptedDataBlock));
+
+ expect(markup).toContain('Encrypted');
+ // The blurred placeholder previews the encrypted shape to the user.
+ expect(markup).toContain('[encrypted]');
+ expect(markup).not.toContain('Decrypt');
+ });
+
+ it('renders an enabled Decrypt button when a decrypt context is present', () => {
+ const ctx: DecryptClickContextValue = {
+ onDecrypt: () => {},
+ isDecrypting: false,
+ };
+
+ const markup = renderToStaticMarkup(
+ createElement(
+ DecryptClickContext.Provider,
+ { value: ctx },
+ createElement(EncryptedDataBlock)
+ )
+ );
+
+ expect(markup).toContain('Decrypt');
+ // The `disabled:`-prefixed Tailwind classes are always present, so assert on
+ // the actual `disabled` attribute instead of a loose substring match.
+ expect(markup).not.toContain('disabled=""');
+ });
+
+ it('disables the Decrypt button while decryption is in progress', () => {
+ const ctx: DecryptClickContextValue = {
+ onDecrypt: () => {},
+ isDecrypting: true,
+ };
+
+ const markup = renderToStaticMarkup(
+ createElement(
+ DecryptClickContext.Provider,
+ { value: ctx },
+ createElement(EncryptedDataBlock)
+ )
+ );
+
+ expect(markup).toContain('Decrypt');
+ expect(markup).toContain('disabled=""');
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f58a922fa3..1b296d155a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1221,9 +1221,6 @@ importers:
react-dom:
specifier: 19.1.0
version: 19.1.0(react@19.1.0)
- react-inspector:
- specifier: 9.0.0
- version: 9.0.0(react@19.1.0)
react-use-measure:
specifier: 2.1.1
version: 2.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -15277,11 +15274,6 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
- react-inspector@9.0.0:
- resolution: {integrity: sha512-w/VJucSeHxlwRa2nfM2k7YhpT1r5EtlDOClSR+L7DyQP91QMdfFEDXDs9bPYN4kzP7umFtom7L0b2GGjph4Kow==}
- peerDependencies:
- react: ^18.0.0 || ^19.0.0
-
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -26057,14 +26049,6 @@ snapshots:
optionalDependencies:
vite: 7.3.2(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)
- '@vitest/mocker@4.0.18(vite@7.3.2(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))':
- dependencies:
- '@vitest/spy': 4.0.18
- estree-walker: 3.0.3
- magic-string: 0.30.21
- optionalDependencies:
- vite: 7.3.2(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)
-
'@vitest/mocker@4.0.18(vite@7.3.2(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.0.18
@@ -33822,10 +33806,6 @@ snapshots:
dependencies:
react: 19.2.3
- react-inspector@9.0.0(react@19.1.0):
- dependencies:
- react: 19.1.0
-
react-is@16.13.1: {}
react-is@17.0.2: {}
@@ -36422,7 +36402,7 @@ snapshots:
vitest@4.0.18(@opentelemetry/api@1.9.1)(@types/node@22.19.0)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0):
dependencies:
'@vitest/expect': 4.0.18
- '@vitest/mocker': 4.0.18(vite@7.3.2(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))
+ '@vitest/mocker': 4.0.18(vite@7.3.2(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))
'@vitest/pretty-format': 4.0.18
'@vitest/runner': 4.0.18
'@vitest/snapshot': 4.0.18