From daff49105a7dd66a22bfadd1ab8f5179ed19f751 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Mon, 19 Jan 2026 14:16:21 -0800 Subject: [PATCH 1/8] Show opaque marker when deserializing custom classes and instances in o11y --- .changeset/forty-tables-lick.md | 6 + packages/core/src/observability.ts | 113 +++++++++++ .../src/sidebar/attribute-panel.tsx | 177 +++++++++++++++--- 3 files changed, 275 insertions(+), 21 deletions(-) create mode 100644 .changeset/forty-tables-lick.md diff --git a/.changeset/forty-tables-lick.md b/.changeset/forty-tables-lick.md new file mode 100644 index 000000000..4d149e944 --- /dev/null +++ b/.changeset/forty-tables-lick.md @@ -0,0 +1,6 @@ +--- +"@workflow/web-shared": patch +"@workflow/core": patch +--- + +Show opaque marker when deserializing custom classes and instances in o11y diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts index 5292f0ed5..c32913d24 100644 --- a/packages/core/src/observability.ts +++ b/packages/core/src/observability.ts @@ -3,6 +3,7 @@ * Shared between CLI and Web UI for consistent behavior. */ +import { inspect } from 'node:util'; import { hydrateStepArguments, hydrateStepReturnValue, @@ -26,6 +27,77 @@ 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 { ...data } [classId] + */ + [inspect.custom]( + _depth: number, + options: import('node:util').InspectOptions + ): string { + const dataStr = inspect(this.data, { ...options, depth: options.depth }); + return `${this.className} ${dataStr} [${this.classId}]`; + } + + /** + * 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 +154,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/web-shared/src/sidebar/attribute-panel.tsx b/packages/web-shared/src/sidebar/attribute-panel.tsx index 65b1c213d..3bc35af37 100644 --- a/packages/web-shared/src/sidebar/attribute-panel.tsx +++ b/packages/web-shared/src/sidebar/attribute-panel.tsx @@ -149,6 +149,92 @@ 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' + ); +}; + +/** + * Renders a ClassInstanceRef as a styled card showing the class name and serialized data + */ +const ClassInstanceRefDisplay = ({ + classInstanceRef, +}: { + classInstanceRef: ClassInstanceRef; +}) => { + return ( +
+
+ + Class instance + + + + + {classInstanceRef.className} +
+
+        {JSON.stringify(classInstanceRef.data, null, 2)}
+      
+
+ ); +}; + /** * Renders a StreamRef as a styled link/badge */ @@ -198,13 +284,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 +305,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 +327,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 (

From 1bf4beedaf7184c5c27e18f6b3d6f470c02cef81 Mon Sep 17 00:00:00 2001
From: Nathan Rajlich 
Date: Mon, 19 Jan 2026 17:41:23 -0800
Subject: [PATCH 2/8] Add unit tests

---
 packages/core/src/observability.test.ts | 325 ++++++++++++++++++++++++
 1 file changed, 325 insertions(+)
 create mode 100644 packages/core/src/observability.test.ts

diff --git a/packages/core/src/observability.test.ts b/packages/core/src/observability.test.ts
new file mode 100644
index 000000000..0c920d6fe
--- /dev/null
+++ b/packages/core/src/observability.test.ts
@@ -0,0 +1,325 @@
+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 { data } [classId]', () => {
+      const ref = new ClassInstanceRef('Point', 'class//file.ts//Point', {
+        x: 1,
+        y: 2,
+      });
+
+      const output = inspect(ref);
+      expect(output).toBe('Point { x: 1, y: 2 } [class//file.ts//Point]');
+    });
+
+    it('should handle nested data', () => {
+      const ref = new ClassInstanceRef('Config', 'class//Config', {
+        nested: { a: 1, b: 2 },
+      });
+
+      const output = inspect(ref);
+      expect(output).toBe('Config { nested: { a: 1, b: 2 } } [class//Config]');
+    });
+
+    it('should handle string data', () => {
+      const ref = new ClassInstanceRef('Token', 'class//Token', 'secret');
+
+      const output = inspect(ref);
+      expect(output).toBe("Token 'secret' [class//Token]");
+    });
+
+    it('should handle null data', () => {
+      const ref = new ClassInstanceRef('Empty', 'class//Empty', null);
+
+      const output = inspect(ref);
+      expect(output).toBe('Empty null [class//Empty]');
+    });
+
+    it('should handle array data', () => {
+      const ref = new ClassInstanceRef('List', 'class//List', [1, 2, 3]);
+
+      const output = inspect(ref);
+      expect(output).toBe('List [ 1, 2, 3 ] [class//List]');
+    });
+  });
+});
+
+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 });
+  });
+});

