Skip to content

Commit 463603c

Browse files
committed
feat(preview): paint comparison status on the read-only canvas
The preview canvas accepts per-block and per-edge diff status: added and modified cards get a ring and a shared status label, removed cards and containers fade to a ghost, changed sub-block rows and sentence chips tint, and ghost edges sit under live ones so a rewired port shows the new line on top.
1 parent 0a1717d commit 463603c

5 files changed

Lines changed: 157 additions & 10 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '@sim/workflow-renderer'
77
import { type EdgeProps, useStore } from '@xyflow/react'
88
import { useShallow } from 'zustand/react/shallow'
9+
import type { EdgeDiffStatus as PreviewEdgeDiffStatus } from '@/lib/workflows/comparison'
910
import {
1011
isEdgeConnectedToEditor,
1112
isEdgeHighlighted,
@@ -72,7 +73,12 @@ const WorkflowEdgeComponent = (props: WorkflowEdgeProps) => {
7273
)?.executionStatus
7374
const runStatus = previewExecutionStatus || lastRunEdges.get(id)
7475

76+
/** A preview comparing two versions hands the edge its status directly. */
77+
const previewDiffStatus = (data as { diffStatus?: PreviewEdgeDiffStatus } | undefined)?.diffStatus
78+
7579
const diffStatus = useMemo((): EdgeDiffStatus => {
80+
if (previewDiffStatus === 'added') return 'new'
81+
if (previewDiffStatus === 'removed') return 'ghost'
7682
if (data?.isDeleted) return 'deleted'
7783
if (!diffAnalysis?.edge_diff || !isDiffReady) return null
7884

@@ -88,6 +94,7 @@ const WorkflowEdgeComponent = (props: WorkflowEdgeProps) => {
8894
}
8995
return null
9096
}, [
97+
previewDiffStatus,
9198
data?.isDeleted,
9299
diffAnalysis,
93100
isDiffReady,

‎apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx‎

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { type CSSProperties, memo, useMemo } from 'react'
4-
import { OverflowText } from '@sim/emcn'
4+
import { cn, OverflowText } from '@sim/emcn'
55
import {
66
CanvasSentenceView,
77
HANDLE_POSITIONS,
@@ -19,6 +19,7 @@ import {
1919
} from '@/lib/workflows/blocks/canvas-sentence'
2020
import { resolveSelectedTriggerId } from '@/lib/workflows/blocks/canvas-trigger-sentence'
2121
import { resolveCanvasCodePreview } from '@/lib/workflows/blocks/code-preview'
22+
import type { BlockDiffStatus } from '@/lib/workflows/comparison'
2223
import {
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'
4143
import { getBlock } from '@/blocks'
4244
import { hasBlockAccent } from '@/blocks/accent'
4345
import { 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+
696755
function 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
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
'use client'
2+
3+
import { cn } from '@sim/emcn'
4+
import type { BlockDiffStatus } from '@/lib/workflows/comparison'
5+
6+
const DIFF_LABEL: Record<BlockDiffStatus, string> = {
7+
added: 'Added',
8+
modified: 'Modified',
9+
removed: 'Removed',
10+
}
11+
12+
const DIFF_LABEL_CLASS: Record<BlockDiffStatus, string> = {
13+
added: 'bg-[var(--brand-accent)] text-white',
14+
modified: 'bg-[var(--warning)] text-white',
15+
removed: 'bg-[var(--surface-7)] text-[var(--text-secondary)]',
16+
}
17+
18+
interface DiffStatusLabelProps {
19+
status: BlockDiffStatus
20+
/** How many fields changed on a modified block; shown after the label when known */
21+
count?: number
22+
}
23+
24+
/**
25+
* The comparison label floating above a canvas card or container: the same
26+
* chrome wherever it appears so a block and a loop read as one system.
27+
*/
28+
export function DiffStatusLabel({ status, count }: DiffStatusLabelProps) {
29+
return (
30+
<div
31+
className={cn(
32+
'-top-[22px] pointer-events-none absolute left-2 z-40 rounded-sm px-1.5 py-0.5 font-medium text-xs leading-[14px]',
33+
DIFF_LABEL_CLASS[status]
34+
)}
35+
>
36+
{DIFF_LABEL[status]}
37+
{status === 'modified' && count ? ` · ${count}` : null}
38+
</div>
39+
)
40+
}

‎apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import { memo } from 'react'
44
import { SubflowNodeView } from '@sim/workflow-renderer'
55
import type { Node, NodeProps } from '@xyflow/react'
6+
import type { BlockDiffStatus } from '@/lib/workflows/comparison'
7+
import { DiffStatusLabel } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/diff-label/diff-label'
68

79
/** Execution status for subflows in preview mode */
810
type ExecutionStatus = 'success' | 'error' | 'not-executed'
@@ -19,6 +21,8 @@ interface WorkflowPreviewSubflowData extends Record<string, unknown> {
1921
isPreviewSelected?: boolean
2022
/** Execution status for highlighting the subflow container */
2123
executionStatus?: ExecutionStatus
24+
/** Comparison status when previewing a version diff */
25+
diffStatus?: BlockDiffStatus
2226
/** Skips expensive computations for thumbnails/template previews (unused in subflow, for consistency) */
2327
lightweight?: boolean
2428
}
@@ -31,19 +35,30 @@ interface WorkflowPreviewSubflowData extends Record<string, unknown> {
3135
type WorkflowPreviewSubflowNode = Node<WorkflowPreviewSubflowData, 'subflowNode'>
3236

3337
function WorkflowPreviewSubflowInner({ data, id }: NodeProps<WorkflowPreviewSubflowNode>) {
34-
return (
38+
const view = (
3539
<SubflowNodeView
3640
id={id}
3741
data={{ ...data, isPreview: true }}
3842
selected={false}
3943
isEnabled={data.enabled ?? true}
4044
isLocked={false}
4145
isFocused={false}
46+
diffStatus={
47+
data.diffStatus === 'added' ? 'new' : data.diffStatus === 'modified' ? 'edited' : undefined
48+
}
4249
nestingLevel={0}
4350
canEditWorkflow={false}
4451
onSelect={() => undefined}
4552
/>
4653
)
54+
if (data.diffStatus !== 'removed') return view
55+
/* A removed container fades like a removed card; its children ghost themselves. */
56+
return (
57+
<div className='relative opacity-45'>
58+
<DiffStatusLabel status='removed' />
59+
{view}
60+
</div>
61+
)
4762
}
4863

4964
export const PreviewSubflow = memo(WorkflowPreviewSubflowInner)

0 commit comments

Comments
 (0)