improvement(workflow): add compact code hover previews (#7074)

* improvement(workflow): add compact code hover previews

* fix(workflow): address code preview review feedback
This commit is contained in:
Vikhyath Mondreti
2026-08-25 13:18:53 -07:00
committed by GitHub
parent 98a453d7e6
commit b515fe0681
16 changed files with 584 additions and 19 deletions
@@ -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 (
<SubBlockRowView
title={title}
displayValue={displayValue}
isMonospace={isMonospaceField}
codePreview={codePreview}
variant={variant}
icon={icon}
/>
@@ -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<WorkflowPreviewBlockData>
<SubBlockRowView
title={subBlock.title ?? subBlock.id}
displayValue={displayValue}
codePreview={resolveCanvasCodePreview(subBlock, rawValue, rawValues)}
variant='inline-value'
/>
)
+8 -1
View File
@@ -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')
})
})
+1
View File
@@ -66,6 +66,7 @@ export const ApiBlock: BlockConfig<RequestResponse> = {
id: 'body',
title: 'Body',
type: 'code',
language: 'json',
placeholder: 'Enter JSON...',
wandConfig: {
enabled: true,
@@ -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()
})
})
@@ -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<Record<string, unknown>>
): 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) }
}
+42 -13
View File
@@ -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({
<div className={cn('flex', wrapText && 'overflow-hidden')} data-row-index={index}>
{showGutter && (
<div
className='flex-shrink-0 select-none pr-0.5 text-right text-[var(--text-muted)] text-xs tabular-nums leading-[21px] dark:text-[var(--code-line-number)]'
className={cn(
'flex-shrink-0 select-none pr-0.5 text-right text-[var(--text-muted)] tabular-nums dark:text-[var(--code-line-number)]',
density === 'compact' ? 'text-caption leading-5' : 'text-xs leading-[21px]'
)}
style={{ width: gutterWidth, marginLeft: leftOffset, ...gutterStyle }}
>
{line.lineNumber}
@@ -740,7 +747,8 @@ function CodeRow({
)}
<pre
className={cn(
'm-0 flex-1 pr-2 pl-2 font-mono text-[var(--text-primary)] text-small leading-[21px] dark:text-[var(--code-foreground)]',
'm-0 flex-1 pr-2 pl-2 font-mono text-[var(--text-primary)] dark:text-[var(--code-foreground)]',
density === 'compact' ? 'text-caption leading-5' : 'text-small leading-[21px]',
wrapText ? 'min-w-0 whitespace-pre-wrap break-words' : 'whitespace-pre'
)}
dangerouslySetInnerHTML={{ __html: line.html || '&nbsp;' }}
@@ -796,6 +804,8 @@ function applySearchHighlightingToLine(
/**
* Props for the Code.Viewer component (readonly code display).
*/
type CodeViewerDensity = 'default' | 'compact'
interface CodeViewerProps {
/** Code content to display */
code: string
@@ -805,6 +815,8 @@ interface CodeViewerProps {
language?: 'javascript' | 'json' | 'python' | 'bash'
/** Additional CSS classes for the container */
className?: string
/** Visual density for read-only code. */
density?: CodeViewerDensity
/** Left padding offset (useful for terminal alignment) */
paddingLeft?: number
/** Inline styles for the gutter (e.g., to override background) */
@@ -891,6 +903,8 @@ type ViewerInnerProps = {
language: 'javascript' | 'json' | 'python' | 'bash'
/** Additional CSS classes for the container */
className?: string
/** Visual density for read-only code. */
density: CodeViewerDensity
/** Left padding offset in pixels */
paddingLeft: number
/** Custom styles for the gutter */
@@ -918,6 +932,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
showGutter,
language,
className,
density,
paddingLeft,
gutterStyle,
wrapText,
@@ -1010,15 +1025,16 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
const virtualizer = useVirtualizer({
count: visibleLines.length,
getScrollElement: () => 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({
<div
style={{
paddingLeft,
paddingTop: '8px',
paddingBottom: '8px',
paddingTop: density === 'compact' ? '6px' : '8px',
paddingBottom: density === 'compact' ? '6px' : '8px',
display: 'grid',
gridTemplateColumns: effectiveShowCollapseColumn
? `${gutterWidth}px ${collapseColumnWidth}px 1fr`
@@ -1252,7 +1270,10 @@ const ViewerInner = memo(function ViewerInner({
return (
<Fragment key={idx}>
<div
className='select-none pr-0.5 text-right text-[var(--text-muted)] text-xs tabular-nums leading-[21px] dark:text-[var(--code-line-number)]'
className={cn(
'select-none pr-0.5 text-right text-[var(--text-muted)] tabular-nums dark:text-[var(--code-line-number)]',
density === 'compact' ? 'text-caption leading-5' : 'text-xs leading-[21px]'
)}
style={gutterStyle}
>
{lineNumber}
@@ -1270,7 +1291,10 @@ const ViewerInner = memo(function ViewerInner({
)}
<pre
className={cn(
'm-0 min-w-0 pr-2 pl-2 font-mono text-[var(--text-primary)] text-small leading-[21px] dark:text-[var(--code-foreground)]',
'm-0 min-w-0 pr-2 pl-2 font-mono text-[var(--text-primary)] dark:text-[var(--code-foreground)]',
density === 'compact'
? 'text-caption leading-5'
: 'text-small leading-[21px]',
whitespaceClass
)}
dangerouslySetInnerHTML={{ __html: html }}
@@ -1291,7 +1315,10 @@ const ViewerInner = memo(function ViewerInner({
<pre
className={cn(
whitespaceClass,
'p-2 font-mono text-[var(--text-primary)] text-small leading-[21px] dark:text-[var(--code-foreground)]'
'font-mono text-[var(--text-primary)] dark:text-[var(--code-foreground)]',
density === 'compact'
? 'px-2 py-1.5 text-caption leading-5'
: 'p-2 text-small leading-[21px]'
)}
style={{ paddingLeft: paddingLeft > 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,
@@ -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`
@@ -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'
@@ -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
)}
+8 -1
View File
@@ -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,
@@ -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<HTMLSpanElement>(null)
const openTimerRef = useRef<number | null>(null)
const closeTimerRef = useRef<number | null>(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<HTMLSpanElement>) => {
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<HTMLSpanElement>) => {
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<HTMLSpanElement>) => {
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<HTMLDivElement>) => {
if (event.key !== 'Escape') return
event.stopPropagation()
handleOpenChange(false)
triggerRef.current?.focus()
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverAnchor asChild>
<span
ref={triggerRef}
role='button'
tabIndex={0}
aria-haspopup='dialog'
aria-expanded={open}
aria-controls={open ? contentId : undefined}
className={className}
onPointerEnter={handleTriggerPointerEnter}
onPointerLeave={() => {
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}
</span>
</PopoverAnchor>
<PopoverContent
id={contentId}
role='dialog'
tabIndex={0}
aria-label='Code preview'
data-code-hover-card=''
align='start'
side='bottom'
sideOffset={-4}
collisionPadding={16}
appearance='tooltip'
maxHeight={CODE_TOOLTIP_MAX_HEIGHT_PX}
onPointerEnter={clearCloseTimer}
onPointerLeave={() => scheduleClose(CONTENT_EXIT_GRACE_MS)}
onFocus={clearCloseTimer}
onBlur={() => scheduleClose(CONTENT_EXIT_GRACE_MS)}
onKeyDown={handleContentKeyDown}
className='nodrag nowheel overflow-hidden overscroll-contain p-0'
>
<Code.Viewer
code={preview.code}
language={preview.language}
density='compact'
className='max-h-[min(16rem,calc(100vh-2rem))] min-h-0 rounded-none border-0 bg-[var(--bg)] shadow-none dark:bg-[var(--bg)]'
/>
</PopoverContent>
</Popover>
)
}
@@ -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(
<OverflowSpan
value='line 1'
className='truncate'
codePreview={{ code, language: 'javascript' }}
/>
)
})
const trigger = host?.querySelector<HTMLElement>('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(
<OverflowSpan
value='const value = 1'
className='truncate'
codePreview={{ code: 'const value = 1', language: 'javascript' }}
/>
)
})
const trigger = host?.querySelector<HTMLElement>('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<HTMLElement>('[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(
<OverflowSpan
value='const value = 1'
className='truncate'
codePreview={{ code: 'const value = 1', language: 'javascript' }}
/>
)
})
const trigger = host?.querySelector<HTMLElement>('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<HTMLElement>('[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(
<OverflowSpan
value='const value = 1'
className='truncate'
codePreview={{ code: 'const value = 1', language: 'javascript' }}
/>
)
})
const trigger = host?.querySelector<HTMLElement>('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()
})
})
@@ -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 (
<CodeHoverCard preview={codePreview} className={className}>
{children ?? value}
</CodeHoverCard>
)
}
return (
<TextOverflowSpan value={value} className={className}>
{children}
</TextOverflowSpan>
)
}
/** Plain clipped text keeps the platform tooltip behavior unchanged. */
function TextOverflowSpan({ value, className, children }: Omit<OverflowSpanProps, 'codePreview'>) {
const { state, handlers } = useFloatingTooltip(isTextClipped)
return (
+9
View File
@@ -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
}
@@ -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({
<OverflowSpan
value={displayValue ?? title}
className={cn('min-w-0 truncate', isMonospace && 'font-mono')}
codePreview={codePreview}
/>
</InlineChip>
)