From 7dd002fa0cf44e3d6a902f6207595fd4fbd5e209 Mon Sep 17 00:00:00 2001 From: krisantrobus <55083528+krisantrobus@users.noreply.github.com> Date: Wed, 22 Jan 2025 08:26:10 -0600 Subject: [PATCH] feat(ai-chat-log): add typewriter animations (#4199) * feat(ai-chat-log): wip typewriter animations * fix(ai-chat-log): remove nested span * feat(ai-chat-log): typewiter code * feat(docs): udpate docs and changesets * chore(ai-chat-log): linting * docs(ai-chat-log): update definition * docs(ai-chat-log): docs spelling * feat(ai-chat-log): typewiter speeds * chore(docs): rearrange ai chat log sections * docs(ai-chat-log): added scollable example * docs(ai-chat-log): modified scrollable exmaple * chore(ai-chat-log): typedocs * feat(ai-chat-log): story for user cancel scroll to end --------- Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .changeset/tough-moles-film.md | 6 + .../ai-chat-log/src/AIChatMessageBody.tsx | 53 ++- .../components/ai-chat-log/src/utils.tsx | 79 ++++ .../ai-chat-log/stories/composer.stories.tsx | 2 +- .../ai-chat-log/stories/parts.stories.tsx | 206 +++++++++- .../stories/scrollableSidePanel.stories.tsx | 366 ++++++++++++++++++ .../stories/useAIChatLogger.stories.tsx | 2 +- .../components/ai-chat-log/type-docs.json | 21 +- .../component-examples/AIChatLogExamples.ts | 132 +++++++ .../pages/components/ai-chat-log/index.mdx | 35 +- 10 files changed, 885 insertions(+), 17 deletions(-) create mode 100644 .changeset/tough-moles-film.md create mode 100644 packages/paste-core/components/ai-chat-log/src/utils.tsx create mode 100644 packages/paste-core/components/ai-chat-log/stories/scrollableSidePanel.stories.tsx diff --git a/.changeset/tough-moles-film.md b/.changeset/tough-moles-film.md new file mode 100644 index 0000000000..467785d164 --- /dev/null +++ b/.changeset/tough-moles-film.md @@ -0,0 +1,6 @@ +--- +"@twilio-paste/ai-chat-log": minor +"@twilio-paste/core": minor +--- + +[AI Chat Log] added optional typewriter animation to AIChatMessageBody diff --git a/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx b/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx index ef51fe17df..d59add5634 100644 --- a/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx +++ b/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx @@ -4,6 +4,7 @@ import type { HTMLPasteProps } from "@twilio-paste/types"; import * as React from "react"; import { AIMessageContext } from "./AIMessageContext"; +import { useAnimatedText } from "./utils"; const Sizes: Record = { default: { @@ -35,11 +36,59 @@ export interface AIChatMessageBodyProps extends HTMLPasteProps<"div"> { * @memberof AIChatMessageBodyProps */ size?: "default" | "fullScreen"; + /** + * Whether the text should be animated with type writer effect + * + * @default false + * @type {boolean} + * @memberof AIChatMessageBodyProps + */ + animated?: boolean; + /** + * A callback when the animation is started + * + * @default false + * @type {() => void} + * @memberof AIChatMessageBodyProps + */ + onAnimationStart?: () => void; + /** + * A callback when the animation is complete + * + * @default false + * @type {() => void} + * @memberof AIChatMessageBodyProps + */ + onAnimationEnd?: () => void; } export const AIChatMessageBody = React.forwardRef( - ({ children, size = "default", element = "AI_CHAT_MESSAGE_BODY", ...props }, ref) => { + ( + { + children, + size = "default", + element = "AI_CHAT_MESSAGE_BODY", + animated = false, + onAnimationEnd, + onAnimationStart, + ...props + }, + ref, + ) => { const { id } = React.useContext(AIMessageContext); + const [showAnimation] = React.useState(animated && children !== undefined); + const animationSpeed = size === "fullScreen" ? 8 : 10; + const { animatedChildren, isAnimating } = useAnimatedText(children, animationSpeed, showAnimation); + + React.useEffect(() => { + if (onAnimationStart && animated && isAnimating) { + onAnimationStart(); + } + + if (animated && !isAnimating && onAnimationEnd) { + onAnimationEnd(); + } + }, [isAnimating, showAnimation]); return ( - {children} + {animatedChildren} ); }, diff --git a/packages/paste-core/components/ai-chat-log/src/utils.tsx b/packages/paste-core/components/ai-chat-log/src/utils.tsx new file mode 100644 index 0000000000..92adfe94b4 --- /dev/null +++ b/packages/paste-core/components/ai-chat-log/src/utils.tsx @@ -0,0 +1,79 @@ +import React, { useEffect, useState } from "react"; + +// Hook to animate text content of React elements +export const useAnimatedText = ( + children: React.ReactNode, + speed = 10, + enabled = true, +): { animatedChildren: React.ReactNode; isAnimating: boolean } => { + const [animatedChildren, setAnimatedChildren] = useState(); + const [textIndex, setTextIndex] = useState(0); + + // Effect to increment textIndex at a specified speed + useEffect(() => { + const interval = setInterval(() => { + setTextIndex((prevIndex) => prevIndex + 1); + }, speed); + + return () => clearInterval(interval); + }, [speed]); + + // Function to calculate the total length of text within nested elements + const calculateTotalTextLength = (nodes: React.ReactNode): number => { + let length = 0; + React.Children.forEach(nodes, (child) => { + if (typeof child === "string") { + length += child.length; + } else if (React.isValidElement(child)) { + length += calculateTotalTextLength(child.props.children); + } + }); + return length; + }; + + // Function to recursively clone children and apply text animation + const cloneChildren = (nodes: React.ReactNode, currentIndex: number): React.ReactNode => { + let currentTextIndex = currentIndex; + return React.Children.map(nodes, (child) => { + if (typeof child === "string") { + // Only include text nodes if their animation has started + if (currentTextIndex > 0) { + const visibleText = child.slice(0, currentTextIndex); + currentTextIndex -= child.length; + return visibleText; + } + return null; + } else if (React.isValidElement(child)) { + const totalChildTextLength = calculateTotalTextLength(child.props.children); + // Only include elements if their text animation has started + if (currentTextIndex > 0) { + const clonedChild = React.cloneElement(child, {}, cloneChildren(child.props.children, currentTextIndex)); + currentTextIndex -= totalChildTextLength; + return clonedChild; + } else if (currentTextIndex === 0 && totalChildTextLength === 0) { + return child; + } + return null; + } + + return child; + }); + }; + + // Effect to update animated children based on the current text index + useEffect(() => { + if (enabled) { + const totaLength = calculateTotalTextLength(children); + if (textIndex <= totaLength) { + setAnimatedChildren(cloneChildren(children, textIndex)); + } + } + }, [children, textIndex, enabled]); + + return { + animatedChildren: enabled ? animatedChildren : children, + isAnimating: enabled && textIndex < calculateTotalTextLength(children), + }; +}; + +export default useAnimatedText; diff --git a/packages/paste-core/components/ai-chat-log/stories/composer.stories.tsx b/packages/paste-core/components/ai-chat-log/stories/composer.stories.tsx index f1883b2d1a..39f1312430 100644 --- a/packages/paste-core/components/ai-chat-log/stories/composer.stories.tsx +++ b/packages/paste-core/components/ai-chat-log/stories/composer.stories.tsx @@ -59,7 +59,7 @@ const BotMessage = (props): JSX.Element => { ) : ( Good Bot - {props.message as string} + {props.message as string} ); }; diff --git a/packages/paste-core/components/ai-chat-log/stories/parts.stories.tsx b/packages/paste-core/components/ai-chat-log/stories/parts.stories.tsx index 0949285fac..0eedc41940 100644 --- a/packages/paste-core/components/ai-chat-log/stories/parts.stories.tsx +++ b/packages/paste-core/components/ai-chat-log/stories/parts.stories.tsx @@ -1,14 +1,34 @@ -// eslint-disable-next-line eslint-comments/disable-enable-pair +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable react/jsx-max-depth */ /* eslint-disable import/no-extraneous-dependencies */ +import { StoryFn } from "@storybook/react"; +import { Anchor } from "@twilio-paste/anchor"; +import { Blockquote, BlockquoteCitation, BlockquoteContent } from "@twilio-paste/blockquote/"; import { Box } from "@twilio-paste/box"; import { Button } from "@twilio-paste/button"; import { ButtonGroup } from "@twilio-paste/button-group"; +import { Callout, CalloutHeading, CalloutText } from "@twilio-paste/callout"; +import { CodeBlock, CodeBlockHeader, CodeBlockWrapper } from "@twilio-paste/code-block"; +import { Disclosure, DisclosureContent, DisclosureHeading } from "@twilio-paste/disclosure"; +import { Heading } from "@twilio-paste/heading"; +import { ArtificialIntelligenceIcon } from "@twilio-paste/icons/esm/ArtificialIntelligenceIcon"; import { CopyIcon } from "@twilio-paste/icons/esm/CopyIcon"; import { RefreshIcon } from "@twilio-paste/icons/esm/RefreshIcon"; import { ThumbsDownIcon } from "@twilio-paste/icons/esm/ThumbsDownIcon"; import { ThumbsUpIcon } from "@twilio-paste/icons/esm/ThumbsUpIcon"; import { UserIcon } from "@twilio-paste/icons/esm/UserIcon"; import { InlineCode } from "@twilio-paste/inline-code"; +import { ListItem, UnorderedList } from "@twilio-paste/list"; +import { Paragraph } from "@twilio-paste/paragraph"; +import { Separator } from "@twilio-paste/separator"; +import { + SidePanel, + SidePanelBody, + SidePanelButton, + SidePanelContainer, + SidePanelHeader, + SidePanelPushContentWrapper, +} from "@twilio-paste/side-panel"; import * as React from "react"; import { @@ -72,7 +92,7 @@ export const AIMessageLoading = (): React.ReactNode => { return ( - +

Pssst! The three rows have dynamic widths. Refresh to see it in action!

@@ -153,7 +173,7 @@ export const FullAIMessage = (): React.ReactNode => { Good Bot - + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Deserunt delectus fuga, necessitatibus eligendiiure adipisci facilis exercitationem officiis dolorem laborum, ex fugiat quisquam itaque, earum sit nesciunt impedit repellat assumenda. @@ -204,3 +224,183 @@ export const FullAIMessage = (): React.ReactNode => { ); }; + +const rubyCode = `#!/usr/bin/ruby + +# Import the library. This is a really really long line that should be wrapped. +require 'tk' + +# Root window. +root = TkRoot.new { + title 'Push Me' + background '#111188' +} + +# Add a label to the root window. +lab = TkLabel.new(root) { + text "Hey there,\nPush a button!" + background '#3333AA' + foreground '#CCCCFF' +} +`; + +export const MessageBodyTypeWriterEnrichedText = (): React.ReactNode => { + return ( + + + With enriched text + + + + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Deserunt + delectus fuga, necessitatibus eligendiiure adipisci facilis exercitationem officiis dolorem laborum, ex fugiat + quisquam itaque, earum sit nesciunt impedit repellat assumenda. new text,{" "} + + 434324 + + + Item 1 + Item 2 + Item 3 + + + + + ); +}; + +export const MessageBodyTypeWriterComplexComponents = (): React.ReactNode => { + return ( + + + With complex components + + + + + + This + is text that contains icons between elements + + + +
+ + With AI-driven products, the design process is no longer just about aesthetics. It’s about designing for + the human experience as a whole. + + +
+
+ + + Heads up! + This is some information you need to know. + + + + + Build a button + + + + + + + Between the World and Me by Ta-Nehisi Coates + + + But race is the child of racism, not the father. And the process of naming “the people” has never been a + matter of genealogy and physiognomy so much as one of hierarchy. Difference in hue and hair is old. But + the belief in the preeminence of hue and hair, the notion that these factors can correctly organize a + society and that they signify deeper attributes, which are indelible—this is the new idea at the heart + of these new people who have been brought up hopelessly, tragically, deceitfully, to believe that they + are white. + + + +
+
+
+ ); +}; + +export const MessageBodyTypeWriterFullscreen = (): React.ReactNode => { + return ( + + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Deserunt + delectus fuga, necessitatibus eligendiiure adipisci facilis exercitationem officiis dolorem laborum, ex fugiat + quisquam itaque, earum sit nesciunt impedit repellat assumenda. new text,{" "} + + 434324 + + + Item 1 + Item 2 + Item 3 + + + ); +}; + +export const MessageBodyTypeWriterDefaultSidePanel: StoryFn = () => { + const [isOpen, setIsOpen] = React.useState(true); + return ( + + + + + + Assistant + + + + + + + + Good Bot + + Lorem ipsum dolor, sit amet consectetur adipisicing elit.{" "} + Deserunt delectus fuga, necessitatibus eligendiiure adipisci facilis exercitationem officiis dolorem + laborum, ex fugiat quisquam itaque, earum sit{" "} + nesciunt impedit repellat assumenda. new text,{" "} + + 434324 + + + Item 1 + Item 2 + Item 3 + + + + + + + + + + Toggle Side Panel + + + + ); +}; +MessageBodyTypeWriterDefaultSidePanel.parameters = { + padding: false, + a11y: { + config: { + rules: [ + { + /* + * Using position="relative" on SidePanel causes it to overflow other themes in stacked and side-by-side views, and therefore fail color contrast checks based on SidePanelBody's content. + * The DefaultVRT test below serves to test color contrast on the Side Panel component without this issue causing false failures. + */ + id: "color-contrast", + selector: "*:not(*)", + }, + ], + }, + }, +}; diff --git a/packages/paste-core/components/ai-chat-log/stories/scrollableSidePanel.stories.tsx b/packages/paste-core/components/ai-chat-log/stories/scrollableSidePanel.stories.tsx new file mode 100644 index 0000000000..32f5cbda26 --- /dev/null +++ b/packages/paste-core/components/ai-chat-log/stories/scrollableSidePanel.stories.tsx @@ -0,0 +1,366 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable react/jsx-max-depth */ +/* eslint-disable import/no-extraneous-dependencies */ +import { StoryFn } from "@storybook/react"; +import { Anchor } from "@twilio-paste/anchor"; +import { Box } from "@twilio-paste/box"; +import { Button } from "@twilio-paste/button"; +import { ButtonGroup } from "@twilio-paste/button-group"; +import { ChatComposer, ChatComposerActionGroup, ChatComposerContainer } from "@twilio-paste/chat-composer"; +import { Heading } from "@twilio-paste/heading"; +import { ArtificialIntelligenceIcon } from "@twilio-paste/icons/esm/ArtificialIntelligenceIcon"; +import { SendIcon } from "@twilio-paste/icons/esm/SendIcon"; +import { ThumbsDownIcon } from "@twilio-paste/icons/esm/ThumbsDownIcon"; +import { ThumbsUpIcon } from "@twilio-paste/icons/esm/ThumbsUpIcon"; +import { + $getRoot, + CLEAR_EDITOR_COMMAND, + COMMAND_PRIORITY_HIGH, + ClearEditorPlugin, + KEY_ENTER_COMMAND, + LexicalEditor, + useLexicalComposerContext, +} from "@twilio-paste/lexical-library"; +import { ListItem, UnorderedList } from "@twilio-paste/list"; +import { Separator } from "@twilio-paste/separator"; +import { + SidePanel, + SidePanelBody, + SidePanelButton, + SidePanelContainer, + SidePanelFooter, + SidePanelHeader, + SidePanelPushContentWrapper, +} from "@twilio-paste/side-panel"; +import * as React from "react"; + +import { + AIChat, + AIChatLog, + AIChatLogger, + AIChatMessage, + AIChatMessageActionCard, + AIChatMessageActionGroup, + AIChatMessageAuthor, + AIChatMessageBody, + AIChatMessageLoading, + useAIChatLogger, +} from "../src"; + +// eslint-disable-next-line import/no-default-export +export default { + title: "Components/AI Chat Log", + component: AIChatLog, +}; + +function getRandomInt(max: number): number { + return Math.floor(Math.random() * max); +} + +const BotMessage = (props: any): JSX.Element => { + const [isLoading, setIsLoading] = React.useState(true); + + setTimeout(() => { + setIsLoading(false); + }, 3000); + return isLoading ? ( + + Good Bot + { + setIsLoading(false); + }} + /> + + ) : ( + + Good Bot + + {props.message} + + + ); +}; + +const EnterKeySubmitPlugin = ({ onKeyDown }: { onKeyDown: () => void }): null => { + const [editor] = useLexicalComposerContext(); + + const handleEnterKey = React.useCallback( + (event: KeyboardEvent) => { + const { shiftKey, ctrlKey } = event; + if (shiftKey || ctrlKey) return false; + event.preventDefault(); + event.stopPropagation(); + onKeyDown(); + editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined); + return true; + }, + [editor, onKeyDown], + ); + + React.useEffect(() => { + return editor.registerCommand(KEY_ENTER_COMMAND, handleEnterKey, COMMAND_PRIORITY_HIGH); + }, [editor, handleEnterKey]); + return null; +}; + +export const SidePanelScroll: StoryFn = () => { + const [isOpen, setIsOpen] = React.useState(true); + const [isAnimating, setIsAnimating] = React.useState(false); + const { aiChats, push } = useAIChatLogger( + { + variant: "user", + content: ( + + Gibby Radki + Hi, I am getting errors codes when sending an SMS. + + ), + }, + { + variant: "bot", + content: ( + + Good Bot + + Error codes can be returned from various parts of the process. What error codes are you encountering? + + + + + + + + + + + Is this helpful? + + + + + + ), + }, + { + variant: "user", + content: ( + + Gibby Radki + I am getting the error 30007 when attemptin to send a mass message. + + ), + }, + { + variant: "bot", + content: ( + + Good Bot + + This is an indicator that the message was filtered (blocked) by Twilio or by the carrier. This may be done + by Twilio for violating Twilio&aposs{" "} + + Messaging Policy + {" "} + or{" "} + + Acceptable Use Policy + + , or by a wireless carrier for violating carrier rules or regulations. + + + + Is this helpful? + + + + + + ), + }, + ); + const [message, setMessage] = React.useState(""); + const [mounted, setMounted] = React.useState(false); + const [userInterctedScroll, setUserInteractedScroll] = React.useState(false); + const loggerRef = React.useRef(null); + const scrollerRef = React.useRef(null); + + React.useEffect(() => { + setMounted(true); + }, []); + + const scrollToChatEnd = (): void => { + const scrollPosition: any = scrollerRef.current; + const scrollHeight: any = loggerRef.current; + scrollPosition?.scrollTo({ top: scrollHeight.scrollHeight, behavior: "smooth" }); + }; + + React.useEffect(() => { + if (!mounted || !loggerRef.current) return; + scrollToChatEnd(); + }, [aiChats, mounted]); + + const handleComposerChange = (editorState: any): void => { + editorState.read(() => { + const text = $getRoot().getTextContent(); + setMessage(text); + }); + }; + + const onAnimationEnd = (): void => { + setIsAnimating(false); + setUserInteractedScroll(false); + }; + + const onAnimationStart = (): void => { + setUserInteractedScroll(false); + setIsAnimating(true); + }; + + const userScrolled = (): void => setUserInteractedScroll(true); + + React.useEffect(() => { + scrollerRef.current?.addEventListener("wheel", userScrolled); + scrollerRef.current?.addEventListener("touchmove", userScrolled); + + const interval = setInterval(() => isAnimating && !userInterctedScroll && scrollToChatEnd(), 5); + return () => { + if (interval) clearInterval(interval); + scrollerRef.current?.removeEventListener("wheel", userScrolled); + scrollerRef.current?.removeEventListener("touchmove", userScrolled); + }; + }, [isAnimating, userInterctedScroll]); + + // eslint-disable-next-line storybook/prefer-pascal-case + const createNewMessage = (newMessage: any, forceBot?: boolean): Omit => { + const messageDirection = getRandomInt(2) === 1 && !forceBot ? "user" : "bot"; + + return { + variant: messageDirection, + content: + messageDirection === "user" ? ( + + Gibby Radki + {newMessage} + + ) : ( + + + + ), + }; + }; + + const submitMessage = (): void => { + if (message === "") return; + push(createNewMessage(message)); + }; + + const pushLargeBotMessage = (): void => { + push( + createNewMessage( + <> + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Deserunt + delectus fuga, necessitatibus eligendiiure adipisci facilis exercitationem officiis dolorem laborum, ex fugiat + quisquam itaque, earum sit nesciunt impedit repellat assumenda. new text,{" "} + + 434324 + + + Item 1 + Item 2 + Item 3 + + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Deserunt + delectus fuga, necessitatibus eligendiiure adipisci facilis exercitationem officiis dolorem laborum, ex fugiat + quisquam itaque, earum sit nesciunt impedit repellat assumenda. new text,{" "} + + 434324 + + , + true, + ), + ); + }; + + const editorInstanceRef = React.useRef(null); + + return ( + + + + + + Assistant + + + + + + + + + + + { + throw e; + }, + }} + placeholder="Type here..." + ariaLabel="Chat input" + editorInstanceRef={editorInstanceRef} + onChange={handleComposerChange} + > + + + + + + + + + + + + Toggle Side Panel + + + + + ); +}; +SidePanelScroll.parameters = { + padding: false, + a11y: { + disable: true, + }, +}; diff --git a/packages/paste-core/components/ai-chat-log/stories/useAIChatLogger.stories.tsx b/packages/paste-core/components/ai-chat-log/stories/useAIChatLogger.stories.tsx index 3c443c64b0..3fe85ba619 100644 --- a/packages/paste-core/components/ai-chat-log/stories/useAIChatLogger.stories.tsx +++ b/packages/paste-core/components/ai-chat-log/stories/useAIChatLogger.stories.tsx @@ -105,7 +105,7 @@ export const UseChatLogger: StoryFn = () => { {isBot ? "Good Bot" : "Gibby Radki"} - {message} + {message}
), }; diff --git a/packages/paste-core/components/ai-chat-log/type-docs.json b/packages/paste-core/components/ai-chat-log/type-docs.json index b5f4f87b59..972b106312 100644 --- a/packages/paste-core/components/ai-chat-log/type-docs.json +++ b/packages/paste-core/components/ai-chat-log/type-docs.json @@ -3186,6 +3186,13 @@ "required": false, "externalProp": true }, + "animated": { + "type": "boolean", + "defaultValue": false, + "required": false, + "externalProp": false, + "description": "Whether the text should be animated with type writer effect" + }, "aria-activedescendant": { "type": "string", "defaultValue": null, @@ -3688,10 +3695,11 @@ "externalProp": true }, "onAnimationEnd": { - "type": "AnimationEventHandler", - "defaultValue": null, + "type": "() => void", + "defaultValue": false, "required": false, - "externalProp": true + "externalProp": false, + "description": "A callback when the animation is complete" }, "onAnimationEndCapture": { "type": "AnimationEventHandler", @@ -3712,10 +3720,11 @@ "externalProp": true }, "onAnimationStart": { - "type": "AnimationEventHandler", - "defaultValue": null, + "type": "() => void", + "defaultValue": false, "required": false, - "externalProp": true + "externalProp": false, + "description": "A callback when the animation is started" }, "onAnimationStartCapture": { "type": "AnimationEventHandler", diff --git a/packages/paste-website/src/component-examples/AIChatLogExamples.ts b/packages/paste-website/src/component-examples/AIChatLogExamples.ts index ff9e831a7e..c6c68d2baa 100644 --- a/packages/paste-website/src/component-examples/AIChatLogExamples.ts +++ b/packages/paste-website/src/component-examples/AIChatLogExamples.ts @@ -712,3 +712,135 @@ const SystemError = () => { render( )`.trim(); + +export const animatedBotWithFeedback = ` +const AnimatedMessageWithFeedback = () => { + const [animated, setAnimated] = React.useState(true) + + const restart = () => { + setAnimated(false) + setTimeout(() => { + setAnimated(true) + }, 100) + } + + return ( + <> + + + + Good Bot + + I found multiple solutions for the issue with your environment variable, TWILIO_AUTH_TOKEN. Other helpful resources can be found at Twilio API Docs. + + + + Is this helpful? + + + + + + + + + + + + ); +}; + +render( + +)`.trim(); + +export const animatedBotScrollable = ` +const exampleAIResponseText = + "Twilio error codes are numeric codes returned by the Twilio API when an error occurs during a request, providing specific information about the problem encountered, such as invalid phone numbers, network issues, or authentication failures; they help developers identify and troubleshoot issues within their applications using Twilio services"; + +const AnimatedBotScrollable = () => { + const [isAnimating, setIsAnimating] = React.useState(false); + const [userInterctedScroll, setUserInteractedScroll] = React.useState(false); + const loggerRef = React.useRef(null); + const scrollerRef = React.useRef(null); + + const { aiChats, push } = useAIChatLogger({ + variant: "bot", + content: ( + + Good Bot + {exampleAIResponseText} + + ), + }); + + const scrollToChatEnd = () => { + const scrollPosition = scrollerRef.current; + const scrollHeight = loggerRef.current; + scrollPosition?.scrollTo({ top: scrollHeight.scrollHeight, behavior: "smooth" }); + }; + + const userScrolled = () => setUserInteractedScroll(true); + + const onAnimationEnd = () => { + setIsAnimating(false); + setUserInteractedScroll(false); + }; + + const onAnimationStart = () => { + setUserInteractedScroll(false); + setIsAnimating(true); + }; + + React.useEffect(() => { + scrollerRef.current?.addEventListener("wheel", userScrolled); + scrollerRef.current?.addEventListener("touchmove", userScrolled); + + const interval = setInterval(() => isAnimating && !userInterctedScroll && scrollToChatEnd(), 5); + + return () => { + if (interval) clearInterval(interval); + scrollerRef.current?.removeEventListener("wheel", userScrolled); + scrollerRef.current?.removeEventListener("touchmove", userScrolled); + }; + }, [isAnimating, userInterctedScroll, scrollerRef]); + + const pushLargeBotMessage = () => { + push({ + variant: "bot", + content: ( + + Good Bot + + {exampleAIResponseText} + + + ), + }); + }; + + return ( + + + + + + + ); +}; + +render( + +)`.trim(); diff --git a/packages/paste-website/src/pages/components/ai-chat-log/index.mdx b/packages/paste-website/src/pages/components/ai-chat-log/index.mdx index feb662d0cb..21f6ace080 100644 --- a/packages/paste-website/src/pages/components/ai-chat-log/index.mdx +++ b/packages/paste-website/src/pages/components/ai-chat-log/index.mdx @@ -22,6 +22,7 @@ import { Button } from "@twilio-paste/button"; import { ButtonGroup } from "@twilio-paste/button-group"; import { HelpText } from "@twilio-paste/help-text"; import { CopyIcon } from "@twilio-paste/icons/esm/CopyIcon"; +import { ResetIcon } from "@twilio-paste/icons/esm/ResetIcon"; import { LogoTwilioIcon } from "@twilio-paste/icons/esm/LogoTwilioIcon"; import { RefreshIcon } from "@twilio-paste/icons/esm/RefreshIcon"; import { ThumbsDownIcon } from "@twilio-paste/icons/esm/ThumbsDownIcon"; @@ -30,6 +31,7 @@ import { Paragraph } from "@twilio-paste/paragraph"; import { Stack } from "@twilio-paste/stack"; import { uid } from "@twilio-paste/uid-library"; + import Logo from "../../../assets/logo.svg"; import { SidebarCategoryRoutes } from "../../../constants"; @@ -58,11 +60,14 @@ import { messageGenerationError, sendingMessageError, systemError, + animatedBotScrollable } from "../../../component-examples/AIChatLogExamples"; import ComponentPageLayout from "../../../layouts/ComponentPageLayout"; import { getFeature, getNavigationData } from "../../../utils/api"; import { Text } from "@twilio-paste/text"; import { Alert } from "@twilio-paste/alert"; +import { Switch } from "@twilio-paste/switch"; +import { InlineCode } from "@twilio-paste/inline-code"; export default ComponentPageLayout; @@ -251,14 +256,14 @@ Actions can still be added in `AIChatMessageBody` which are returned from the AI {botWithBodyActions} -### Loading States +### Generating Messages + +#### Loading Use the `AIChatMessageLoading` component to indicate that the bot is typing or processing a response. During this time **no user input should be accepted**. No new messages should be added to a chat until the AI operation is finished processing. The SkeletonLoader lengths vary on each render to give a more natural pending message body interaction. -#### Loading - -#### Loading with Stop Button +##### Loading with Stop Button +#### Animating +The `AIChatMessageBody` component has an optional `animated` prop that can be used to apply a typewriter animation to the text. This should only be applied to the messages received from the AI. + +It also accepts `onAnimationStart` and `onAnimationEnd` props to trigger actions when the animation starts and ends allowing additional logic such as scrolling to be implemented. + + + {animatedBotScrollable} + + + ### Customizing Avatar `AIChatMessageAuthor` can be customized by passing an icon, image, or string to the `avatarIcon`, `avatarSrc`, or `avatarName` props. [Learn more about the API](/components/ai-chat-log/api#aichatmessageauthor).