11'use client'
22
33import { type CSSProperties , memo , useMemo } from 'react'
4- import { OverflowText } from '@sim/emcn'
4+ import { cn , OverflowText } from '@sim/emcn'
55import {
66 CanvasSentenceView ,
77 HANDLE_POSITIONS ,
@@ -19,6 +19,7 @@ import {
1919} from '@/lib/workflows/blocks/canvas-sentence'
2020import { resolveSelectedTriggerId } from '@/lib/workflows/blocks/canvas-trigger-sentence'
2121import { resolveCanvasCodePreview } from '@/lib/workflows/blocks/code-preview'
22+ import type { BlockDiffStatus } from '@/lib/workflows/comparison'
2223import {
2324 getDisplayValue ,
2425 hasDisplayableRowValue ,
@@ -38,6 +39,7 @@ import {
3839 isSubBlockVisibleForMode ,
3940 isToolInputOnlySubBlock ,
4041} from '@/lib/workflows/subblocks/visibility'
42+ import { DiffStatusLabel } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/diff-label/diff-label'
4143import { getBlock } from '@/blocks'
4244import { hasBlockAccent } from '@/blocks/accent'
4345import { SELECTOR_TYPES_HYDRATION_REQUIRED , type SubBlockConfig } from '@/blocks/types'
@@ -98,6 +100,16 @@ interface WorkflowPreviewBlockData extends Record<string, unknown> {
98100 hasErrorConnection ?: boolean
99101 /** Skips expensive subblock computations for thumbnails/template previews */
100102 lightweight ?: boolean
103+ /** Comparison status when previewing a version diff */
104+ diffStatus ?: BlockDiffStatus
105+ /** Sub-block ids the comparison reported as changed on this block */
106+ changedFields ?: string [ ]
107+ }
108+
109+ /** A removed card fades instead of ringing, so only the live statuses have a ring. */
110+ const DIFF_RING_CLASS : Record < Exclude < BlockDiffStatus , 'removed' > , string > = {
111+ added : 'ring-[var(--brand-accent)]' ,
112+ modified : 'ring-[var(--warning)]' ,
101113}
102114
103115/**
@@ -118,6 +130,8 @@ interface SubBlockRowProps {
118130 rawValue ?: unknown
119131 workflowMap : Record < string , WorkflowMetadata >
120132 workflowLabelsReady : boolean
133+ /** The comparison reported this field as changed; tint the row */
134+ changed ?: boolean
121135}
122136
123137/**
@@ -198,6 +212,7 @@ const SubBlockRow = memo(function SubBlockRow({
198212 rawValue,
199213 workflowMap,
200214 workflowLabelsReady,
215+ changed = false ,
201216} : SubBlockRowProps ) {
202217 const displayValue = resolvePreviewDisplayValue (
203218 value ,
@@ -208,7 +223,12 @@ const SubBlockRow = memo(function SubBlockRow({
208223 )
209224
210225 return (
211- < div className = 'flex h-5 items-center gap-2' >
226+ < div
227+ className = { cn (
228+ 'flex h-5 items-center gap-2' ,
229+ changed && '-mx-1 rounded-sm bg-[color-mix(in_srgb,var(--warning)_14%,transparent)] px-1'
230+ ) }
231+ >
212232 < OverflowText label = { title } className = 'text-[var(--text-tertiary)] text-sm capitalize' />
213233 { displayValue !== undefined && (
214234 < OverflowText
@@ -242,7 +262,10 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockNode>
242262 errorEnabled = false ,
243263 hasErrorConnection = false ,
244264 lightweight = false ,
265+ diffStatus,
266+ changedFields,
245267 } = data
268+ const changedFieldSet = useMemo ( ( ) => new Set ( changedFields ?? [ ] ) , [ changedFields ] )
246269
247270 const blockConfig = getBlock ( type )
248271 const effectiveTrigger = isTrigger || type === 'starter'
@@ -486,13 +509,32 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockNode>
486509
487510 const hasError = executionStatus === 'error'
488511 const hasSuccess = executionStatus === 'success'
512+ const isRemoved = diffStatus === 'removed'
489513
490514 return (
491- < div className = 'relative w-[250px] select-none rounded-2xl border-[1.5px] border-[var(--border-1)] bg-[var(--surface-2)]' >
515+ < div
516+ className = { cn (
517+ 'relative w-[250px] select-none rounded-2xl border-[1.5px] border-[var(--border-1)] bg-[var(--surface-2)]' ,
518+ /* Ghost: the same card, just faded, so the eye reads "used to be here" not "broken". */
519+ isRemoved &&
520+ 'border-[var(--border)] bg-[var(--surface-1)] [&>[data-ghost-content]]:opacity-45'
521+ ) }
522+ >
523+ { /* Comparison label above the card */ }
524+ { diffStatus && < DiffStatusLabel status = { diffStatus } count = { changedFieldSet . size } /> }
492525 { /* Selection ring overlay (takes priority over execution rings) */ }
493526 { isPreviewSelected && (
494527 < div className = 'pointer-events-none absolute inset-0 z-40 rounded-2xl ring-[1.5px] ring-[var(--text-secondary)]' />
495528 ) }
529+ { /* Comparison ring overlay */ }
530+ { ! isPreviewSelected && diffStatus && diffStatus !== 'removed' && (
531+ < div
532+ className = { cn (
533+ 'pointer-events-none absolute inset-0 z-40 rounded-2xl ring-[1.5px]' ,
534+ DIFF_RING_CLASS [ diffStatus ]
535+ ) }
536+ />
537+ ) }
496538 { /* Success ring overlay (only shown if not selected) */ }
497539 { ! isPreviewSelected && hasSuccess && (
498540 < div className = 'pointer-events-none absolute inset-0 z-40 rounded-2xl ring-[1.5px] ring-[var(--brand-accent)]' />
@@ -514,7 +556,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockNode>
514556 ) }
515557
516558 { /* Header - matches WorkflowBlock structure */ }
517- < div className = 'flex h-[40px] items-center justify-between px-2' >
559+ < div data-ghost-content = '' className = 'flex h-[40px] items-center justify-between px-2' >
518560 < div className = 'relative z-10 flex min-w-0 flex-1 items-center' >
519561 < OverflowText
520562 label = { humanizeBlockName ( canvasPresentation . title ) }
@@ -535,7 +577,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockNode>
535577
536578 { /* Content area with subblocks */ }
537579 { hasContentBelowHeader && (
538- < div className = 'flex flex-col gap-2 p-2' >
580+ < div data-ghost-content = '' className = 'flex flex-col gap-2 p-2' >
539581 { type === 'condition' ? (
540582 conditionRows . map ( ( cond ) => (
541583 < SubBlockRow
@@ -544,6 +586,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockNode>
544586 value = { lightweight ? undefined : getDisplayValue ( cond . value ) }
545587 workflowMap = { workflowMap }
546588 workflowLabelsReady = { workflowLabelsReady }
589+ changed = { changedFieldSet . has ( 'conditions' ) }
547590 />
548591 ) )
549592 ) : sentenceSegments ? (
@@ -564,14 +607,21 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockNode>
564607 back as the `-` sentinel. That reads as noise mid-sentence, so
565608 hand the slot back and let its noun stand in instead. */
566609 if ( ! displayValue || displayValue === '-' ) return null
567- return (
610+ const chip = (
568611 < SubBlockRowView
569612 title = { subBlock . title ?? subBlock . id }
570613 displayValue = { displayValue }
571614 codePreview = { resolveCanvasCodePreview ( subBlock , rawValue , rawValues ) }
572615 variant = 'inline-value'
573616 />
574617 )
618+ if ( ! changedFieldSet . has ( subBlockId ) ) return chip
619+ /* The chip, not the sentence, is what changed. */
620+ return (
621+ < span className = 'rounded-sm bg-[color-mix(in_srgb,var(--warning)_18%,transparent)] ring-1 ring-[color-mix(in_srgb,var(--warning)_45%,transparent)]' >
622+ { chip }
623+ </ span >
624+ )
575625 } }
576626 />
577627 ) : type === 'router_v2' ? (
@@ -590,6 +640,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockNode>
590640 value = { lightweight ? undefined : getDisplayValue ( route . value ) }
591641 workflowMap = { workflowMap }
592642 workflowLabelsReady = { workflowLabelsReady }
643+ changed = { changedFieldSet . has ( 'routes' ) }
593644 />
594645 ) ) }
595646 </ >
@@ -610,6 +661,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockNode>
610661 rawValue = { rawValue }
611662 workflowMap = { workflowMap }
612663 workflowLabelsReady = { workflowLabelsReady }
664+ changed = { changedFieldSet . has ( subBlock . id ) }
613665 />
614666 )
615667 } )
@@ -693,6 +745,13 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockNode>
693745 * @param nextProps - Next render props
694746 * @returns True if render should be skipped (props are equal)
695747 */
748+ /** Same changed-field list, by identity first so the common unchanged case costs nothing. */
749+ function sameFields ( prev : string [ ] | undefined , next : string [ ] | undefined ) : boolean {
750+ if ( prev === next ) return true
751+ if ( ! prev || ! next || prev . length !== next . length ) return false
752+ return prev . every ( ( field , index ) => field === next [ index ] )
753+ }
754+
696755function shouldSkipPreviewBlockRender (
697756 prevProps : NodeProps < WorkflowPreviewBlockNode > ,
698757 nextProps : NodeProps < WorkflowPreviewBlockNode >
@@ -707,7 +766,9 @@ function shouldSkipPreviewBlockRender(
707766 prevProps . data . executionStatus !== nextProps . data . executionStatus ||
708767 prevProps . data . errorEnabled !== nextProps . data . errorEnabled ||
709768 prevProps . data . hasErrorConnection !== nextProps . data . hasErrorConnection ||
710- prevProps . data . lightweight !== nextProps . data . lightweight
769+ prevProps . data . lightweight !== nextProps . data . lightweight ||
770+ prevProps . data . diffStatus !== nextProps . data . diffStatus ||
771+ ! sameFields ( prevProps . data . changedFields , nextProps . data . changedFields )
711772 ) {
712773 return false
713774 }
0 commit comments