Skip to content

Conversation V2 Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Pragmatic rewrite of the conversation rendering layer -- cleaner DOM, configurable defaults, flow-group support, choice feedback/stagger, scroll/entrance behavior, responsive prep, DCO-ready architecture.

Architecture: V2 replaces only the rendering layer (MessageHolder, Choice, CreativeConversationBlock template). The flow processor (conversationFlow.ts), data model (FlowComponentInterface), and flow modifier are reused unchanged. A conversationVersion: 1 | 2 flag on ConversationProperties gates the new rendering path, so existing creatives keep working. All hardcoded values become configurable properties with backward-compatible defaults matching current behavior.

Tech Stack: Vue 3 (Creative-Engine), TypeScript, JSS (computed styles()), CSS custom properties, existing AnimationMixin


Prerequisite: Understanding

Before starting any task, read the feasibility study and visual companion:

  • Cavai-Documentation/src/DocumentationTexts/todos/ConversationRedesign/feasibility.md
  • Cavai-Documentation/src/DocumentationTexts/todos/ConversationRedesign/flow-group-visual-companion.html

Key Engine conventions:

  • No <style> blocks in Engine components. All CSS goes through the styles() computed (JSS).
  • DataStore is the global reactive state container.
  • AnimationMixin handles all block-level animations (fade, slide, scale, rotate, blur, skew).
  • Components emit events via DataStore.emitter.

File Structure

Creative-Engine (new files)

FileResponsibility
src/components/conversationflow/MessageHolderV2.vueV2 message wrapper -- clean DOM, data attributes, configurable entrance
src/components/conversationflow/FlowGroup.vuedisplay: contents wrapper for flow-group visual grouping
src/components/blocks/basic/ChoiceV2.vueV2 choice rendering -- flex/grid layout, variant/outcome, feedback delay, configurable stagger
src/utils/autoScrollV2.tsConfigurable scroll -- duration, easing, direction, abort-on-user-scroll

Creative-Engine (modified files)

FileWhat changes
src/components/creative/CreativeConversationBlock/CreativeConversationBlock.vueTemplate branches on conversationVersion, groupedFlow computed for flow-group wrapping
src/interfaces/jsonTypes/payload-v2/index.tsNew fields on ConversationProperties, new ChoiceV2Properties type
src/interfaces/components/FlowComponent/FlowComponentInterface.tsAdd variant?: string, outcome?: string, flowGroup?: string
src/utils/constants.tsExport conversation default constants (currently only speed/render maps)

Application-Frontend (modified files)

FileWhat changes
src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.tsV2 conversation properties types
src/pages/Chatbots/components/BuilderVisuals/Blocks/data/defaults.tsV2 defaults
src/pages/Chatbots/components/BuilderVisuals/Configuration/configs/ConversationConfiguration.vueV2 config sections (conditional on version)
New section components as needed for V2-only settingsEntrance, scroll, choice layout, feedback

Chunk 1: Foundation -- Clean DOM + Configurable Defaults

This chunk creates the V2 rendering path with a clean MessageHolderV2 that replaces all hardcoded values with configurable properties.

Task 1: Add conversationVersion to type definitions

Files:

  • Modify: Creative-Engine/src/interfaces/jsonTypes/payload-v2/index.ts:166-185

  • Modify: Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.ts:261-336

  • Modify: Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Blocks/data/defaults.ts:469-574

  • [ ] Step 1: Add conversationVersion to Engine ConversationProperties

In Creative-Engine/src/interfaces/jsonTypes/payload-v2/index.ts, add to the ConversationProperties type:

ts
export type ConversationProperties = BasicConfig
  & MandatorySizeConfig
  & BorderConfig
  & BoxShadowConfig
  & BackgroundConfig
  & FlexAlignConfig
  & BackgroundBlurConfig
  & CustomStylesConfig & {
    conversationVersion?: 1 | 2  // NEW -- undefined or 1 = legacy, 2 = V2
    speed: 'conversational' | 'swift' | 'instant' | number
    typingAnimation: 'none' | 'typeIn' | 'fadeIn' | boolean
    scrollOut: 'cutOff' | 'fadeOut'
    senderIcon?: {
      url: string
      imageName: string
    }
    messageProperties: MessageProperties
    choiceProperties: ChoiceProperties
    responseProperties: ResponseProperties
    rotate: number

    // -- V2 entrance --
    entranceEffect?: 'slide' | 'fade' | 'none'
    entranceDistance?: number   // px, default 10
    entranceDuration?: number  // ms, default 200

    // -- V2 scroll --
    scrollDuration?: number    // ms, default 400
    scrollEasing?: string      // CSS easing, default 'cubic-bezier(0.22, 1, 0.36, 1)'

    // -- V2 choice feedback --
    choiceFeedbackDelay?: number  // ms, 0 = instant (today's behavior)
  }
  • [ ] Step 2: Add matching types in Frontend

In Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.ts, add the same new fields to the frontend ConversationProperties type. Follow the existing pattern in that file.

  • [ ] Step 3: Add defaults

In Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Blocks/data/defaults.ts, add to conversationDefaults:

ts
// V2 defaults -- only used when conversationVersion === 2
// Not included in default object (undefined = V1 behavior)

No default value for conversationVersion -- undefined means V1 (backward-compatible). The V2 entrance/scroll/feedback defaults are only meaningful when V2 is active, so they don't need to exist on V1 creatives.

  • [ ] Step 4: Commit
bash
git add -p
git commit -m "feat(types): add conversationVersion and V2 properties to ConversationProperties"

Task 2: Add variant and outcome to FlowComponentInterface

Files:

  • Modify: Creative-Engine/src/interfaces/components/FlowComponent/FlowComponentInterface.ts:9-102

  • [ ] Step 1: Add fields to the interface

ts
export interface FlowComponentInterface {
  name: string
  type: string
  // ... existing fields ...

  variant?: string   // Visual classification: 'success', 'error', 'primary', or any custom string
  outcome?: string   // Logical classification: 'correct', 'category-a', or any custom string
  flowGroup?: string // Group ID for flow-group wrapping (e.g., 'group-1')

  // ... rest of existing fields ...
}

These are optional strings. variant renders as CSS class .choice-variant-{value} for visual styling. outcome renders as .choice-outcome-{value} for system behavior (e.g., quiz feedback). Both are set per flow component in the flow editor (future frontend work).

  • [ ] Step 2: Commit
bash
git add -p
git commit -m "feat(types): add variant, outcome, flowGroup to FlowComponentInterface"

Task 3: Extract hardcoded constants

Files:

  • Modify: Creative-Engine/src/utils/constants.ts

  • [ ] Step 1: Add conversation rendering constants

Add to the bottom of constants.ts:

ts
// -- Conversation V2 defaults --
// These match current hardcoded behavior so V2 renders identically by default.

export const CONVERSATION_DEFAULTS = {
  // MessageHolder entrance
  entranceEffect: 'slide' as const,
  entranceDistance: 10,       // px -- currently hardcoded translate3d(0, 10px, 0)
  entranceDuration: 200,     // ms -- currently hardcoded 200ms ease-out

  // Message spacing
  messageMarginX: 15,        // px -- currently hardcoded marginLeft/marginRight: 15px
  messagePaddingTop: 15,     // px -- currently hardcoded &:first-child paddingTop: 15px
  messageGapY: 3,            // px -- currently hardcoded 3px for not-first-in-block
  inputNeededGapY: 6,        // px -- currently hardcoded 6px for input-needed state

  // Bubble defaults
  bubblePaddingX: 12,        // px -- currently hardcoded padding: 8px 12px
  bubblePaddingY: 8,         // px
  bubbleBorderRadius: 15,    // px -- currently hardcoded in 5 places

  // Avatar
  avatarSize: 30,            // px -- width/height of sender icon
  avatarMargin: 6,           // px -- margin-right of sender icon
  avatarOffset: 36,          // px -- paddingLeft when icon present (30 + 6)

  // Choice stagger
  choiceStaggerInterval: 120, // ms -- currently hardcoded index * 120
  choiceStaggerDuration: 300, // ms -- currently hardcoded 300ms
  choiceStaggerOffset: 20,    // px -- currently hardcoded top: 20px in keyframe

  // Scroll
  scrollDuration: 400,        // ms -- currently SCROLL_DURATION in autoScroll.ts
  scrollEasing: 'cubic-bezier(0.22, 1, 0.36, 1)',

  // Scroll timing offset
  scrollTimingOffset: 75,     // ms -- currently hardcoded typingDelay - 75
} as const
  • [ ] Step 2: Commit
bash
git add src/utils/constants.ts
git commit -m "feat(constants): extract hardcoded conversation values to named constants"

Task 4: Create MessageHolderV2.vue

Files:

  • Create: Creative-Engine/src/components/conversationflow/MessageHolderV2.vue
  • Reference: Creative-Engine/src/components/conversationflow/MessageHolder.vue (691 lines)

This is the core of V2. MessageHolderV2 does the same job as MessageHolder but with:

  • Clean semantic DOM with data-* attributes instead of class soup

  • All spacing/sizing from conversation properties (no hardcoded px values)

  • CSS custom properties for easy override

  • Entrance animation using AnimationMixin patterns instead of hardcoded translate3d

  • [ ] Step 1: Create the component

Create MessageHolderV2.vue. The component should:

Template:

