diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index b03671581b3..a872349d51e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -48,6 +48,7 @@ import { resolveCanvasSentence, } from '@/lib/workflows/blocks/canvas-sentence' import { resolveSelectedTriggerId } from '@/lib/workflows/blocks/canvas-trigger-sentence' +import { resolveCanvasCodePreview } from '@/lib/workflows/blocks/code-preview' import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions' import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology' import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies' @@ -288,6 +289,9 @@ const areSubBlockRowPropsEqual = ( const prevValue = subBlockId ? prevProps.allSubBlockValues?.[subBlockId]?.value : undefined const nextValue = subBlockId ? nextProps.allSubBlockValues?.[subBlockId]?.value : undefined const valueEqual = prevValue === nextValue || isEqual(prevValue, nextValue) + const codeLanguageEqual = + prevProps.subBlock?.type !== 'code' || + prevProps.allSubBlockValues?.language?.value === nextProps.allSubBlockValues?.language?.value return ( prevProps.title === nextProps.title && @@ -298,6 +302,7 @@ const areSubBlockRowPropsEqual = ( prevProps.workflowId === nextProps.workflowId && prevProps.blockId === nextProps.blockId && valueEqual && + codeLanguageEqual && prevProps.displayAdvancedOptions === nextProps.displayAdvancedOptions && prevProps.canonicalIndex === nextProps.canonicalIndex && prevProps.canonicalModeOverrides === nextProps.canonicalModeOverrides && @@ -596,12 +601,15 @@ const SubBlockRow = memo(function SubBlockRow({ webhookUrlDisplayValue || selectorDisplayName const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value) + const codePreview = + variant === 'inline-value' ? resolveCanvasCodePreview(subBlock, rawValue, rawValues) : undefined return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index 399f9b53c06..c43c5785941 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -17,6 +17,7 @@ import { resolveCanvasSentence, } from '@/lib/workflows/blocks/canvas-sentence' import { resolveSelectedTriggerId } from '@/lib/workflows/blocks/canvas-trigger-sentence' +import { resolveCanvasCodePreview } from '@/lib/workflows/blocks/code-preview' import { getDisplayValue, hasDisplayableRowValue, @@ -562,6 +563,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps ) diff --git a/apps/sim/blocks/blocks/api.test.ts b/apps/sim/blocks/blocks/api.test.ts index 4763ee57e6b..483cfb7c6a8 100644 --- a/apps/sim/blocks/blocks/api.test.ts +++ b/apps/sim/blocks/blocks/api.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { ApiBlock } from '@/blocks/blocks/api' -describe('API block redirect policy', () => { +describe('API block', () => { it('uses a versioned safe default without changing legacy blocks', () => { const version = ApiBlock.subBlocks.find((subBlock) => subBlock.id === 'redirectPolicyVersion') const sendCredentials = ApiBlock.subBlocks.find( @@ -14,4 +14,11 @@ describe('API block redirect policy', () => { expect(sendCredentials?.mode).toBe('advanced') expect(sendCredentials?.defaultValue).toBe(true) }) + + it('marks the request body as JSON for code previews', () => { + const body = ApiBlock.subBlocks.find((subBlock) => subBlock.id === 'body') + + expect(body?.type).toBe('code') + expect(body?.language).toBe('json') + }) }) diff --git a/apps/sim/blocks/blocks/api.ts b/apps/sim/blocks/blocks/api.ts index 40c9cb323b5..eab5b60c67d 100644 --- a/apps/sim/blocks/blocks/api.ts +++ b/apps/sim/blocks/blocks/api.ts @@ -66,6 +66,7 @@ export const ApiBlock: BlockConfig = { id: 'body', title: 'Body', type: 'code', + language: 'json', placeholder: 'Enter JSON...', wandConfig: { enabled: true, diff --git a/apps/sim/lib/workflows/blocks/code-preview.test.ts b/apps/sim/lib/workflows/blocks/code-preview.test.ts new file mode 100644 index 00000000000..6d0942feab1 --- /dev/null +++ b/apps/sim/lib/workflows/blocks/code-preview.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { resolveCanvasCodePreview } from '@/lib/workflows/blocks/code-preview' +import type { SubBlockConfig } from '@/blocks/types' + +const CODE_SUBBLOCK: SubBlockConfig = { + id: 'code', + type: 'code', + language: 'javascript', +} + +describe('resolveCanvasCodePreview', () => { + it('uses the selected language when the block has a language field', () => { + expect( + resolveCanvasCodePreview(CODE_SUBBLOCK, 'print("hello")', { language: 'python' }) + ).toEqual({ + code: 'print("hello")', + language: 'python', + }) + }) + + it('maps the stored shell language to the Prism bash grammar', () => { + expect( + resolveCanvasCodePreview({ ...CODE_SUBBLOCK, language: 'shell' }, 'echo hello', {}) + ).toEqual({ + code: 'echo hello', + language: 'bash', + }) + }) + + it('falls back to the subblock language when the selected language is empty', () => { + expect(resolveCanvasCodePreview(CODE_SUBBLOCK, 'return true', { language: '' })).toEqual({ + code: 'return true', + language: 'javascript', + }) + }) + + it('does not preview non-code, password, empty, or non-string values', () => { + expect( + resolveCanvasCodePreview({ ...CODE_SUBBLOCK, type: 'short-input' }, 'hello', {}) + ).toBeUndefined() + expect( + resolveCanvasCodePreview({ ...CODE_SUBBLOCK, password: true }, 'secret', {}) + ).toBeUndefined() + expect(resolveCanvasCodePreview(CODE_SUBBLOCK, ' ', {})).toBeUndefined() + expect(resolveCanvasCodePreview(CODE_SUBBLOCK, { source: 'code' }, {})).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/workflows/blocks/code-preview.ts b/apps/sim/lib/workflows/blocks/code-preview.ts new file mode 100644 index 00000000000..54c13477775 --- /dev/null +++ b/apps/sim/lib/workflows/blocks/code-preview.ts @@ -0,0 +1,38 @@ +import type { CodePreview, CodePreviewLanguage } from '@sim/workflow-renderer' +import type { SubBlockConfig } from '@/blocks/types' + +/** Maps stored editor languages to the Prism grammar used by the shared viewer. */ +function resolveCodePreviewLanguage(language: unknown): CodePreviewLanguage { + switch (language) { + case 'json': + case 'python': + case 'javascript': + return language + case 'shell': + return 'bash' + default: + return 'javascript' + } +} + +/** Builds a rich preview only for safe, non-empty code fields on the canvas. */ +export function resolveCanvasCodePreview( + subBlock: SubBlockConfig | undefined, + rawValue: unknown, + values: Readonly> +): CodePreview | undefined { + if ( + subBlock?.type !== 'code' || + subBlock.password === true || + typeof rawValue !== 'string' || + rawValue.trim().length === 0 + ) { + return undefined + } + + const language = + typeof values.language === 'string' && values.language.length > 0 + ? values.language + : subBlock.language + return { code: rawValue, language: resolveCodePreviewLanguage(language) } +} diff --git a/packages/emcn/src/components/code/code.tsx b/packages/emcn/src/components/code/code.tsx index b23a16bf74c..b01b0424bee 100644 --- a/packages/emcn/src/components/code/code.tsx +++ b/packages/emcn/src/components/code/code.tsx @@ -116,6 +116,7 @@ function highlightOrEscape(prism: PrismModule | null, text: string, language: st * All code editors in the app should use these values for consistency. */ export const CODE_LINE_HEIGHT_PX = 21 +const COMPACT_CODE_LINE_HEIGHT_PX = 20 /** * Gutter width values based on the number of digits in line numbers. @@ -679,6 +680,8 @@ interface CodeRowProps { showGutter: boolean /** Custom styles for the gutter */ gutterStyle?: React.CSSProperties + /** Visual density for read-only code. */ + density: CodeViewerDensity /** Left offset for alignment */ leftOffset: number /** Whether to wrap long lines */ @@ -703,6 +706,7 @@ function CodeRow({ gutterWidth, showGutter, gutterStyle, + density, leftOffset, wrapText, showCollapseColumn, @@ -718,7 +722,10 @@ function CodeRow({
{showGutter && (
{line.lineNumber} @@ -740,7 +747,8 @@ function CodeRow({ )}
 scrollRef.current,
-    estimateSize: () => CODE_LINE_HEIGHT_PX,
+    estimateSize: () => (density === 'compact' ? COMPACT_CODE_LINE_HEIGHT_PX : CODE_LINE_HEIGHT_PX),
     overscan: 5,
   })
 
   /**
-   * Drop cached row measurements when leaving wrap mode: the measureElement
-   * refs detach with their wrapped heights still cached, and falling back to
-   * the fixed estimate is exactly correct for nowrap rows. Entering wrap needs
-   * no reset — refs re-attach and re-measure as rows render.
+   * Drop cached row measurements when leaving wrap mode or changing density:
+   * the measureElement refs detach with their wrapped heights still cached,
+   * and falling back to the current fixed estimate is exactly correct for
+   * nowrap rows. Entering wrap needs no reset — refs re-attach and re-measure
+   * as rows render.
    *
    * Deliberately NOT keyed on content (`visibleLines`): `measure()` wipes the
    * cache without re-measuring mounted rows (ResizeObserver only fires on size
@@ -1029,7 +1045,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
    */
   useEffect(() => {
     if (!wrapText) virtualizer.measure()
-  }, [wrapText, virtualizer])
+  }, [density, wrapText, virtualizer])
 
   useEffect(() => {
     if (!searchQuery?.trim() || matchCount === 0 || !scrollRef.current) return
@@ -1107,6 +1123,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
                 gutterWidth={gutterWidth}
                 showGutter={showGutter}
                 gutterStyle={gutterStyle}
+                density={density}
                 leftOffset={paddingLeft}
                 wrapText={wrapText}
                 showCollapseColumn={effectiveShowCollapseColumn}
@@ -1131,6 +1148,7 @@ const ViewerInner = memo(function ViewerInner({
   showGutter,
   language,
   className,
+  density,
   paddingLeft,
   gutterStyle,
   wrapText,
@@ -1236,8 +1254,8 @@ const ViewerInner = memo(function ViewerInner({
           
{lineNumber} @@ -1270,7 +1291,10 @@ const ViewerInner = memo(function ViewerInner({ )}
 0 ? paddingLeft : undefined }}
           dangerouslySetInnerHTML={{ __html: highlightedCode }}
@@ -1330,6 +1357,7 @@ function Viewer({
   showGutter = false,
   language = 'json',
   className,
+  density = 'default',
   paddingLeft = 0,
   gutterStyle,
   wrapText = false,
@@ -1345,6 +1373,7 @@ function Viewer({
     showGutter,
     language,
     className,
+    density,
     paddingLeft,
     gutterStyle,
     wrapText,
diff --git a/packages/emcn/src/components/popover/popover.tsx b/packages/emcn/src/components/popover/popover.tsx
index 6ecd0d529a0..38cb02e2de3 100644
--- a/packages/emcn/src/components/popover/popover.tsx
+++ b/packages/emcn/src/components/popover/popover.tsx
@@ -56,6 +56,7 @@ import { createPortal } from 'react-dom'
 import { Check, ChevronLeft, ChevronRight, Search } from '../../icons'
 import { cn } from '../../lib/cn'
 import { chipActiveSurfaceClass, chipHoverSurfaceClass } from '../chip/chip-chrome'
+import { TOOLTIP_MAX_WIDTH_PX, TOOLTIP_SURFACE_CLASS } from '../tooltip/tooltip-styles'
 
 type PopoverSize = 'sm' | 'md'
 type PopoverColorScheme = 'default' | 'inverted'
@@ -388,6 +389,11 @@ interface PopoverContentProps
    * @default false
    */
   border?: boolean
+  /**
+   * Applies a semantic platform surface treatment.
+   * @default 'default'
+   */
+  appearance?: 'default' | 'tooltip'
   /**
    * Flip to avoid viewport collisions
    * @default true
@@ -428,6 +434,7 @@ const PopoverContent = React.forwardRef<
       sideOffset,
       collisionPadding = 8,
       border = false,
+      appearance = 'default',
       avoidCollisions = true,
       showArrow = false,
       arrowClassName,
@@ -528,8 +535,14 @@ const PopoverContent = React.forwardRef<
     // management to avoid conflicts between the popover's internal selection index
     // and the component's custom navigation state.
 
+    const effectiveMaxWidth =
+      maxWidth !== undefined
+        ? `${maxWidth}px`
+        : appearance === 'tooltip'
+          ? `min(${TOOLTIP_MAX_WIDTH_PX}px, calc(100vw - 2rem))`
+          : undefined
     const hasUserWidthConstraint =
-      maxWidth !== undefined ||
+      effectiveMaxWidth !== undefined ||
       minWidth !== undefined ||
       style?.minWidth !== undefined ||
       style?.maxWidth !== undefined ||
@@ -590,6 +603,7 @@ const PopoverContent = React.forwardRef<
           showArrow ? 'overflow-visible' : 'overflow-auto',
           STYLES.colorScheme[colorScheme].content,
           STYLES.content,
+          appearance === 'tooltip' && TOOLTIP_SURFACE_CLASS,
           hasUserWidthConstraint &&
             '[&_.flex-1:not([data-popover-scroll])]:truncate [&_[data-popover-section]]:truncate',
           border && 'border border-[var(--border-1)]',
@@ -597,7 +611,7 @@ const PopoverContent = React.forwardRef<
         )}
         style={{
           maxHeight: `${maxHeight || 400}px`,
-          maxWidth: maxWidth !== undefined ? `${maxWidth}px` : 'calc(100vw - 16px)',
+          maxWidth: effectiveMaxWidth ?? 'calc(100vw - 16px)',
           minWidth:
             minWidth !== undefined
               ? `${minWidth}px`
diff --git a/packages/emcn/src/components/tooltip/tooltip-styles.ts b/packages/emcn/src/components/tooltip/tooltip-styles.ts
new file mode 100644
index 00000000000..66f9d4f1638
--- /dev/null
+++ b/packages/emcn/src/components/tooltip/tooltip-styles.ts
@@ -0,0 +1,6 @@
+/** Canonical maximum width shared by standard and interactive tooltip surfaces. */
+export const TOOLTIP_MAX_WIDTH_PX = 256
+
+/** Canonical platform tooltip chrome, without positioning or content padding. */
+export const TOOLTIP_SURFACE_CLASS =
+  'w-fit max-w-[min(16rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg)] text-[var(--text-body)] text-caption shadow-sm'
diff --git a/packages/emcn/src/components/tooltip/tooltip.tsx b/packages/emcn/src/components/tooltip/tooltip.tsx
index 265dca0277b..601ec570d67 100644
--- a/packages/emcn/src/components/tooltip/tooltip.tsx
+++ b/packages/emcn/src/components/tooltip/tooltip.tsx
@@ -4,6 +4,7 @@ import * as React from 'react'
 import { Slot } from '@radix-ui/react-slot'
 import { createPortal } from 'react-dom'
 import { cn } from '../../lib/cn'
+import { TOOLTIP_SURFACE_CLASS } from './tooltip-styles'
 
 const TOOLTIP_OFFSET = 16
 const EDGE_GUTTER = 16
@@ -365,7 +366,8 @@ export const FloatingTooltip = React.memo(function FloatingTooltip({
       aria-hidden={role ? undefined : 'true'}
       data-native-surface-overlay=''
       className={cn(
-        'pointer-events-none fixed top-0 left-0 z-[var(--z-tooltip)] w-fit max-w-[min(16rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg)] px-2 py-1.5 text-[var(--text-body)] text-caption opacity-100 shadow-sm transition-[opacity,translate] duration-150 ease-out',
+        TOOLTIP_SURFACE_CLASS,
+        'pointer-events-none fixed top-0 left-0 z-[var(--z-tooltip)] px-2 py-1.5 opacity-100 transition-[opacity,translate] duration-150 ease-out',
         'motion-reduce:transition-none',
         className
       )}
diff --git a/packages/workflow-renderer/src/index.ts b/packages/workflow-renderer/src/index.ts
index ad4fdbcf999..1c8476eec05 100644
--- a/packages/workflow-renderer/src/index.ts
+++ b/packages/workflow-renderer/src/index.ts
@@ -38,7 +38,14 @@ export {
   type SubflowNodeViewProps,
   SubflowStartView,
 } from './subflow/subflow-node-view'
-export type { BlockRunStatus, DiffStatus, EdgeDiffStatus, EdgeRunStatus } from './types'
+export type {
+  BlockRunStatus,
+  CodePreview,
+  CodePreviewLanguage,
+  DiffStatus,
+  EdgeDiffStatus,
+  EdgeRunStatus,
+} from './types'
 export {
   type CanvasSentenceSegment,
   CanvasSentenceView,
diff --git a/packages/workflow-renderer/src/lib/code-hover-card.tsx b/packages/workflow-renderer/src/lib/code-hover-card.tsx
new file mode 100644
index 00000000000..16a7faca328
--- /dev/null
+++ b/packages/workflow-renderer/src/lib/code-hover-card.tsx
@@ -0,0 +1,183 @@
+'use client'
+
+import {
+  type KeyboardEvent as ReactKeyboardEvent,
+  type ReactNode,
+  type PointerEvent as ReactPointerEvent,
+  useCallback,
+  useEffect,
+  useId,
+  useRef,
+  useState,
+} from 'react'
+import { Code, isTextClipped, Popover, PopoverAnchor, PopoverContent } from '@sim/emcn'
+import type { CodePreview } from '../types'
+
+const OPEN_DELAY_MS = 300
+const TRIGGER_EXIT_GRACE_MS = 600
+const CONTENT_EXIT_GRACE_MS = 120
+const CODE_TOOLTIP_MAX_HEIGHT_PX = 256
+
+interface CodeHoverCardProps {
+  preview: CodePreview
+  className: string
+  children: ReactNode
+}
+
+/** Interactive, two-axis-scrollable source preview anchored to a canvas code chip. */
+export function CodeHoverCard({ preview, className, children }: CodeHoverCardProps) {
+  const [open, setOpen] = useState(false)
+  const contentId = useId()
+  const triggerRef = useRef(null)
+  const openTimerRef = useRef(null)
+  const closeTimerRef = useRef(null)
+
+  const clearOpenTimer = useCallback(() => {
+    if (openTimerRef.current === null) return
+    window.clearTimeout(openTimerRef.current)
+    openTimerRef.current = null
+  }, [])
+
+  const clearCloseTimer = useCallback(() => {
+    if (closeTimerRef.current === null) return
+    window.clearTimeout(closeTimerRef.current)
+    closeTimerRef.current = null
+  }, [])
+
+  const handleTriggerPointerEnter = (event: ReactPointerEvent) => {
+    if (!isTextClipped(event.currentTarget)) return
+    clearCloseTimer()
+    if (open || openTimerRef.current !== null) return
+    openTimerRef.current = window.setTimeout(() => {
+      openTimerRef.current = null
+      setOpen(true)
+    }, OPEN_DELAY_MS)
+  }
+
+  const scheduleClose = (delay: number) => {
+    clearOpenTimer()
+    clearCloseTimer()
+    closeTimerRef.current = window.setTimeout(() => {
+      closeTimerRef.current = null
+      setOpen(false)
+    }, delay)
+  }
+
+  const openImmediatelyIfClipped = (trigger: HTMLSpanElement) => {
+    if (!isTextClipped(trigger)) return
+    clearOpenTimer()
+    clearCloseTimer()
+    setOpen(true)
+  }
+
+  const handleOpenChange = useCallback(
+    (nextOpen: boolean) => {
+      if (!nextOpen) {
+        clearOpenTimer()
+        clearCloseTimer()
+      }
+      setOpen(nextOpen)
+    },
+    [clearCloseTimer, clearOpenTimer]
+  )
+
+  useEffect(
+    () => () => {
+      clearOpenTimer()
+      clearCloseTimer()
+    },
+    [clearCloseTimer, clearOpenTimer]
+  )
+
+  const handleTriggerPointerDown = (event: ReactPointerEvent) => {
+    event.preventDefault()
+    if (event.pointerType === 'touch' || event.pointerType === 'pen') {
+      event.stopPropagation()
+      if (open) {
+        handleOpenChange(false)
+      } else {
+        openImmediatelyIfClipped(event.currentTarget)
+      }
+      return
+    }
+    handleOpenChange(false)
+  }
+
+  const handleTriggerKeyDown = (event: ReactKeyboardEvent) => {
+    if (event.key === 'Escape') {
+      event.stopPropagation()
+      handleOpenChange(false)
+      return
+    }
+    if (event.key !== 'Enter' && event.key !== ' ') return
+
+    event.preventDefault()
+    event.stopPropagation()
+    if (open) {
+      handleOpenChange(false)
+    } else {
+      openImmediatelyIfClipped(event.currentTarget)
+    }
+  }
+
+  const handleContentKeyDown = (event: ReactKeyboardEvent) => {
+    if (event.key !== 'Escape') return
+    event.stopPropagation()
+    handleOpenChange(false)
+    triggerRef.current?.focus()
+  }
+
+  return (
+    
+      
+         {
+            if (document.activeElement !== triggerRef.current) {
+              scheduleClose(TRIGGER_EXIT_GRACE_MS)
+            }
+          }}
+          onPointerDown={handleTriggerPointerDown}
+          onFocus={(event) => openImmediatelyIfClipped(event.currentTarget)}
+          onBlur={() => scheduleClose(TRIGGER_EXIT_GRACE_MS)}
+          onKeyDown={handleTriggerKeyDown}
+        >
+          {children}
+        
+      
+       scheduleClose(CONTENT_EXIT_GRACE_MS)}
+        onFocus={clearCloseTimer}
+        onBlur={() => scheduleClose(CONTENT_EXIT_GRACE_MS)}
+        onKeyDown={handleContentKeyDown}
+        className='nodrag nowheel overflow-hidden overscroll-contain p-0'
+      >
+        
+      
+    
+  )
+}
diff --git a/packages/workflow-renderer/src/lib/overflow-span-mount.test.tsx b/packages/workflow-renderer/src/lib/overflow-span-mount.test.tsx
new file mode 100644
index 00000000000..7f959a3f520
--- /dev/null
+++ b/packages/workflow-renderer/src/lib/overflow-span-mount.test.tsx
@@ -0,0 +1,186 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { OverflowSpan } from './overflow-span'
+
+let root: Root | null = null
+let host: HTMLDivElement | null = null
+
+beforeEach(() => {
+  vi.useFakeTimers()
+  vi.stubGlobal(
+    'ResizeObserver',
+    class ResizeObserver {
+      observe() {}
+      unobserve() {}
+      disconnect() {}
+    }
+  )
+  host = document.createElement('div')
+  document.body.appendChild(host)
+  root = createRoot(host)
+})
+
+afterEach(() => {
+  if (root) act(() => root?.unmount())
+  host?.remove()
+  root = null
+  host = null
+  vi.runOnlyPendingTimers()
+  vi.useRealTimers()
+  vi.unstubAllGlobals()
+})
+
+describe('OverflowSpan code preview', () => {
+  it('renders the complete source after the code-card dwell', () => {
+    const code = Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join('\n')
+    act(() => {
+      root?.render(
+        
+      )
+    })
+
+    const trigger = host?.querySelector('span')
+    if (!trigger) throw new Error('Overflow trigger did not render')
+    Object.defineProperties(trigger, {
+      clientWidth: { configurable: true, value: 50 },
+      scrollWidth: { configurable: true, value: 200 },
+    })
+
+    act(() => {
+      trigger.dispatchEvent(
+        new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 })
+      )
+      vi.advanceTimersByTime(299)
+    })
+    expect(document.querySelector('[data-native-surface-overlay]')).toBeNull()
+
+    act(() => {
+      vi.advanceTimersByTime(1)
+    })
+    const preview = document.querySelector('[data-code-hover-card]')
+    expect(preview).toHaveTextContent('line 20')
+    expect(preview).toHaveClass('w-fit', 'max-w-[min(16rem,calc(100vw-2rem))]', 'shadow-sm')
+    expect(preview).toHaveStyle({ maxWidth: 'min(256px, calc(100vw - 2rem))' })
+    expect(preview?.querySelector('.overflow-x-auto')).toHaveClass('overflow-y-auto')
+    expect(preview?.querySelector('.tabular-nums')).toBeNull()
+    expect(preview?.querySelector('pre')).toHaveClass('px-2', 'py-1.5', 'text-caption', 'leading-5')
+  })
+
+  it('stays open while the pointer crosses into the scrollable preview', () => {
+    act(() => {
+      root?.render(
+        
+      )
+    })
+
+    const trigger = host?.querySelector('span')
+    if (!trigger) throw new Error('Overflow trigger did not render')
+    Object.defineProperties(trigger, {
+      clientWidth: { configurable: true, value: 50 },
+      scrollWidth: { configurable: true, value: 200 },
+    })
+
+    act(() => {
+      trigger.dispatchEvent(
+        new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 })
+      )
+      vi.advanceTimersByTime(300)
+    })
+    const preview = document.querySelector('[data-code-hover-card]')
+    if (!preview) throw new Error('Code preview did not render')
+
+    act(() => {
+      trigger.dispatchEvent(new MouseEvent('pointerout', { bubbles: true }))
+      vi.advanceTimersByTime(500)
+      preview.dispatchEvent(new MouseEvent('pointerover', { bubbles: true }))
+      vi.advanceTimersByTime(600)
+    })
+    expect(document.querySelector('[data-code-hover-card]')).not.toBeNull()
+
+    act(() => {
+      preview.dispatchEvent(new MouseEvent('pointerout', { bubbles: true }))
+      vi.advanceTimersByTime(119)
+    })
+    expect(document.querySelector('[data-code-hover-card]')).not.toBeNull()
+
+    act(() => {
+      vi.advanceTimersByTime(1)
+    })
+    expect(document.querySelector('[data-code-hover-card]')).toBeNull()
+  })
+
+  it('opens from the keyboard and keeps the preview available while it is focused', () => {
+    act(() => {
+      root?.render(
+        
+      )
+    })
+
+    const trigger = host?.querySelector('span[role="button"]')
+    if (!trigger) throw new Error('Overflow trigger did not render')
+    Object.defineProperties(trigger, {
+      clientWidth: { configurable: true, value: 50 },
+      scrollWidth: { configurable: true, value: 200 },
+    })
+
+    act(() => trigger.focus())
+    const preview = document.querySelector('[data-code-hover-card]')
+    expect(preview).not.toBeNull()
+    expect(trigger).toHaveAttribute('aria-expanded', 'true')
+
+    act(() => preview?.focus())
+    act(() => vi.advanceTimersByTime(600))
+    expect(document.querySelector('[data-code-hover-card]')).not.toBeNull()
+
+    act(() =>
+      preview?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }))
+    )
+    expect(document.querySelector('[data-code-hover-card]')).toBeNull()
+    expect(trigger).toHaveFocus()
+  })
+
+  it('toggles the clipped preview on touch', () => {
+    act(() => {
+      root?.render(
+        
+      )
+    })
+
+    const trigger = host?.querySelector('span[role="button"]')
+    if (!trigger) throw new Error('Overflow trigger did not render')
+    Object.defineProperties(trigger, {
+      clientWidth: { configurable: true, value: 50 },
+      scrollWidth: { configurable: true, value: 200 },
+    })
+    const pointerDown = () => {
+      const event = new MouseEvent('pointerdown', { bubbles: true, cancelable: true })
+      Object.defineProperty(event, 'pointerType', { value: 'touch' })
+      trigger.dispatchEvent(event)
+    }
+
+    act(pointerDown)
+    expect(document.querySelector('[data-code-hover-card]')).not.toBeNull()
+    act(pointerDown)
+    expect(document.querySelector('[data-code-hover-card]')).toBeNull()
+  })
+})
diff --git a/packages/workflow-renderer/src/lib/overflow-span.tsx b/packages/workflow-renderer/src/lib/overflow-span.tsx
index b86caeff67d..9ddfdc11d0e 100644
--- a/packages/workflow-renderer/src/lib/overflow-span.tsx
+++ b/packages/workflow-renderer/src/lib/overflow-span.tsx
@@ -1,9 +1,13 @@
 import type { ReactNode } from 'react'
 import { FloatingTooltip, isTextClipped, useFloatingTooltip } from '@sim/emcn'
+import type { CodePreview } from '../types'
+import { CodeHoverCard } from './code-hover-card'
 
 interface OverflowSpanProps {
   value: string
   className: string
+  /** Rich content shown instead of the plain value when this is clipped code. */
+  codePreview?: CodePreview
   /**
    * Decorated rendering of `value` — the same characters, wrapped. Used to mark
    * a search hit inside a name without letting the decoration reach the
@@ -19,7 +23,24 @@ interface OverflowSpanProps {
  * attribute here: on the canvas it pops the browser's raw, unstyled tooltip
  * with the full untruncated value (including raw code/JSON) over the graph.
  */
-export function OverflowSpan({ value, className, children }: OverflowSpanProps) {
+export function OverflowSpan({ value, className, codePreview, children }: OverflowSpanProps) {
+  if (codePreview) {
+    return (
+      
+        {children ?? value}
+      
+    )
+  }
+
+  return (
+    
+      {children}
+    
+  )
+}
+
+/** Plain clipped text keeps the platform tooltip behavior unchanged. */
+function TextOverflowSpan({ value, className, children }: Omit) {
   const { state, handlers } = useFloatingTooltip(isTextClipped)
 
   return (
diff --git a/packages/workflow-renderer/src/types.ts b/packages/workflow-renderer/src/types.ts
index ec7615fab41..5cd52a10729 100644
--- a/packages/workflow-renderer/src/types.ts
+++ b/packages/workflow-renderer/src/types.ts
@@ -17,3 +17,12 @@ export type DiffStatus = 'new' | 'edited' | undefined
 
 /** Execution outcome of a block on its run path. */
 export type BlockRunStatus = 'success' | 'error' | undefined
+
+/** Syntax languages supported by canvas code previews. */
+export type CodePreviewLanguage = 'javascript' | 'json' | 'python' | 'bash'
+
+/** Rich preview payload for a code value in the pure workflow renderer. */
+export interface CodePreview {
+  code: string
+  language: CodePreviewLanguage
+}
diff --git a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx
index c74d21bac3c..2f7eec00e64 100644
--- a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx
+++ b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx
@@ -1,6 +1,7 @@
 import type { ComponentType } from 'react'
 import { cn } from '@sim/emcn'
 import { OverflowSpan } from '../lib/overflow-span'
+import type { CodePreview } from '../types'
 import { InlineChip } from './inline-chip'
 
 /**
@@ -16,6 +17,8 @@ export interface SubBlockRowViewProps {
   displayValue?: string
   /** Render the value in a monospace font (e.g. filter expressions). */
   isMonospace?: boolean
+  /** Rich preview for an inline code value; ordinary values keep the text tooltip. */
+  codePreview?: CodePreview
   /**
    * Leading icon for the `meta` variant; without one the variant falls back
    * to the labeled `row` presentation.
@@ -46,6 +49,7 @@ export function SubBlockRowView({
   title,
   displayValue,
   isMonospace,
+  codePreview,
   icon: Icon,
   variant = 'row',
 }: SubBlockRowViewProps) {
@@ -55,6 +59,7 @@ export function SubBlockRowView({
         
       
     )