From 9d7ed0882deb3fd2ee1ce309d63f2e1bc64268ac Mon Sep 17 00:00:00 2001
From: Nathan Rajlich 
Date: Mon, 19 Jan 2026 18:05:38 -0800
Subject: [PATCH 3/8] Parse filename from class ID

---
 packages/core/src/observability.test.ts | 63 ++++++++++++++++++-------
 packages/core/src/observability.ts      |  7 ++-
 packages/core/src/parse-name.test.ts    | 40 +++++++++++++++-
 packages/core/src/parse-name.ts         | 11 +++++
 4 files changed, 101 insertions(+), 20 deletions(-)

diff --git a/packages/core/src/observability.test.ts b/packages/core/src/observability.test.ts
index 0c920d6fe..20fb7aaa1 100644
--- a/packages/core/src/observability.test.ts
+++ b/packages/core/src/observability.test.ts
@@ -88,44 +88,73 @@ describe('ClassInstanceRef', () => {
   });
 
   describe('[inspect.custom]', () => {
-    it('should render as ClassName { data } [classId]', () => {
-      const ref = new ClassInstanceRef('Point', 'class//file.ts//Point', {
-        x: 1,
-        y: 2,
-      });
+    it('should render as ClassName { data } [filepath]', () => {
+      const ref = new ClassInstanceRef(
+        'Point',
+        'class//workflows/user-signup.ts//Point',
+        { x: 1, y: 2 }
+      );
 
       const output = inspect(ref);
-      expect(output).toBe('Point { x: 1, y: 2 } [class//file.ts//Point]');
+      expect(output).toBe('Point { x: 1, y: 2 } [workflows/user-signup.ts]');
     });
 
-    it('should handle nested data', () => {
-      const ref = new ClassInstanceRef('Config', 'class//Config', {
-        nested: { a: 1, b: 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);
-      expect(output).toBe('Config { nested: { a: 1, b: 2 } } [class//Config]');
+      expect(output).toBe(
+        'Config { nested: { a: 1, b: 2 } } [lib/models/config.ts]'
+      );
     });
 
     it('should handle string data', () => {
-      const ref = new ClassInstanceRef('Token', 'class//Token', 'secret');
+      const ref = new ClassInstanceRef(
+        'Token',
+        'class//auth/token.ts//Token',
+        'secret'
+      );
 
       const output = inspect(ref);
-      expect(output).toBe("Token 'secret' [class//Token]");
+      expect(output).toBe("Token 'secret' [auth/token.ts]");
     });
 
     it('should handle null data', () => {
-      const ref = new ClassInstanceRef('Empty', 'class//Empty', null);
+      const ref = new ClassInstanceRef(
+        'Empty',
+        'class//utils/empty.ts//Empty',
+        null
+      );
 
       const output = inspect(ref);
-      expect(output).toBe('Empty null [class//Empty]');
+      expect(output).toBe('Empty null [utils/empty.ts]');
     });
 
     it('should handle array data', () => {
-      const ref = new ClassInstanceRef('List', 'class//List', [1, 2, 3]);
+      const ref = new ClassInstanceRef(
+        'List',
+        'class//collections/list.ts//List',
+        [1, 2, 3]
+      );
+
+      const output = inspect(ref);
+      expect(output).toBe('List [ 1, 2, 3 ] [collections/list.ts]');
+    });
+
+    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);
-      expect(output).toBe('List [ 1, 2, 3 ] [class//List]');
+      // Falls back to extracting middle portion
+      expect(output).toBe('Point { x: 1, y: 2 } [test//TestPoint]');
     });
   });
 });
diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts
index c32913d24..69c76b0b9 100644
--- a/packages/core/src/observability.ts
+++ b/packages/core/src/observability.ts
@@ -4,6 +4,7 @@
  */
 
 import { inspect } from 'node:util';
+import { parseClassName } from './parse-name.js';
 import {
   hydrateStepArguments,
   hydrateStepReturnValue,
@@ -53,14 +54,16 @@ export class ClassInstanceRef {
 
   /**
    * Custom inspect for Node.js util.inspect (used by console.log, CLI, etc.)
-   * Renders as: ClassName { ...data } [classId]
+   * Renders as: ClassName { ...data } [filepath]
    */
   [inspect.custom](
     _depth: number,
     options: import('node:util').InspectOptions
   ): string {
     const dataStr = inspect(this.data, { ...options, depth: options.depth });
-    return `${this.className} ${dataStr} [${this.classId}]`;
+    const parsed = parseClassName(this.classId);
+    const filePath = parsed?.path ?? this.classId;
+    return `${this.className} ${dataStr} [${filePath}]`;
   }
 
   /**
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);
+}

From 7a26fa569cdeaf001d88b1af459bca8b3aa48705 Mon Sep 17 00:00:00 2001
From: Nathan Rajlich 
Date: Tue, 20 Jan 2026 00:42:34 -0800
Subject: [PATCH 4/8] color hash

---
 .../src/sidebar/attribute-panel.tsx           | 61 ++++++++++++++++---
 1 file changed, 54 insertions(+), 7 deletions(-)

diff --git a/packages/web-shared/src/sidebar/attribute-panel.tsx b/packages/web-shared/src/sidebar/attribute-panel.tsx
index 3bc35af37..e7ada817c 100644
--- a/packages/web-shared/src/sidebar/attribute-panel.tsx
+++ b/packages/web-shared/src/sidebar/attribute-panel.tsx
@@ -183,27 +183,74 @@ const isClassInstanceRef = (value: unknown): value is ClassInstanceRef => {
 };
 
 /**
- * Renders a ClassInstanceRef as a styled card showing the class name and serialized data
+ * Color palette for class instance badges.
+ * Each entry has: [background, border, text] colors.
+ * These are designed to be visually distinct and accessible.
+ */
+const CLASS_COLOR_PALETTE: Array<[string, string, string]> = [
+  ['#E8F5E9', '#4CAF50', '#1B5E20'], // Green
+  ['#E3F2FD', '#2196F3', '#0D47A1'], // Blue
+  ['#FFF3E0', '#FF9800', '#E65100'], // Orange
+  ['#F3E5F5', '#9C27B0', '#4A148C'], // Purple
+  ['#E0F7FA', '#00BCD4', '#006064'], // Cyan
+  ['#FCE4EC', '#E91E63', '#880E4F'], // Pink
+  ['#FFF8E1', '#FFC107', '#FF6F00'], // Amber
+  ['#E8EAF6', '#3F51B5', '#1A237E'], // Indigo
+  ['#F1F8E9', '#8BC34A', '#33691E'], // Light Green
+  ['#FFEBEE', '#F44336', '#B71C1C'], // Red
+  ['#E0F2F1', '#009688', '#004D40'], // Teal
+  ['#EDE7F6', '#673AB7', '#311B92'], // Deep Purple
+];
+
+/**
+ * Simple string hash function (djb2 algorithm).
+ * Returns a consistent number for a given string.
+ */
+const hashString = (str: string): number => {
+  let hash = 5381;
+  for (let i = 0; i < str.length; i++) {
+    hash = (hash * 33) ^ str.charCodeAt(i);
+  }
+  return hash >>> 0; // Convert to unsigned 32-bit integer
+};
+
+/**
+ * Get consistent colors for a class name.
+ * Returns [backgroundColor, borderColor, textColor].
+ */
+const getClassColors = (
+  className: string
+): { bg: string; border: string; text: string } => {
+  const index = hashString(className) % CLASS_COLOR_PALETTE.length;
+  const [bg, border, text] = CLASS_COLOR_PALETTE[index];
+  return { bg, border, 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.
  */
 const ClassInstanceRefDisplay = ({
   classInstanceRef,
 }: {
   classInstanceRef: ClassInstanceRef;
 }) => {
+  const colors = getClassColors(classInstanceRef.classId);
+
   return (
     
@@ -227,7 +274,7 @@ const ClassInstanceRefDisplay = ({
         {JSON.stringify(classInstanceRef.data, null, 2)}
       
From a350590e2e7f5e27da547877f588b5a30d396d9c Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 20 Jan 2026 00:55:01 -0800 Subject: [PATCH 5/8] Try out different colors --- .../src/sidebar/attribute-panel.tsx | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/packages/web-shared/src/sidebar/attribute-panel.tsx b/packages/web-shared/src/sidebar/attribute-panel.tsx index e7ada817c..6b99b9234 100644 --- a/packages/web-shared/src/sidebar/attribute-panel.tsx +++ b/packages/web-shared/src/sidebar/attribute-panel.tsx @@ -184,22 +184,22 @@ const isClassInstanceRef = (value: unknown): value is ClassInstanceRef => { /** * Color palette for class instance badges. - * Each entry has: [background, border, text] colors. - * These are designed to be visually distinct and accessible. + * Each entry has: [headerBg, bodyBg, textColor] colors. + * Uses darker body backgrounds for better visual appearance. */ const CLASS_COLOR_PALETTE: Array<[string, string, string]> = [ - ['#E8F5E9', '#4CAF50', '#1B5E20'], // Green - ['#E3F2FD', '#2196F3', '#0D47A1'], // Blue - ['#FFF3E0', '#FF9800', '#E65100'], // Orange - ['#F3E5F5', '#9C27B0', '#4A148C'], // Purple - ['#E0F7FA', '#00BCD4', '#006064'], // Cyan - ['#FCE4EC', '#E91E63', '#880E4F'], // Pink - ['#FFF8E1', '#FFC107', '#FF6F00'], // Amber - ['#E8EAF6', '#3F51B5', '#1A237E'], // Indigo - ['#F1F8E9', '#8BC34A', '#33691E'], // Light Green - ['#FFEBEE', '#F44336', '#B71C1C'], // Red - ['#E0F2F1', '#009688', '#004D40'], // Teal - ['#EDE7F6', '#673AB7', '#311B92'], // Deep Purple + ['#4CAF50', '#1B5E20', '#A5D6A7'], // Green + ['#2196F3', '#0D47A1', '#90CAF9'], // Blue + ['#FF9800', '#E65100', '#FFCC80'], // Orange + ['#9C27B0', '#4A148C', '#CE93D8'], // Purple + ['#00BCD4', '#006064', '#80DEEA'], // Cyan + ['#E91E63', '#880E4F', '#F48FB1'], // Pink + ['#FFC107', '#FF6F00', '#FFE082'], // Amber + ['#3F51B5', '#1A237E', '#9FA8DA'], // Indigo + ['#8BC34A', '#33691E', '#C5E1A5'], // Light Green + ['#F44336', '#B71C1C', '#EF9A9A'], // Red + ['#009688', '#004D40', '#80CBC4'], // Teal + ['#673AB7', '#311B92', '#B39DDB'], // Deep Purple ]; /** @@ -215,15 +215,15 @@ const hashString = (str: string): number => { }; /** - * Get consistent colors for a class name. - * Returns [backgroundColor, borderColor, textColor]. + * Get consistent colors for a class ID. + * Returns header background, body background, and text color. */ const getClassColors = ( - className: string -): { bg: string; border: string; text: string } => { - const index = hashString(className) % CLASS_COLOR_PALETTE.length; - const [bg, border, text] = CLASS_COLOR_PALETTE[index]; - return { bg, border, text }; + classId: string +): { header: string; body: string; text: string } => { + const index = hashString(classId) % CLASS_COLOR_PALETTE.length; + const [header, body, text] = CLASS_COLOR_PALETTE[index]; + return { header, body, text }; }; /** @@ -241,16 +241,15 @@ const ClassInstanceRefDisplay = ({
From a3f6cf6bfa90b913ccfaad63c1416ece0d99ea65 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 20 Jan 2026 01:10:48 -0800 Subject: [PATCH 6/8] Use color-hash module and HSL transformations --- packages/web-shared/package.json | 2 + .../web-shared/src/hooks/use-dark-mode.ts | 32 +++++++++ .../src/sidebar/attribute-panel.tsx | 68 ++++++++++--------- pnpm-lock.yaml | 16 +++++ 4 files changed, 86 insertions(+), 32 deletions(-) create mode 100644 packages/web-shared/src/hooks/use-dark-mode.ts 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 6b99b9234..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'; @@ -182,60 +183,63 @@ const isClassInstanceRef = (value: unknown): value is ClassInstanceRef => { ); }; +import ColorHash from 'color-hash'; + /** - * Color palette for class instance badges. - * Each entry has: [headerBg, bodyBg, textColor] colors. - * Uses darker body backgrounds for better visual appearance. + * Color hash instance configured for nice saturation and lightness. + * Returns HSL values which we can transform for different use cases. */ -const CLASS_COLOR_PALETTE: Array<[string, string, string]> = [ - ['#4CAF50', '#1B5E20', '#A5D6A7'], // Green - ['#2196F3', '#0D47A1', '#90CAF9'], // Blue - ['#FF9800', '#E65100', '#FFCC80'], // Orange - ['#9C27B0', '#4A148C', '#CE93D8'], // Purple - ['#00BCD4', '#006064', '#80DEEA'], // Cyan - ['#E91E63', '#880E4F', '#F48FB1'], // Pink - ['#FFC107', '#FF6F00', '#FFE082'], // Amber - ['#3F51B5', '#1A237E', '#9FA8DA'], // Indigo - ['#8BC34A', '#33691E', '#C5E1A5'], // Light Green - ['#F44336', '#B71C1C', '#EF9A9A'], // Red - ['#009688', '#004D40', '#80CBC4'], // Teal - ['#673AB7', '#311B92', '#B39DDB'], // Deep Purple -]; +const colorHash = new ColorHash({ + saturation: [0.5, 0.6, 0.7], + lightness: [0.4, 0.5, 0.6], +}); /** - * Simple string hash function (djb2 algorithm). - * Returns a consistent number for a given string. + * Convert HSL to CSS hsl() string */ -const hashString = (str: string): number => { - let hash = 5381; - for (let i = 0; i < str.length; i++) { - hash = (hash * 33) ^ str.charCodeAt(i); - } - return hash >>> 0; // Convert to unsigned 32-bit integer +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. - * Returns header background, body background, and text color. + * 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 + classId: string, + isDark: boolean ): { header: string; body: string; text: string } => { - const index = hashString(classId) % CLASS_COLOR_PALETTE.length; - const [header, body, text] = CLASS_COLOR_PALETTE[index]; - return { header, body, text }; + 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 colors = getClassColors(classInstanceRef.classId); + const isDark = useDarkMode(); + const colors = getClassColors(classInstanceRef.classId, isDark); return (
=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: {} From 10414c03e38562a3b43df5e81803e041e54bc501 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 20 Jan 2026 01:39:26 -0800 Subject: [PATCH 7/8] CLI style tweaks --- packages/core/src/observability.test.ts | 47 ++++++++++++++++--------- packages/core/src/observability.ts | 13 +++++-- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/packages/core/src/observability.test.ts b/packages/core/src/observability.test.ts index 20fb7aaa1..a2d4cdc51 100644 --- a/packages/core/src/observability.test.ts +++ b/packages/core/src/observability.test.ts @@ -88,15 +88,15 @@ describe('ClassInstanceRef', () => { }); describe('[inspect.custom]', () => { - it('should render as ClassName { data } [filepath]', () => { + 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); - expect(output).toBe('Point { x: 1, y: 2 } [workflows/user-signup.ts]'); + const output = inspect(ref, { colors: false }); + expect(output).toBe('Point@user-signup.ts { x: 1, y: 2 }'); }); it('should handle nested file paths', () => { @@ -106,10 +106,8 @@ describe('ClassInstanceRef', () => { { nested: { a: 1, b: 2 } } ); - const output = inspect(ref); - expect(output).toBe( - 'Config { nested: { a: 1, b: 2 } } [lib/models/config.ts]' - ); + const output = inspect(ref, { colors: false }); + expect(output).toBe('Config@config.ts { nested: { a: 1, b: 2 } }'); }); it('should handle string data', () => { @@ -119,8 +117,8 @@ describe('ClassInstanceRef', () => { 'secret' ); - const output = inspect(ref); - expect(output).toBe("Token 'secret' [auth/token.ts]"); + const output = inspect(ref, { colors: false }); + expect(output).toBe("Token@token.ts 'secret'"); }); it('should handle null data', () => { @@ -130,8 +128,8 @@ describe('ClassInstanceRef', () => { null ); - const output = inspect(ref); - expect(output).toBe('Empty null [utils/empty.ts]'); + const output = inspect(ref, { colors: false }); + expect(output).toBe('Empty@empty.ts null'); }); it('should handle array data', () => { @@ -141,8 +139,8 @@ describe('ClassInstanceRef', () => { [1, 2, 3] ); - const output = inspect(ref); - expect(output).toBe('List [ 1, 2, 3 ] [collections/list.ts]'); + const output = inspect(ref, { colors: false }); + expect(output).toBe('List@list.ts [ 1, 2, 3 ]'); }); it('should handle simple classId format gracefully', () => { @@ -152,9 +150,26 @@ describe('ClassInstanceRef', () => { y: 2, }); - const output = inspect(ref); - // Falls back to extracting middle portion - expect(output).toBe('Point { x: 1, y: 2 } [test//TestPoint]'); + 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:'); }); }); }); diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts index 69c76b0b9..383d32dad 100644 --- a/packages/core/src/observability.ts +++ b/packages/core/src/observability.ts @@ -54,16 +54,23 @@ export class ClassInstanceRef { /** * Custom inspect for Node.js util.inspect (used by console.log, CLI, etc.) - * Renders as: ClassName { ...data } [filepath] + * Renders as: ClassName@filename { ...data } + * The @filename portion is styled gray (like undefined in Node.js) */ [inspect.custom]( _depth: number, - options: import('node:util').InspectOptions + 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; - return `${this.className} ${dataStr} [${filePath}]`; + // 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}`; } /** From c889dd113cf8f90cfd18c59181ee9e0917d034a6 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 20 Jan 2026 01:49:58 -0800 Subject: [PATCH 8/8] Update .changeset/forty-tables-lick.md Co-authored-by: Peter Wielander Signed-off-by: Nathan Rajlich --- .changeset/forty-tables-lick.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/forty-tables-lick.md b/.changeset/forty-tables-lick.md index 4d149e944..7212081e1 100644 --- a/.changeset/forty-tables-lick.md +++ b/.changeset/forty-tables-lick.md @@ -3,4 +3,4 @@ "@workflow/core": patch --- -Show opaque marker when deserializing custom classes and instances in o11y +Show custom class serialization UI and class names in o11y