diff --git a/.changeset/forty-tables-lick.md b/.changeset/forty-tables-lick.md new file mode 100644 index 000000000..7212081e1 --- /dev/null +++ b/.changeset/forty-tables-lick.md @@ -0,0 +1,6 @@ +--- +"@workflow/web-shared": patch +"@workflow/core": patch +--- + +Show custom class serialization UI and class names in o11y diff --git a/packages/core/src/observability.test.ts b/packages/core/src/observability.test.ts new file mode 100644 index 000000000..a2d4cdc51 --- /dev/null +++ b/packages/core/src/observability.test.ts @@ -0,0 +1,369 @@ +import { inspect } from 'node:util'; +import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from '@workflow/serde'; +import { describe, expect, it } from 'vitest'; +import { registerSerializationClass } from './class-serialization.js'; +import { + CLASS_INSTANCE_REF_TYPE, + ClassInstanceRef, + extractStreamIds, + hydrateResourceIO, + isClassInstanceRef, + isStreamId, + isStreamRef, + STREAM_REF_TYPE, + truncateId, +} from './observability.js'; +import { dehydrateStepReturnValue } from './serialization.js'; + +describe('ClassInstanceRef', () => { + describe('constructor and properties', () => { + it('should create instance with correct properties', () => { + const ref = new ClassInstanceRef('Point', 'class//file.ts//Point', { + x: 1, + y: 2, + }); + + expect(ref.className).toBe('Point'); + expect(ref.classId).toBe('class//file.ts//Point'); + expect(ref.data).toEqual({ x: 1, y: 2 }); + expect(ref.__type).toBe(CLASS_INSTANCE_REF_TYPE); + }); + + it('should handle various data types', () => { + // Object data + const objRef = new ClassInstanceRef('Config', 'class//Config', { + key: 'value', + }); + expect(objRef.data).toEqual({ key: 'value' }); + + // String data + const strRef = new ClassInstanceRef('Token', 'class//Token', 'abc123'); + expect(strRef.data).toBe('abc123'); + + // Number data + const numRef = new ClassInstanceRef('Counter', 'class//Counter', 42); + expect(numRef.data).toBe(42); + + // Null data + const nullRef = new ClassInstanceRef('Empty', 'class//Empty', null); + expect(nullRef.data).toBeNull(); + + // Array data + const arrRef = new ClassInstanceRef('List', 'class//List', [1, 2, 3]); + expect(arrRef.data).toEqual([1, 2, 3]); + }); + }); + + describe('toJSON()', () => { + it('should return plain object representation', () => { + const ref = new ClassInstanceRef('Point', 'class//file.ts//Point', { + x: 1, + y: 2, + }); + + expect(ref.toJSON()).toEqual({ + __type: CLASS_INSTANCE_REF_TYPE, + className: 'Point', + classId: 'class//file.ts//Point', + data: { x: 1, y: 2 }, + }); + }); + + it('should be used by JSON.stringify', () => { + const ref = new ClassInstanceRef('Point', 'class//file.ts//Point', { + x: 1, + y: 2, + }); + + const json = JSON.stringify(ref); + const parsed = JSON.parse(json); + + expect(parsed).toEqual({ + __type: CLASS_INSTANCE_REF_TYPE, + className: 'Point', + classId: 'class//file.ts//Point', + data: { x: 1, y: 2 }, + }); + }); + }); + + describe('[inspect.custom]', () => { + it('should render as ClassName@filename { data }', () => { + const ref = new ClassInstanceRef( + 'Point', + 'class//workflows/user-signup.ts//Point', + { x: 1, y: 2 } + ); + + const output = inspect(ref, { colors: false }); + expect(output).toBe('Point@user-signup.ts { x: 1, y: 2 }'); + }); + + it('should handle nested file paths', () => { + const ref = new ClassInstanceRef( + 'Config', + 'class//lib/models/config.ts//Config', + { nested: { a: 1, b: 2 } } + ); + + const output = inspect(ref, { colors: false }); + expect(output).toBe('Config@config.ts { nested: { a: 1, b: 2 } }'); + }); + + it('should handle string data', () => { + const ref = new ClassInstanceRef( + 'Token', + 'class//auth/token.ts//Token', + 'secret' + ); + + const output = inspect(ref, { colors: false }); + expect(output).toBe("Token@token.ts 'secret'"); + }); + + it('should handle null data', () => { + const ref = new ClassInstanceRef( + 'Empty', + 'class//utils/empty.ts//Empty', + null + ); + + const output = inspect(ref, { colors: false }); + expect(output).toBe('Empty@empty.ts null'); + }); + + it('should handle array data', () => { + const ref = new ClassInstanceRef( + 'List', + 'class//collections/list.ts//List', + [1, 2, 3] + ); + + const output = inspect(ref, { colors: false }); + expect(output).toBe('List@list.ts [ 1, 2, 3 ]'); + }); + + it('should handle simple classId format gracefully', () => { + // Fallback for non-standard classId format + const ref = new ClassInstanceRef('Point', 'test//TestPoint', { + x: 1, + y: 2, + }); + + const output = inspect(ref, { colors: false }); + // Falls back to extracting just the last segment as filename + expect(output).toBe('Point@TestPoint { x: 1, y: 2 }'); + }); + + it('should style @filename gray when colors are enabled', () => { + const ref = new ClassInstanceRef( + 'Point', + 'class//workflows/point.ts//Point', + { x: 1, y: 2 } + ); + + const output = inspect(ref, { colors: true }); + // When colors are enabled, the @filename should have ANSI escape codes + expect(output).toContain('Point'); + // Check for ANSI escape codes (gray/dim styling for @filename) + expect(output).toMatch(/\x1b\[90m@point\.ts\x1b\[39m/); + // Data is also present (may have color codes for numbers) + expect(output).toContain('x:'); + expect(output).toContain('y:'); + }); + }); +}); + +describe('isClassInstanceRef', () => { + it('should return true for ClassInstanceRef instances', () => { + const ref = new ClassInstanceRef('Point', 'class//Point', { x: 1, y: 2 }); + expect(isClassInstanceRef(ref)).toBe(true); + }); + + it('should return true for plain objects with correct structure', () => { + const plainObj = { + __type: CLASS_INSTANCE_REF_TYPE, + className: 'Point', + classId: 'class//Point', + data: { x: 1, y: 2 }, + }; + expect(isClassInstanceRef(plainObj)).toBe(true); + }); + + it('should return true for JSON-parsed ClassInstanceRef', () => { + const ref = new ClassInstanceRef('Point', 'class//Point', { x: 1, y: 2 }); + const parsed = JSON.parse(JSON.stringify(ref)); + expect(isClassInstanceRef(parsed)).toBe(true); + }); + + it('should return false for null', () => { + expect(isClassInstanceRef(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isClassInstanceRef(undefined)).toBe(false); + }); + + it('should return false for plain objects without __type', () => { + expect(isClassInstanceRef({ className: 'Point', data: {} })).toBe(false); + }); + + it('should return false for objects with wrong __type', () => { + expect( + isClassInstanceRef({ + __type: 'wrong_type', + className: 'Point', + data: {}, + }) + ).toBe(false); + }); + + it('should return false for objects without className', () => { + expect( + isClassInstanceRef({ + __type: CLASS_INSTANCE_REF_TYPE, + classId: 'class//Point', + data: {}, + }) + ).toBe(false); + }); +}); + +describe('isStreamRef', () => { + it('should return true for valid StreamRef', () => { + const streamRef = { + __type: STREAM_REF_TYPE, + streamId: 'strm_123', + }; + expect(isStreamRef(streamRef)).toBe(true); + }); + + it('should return false for null', () => { + expect(isStreamRef(null)).toBe(false); + }); + + it('should return false for wrong __type', () => { + expect(isStreamRef({ __type: 'wrong', streamId: 'strm_123' })).toBe(false); + }); +}); + +describe('isStreamId', () => { + it('should return true for valid stream ID', () => { + expect(isStreamId('strm_abc123')).toBe(true); + }); + + it('should return false for non-stream strings', () => { + expect(isStreamId('not_a_stream')).toBe(false); + }); + + it('should return false for non-strings', () => { + expect(isStreamId(123)).toBe(false); + expect(isStreamId(null)).toBe(false); + expect(isStreamId({})).toBe(false); + }); +}); + +describe('extractStreamIds', () => { + it('should extract stream IDs from flat objects', () => { + const obj = { stream: 'strm_123', other: 'not_stream' }; + expect(extractStreamIds(obj)).toEqual(['strm_123']); + }); + + it('should extract stream IDs from nested objects', () => { + const obj = { + level1: { + level2: { + stream: 'strm_abc', + }, + }, + }; + expect(extractStreamIds(obj)).toEqual(['strm_abc']); + }); + + it('should extract stream IDs from arrays', () => { + const arr = ['strm_1', 'strm_2', 'not_stream']; + expect(extractStreamIds(arr)).toEqual(['strm_1', 'strm_2']); + }); + + it('should deduplicate stream IDs', () => { + const obj = { a: 'strm_same', b: 'strm_same' }; + expect(extractStreamIds(obj)).toEqual(['strm_same']); + }); + + it('should return empty array for no streams', () => { + expect(extractStreamIds({ foo: 'bar' })).toEqual([]); + }); +}); + +describe('truncateId', () => { + it('should not truncate short IDs', () => { + expect(truncateId('short', 12)).toBe('short'); + }); + + it('should truncate long IDs', () => { + expect(truncateId('verylongidentifier', 12)).toBe('verylongiden...'); + }); + + it('should use default max length of 12', () => { + expect(truncateId('123456789012')).toBe('123456789012'); + expect(truncateId('1234567890123')).toBe('123456789012...'); + }); +}); + +describe('hydrateResourceIO with custom class instances', () => { + // Create a test class with serialization symbols + class TestPoint { + constructor( + public x: number, + public y: number + ) {} + + static [WORKFLOW_SERIALIZE](instance: TestPoint) { + return { x: instance.x, y: instance.y }; + } + + static [WORKFLOW_DESERIALIZE](data: { x: number; y: number }) { + return new TestPoint(data.x, data.y); + } + } + + // Register the class for serialization (so dehydrate works) + // but note: in o11y context, classes are NOT registered, which is the point of this test + (TestPoint as any).classId = 'test//TestPoint'; + registerSerializationClass('test//TestPoint', TestPoint); + + it('should convert Instance type to ClassInstanceRef in step output', () => { + // Simulate serialized step data with a custom class instance + const point = new TestPoint(3, 4); + const serialized = dehydrateStepReturnValue(point, [], 'wrun_test'); + + // Create a step resource with serialized output + const step = { + stepId: 'step_123', + runId: 'wrun_test', + output: serialized, + }; + + // Hydrate the step - this should convert Instance to ClassInstanceRef + // because the class is not registered in the o11y context (streamPrintRevivers) + const hydrated = hydrateResourceIO(step); + + // The output should be a ClassInstanceRef + expect(isClassInstanceRef(hydrated.output)).toBe(true); + expect(hydrated.output.className).toBe('TestPoint'); + expect(hydrated.output.classId).toBe('test//TestPoint'); + expect(hydrated.output.data).toEqual({ x: 3, y: 4 }); + }); + + it('should preserve ClassInstanceRef through JSON roundtrip', () => { + const ref = new ClassInstanceRef('Point', 'class//Point', { x: 1, y: 2 }); + const json = JSON.stringify(ref); + const parsed = JSON.parse(json); + + // After parsing, it's a plain object but still recognized + expect(isClassInstanceRef(parsed)).toBe(true); + expect(parsed.className).toBe('Point'); + expect(parsed.classId).toBe('class//Point'); + expect(parsed.data).toEqual({ x: 1, y: 2 }); + }); +}); diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts index 5292f0ed5..383d32dad 100644 --- a/packages/core/src/observability.ts +++ b/packages/core/src/observability.ts @@ -3,6 +3,8 @@ * Shared between CLI and Web UI for consistent behavior. */ +import { inspect } from 'node:util'; +import { parseClassName } from './parse-name.js'; import { hydrateStepArguments, hydrateStepReturnValue, @@ -26,6 +28,86 @@ export interface StreamRef { streamId: string; } +/** + * Marker for custom class instance references. + * Used in observability to represent serialized class instances + * that cannot be fully deserialized (because the class is not registered). + */ +export const CLASS_INSTANCE_REF_TYPE = '__workflow_class_instance_ref__'; + +/** + * A class instance reference that contains the class name and serialized data. + * This is used during o11y hydration when a custom class instance is encountered + * but the class is not registered for deserialization. + * + * Provides a custom `util.inspect.custom` representation for nice CLI output: + * `Point { x: 1, y: 2 } [class//path/to/file.ts//Point]` + */ +export class ClassInstanceRef { + readonly __type = CLASS_INSTANCE_REF_TYPE; + + constructor( + public readonly className: string, + public readonly classId: string, + public readonly data: unknown + ) {} + + /** + * Custom inspect for Node.js util.inspect (used by console.log, CLI, etc.) + * Renders as: ClassName@filename { ...data } + * The @filename portion is styled gray (like undefined in Node.js) + */ + [inspect.custom]( + _depth: number, + options: import('node:util').InspectOptionsStylized + ): string { + const dataStr = inspect(this.data, { ...options, depth: options.depth }); + const parsed = parseClassName(this.classId); + const filePath = parsed?.path ?? this.classId; + // Extract just the filename from the path + const fileName = filePath.split('/').pop() ?? filePath; + // Style the @filename portion gray using the 'undefined' style + const styledFileName = options.stylize + ? options.stylize(`@${fileName}`, 'undefined') + : `@${fileName}`; + return `${this.className}${styledFileName} ${dataStr}`; + } + + /** + * For JSON.stringify - returns a plain object representation + */ + toJSON(): { + __type: string; + className: string; + classId: string; + data: unknown; + } { + return { + __type: this.__type, + className: this.className, + classId: this.classId, + data: this.data, + }; + } +} + +/** + * Check if a value is a ClassInstanceRef object + */ +export const isClassInstanceRef = ( + value: unknown +): value is ClassInstanceRef => { + return ( + value instanceof ClassInstanceRef || + (value !== null && + typeof value === 'object' && + '__type' in value && + value.__type === CLASS_INSTANCE_REF_TYPE && + 'className' in value && + typeof value.className === 'string') + ); +}; + /** * Check if a value is a stream ID string */ @@ -82,18 +164,59 @@ const serializedStepFunctionToString = (value: unknown): string => { return ''; }; +/** + * Extract the class name from a classId. + * The classId format is typically "path/to/file/ClassName" so we extract the last segment. + */ +const extractClassName = (classId: string): string => { + if (!classId) return 'Unknown'; + const parts = classId.split('/'); + return parts[parts.length - 1] || classId; +}; + +/** + * Convert a serialized class instance to a ClassInstanceRef for o11y display. + * This allows viewing custom class instances in the UI without needing + * the class to be registered for deserialization. + */ +const serializedInstanceToRef = (value: { + classId: string; + data: unknown; +}): ClassInstanceRef => { + return new ClassInstanceRef( + extractClassName(value.classId), + value.classId, + value.data + ); +}; + +/** + * Convert a serialized class reference to a string representation. + * This is used for Class type (the constructor reference itself, not an instance). + */ +const serializedClassToString = (value: { classId: string }): string => { + const className = extractClassName(value.classId); + return ``; +}; + /** * This is an extra reviver for devalue that takes any streams that would be converted, * into actual streams, and instead formats them as StreamRef objects for display in the UI. * * This is mainly because we don't want to open any streams that we aren't going to read from, * and so we can get the string ID/name, which the serializer stream doesn't provide. + * + * Also handles custom class instances (Instance) and class references (Class) by converting + * them to opaque markers, since the custom classes are not registered for deserialization + * in the o11y context. */ const streamPrintRevivers: Record any> = { ReadableStream: streamToStreamRef, WritableStream: streamToStreamRef, TransformStream: streamToStreamRef, StepFunction: serializedStepFunctionToString, + Instance: serializedInstanceToRef, + Class: serializedClassToString, }; const hydrateStepIO = < diff --git a/packages/core/src/parse-name.test.ts b/packages/core/src/parse-name.test.ts index 236f7cd22..31089b879 100644 --- a/packages/core/src/parse-name.test.ts +++ b/packages/core/src/parse-name.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { parseStepName, parseWorkflowName } from './parse-name'; +import { parseClassName, parseStepName, parseWorkflowName } from './parse-name'; describe('parseWorkflowName', () => { test('should parse a valid workflow name with Unix path', () => { @@ -129,3 +129,41 @@ describe('parseStepName', () => { }); }); }); + +describe('parseClassName', () => { + test('should parse a valid class ID', () => { + const result = parseClassName('class//src/models/point.ts//Point'); + expect(result).toEqual({ + shortName: 'Point', + path: 'src/models/point.ts', + functionName: 'Point', + }); + }); + + test('should parse class ID with nested path', () => { + const result = parseClassName('class//workflows/user-signup.ts//UserData'); + expect(result).toEqual({ + shortName: 'UserData', + path: 'workflows/user-signup.ts', + functionName: 'UserData', + }); + }); + + test('should return null for invalid class IDs', () => { + expect(parseClassName('invalid')).toBeNull(); + expect(parseClassName('class//')).toBeNull(); + expect(parseClassName('step//path//fn')).toBeNull(); + expect(parseClassName('workflow//path//fn')).toBeNull(); + }); + + test('should handle class ID with Windows-style path', () => { + const result = parseClassName( + 'class//C:/dev/project/models/config.ts//Config' + ); + expect(result).toEqual({ + shortName: 'Config', + path: 'C:/dev/project/models/config.ts', + functionName: 'Config', + }); + }); +}); diff --git a/packages/core/src/parse-name.ts b/packages/core/src/parse-name.ts index 91b376de5..6db9b3e0d 100644 --- a/packages/core/src/parse-name.ts +++ b/packages/core/src/parse-name.ts @@ -62,3 +62,14 @@ export function parseWorkflowName(name: string) { export function parseStepName(name: string) { return parseName('step', name); } + +/** + * Parse a class ID into its components. + * + * @param name - The class ID to parse (e.g., "class//path/to/file.ts//ClassName"). + * @returns An object with `shortName`, `path`, and `functionName` (className) properties. + * When the name is invalid, returns `null`. + */ +export function parseClassName(name: string) { + return parseName('class', name); +} diff --git a/packages/web-shared/package.json b/packages/web-shared/package.json index 4b327a1fe..5c6e10c09 100644 --- a/packages/web-shared/package.json +++ b/packages/web-shared/package.json @@ -45,6 +45,7 @@ "@workflow/world-vercel": "workspace:*", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "color-hash": "2.0.2", "date-fns": "4.1.0", "lucide-react": "0.469.0", "react": "19.1.0", @@ -58,6 +59,7 @@ }, "devDependencies": { "@biomejs/biome": "catalog:", + "@types/color-hash": "2.0.0", "@types/node": "catalog:", "@types/react": "19", "@types/react-dom": "19", diff --git a/packages/web-shared/src/hooks/use-dark-mode.ts b/packages/web-shared/src/hooks/use-dark-mode.ts new file mode 100644 index 000000000..17d043c57 --- /dev/null +++ b/packages/web-shared/src/hooks/use-dark-mode.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; + +/** + * Hook that detects if dark mode is active and reacts to theme changes. + * Observes the 'dark' class on the document element, which is how + * next-themes and similar libraries apply the theme. + * + * @returns `true` if dark mode is active, `false` otherwise + */ +export const useDarkMode = (): boolean => { + const [isDark, setIsDark] = useState(() => { + if (typeof document === 'undefined') return false; + return document.documentElement.classList.contains('dark'); + }); + + useEffect(() => { + if (typeof document === 'undefined') return; + + const observer = new MutationObserver(() => { + setIsDark(document.documentElement.classList.contains('dark')); + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }); + + return () => observer.disconnect(); + }, []); + + return isDark; +}; diff --git a/packages/web-shared/src/sidebar/attribute-panel.tsx b/packages/web-shared/src/sidebar/attribute-panel.tsx index 65b1c213d..24930ebdd 100644 --- a/packages/web-shared/src/sidebar/attribute-panel.tsx +++ b/packages/web-shared/src/sidebar/attribute-panel.tsx @@ -6,6 +6,7 @@ import type { ModelMessage } from 'ai'; import type { ReactNode } from 'react'; import { createContext, useContext, useMemo, useState } from 'react'; import { ErrorCard } from '../components/ui/error-card'; +import { useDarkMode } from '../hooks/use-dark-mode'; import { extractConversation, isDoStreamStep } from '../lib/utils'; import { ConversationView } from './conversation-view'; import { DetailCard } from './detail-card'; @@ -149,6 +150,141 @@ const isStreamRef = (value: unknown): value is StreamRef => { ); }; +/** + * Marker for custom class instance references. + * This is duplicated from @workflow/core/observability to avoid pulling in + * Node.js dependencies into the client bundle. + */ +const CLASS_INSTANCE_REF_TYPE = '__workflow_class_instance_ref__'; + +/** + * A class instance reference object that contains the class name and serialized data. + * Used in o11y when a custom class instance is encountered but the class is not + * registered for deserialization. + */ +interface ClassInstanceRef { + __type: typeof CLASS_INSTANCE_REF_TYPE; + className: string; + classId: string; + data: unknown; +} + +/** + * Check if a value is a ClassInstanceRef object + */ +const isClassInstanceRef = (value: unknown): value is ClassInstanceRef => { + return ( + value !== null && + typeof value === 'object' && + '__type' in value && + value.__type === CLASS_INSTANCE_REF_TYPE && + 'className' in value && + typeof value.className === 'string' + ); +}; + +import ColorHash from 'color-hash'; + +/** + * Color hash instance configured for nice saturation and lightness. + * Returns HSL values which we can transform for different use cases. + */ +const colorHash = new ColorHash({ + saturation: [0.5, 0.6, 0.7], + lightness: [0.4, 0.5, 0.6], +}); + +/** + * Convert HSL to CSS hsl() string + */ +const hslToString = (h: number, s: number, l: number): string => { + return `hsl(${h}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`; +}; + +/** + * Get consistent colors for a class ID using color-hash and HSL transformations. + * Adjusts colors based on light/dark mode for optimal appearance. + */ +const getClassColors = ( + classId: string, + isDark: boolean +): { header: string; body: string; text: string } => { + const [h, s, l] = colorHash.hsl(classId); + + if (isDark) { + // Dark mode: vibrant header, dark body, light text + return { + header: hslToString(h, s, Math.min(l + 0.1, 0.6)), // Slightly brighter header + body: hslToString(h, s * 0.8, 0.15), // Very dark, slightly desaturated body + text: hslToString(h, s * 0.6, 0.8), // Light, slightly desaturated text + }; + } else { + // Light mode: vibrant header, light body, dark text + return { + header: hslToString(h, s, l), // Use base color for header + body: hslToString(h, s * 0.4, 0.95), // Very light, desaturated body + text: hslToString(h, s * 0.8, 0.25), // Dark, saturated text + }; + } +}; + +/** + * Renders a ClassInstanceRef as a styled card showing the class name and serialized data. + * The header color is determined by hashing the classId for visual distinction. + * Reacts to theme changes for proper dark/light mode support. + */ +const ClassInstanceRefDisplay = ({ + classInstanceRef, +}: { + classInstanceRef: ClassInstanceRef; +}) => { + const isDark = useDarkMode(); + const colors = getClassColors(classInstanceRef.classId, isDark); + + return ( +
+
+ + Class instance + + + + + {classInstanceRef.className} +
+
+        {JSON.stringify(classInstanceRef.data, null, 2)}
+      
+
+ ); +}; + /** * Renders a StreamRef as a styled link/badge */ @@ -198,13 +334,19 @@ const StreamRefDisplay = ({ streamRef }: { streamRef: StreamRef }) => { }; /** - * Recursively transforms a value for JSON display, replacing StreamRef objects - * with placeholder strings that can be identified and replaced with React elements + * Recursively transforms a value for JSON display, replacing StreamRef and + * ClassInstanceRef objects with placeholder strings that can be identified + * and replaced with React elements. */ const transformValueForDisplay = ( value: unknown -): { json: string; streamRefs: Map } => { +): { + json: string; + streamRefs: Map; + classInstanceRefs: Map; +} => { const streamRefs = new Map(); + const classInstanceRefs = new Map(); let counter = 0; const transform = (v: unknown): unknown => { @@ -213,6 +355,11 @@ const transformValueForDisplay = ( streamRefs.set(placeholder, v); return placeholder; } + if (isClassInstanceRef(v)) { + const placeholder = `__CLASS_INSTANCE_REF_${counter++}__`; + classInstanceRefs.set(placeholder, v); + return placeholder; + } if (Array.isArray(v)) { return v.map(transform); } @@ -230,14 +377,16 @@ const transformValueForDisplay = ( return { json: JSON.stringify(transformed, null, 2), streamRefs, + classInstanceRefs, }; }; const JsonBlock = (value: unknown) => { - const { json, streamRefs } = transformValueForDisplay(value); + const { json, streamRefs, classInstanceRefs } = + transformValueForDisplay(value); - // If no stream refs, just render plain JSON - if (streamRefs.size === 0) { + // If no special refs, just render plain JSON + if (streamRefs.size === 0 && classInstanceRefs.size === 0) { return (
 {
     );
   }
 
-  // Split the JSON by stream ref placeholders and render with React elements
-  const parts: ReactNode[] = [];
-  let remaining = json;
+  // Build a combined map of all placeholders to their React elements
+  const placeholderComponents = new Map();
   let keyIndex = 0;
 
   for (const [placeholder, streamRef] of streamRefs) {
-    const index = remaining.indexOf(`"${placeholder}"`);
-    if (index !== -1) {
-      // Add text before the placeholder
-      if (index > 0) {
-        parts.push(remaining.slice(0, index));
+    placeholderComponents.set(
+      placeholder,
+      
+    );
+  }
+
+  for (const [placeholder, classInstanceRef] of classInstanceRefs) {
+    placeholderComponents.set(
+      placeholder,
+      
+    );
+  }
+
+  // Split the JSON by all placeholders and render with React elements
+  const parts: ReactNode[] = [];
+  let remaining = json;
+
+  // Process placeholders in order of their appearance in the string
+  while (remaining.length > 0) {
+    let earliestIndex = -1;
+    let earliestPlaceholder = '';
+    let earliestComponent: ReactNode = null;
+
+    // Find the earliest placeholder in the remaining string
+    for (const [placeholder, component] of placeholderComponents) {
+      const index = remaining.indexOf(`"${placeholder}"`);
+      if (index !== -1 && (earliestIndex === -1 || index < earliestIndex)) {
+        earliestIndex = index;
+        earliestPlaceholder = placeholder;
+        earliestComponent = component;
       }
-      // Add the StreamRef component
-      parts.push();
-      remaining = remaining.slice(index + placeholder.length + 2); // +2 for quotes
     }
-  }
 
-  // Add any remaining text
-  if (remaining) {
-    parts.push(remaining);
+    if (earliestIndex === -1) {
+      // No more placeholders found, add the rest
+      parts.push(remaining);
+      break;
+    }
+
+    // Add text before the placeholder
+    if (earliestIndex > 0) {
+      parts.push(remaining.slice(0, earliestIndex));
+    }
+
+    // Add the component
+    parts.push(earliestComponent);
+
+    // Move past the placeholder
+    remaining = remaining.slice(earliestIndex + earliestPlaceholder.length + 2); // +2 for quotes
   }
 
   return (
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d65ed6d32..79514cf66 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1026,6 +1026,9 @@ importers:
       clsx:
         specifier: 2.1.1
         version: 2.1.1
+      color-hash:
+        specifier: 2.0.2
+        version: 2.0.2
       date-fns:
         specifier: 4.1.0
         version: 4.1.0
@@ -1060,6 +1063,9 @@ importers:
       '@biomejs/biome':
         specifier: 'catalog:'
         version: 2.3.3
+      '@types/color-hash':
+        specifier: 2.0.0
+        version: 2.0.0
       '@types/node':
         specifier: 'catalog:'
         version: 22.19.0
@@ -6380,6 +6386,9 @@ packages:
   '@types/chai@5.2.2':
     resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
 
+  '@types/color-hash@2.0.0':
+    resolution: {integrity: sha512-wVZU2AthjkuxcK8IQl2lpVzWZxu/nuOoQfEBv0cxsbV8mVlCiPExNaw9/Z1PBC0ostupYr8KbTmT2J4+qrOW7w==}
+
   '@types/connect@3.4.38':
     resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
 
@@ -7560,6 +7569,9 @@ packages:
     resolution: {integrity: sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg==}
     engines: {node: '>=14.6'}
 
+  color-hash@2.0.2:
+    resolution: {integrity: sha512-6exeENAqBTuIR1wIo36mR8xVVBv6l1hSLd7Qmvf6158Ld1L15/dbahR9VUOiX7GmGJBCnQyS0EY+I8x+wa7egg==}
+
   color-name@1.1.4:
     resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
 
@@ -19021,6 +19033,8 @@ snapshots:
     dependencies:
       '@types/deep-eql': 4.0.2
 
+  '@types/color-hash@2.0.0': {}
+
   '@types/connect@3.4.38':
     dependencies:
       '@types/node': 24.6.2
@@ -20629,6 +20643,8 @@ snapshots:
     dependencies:
       color-name: 2.1.0
 
+  color-hash@2.0.2: {}
+
   color-name@1.1.4: {}
 
   color-name@2.1.0: {}