html
<template>
  <div
    v-show="!isRuntimeHidden"
    :class="[blockClassNames.messageWrap]"
    :data-step="stepIndex"
    :data-type="componentType"
    :data-variant="variant"
    :data-outcome="outcome"
    :data-input-needed="inputNeeded"
    :data-first-in-block="firstInBlock"
    :data-last-in-block="lastInBlock"
    :data-input-entered="inputEntered"
  >
    <!-- Avatar (sender icon) -->
    <div
      v-if="showAvatar"
      :class="[blockClassNames.avatar]"
    >
      <img :src="senderIconUrl" alt="" />
    </div>

    <!-- Message bubble -->
    <div
      v-if="showBubble"
      v-show="!inputFromInputComponent || inputEntered"
      :class="[blockClassNames.bubble]"
    >
      <component
        :is="activeComponent"
        v-bind="componentProps"
        @input-entered="onInputEntered"
        @user-action="onUserAction"
        @scroll-to-bottom="$emit('scroll-to-bottom')"
      />
    </div>

    <!-- Teleport for fill-container mode -->
    <Teleport
      v-if="shouldTeleport"
      :to="teleportTarget"
    >
      <component
        :is="activeComponent"
        v-bind="componentProps"
      />
    </Teleport>
  </div>
</template>

Key differences from V1 MessageHolder:

  1. data-* attributes for state instead of complex class combinations
  2. No hardcoded px values in styles() -- all from conversationProps or CONVERSATION_DEFAULTS
  3. Entrance animation as a CSS custom property --entrance-transform / --entrance-duration
  4. Variant/outcome classes from flow component data

styles() computed pattern:

ts
computed: {
  conversationProps() {
    return DataStore.creativeSettings.creativeBlocks?.conversationProperties
  },

  defaults() {
    return CONVERSATION_DEFAULTS
  },

  styles() {
    const { defaults: d, conversationProps: cp } = this
    const entranceDistance = cp?.entranceDistance ?? d.entranceDistance
    const entranceDuration = cp?.entranceDuration ?? d.entranceDuration
    const marginX = d.messageMarginX  // Could become configurable later
    const avatarOffset = d.avatarOffset

    return {
      [this.toClassSelector(this.blockClassNames.messageWrap)]: {
        '--entrance-distance': `${entranceDistance}px`,
        '--entrance-duration': `${entranceDuration}ms`,
        'marginBottom': '0px',
        'opacity': 0,
        'transform': `translate3d(0, var(--entrance-distance), 0)`,
        'marginLeft': `${marginX}px`,
        'marginRight': `${marginX}px`,
        'transition': `opacity var(--entrance-duration) ease-out, transform var(--entrance-duration) ease-out`,

        '&[data-first-in-block="true"]': {
          paddingTop: `${d.messagePaddingTop}px`,
        },

        '&:not([data-first-in-block="true"])': {
          marginTop: `${d.messageGapY}px`,
        },

        '&[data-input-needed="true"]:not([data-first-in-block="true"])': {
          marginTop: `${d.inputNeededGapY}px`,
        },

        // Visible state
        '&.visible': {
          opacity: 1,
          transform: 'translate3d(0, 0, 0)',
        },

        // Avatar offset
        ...(this.hasSenderIcon ? {
          paddingLeft: `${avatarOffset}px`,
        } : {}),
      },

      // Bubble styles -- use properties from messageProperties/choiceProperties/responseProperties
      [this.toClassSelector(this.blockClassNames.bubble)]: {
        padding: `${d.bubblePaddingY}px ${d.bubblePaddingX}px`,
        borderRadius: `${d.bubbleBorderRadius}px`,
        // Background, font, border from the relevant *Properties block
        ...this.bubbleStylesFromProperties,
      },

      // Avatar styles
      [this.toClassSelector(this.blockClassNames.avatar)]: {
        width: `${d.avatarSize}px`,
        height: `${d.avatarSize}px`,
        marginRight: `${d.avatarMargin}px`,
      },
    }
  },
}

The full component should mirror MessageHolder.vue's logic (mounted visibility timing, typing delay, scroll triggers, component switching on inputEntered) but use the constants and conversation properties instead of hardcoded values.

  • [ ] Step 2: Verify it renders identically to V1

Mount both V1 and V2 side by side with the same liveFlow data. Every visual detail should match. The DOM structure will differ (data attributes vs classes) but the rendered output should be pixel-identical.

  • [ ] Step 3: Commit
bash
git add src/components/conversationflow/MessageHolderV2.vue
git commit -m "feat(engine): add MessageHolderV2 with configurable defaults and clean DOM"

Task 5: Wire V2 rendering path in CreativeConversationBlock

Files:

  • Modify: Creative-Engine/src/components/creative/CreativeConversationBlock/CreativeConversationBlock.vue:1-40

  • [ ] Step 1: Add version-conditional template

Update the template to branch on conversationVersion:

