diff --git a/apps/builder/contexts/TypebotContext/actions/edges.ts b/apps/builder/contexts/TypebotContext/actions/edges.ts index 0b3941e093b..de9770b3feb 100644 --- a/apps/builder/contexts/TypebotContext/actions/edges.ts +++ b/apps/builder/contexts/TypebotContext/actions/edges.ts @@ -90,15 +90,16 @@ export const deleteEdgeDraft = ( ) => { const edgeIndex = typebot.edges.findIndex(byId(edgeId)) if (edgeIndex === -1) return - deleteOutgoingEdgeIdProps(typebot, edgeIndex) + deleteOutgoingEdgeIdProps(typebot, edgeId) typebot.edges.splice(edgeIndex, 1) } const deleteOutgoingEdgeIdProps = ( typebot: WritableDraft, - edgeIndex: number + edgeId: string ) => { - const edge = typebot.edges[edgeIndex] + const edge = typebot.edges.find(byId(edgeId)) + if (!edge) return const fromBlockIndex = typebot.blocks.findIndex(byId(edge.from.blockId)) const fromStepIndex = typebot.blocks[fromBlockIndex].steps.findIndex( byId(edge.from.stepId) @@ -122,16 +123,16 @@ export const cleanUpEdgeDraft = ( typebot: WritableDraft, deletedNodeId: string ) => { - typebot.edges = typebot.edges.filter( - (edge) => - ![ - edge.from.blockId, - edge.from.stepId, - edge.from.itemId, - edge.to.blockId, - edge.to.stepId, - ].includes(deletedNodeId) + const edgesToDelete = typebot.edges.filter((edge) => + [ + edge.from.blockId, + edge.from.stepId, + edge.from.itemId, + edge.to.blockId, + edge.to.stepId, + ].includes(deletedNodeId) ) + edgesToDelete.forEach((edge) => deleteEdgeDraft(typebot, edge.id)) } const removeExistingEdge = ( diff --git a/apps/builder/playwright/tests/bubbles/image.spec.ts b/apps/builder/playwright/tests/bubbles/image.spec.ts index 7700c0ffda8..f194a8dbac2 100644 --- a/apps/builder/playwright/tests/bubbles/image.spec.ts +++ b/apps/builder/playwright/tests/bubbles/image.spec.ts @@ -35,7 +35,7 @@ test.describe.parallel('Image bubble step', () => { await expect(page.locator('img')).toHaveAttribute( 'src', new RegExp( - `https://s3.eu-west-3.amazonaws.com/typebot/typebots/${typebotId}/avatar.jpg`, + `http://localhost:9000/typebot/public/typebots/${typebotId}/avatar.jpg`, 'gm' ) ) diff --git a/packages/bot-engine/src/components/ChatBlock/ChatBlock.tsx b/packages/bot-engine/src/components/ChatBlock/ChatBlock.tsx index cd081a5931b..3b2529fcf27 100644 --- a/packages/bot-engine/src/components/ChatBlock/ChatBlock.tsx +++ b/packages/bot-engine/src/components/ChatBlock/ChatBlock.tsx @@ -4,7 +4,9 @@ import { AvatarSideContainer } from './AvatarSideContainer' import { useTypebot } from '../../contexts/TypebotContext' import { isBubbleStep, + isBubbleStepType, isChoiceInput, + isDefined, isInputStep, isIntegrationStep, isLogicStep, @@ -17,6 +19,7 @@ import { useAnswers } from 'contexts/AnswersContext' import { BubbleStep, InputStep, Step } from 'models' import { HostBubble } from './ChatStep/bubbles/HostBubble' import { InputChatStep } from './ChatStep/InputChatStep' +import { getLastChatStepType } from 'services/chat' type ChatBlockProps = { steps: Step[] @@ -25,6 +28,8 @@ type ChatBlockProps = { onBlockEnd: (edgeId?: string) => void } +type ChatDisplayChunk = { bubbles: BubbleStep[]; input?: InputStep } + export const ChatBlock = ({ steps, startStepIndex, @@ -40,30 +45,52 @@ export const ChatBlock = ({ onNewLog, } = useTypebot() const { resultValues } = useAnswers() - const [displayedSteps, setDisplayedSteps] = useState([]) - const bubbleSteps = displayedSteps.filter((step) => - isBubbleStep(step) - ) as BubbleStep[] - const inputSteps = displayedSteps.filter((step) => - isInputStep(step) - ) as InputStep[] - const avatarSideContainerRef = useRef() + const [processedSteps, setProcessedSteps] = useState([]) + const [displayedChunks, setDisplayedChunks] = useState([]) + + const insertStepInStack = (nextStep: Step) => { + setProcessedSteps([...processedSteps, nextStep]) + if (isBubbleStep(nextStep)) { + const lastStepType = getLastChatStepType(processedSteps) + lastStepType && isBubbleStepType(lastStepType) + ? setDisplayedChunks( + displayedChunks.map((c, idx) => + idx === displayedChunks.length - 1 + ? { bubbles: [...c.bubbles, nextStep] } + : c + ) + ) + : setDisplayedChunks([...displayedChunks, { bubbles: [nextStep] }]) + } + if (isInputStep(nextStep)) { + return displayedChunks.length === 0 || + isDefined(displayedChunks[displayedChunks.length - 1].input) + ? setDisplayedChunks([ + ...displayedChunks, + { bubbles: [], input: nextStep }, + ]) + : setDisplayedChunks( + displayedChunks.map((c, idx) => + idx === displayedChunks.length - 1 ? { ...c, input: nextStep } : c + ) + ) + } + } useEffect(() => { const nextStep = steps[startStepIndex] - if (nextStep) setDisplayedSteps([...displayedSteps, nextStep]) + if (nextStep) insertStepInStack(nextStep) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) useEffect(() => { - avatarSideContainerRef.current?.refreshTopOffset() onScroll() onNewStepDisplayed() // eslint-disable-next-line react-hooks/exhaustive-deps - }, [displayedSteps]) + }, [processedSteps]) const onNewStepDisplayed = async () => { - const currentStep = [...displayedSteps].pop() + const currentStep = [...processedSteps].pop() if (!currentStep) return if (isLogicStep(currentStep)) { const nextEdgeId = executeLogic( @@ -95,13 +122,12 @@ export const ChatBlock = ({ const displayNextStep = (answerContent?: string, isRetry?: boolean) => { onScroll() - const currentStep = [...displayedSteps].pop() + const currentStep = [...processedSteps].pop() if (currentStep) { if (isRetry && stepCanBeRetried(currentStep)) - return setDisplayedSteps([ - ...displayedSteps, - parseRetryStep(currentStep, typebot.variables, createEdge), - ]) + return insertStepInStack( + parseRetryStep(currentStep, typebot.variables, createEdge) + ) if ( isInputStep(currentStep) && currentStep.options?.variableId && @@ -118,11 +144,11 @@ export const ChatBlock = ({ if (nextEdgeId) return onBlockEnd(nextEdgeId) } - if (currentStep?.outgoingEdgeId || displayedSteps.length === steps.length) + if (currentStep?.outgoingEdgeId || processedSteps.length === steps.length) return onBlockEnd(currentStep.outgoingEdgeId) } - const nextStep = steps[displayedSteps.length] - if (nextStep) setDisplayedSteps([...displayedSteps, nextStep]) + const nextStep = steps[processedSteps.length] + if (nextStep) insertStepInStack(nextStep) } const avatarSrc = typebot.theme.chat.hostAvatar?.url @@ -130,46 +156,75 @@ export const ChatBlock = ({ return (
-
- {bubbleSteps.length > 0 && - (typebot.theme.chat.hostAvatar?.isEnabled ?? true) && ( - - )} - - {bubbleSteps.map((step) => ( - - - - ))} - -
+ {displayedChunks.map((chunk, idx) => ( + + ))} +
+
+ ) +} + +type Props = { + displayChunk: ChatDisplayChunk + hostAvatar: { isEnabled: boolean; src?: string } + onDisplayNextStep: (answerContent?: string, isRetry?: boolean) => void +} +const ChatChunks = ({ + displayChunk: { bubbles, input }, + hostAvatar, + onDisplayNextStep, +}: Props) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const avatarSideContainerRef = useRef() + + useEffect(() => { + avatarSideContainerRef.current?.refreshTopOffset() + }) + + return ( + <> +
+ {hostAvatar.isEnabled && ( + + )} - {inputSteps.map((step) => ( + {bubbles.map((step) => ( - + ))}
- + + {input && ( + + )} + + ) } diff --git a/packages/bot-engine/src/components/ConversationContainer.tsx b/packages/bot-engine/src/components/ConversationContainer.tsx index ef165420447..489e71f27ec 100644 --- a/packages/bot-engine/src/components/ConversationContainer.tsx +++ b/packages/bot-engine/src/components/ConversationContainer.tsx @@ -75,10 +75,12 @@ export const ConversationContainer = ({ const autoScrollToBottom = () => { if (!scrollableContainer.current) return - scroll.scrollToBottom({ - duration: 500, - container: scrollableContainer.current, - }) + setTimeout(() => { + scroll.scrollToBottom({ + duration: 500, + container: scrollableContainer.current, + }) + }, 1) } return ( diff --git a/packages/bot-engine/src/services/chat.ts b/packages/bot-engine/src/services/chat.ts index aadfada994f..265edbc840b 100644 --- a/packages/bot-engine/src/services/chat.ts +++ b/packages/bot-engine/src/services/chat.ts @@ -1,4 +1,12 @@ -import { TypingEmulation } from 'models' +import { + BubbleStep, + BubbleStepType, + InputStep, + InputStepType, + Step, + TypingEmulation, +} from 'models' +import { isBubbleStep, isInputStep } from 'utils' export const computeTypingTimeout = ( bubbleContent: string, @@ -14,3 +22,12 @@ export const computeTypingTimeout = ( typingTimeout = typingSettings.maxDelay * 1000 return typingTimeout } + +export const getLastChatStepType = ( + steps: Step[] +): BubbleStepType | InputStepType | undefined => { + const displayedSteps = steps.filter( + (s) => isBubbleStep(s) || isInputStep(s) + ) as (BubbleStep | InputStep)[] + return displayedSteps.pop()?.type +}