html
<template>
  <div
    v-show="!isRuntimeHidden"
    :class="[blockClassNames.wrap]"
  >
    <div
      ref="creativeconversationblock"
      :class="[blockClassNames.typeBased, blockClassNames.idBased]"
    >
      <div
        ref="scrollviewport"
        :class="[
          blockClassNames.scrollViewport,
          {
            noscroll: singleBlockFillsContainer,
            hide: singleBlockFillsContainer === 'container',
          },
        ]"
      >
        <div
          ref="scrollcontent"
          :class="[blockClassNames.scrollContent]"
          @click.self="$emit('bgClick')"
        >
          <!-- V1: existing flat iteration -->
          <template v-if="conversationVersion !== 2">
            <MessageHolder
              v-for="(componentArray, index) in liveFlow"
              :key="componentArray?.[0].name"
              :ref="`messageholder${index}`"
              :components="componentArray"
              :first-in-block="firstInBlock[index]"
              :last-in-block="lastInBlock[index]"
              :scroll-content-el="$refs.scrollcontent"
              @scroll-to-bottom="autoScrollToBottom"
              @bg-click="$emit('bgClick')"
            />
          </template>

          <!-- V2: flow-group aware iteration -->
          <template v-else>
            <template v-for="(entry, index) in groupedFlow">
              <!-- Ungrouped step -->
              <MessageHolderV2
                v-if="!entry.isGroup"
                :key="entry.components[0]?.[0].name"
                :components="entry.components[0]"
                :step-index="entry.stepIndex"
                :first-in-block="firstInBlock[entry.stepIndex]"
                :last-in-block="lastInBlock[entry.stepIndex]"
                :scroll-content-el="$refs.scrollcontent"
                @scroll-to-bottom="autoScrollToBottom"
                @bg-click="$emit('bgClick')"
              />

              <!-- Flow group -->
              <FlowGroup
                v-else
                :key="entry.groupId"
                :group-id="entry.groupId"
              >
                <MessageHolderV2
                  v-for="(componentArray, subIndex) in entry.components"
                  :key="componentArray?.[0].name"
                  :components="componentArray"
                  :step-index="entry.stepIndices[subIndex]"
                  :first-in-block="subIndex === 0"
                  :last-in-block="subIndex === entry.components.length - 1"
                  :scroll-content-el="$refs.scrollcontent"
                  @scroll-to-bottom="autoScrollToBottom"
                  @bg-click="$emit('bgClick')"
                />
              </FlowGroup>
            </template>
          </template>
        </div>
      </div>
    </div>
  </div>
</template>
  • [ ] Step 2: Add computed properties
ts
computed: {
  conversationVersion() {
    return this.typedBlock?.conversationVersion ?? 1
  },

  /**
   * Groups liveFlow entries by their flowGroup ID.
   * Consecutive entries with the same flowGroup are wrapped in a FlowGroup.
   * Entries without flowGroup remain ungrouped.
   *
   * Example input:  [A(no group), B(group-1), C(group-1), D(no group)]
   * Example output: [
   *   { isGroup: false, components: [A], stepIndex: 0 },
   *   { isGroup: true, groupId: 'group-1', components: [B, C], stepIndices: [1, 2] },
   *   { isGroup: false, components: [D], stepIndex: 3 },
   * ]
   */
  groupedFlow() {
    const result = []
    let currentGroup = null

    for (let i = 0; i < this.liveFlow.length; i++) {
      const componentArray = this.liveFlow[i]
      const groupId = componentArray?.[0]?.flowGroup

      if (!groupId) {
        // Ungrouped
        if (currentGroup) {
          result.push(currentGroup)
          currentGroup = null
        }

        result.push({
          isGroup: false,
          components: [componentArray],
          stepIndex: i,
        })
      }
      else if (currentGroup?.groupId === groupId) {
        // Continue existing group
        currentGroup.components.push(componentArray)
        currentGroup.stepIndices.push(i)
      }
      else {
        // Start new group
        if (currentGroup) {
          result.push(currentGroup)
        }

        currentGroup = {
          isGroup: true,
          groupId,
          components: [componentArray],
          stepIndices: [i],
        }
      }
    }

    if (currentGroup) {
      result.push(currentGroup)
    }

    return result
  },
}
  • [ ] Step 3: Import new components

Add imports for MessageHolderV2 and FlowGroup to the component's components option.

  • [ ] Step 4: Commit
bash
git add -p
git commit -m "feat(engine): wire V2 rendering path in CreativeConversationBlock"

Chunk 2: Flow-Group + Choice Layout

Task 6: Create FlowGroup.vue

Files:

  • Create: Creative-Engine/src/components/conversationflow/FlowGroup.vue

  • [ ] Step 1: Create the component

FlowGroup uses the display: contents trick: invisible to layout by default, but "materializes" when a designer overrides it via block styles to display: flex or display: grid.

html
<template>
  <div
    :class="[blockClassNames.flowGroup]"
    :data-flow-group="groupId"
  >
    <slot />
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import { StyleAndClassNameGenerationMixin } from '@/mixins/StyleAndClassNameGenerationMixin'

export default defineComponent({
  name: 'FlowGroup',

  mixins: [StyleAndClassNameGenerationMixin],

  props: {
    groupId: { type: String, required: true },
  },

  computed: {
    styles() {
      return {
        [this.toClassSelector(this.blockClassNames.flowGroup)]: {
          display: 'contents',
        },
      }
    },
  },
})
</script>

Why display: contents: The wrapper exists in the DOM for targeting via custom CSS or block styles, but by default it doesn't affect layout at all. The children behave as if they're direct children of the scroll-content. A designer can override to display: flex; flex-direction: column; gap: 8px; background: rgba(0,0,0,0.1); border-radius: 12px; padding: 12px; to create a "question card" look.

  • [ ] Step 2: Commit
bash
git add src/components/conversationflow/FlowGroup.vue
git commit -m "feat(engine): add FlowGroup wrapper with display:contents default"

Task 7: Create ChoiceV2.vue

Files:

  • Create: Creative-Engine/src/components/blocks/basic/ChoiceV2.vue

  • Reference: Creative-Engine/src/components/blocks/basic/Choice.vue (316 lines)

  • [ ] Step 1: Create ChoiceV2

ChoiceV2 replaces Choice.vue's rendering with:

  • Flex/grid layout instead of float
  • Configurable stagger from constants (not hardcoded 120ms * index)
  • variant and outcome CSS classes on each button
  • Feedback delay support (hold choices visible after click)
html
<template>
  <div
    :class="[blockClassNames.choices]"
    :data-layout="layout"
  >
    <button
      v-for="(comp, index) in blockArray"
      :key="comp.name"
      :class="[
        blockClassNames.choiceButton,
        {
          selected: selectedIndex === index,
          'feedback-active': feedbackActive,
        },
      ]"
      :data-variant="comp.variant"
      :data-outcome="comp.outcome"
      :disabled="feedbackActive || inputEntered"
      @click="doSelection(comp, index)"
    >
      <!-- Choice content: text, image, or both -->
      <span v-if="comp.payload?.text" v-html="comp.payload.text" />
      <img
        v-if="comp.payload?.image"
        :src="comp.payload.image"
        alt=""
      />
    </button>
  </div>
</template>

Key styles() differences from Choice.vue:

ts
computed: {
  layout() {
    return this.choiceProperties?.layout || 'inline'
  },

  styles() {
    const { defaults: d } = this
    const staggerInterval = d.choiceStaggerInterval
    const staggerDuration = d.choiceStaggerDuration
    const staggerOffset = d.choiceStaggerOffset

    return {
      // Keyframe -- same animation, values from constants
      '@keyframes choiceAppear': {
        '0%': { opacity: 0, transform: `translateY(${staggerOffset}px)` },
        '100%': { opacity: 1, transform: 'translateY(0)' },
      },

      // Container -- flex by default, grid when layout === 'grid'
      [this.toClassSelector(this.blockClassNames.choices)]: {
        display: 'flex',
        flexWrap: 'wrap',
        gap: '6px',
        justifyContent: this.choiceAlignment,

        '&[data-layout="grid"]': {
          display: 'grid',
          gridTemplateColumns: `repeat(${this.choiceProperties?.columns || 2}, 1fr)`,
        },

        '&[data-layout="stack"]': {
          flexDirection: 'column',
        },
      },

      // Buttons -- stagger from constants
      [this.toClassSelector(this.blockClassNames.choiceButton)]: {
        // Per-button stagger via CSS custom property set inline
        'animationDelay': 'var(--stagger-delay)',
        'animationDuration': `${staggerDuration}ms`,
        'animationTimingFunction': 'cubic-bezier(0, 1, 1, 1)',
        'animationName': 'choiceAppear',
        'animationFillMode': 'forwards',
        'opacity': 0,

        // ... background, font, border from choiceProperties ...

        // Feedback state
        '&.feedback-active': {
          pointerEvents: 'none',
        },

        '&.feedback-active.selected': {
          // Selected button stays full opacity
          opacity: 1,
        },

        '&.feedback-active:not(.selected)': {
          opacity: 0.5,
          transition: 'opacity 200ms ease',
        },
      },
    }
  },
},

methods: {
  doSelection(comp, index) {
    if (this.feedbackActive || this.inputEntered) {
      return
    }

    // Animation triggers (same as Choice.vue)
    this.emitAnimationTriggers(comp)

    // Analytics
    DataStore.emitter.emit('cause:components', {
      name: 'user-action',
      meta: { textPayload: comp.payload?.text },
    })

    const feedbackDelay = this.conversationProps?.choiceFeedbackDelay ?? 0

    if (feedbackDelay > 0) {
      // Feedback phase: show selection, wait, then progress
      this.selectedIndex = index
      this.feedbackActive = true

      setTimeout(() => {
        this.feedbackActive = false
        this.emitInputEntered(comp, index)
      }, feedbackDelay)
    }
    else {
      // Instant (current behavior)
      this.emitInputEntered(comp, index)
    }
  },

  emitInputEntered(comp, index) {
    this.$emit('input-entered', { choiceIndex: index })

    setTimeout(() => {
      this.$emit('scroll-to-bottom')
    }, 20)
  },
},

The stagger delay is set as an inline style attribute per button: --stagger-delay: ${index * staggerInterval + 1}ms. This keeps the JSS clean while allowing per-button timing.

  • [ ] Step 2: Commit
bash
git add src/components/blocks/basic/ChoiceV2.vue
git commit -m "feat(engine): add ChoiceV2 with flex/grid layout, variant/outcome, feedback delay"

Task 8: Wire ChoiceV2 in MessageHolderV2

Files:

  • Modify: Creative-Engine/src/components/conversationflow/MessageHolderV2.vue

  • [ ] Step 1: Import and use ChoiceV2

In MessageHolderV2, when rendering choice components, use ChoiceV2 instead of Choice:

ts
import ChoiceV2 from '@/components/blocks/basic/ChoiceV2.vue'

// In the dynamic component resolution:
const componentMap = {
  Choice: ChoiceV2,
  // ... other components remain the same
}
  • [ ] Step 2: Commit
bash
git add -p
git commit -m "feat(engine): wire ChoiceV2 into MessageHolderV2"

Chunk 3: Scroll + Entrance Behavior

Task 9: Create autoScrollV2.ts

Files:

  • Create: Creative-Engine/src/utils/autoScrollV2.ts

  • Reference: Creative-Engine/src/utils/autoScroll.ts (105 lines)

  • [ ] Step 1: Create configurable scroll utility

ts
import { CONVERSATION_DEFAULTS } from '@/utils/constants'

type ScrollConfig = {
  duration?: number
  easing?: string // Not used in JS (we compute our own), but exposed for CSS transitions
  abortOnUserScroll?: boolean
}

let currentAnimation: number | null = null

/**
 * Scrolls an element to the bottom with configurable duration and easing.
 *
 * Uses the same weighted quintic easing as autoScroll.ts by default,
 * but duration is configurable via conversation properties.
 */
export const AutoScrollV2 = {
  scrollTo(el: HTMLElement, config: ScrollConfig = {}) {
    const duration = config.duration ?? CONVERSATION_DEFAULTS.scrollDuration

    if (duration === 0) {
      el.scrollTop = el.scrollHeight - el.clientHeight
      return
    }

    // Cancel any in-progress scroll
    if (currentAnimation !== null) {
      cancelAnimationFrame(currentAnimation)
      currentAnimation = null
    }

    const startScrollTop = el.scrollTop
    const targetScrollTop = el.scrollHeight - el.clientHeight
    const distance = targetScrollTop - startScrollTop

    if (distance <= 0) {
      return
    }

    const startTime = performance.now()

    const step = (now: number) => {
      const elapsed = now - startTime
      const progress = Math.min(elapsed / duration, 1)

      // Weighted quintic ease-out (same as autoScroll.ts)
      const complexEase = 1 - (1 - progress) ** 5
      const eased = 0.9 * complexEase + 0.1 * progress

      el.scrollTop = startScrollTop + distance * eased

      if (progress < 1) {
        currentAnimation = requestAnimationFrame(step)
      }
      else {
        currentAnimation = null
      }
    }

    currentAnimation = requestAnimationFrame(step)
  },

  cancel() {
    if (currentAnimation !== null) {
      cancelAnimationFrame(currentAnimation)
      currentAnimation = null
    }
  },
}
  • [ ] Step 2: Use in MessageHolderV2

Replace AutoScroll.scrollTo(el) calls in the V2 path with:

ts
AutoScrollV2.scrollTo(el, {
  duration: this.conversationProps?.scrollDuration,
})
  • [ ] Step 3: Commit
bash
git add src/utils/autoScrollV2.ts
git commit -m "feat(engine): add AutoScrollV2 with configurable duration"

Task 10: Configurable entrance in MessageHolderV2

Files:

  • Modify: Creative-Engine/src/components/conversationflow/MessageHolderV2.vue

  • [ ] Step 1: Support entrance modes

The entranceEffect property controls how new messages appear:

  • 'slide' (default): Current behavior -- translateY + opacity
  • 'fade': Opacity only, no translation
  • 'none': Instant, no animation

In styles():

ts
const entranceEffect = cp?.entranceEffect ?? d.entranceEffect
const entranceDistance = cp?.entranceDistance ?? d.entranceDistance
const entranceDuration = cp?.entranceDuration ?? d.entranceDuration

const getEntranceTransform = () => {
  switch (entranceEffect) {
    case 'slide':
      return `translate3d(0, ${entranceDistance}px, 0)`
    case 'fade':
    case 'none':
      return 'none'
  }
}

const getEntranceTransition = () => {
  if (entranceEffect === 'none') {
    return 'none'
  }

  return `opacity ${entranceDuration}ms ease-out, transform ${entranceDuration}ms ease-out`
}
  • [ ] Step 2: Commit
bash
git add -p
git commit -m "feat(engine): configurable entrance effect in MessageHolderV2"

Chunk 4: Frontend Configuration UI

Task 11: Add V2 toggle to ConversationConfiguration

Files:

  • Modify: Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Configuration/configs/ConversationConfiguration.vue

  • [ ] Step 1: Add version toggle

At the top of the configuration panel, add a toggle that switches between V1 and V2:

html
<OptionList
  v-if="showVersionToggle"
  :title="$t('builder.conversation.version')"
  :value="blockData.conversationVersion || 1"
  :options="versionOptions"
  @input="updateValue('conversationVersion', $event)"
/>

The toggle should only appear for new creatives or when explicitly enabled (e.g., via a feature flag or workspace setting). Existing creatives stay on V1 by default.

  • [ ] Step 2: Add V2-only sections

Conditionally show new configuration sections when V2 is active:

html
<template v-if="isV2">
  <!-- Entrance -->
  <EntranceEffectSection
    :path="path"
    :override-props="overrideProps"
  />

  <!-- Scroll -->
  <ScrollBehaviorSection
    :path="path"
    :override-props="overrideProps"
  />

  <!-- Choice Feedback -->
  <ChoiceFeedbackSection
    :path="path"
    :override-props="overrideProps"
  />
</template>
  • [ ] Step 3: Add i18n keys

Add to src/assets/i18n/en.js under the builder.conversation section:

js
conversation: {
  // ... existing keys ...
  version: 'Rendering Version',
  versionV1: 'Classic',
  versionV2: 'V2',
  entranceEffect: 'Entrance Effect',
  entranceDistance: 'Entrance Distance',
  entranceDuration: 'Entrance Duration',
  scrollDuration: 'Scroll Duration',
  choiceFeedbackDelay: 'Choice Feedback Delay',
  choiceLayout: 'Choice Layout',
  choiceColumns: 'Columns',
}
  • [ ] Step 4: Commit
bash
git add -p
git commit -m "feat(frontend): add V2 configuration sections to ConversationConfiguration"

Task 12: Create V2 section components

Files:

  • Create section components in Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Configuration/components/ as needed

  • [ ] Step 1: Create EntranceEffectSection

Uses sectionLogic mixin. Contains:

  • OptionList for entrance type (slide, fade, none)

  • InputField for distance (px) -- only shown when type is 'slide'

  • InputField for duration (ms)

  • [ ] Step 2: Create ChoiceFeedbackSection

Uses sectionLogic mixin. Contains:

  • InputField for feedback delay (ms, 0 = instant)

  • Help text explaining that 0 means current behavior

  • [ ] Step 3: Create ChoiceLayoutSection

Uses sectionLogic mixin. Contains:

  • OptionList for layout mode (inline, stack, grid)

  • InputField for columns -- only shown when layout is 'grid'

  • [ ] Step 4: Add sectionSettings entries

In the relevant utils.ts, add entries for each new section component:

ts
EntranceEffectSection: {
  title: 'builder.conversation.entranceEffect',
  properties: ['entranceEffect', 'entranceDistance', 'entranceDuration'],
},
ChoiceFeedbackSection: {
  title: 'builder.conversation.choiceFeedbackDelay',
  properties: ['choiceFeedbackDelay'],
},
ChoiceLayoutSection: {
  title: 'builder.conversation.choiceLayout',
  properties: ['choiceLayout', 'choiceColumns'],
},
  • [ ] Step 5: Commit
bash
git add -p
git commit -m "feat(frontend): add V2 section components for entrance, feedback, layout"

Chunk 5: Polish + CSS Reset + Responsive Prep

Task 13: CSS custom properties layer

Files:

  • Modify: Creative-Engine/src/components/conversationflow/MessageHolderV2.vue

  • Modify: Creative-Engine/src/components/blocks/basic/ChoiceV2.vue

  • [ ] Step 1: Expose key values as CSS custom properties

In the V2 scroll-content container, set CSS custom properties that designers can override:

ts
// In CreativeConversationBlock styles(), V2 path:
[scrollContentSelector]: {
  '--conv-message-margin-x': `${d.messageMarginX}px`,
  '--conv-message-gap-y': `${d.messageGapY}px`,
  '--conv-bubble-padding': `${d.bubblePaddingY}px ${d.bubblePaddingX}px`,
  '--conv-bubble-radius': `${d.bubbleBorderRadius}px`,
  '--conv-entrance-distance': `${entranceDistance}px`,
  '--conv-entrance-duration': `${entranceDuration}ms`,
  '--conv-choice-gap': '6px',
  '--conv-choice-stagger': `${d.choiceStaggerInterval}ms`,
}

MessageHolderV2 and ChoiceV2 reference these variables:

ts
marginLeft: 'var(--conv-message-margin-x)',
marginRight: 'var(--conv-message-margin-x)',

This means a designer can override with a single custom CSS rule:

css
.scroll-content { --conv-bubble-radius: 8px; }
  • [ ] Step 2: Commit
bash
git add -p
git commit -m "feat(engine): expose conversation values as CSS custom properties"

Task 14: Clean slate toggle

Files:

  • Modify: Creative-Engine/src/interfaces/jsonTypes/payload-v2/index.ts

  • Modify: Creative-Engine/src/components/conversationflow/MessageHolderV2.vue

  • [ ] Step 1: Add cleanSlate property

Add to ConversationProperties:

ts
cleanSlate?: boolean  // When true, minimal default styles -- designer builds from scratch
  • [ ] Step 2: Implement in MessageHolderV2

When cleanSlate is true, the message-wrap and bubble get minimal styles:

ts
if (this.conversationProps?.cleanSlate) {
  return {
    [messageWrapSelector]: {
      // Only layout essentials, no visual opinions
      marginBottom: '0px',
      opacity: 0,
      '&.visible': { opacity: 1 },
    },
    [bubbleSelector]: {
      // No padding, no border-radius, no background
      // Designer adds everything via custom CSS or block styles
    },
  }
}
  • [ ] Step 3: Add frontend toggle

Add a "Clean Slate" toggle to ConversationConfiguration (V2 only):

html
<ToggleSection
  v-if="isV2"
  :path="path"
  property="cleanSlate"
  :title="$t('builder.conversation.cleanSlate')"
/>
  • [ ] Step 4: Commit
bash
git add -p
git commit -m "feat: add cleanSlate toggle for minimal conversation styling"

Task 15: Responsive considerations

Files:

  • Modify: Creative-Engine/src/components/conversationflow/MessageHolderV2.vue

  • [ ] Step 1: Use relative units where possible

In MessageHolderV2's styles, use em for spacing that should scale with font size, and keep px for structural layout. The CSS custom properties layer (Task 13) already enables easy override per breakpoint via custom CSS.

Key responsive improvements:

  • Message margins: use CSS custom properties so they can be overridden per creative size
  • Choice button padding: use em so it scales with font size
  • Avatar size: keep px but expose as custom property

This is a soft improvement -- no new properties needed. The custom properties + clean slate toggle give designers full control.

  • [ ] Step 2: Commit
bash
git add -p
git commit -m "feat(engine): responsive-friendly units in MessageHolderV2"

Migration Strategy

How existing creatives work

  1. conversationVersion is undefined on all existing creatives
  2. undefined falls through to 1 in the conversationVersion ?? 1 check
  3. V1 rendering path is used -- zero behavioral change
  4. All new V2 properties are optional with undefined defaults
  5. The backend doesn't need changes -- it stores whatever JSON the frontend sends

How new V2 creatives work

  1. User toggles "V2" in conversation configuration (or new creatives default to V2 via workspace setting)
  2. conversationVersion: 2 is set on the creative
  3. V2 rendering path activates: MessageHolderV2, ChoiceV2, FlowGroup, AutoScrollV2
  4. All V2 properties use CONVERSATION_DEFAULTS when not explicitly set
  5. Result looks identical to V1 by default -- the difference is configurability

Migrating existing creatives

Not automatic. A creative stays on V1 until someone explicitly switches it. This is intentional:

  • V2 rendering is slightly different in DOM structure (data attributes vs classes)
  • Custom CSS targeting V1 class names would break
  • The creative owner should verify after switching

Future Work (Not in This Plan)

These features build on V2's architecture but are separate efforts:

  • DCO/variable substitution in messages: {variable} syntax in message text, resolved at render time. Depends on CAV-31 (variable system).
  • Rich text formatting: Markdown or limited HTML in message bubbles. Depends on #1812.
  • Flow-group configuration UI: Frontend flow editor support for grouping steps. Currently groups can only be set in JSON.
  • Per-step variant/outcome UI: Frontend flow editor fields for setting variant and outcome on individual flow components.
  • Scroll-driven animations: Map animation progress to scroll position (scroll-progress trigger type). Depends on animation system maturity.
  • Choice layout templates: Preset layouts (quiz 2x2, horizontal pills, vertical stack) as one-click options.
  • Transition between V1 and V2: A migration assistant that previews V2 rendering alongside V1 for comparison.

  • todos/ConversationRedesign/feasibility.md -- Problem analysis and approach comparison (this plan supersedes the Recommendation section)
  • todos/ConversationRedesign/flow-group-visual-companion.html -- Interactive visual showing display:contents trick and flow-group concept
  • architecture/architecture-blueprint.md -- Overall system architecture
  • todos/Animations/ -- Animation system docs (AnimationMixin, effects, triggers)

Internal documentation