diff --git a/app/components/chat/Messages.client.tsx b/app/components/chat/Messages.client.tsx deleted file mode 100644 index df065d7a..00000000 --- a/app/components/chat/Messages.client.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import React, { Suspense, useState } from 'react'; -import { classNames } from '~/utils/classNames'; -import WithTooltip from '~/components/ui/Tooltip'; -import { - parseTestResultsMessage, - type Message, - TEST_RESULTS_CATEGORY, - DESCRIBE_APP_CATEGORY, - parseDescribeAppMessage, - SEARCH_ARBORETUM_CATEGORY, - type AppDescription, - parseSearchArboretumResult, - FEATURE_DONE_CATEGORY, - parseFeatureDoneMessage, - USER_RESPONSE_CATEGORY, -} from '~/lib/persistence/message'; -import { MessageContents } from './MessageContents'; - -interface MessagesProps { - id?: string; - className?: string; - hasPendingMessage?: boolean; - pendingMessageStatus?: string; - messages?: Message[]; -} - -function renderAppFeatures(allMessages: Message[], message: Message, index: number) { - let arboretumDescription: AppDescription | undefined; - let appDescription: AppDescription | undefined; - switch (message.category) { - case DESCRIBE_APP_CATEGORY: - appDescription = parseDescribeAppMessage(message); - break; - case SEARCH_ARBORETUM_CATEGORY: { - const result = parseSearchArboretumResult(message); - if (result) { - arboretumDescription = result.arboretumDescription; - appDescription = result.revisedDescription; - } - break; - } - } - - if (!appDescription) { - return null; - } - - const finishedFeatures = new Set(); - for (let i = index; i < allMessages.length; i++) { - if (allMessages[i].category == USER_RESPONSE_CATEGORY) { - break; - } - if (allMessages[i].category == FEATURE_DONE_CATEGORY) { - const result = parseFeatureDoneMessage(allMessages[i]); - if (result) { - finishedFeatures.add(result.featureDescription); - } - } - } - - return ( -
-
-
Development Plan
-
{appDescription.description}
- {arboretumDescription && ( - <> -
Prebuilt App
-
I found a prebuilt app that will be a good starting point:
-
{arboretumDescription.description}
- - )} -
Features
- {appDescription.features.map((feature) => ( -
-
-
{feature}
-
- ))} -
-
- ); -} - -function renderTestResults(message: Message, index: number) { - const testResults = parseTestResultsMessage(message); - - return ( -
-
-
Test Results
- {testResults.map((result) => ( -
-
- {result.recordingId ? ( - - {result.title} - - ) : ( -
{result.title}
- )} -
- ))} -
-
- ); -} - -function renderFeatureDone(message: Message, index: number) { - const result = parseFeatureDoneMessage(message); - if (!result) { - return null; - } - return ( -
-
-
Feature Done
-
{result.featureDescription}
-
-
- ); -} - -export const Messages = React.forwardRef((props: MessagesProps, ref) => { - const { id, hasPendingMessage = false, pendingMessageStatus = '', messages = [] } = props; - const [showDetailMessageIds, setShowDetailMessageIds] = useState([]); - - // Get the last user response before a given message, or null if there is - // no user response between this and the last user message. - const getLastUserResponse = (index: number) => { - for (let i = index - 1; i >= 0; i--) { - if (messages[i].category === USER_RESPONSE_CATEGORY) { - return messages[i]; - } - if (messages[i].role === 'user') { - return null; - } - } - return null; - }; - - // Return whether the test results at index are the last for the associated user response. - const isLastTestResults = (index: number) => { - let lastIndex = -1; - for (let i = index; i < messages.length; i++) { - const { category } = messages[i]; - if (category === USER_RESPONSE_CATEGORY) { - return lastIndex === index; - } - if (category === TEST_RESULTS_CATEGORY) { - lastIndex = i; - } - } - return lastIndex === index; - }; - - const hasLaterSearchArboretumMessage = (index: number) => { - for (let i = index + 1; i < messages.length; i++) { - const { category } = messages[i]; - if (category === USER_RESPONSE_CATEGORY) { - return false; - } - if (category === SEARCH_ARBORETUM_CATEGORY) { - return true; - } - } - return false; - }; - - const renderMessage = (message: Message, index: number) => { - const { role, repositoryId } = message; - const isUserMessage = role === 'user'; - const isFirst = index === 0; - const isLast = index === messages.length - 1; - - if (!isUserMessage && message.category && message.category !== USER_RESPONSE_CATEGORY) { - const lastUserResponse = getLastUserResponse(index); - const showDetails = !lastUserResponse || showDetailMessageIds.includes(lastUserResponse.id); - - if (message.category === DESCRIBE_APP_CATEGORY) { - // We only render the DescribeApp if there is no later arboretum match, - // which will be rendered instead. - if (hasLaterSearchArboretumMessage(index) && !showDetails) { - return null; - } - return renderAppFeatures(messages, message, index); - } - - if (message.category === TEST_RESULTS_CATEGORY) { - // The default view only shows the last test results for each user response. - if (!isLastTestResults(index) && !showDetails) { - return null; - } - return renderTestResults(message, index); - } - - if (message.category === SEARCH_ARBORETUM_CATEGORY) { - return renderAppFeatures(messages, message, index); - } - - if (message.category === FEATURE_DONE_CATEGORY && showDetails) { - return renderFeatureDone(message, index); - } - - if (!showDetails) { - return null; - } - } - - return ( -
-
- } - > - {isUserMessage && ( -
-
-
- )} -
- -
- {!isUserMessage && message.category === 'UserResponse' && showDetailMessageIds.includes(message.id) && ( -
- -
- )} - {!isUserMessage && message.category === 'UserResponse' && !showDetailMessageIds.includes(message.id) && ( -
- -
- )} - {repositoryId && ( -
- -
- )} - -
- ); - }; - - return ( -
- {messages.length > 0 ? messages.map(renderMessage) : null} - {hasPendingMessage && ( -
- - {pendingMessageStatus ? `${pendingMessageStatus}...` : ''} -
- )} -
- ); -}); diff --git a/app/components/chat/Messages/Messages.client.tsx b/app/components/chat/Messages/Messages.client.tsx new file mode 100644 index 00000000..9410708e --- /dev/null +++ b/app/components/chat/Messages/Messages.client.tsx @@ -0,0 +1,386 @@ +import React, { Suspense, useState, useEffect, useRef, useCallback } from 'react'; +import { classNames } from '~/utils/classNames'; +import WithTooltip from '~/components/ui/Tooltip'; +import { + parseTestResultsMessage, + type Message, + TEST_RESULTS_CATEGORY, + DESCRIBE_APP_CATEGORY, + parseDescribeAppMessage, + SEARCH_ARBORETUM_CATEGORY, + type AppDescription, + parseSearchArboretumResult, + FEATURE_DONE_CATEGORY, + parseFeatureDoneMessage, + USER_RESPONSE_CATEGORY, +} from '~/lib/persistence/message'; +import { MessageContents } from './components/MessageContents'; +import { JumpToBottom } from './components/JumpToBottom'; + +interface MessagesProps { + id?: string; + className?: string; + hasPendingMessage?: boolean; + pendingMessageStatus?: string; + messages?: Message[]; +} + +function renderAppFeatures(allMessages: Message[], message: Message, index: number) { + let arboretumDescription: AppDescription | undefined; + let appDescription: AppDescription | undefined; + switch (message.category) { + case DESCRIBE_APP_CATEGORY: + appDescription = parseDescribeAppMessage(message); + break; + case SEARCH_ARBORETUM_CATEGORY: { + const result = parseSearchArboretumResult(message); + if (result) { + arboretumDescription = result.arboretumDescription; + appDescription = result.revisedDescription; + } + break; + } + } + + if (!appDescription) { + return null; + } + + const finishedFeatures = new Set(); + for (let i = index; i < allMessages.length; i++) { + if (allMessages[i].category == USER_RESPONSE_CATEGORY) { + break; + } + if (allMessages[i].category == FEATURE_DONE_CATEGORY) { + const result = parseFeatureDoneMessage(allMessages[i]); + if (result) { + finishedFeatures.add(result.featureDescription); + } + } + } + + return ( +
+
+
Development Plan
+
{appDescription.description}
+ {arboretumDescription && ( + <> +
Prebuilt App
+
I found a prebuilt app that will be a good starting point:
+
{arboretumDescription.description}
+ + )} +
Features
+ {appDescription.features.map((feature) => ( +
+
+
{feature}
+
+ ))} +
+
+ ); +} + +function renderTestResults(message: Message, index: number) { + const testResults = parseTestResultsMessage(message); + + return ( +
+
+
Test Results
+ {testResults.map((result) => ( +
+
+ {result.recordingId ? ( + + {result.title} + + ) : ( +
{result.title}
+ )} +
+ ))} +
+
+ ); +} + +function renderFeatureDone(message: Message, index: number) { + const result = parseFeatureDoneMessage(message); + if (!result) { + return null; + } + return ( +
+
+
Feature Done
+
{result.featureDescription}
+
+
+ ); +} + +export const Messages = React.forwardRef( + ({ messages = [], hasPendingMessage = false, pendingMessageStatus = '' }, ref) => { + const [showDetailMessageIds, setShowDetailMessageIds] = useState([]); + const [showJumpToBottom, setShowJumpToBottom] = useState(false); + const containerRef = useRef(null); + + const setRefs = useCallback( + (element: HTMLDivElement | null) => { + containerRef.current = element; + + if (typeof ref === 'function') { + ref(element); + } else if (ref) { + ref.current = element; + } + }, + [ref] + ); + + const handleScroll = () => { + if (!containerRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = containerRef.current; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + + setShowJumpToBottom(distanceFromBottom > 50); + }; + + const scrollToBottom = () => { + if (!containerRef.current) return; + + containerRef.current.scrollTo({ + top: containerRef.current.scrollHeight, + behavior: 'smooth' + }); + }; + + useEffect(() => { + const container = containerRef.current; + if (container) { + container.addEventListener('scroll', handleScroll); + return () => container.removeEventListener('scroll', handleScroll); + } + }, []); + + useEffect(() => { + if (!showJumpToBottom) { + scrollToBottom(); + } + }, [messages, showJumpToBottom]); + + // Get the last user response before a given message, or null if there is + // no user response between this and the last user message. + const getLastUserResponse = (index: number) => { + for (let i = index - 1; i >= 0; i--) { + if (messages[i].category === USER_RESPONSE_CATEGORY) { + return messages[i]; + } + if (messages[i].role === 'user') { + return null; + } + } + return null; + }; + + // Return whether the test results at index are the last for the associated user response. + const isLastTestResults = (index: number) => { + let lastIndex = -1; + for (let i = index; i < messages.length; i++) { + const { category } = messages[i]; + if (category === USER_RESPONSE_CATEGORY) { + return lastIndex === index; + } + if (category === TEST_RESULTS_CATEGORY) { + lastIndex = i; + } + } + return lastIndex === index; + }; + + const hasLaterSearchArboretumMessage = (index: number) => { + for (let i = index + 1; i < messages.length; i++) { + const { category } = messages[i]; + if (category === USER_RESPONSE_CATEGORY) { + return false; + } + if (category === SEARCH_ARBORETUM_CATEGORY) { + return true; + } + } + return false; + }; + + const renderMessage = (message: Message, index: number) => { + const { role, repositoryId } = message; + const isUserMessage = role === 'user'; + const isFirst = index === 0; + const isLast = index === messages.length - 1; + + if (!isUserMessage && message.category && message.category !== USER_RESPONSE_CATEGORY) { + const lastUserResponse = getLastUserResponse(index); + const showDetails = !lastUserResponse || showDetailMessageIds.includes(lastUserResponse.id); + + if (message.category === DESCRIBE_APP_CATEGORY) { + // We only render the DescribeApp if there is no later arboretum match, + // which will be rendered instead. + if (hasLaterSearchArboretumMessage(index) && !showDetails) { + return null; + } + return renderAppFeatures(messages, message, index); + } + + if (message.category === TEST_RESULTS_CATEGORY) { + // The default view only shows the last test results for each user response. + if (!isLastTestResults(index) && !showDetails) { + return null; + } + return renderTestResults(message, index); + } + + if (message.category === SEARCH_ARBORETUM_CATEGORY) { + return renderAppFeatures(messages, message, index); + } + + if (message.category === FEATURE_DONE_CATEGORY && showDetails) { + return renderFeatureDone(message, index); + } + + if (!showDetails) { + return null; + } + } + + return ( +
+
+ } + > + {isUserMessage && ( +
+
+
+ )} +
+ +
+ {!isUserMessage && message.category === 'UserResponse' && showDetailMessageIds.includes(message.id) && ( +
+ +
+ )} + {!isUserMessage && message.category === 'UserResponse' && !showDetailMessageIds.includes(message.id) && ( +
+ +
+ )} + {repositoryId && ( +
+ +
+ )} + +
+ ); + }; + + return ( +
+
+ {messages.length > 0 ? messages.map(renderMessage) : null} + {hasPendingMessage && ( +
+ + {pendingMessageStatus ? `${pendingMessageStatus}...` : ''} +
+ )} +
+ +
+ ); +}); diff --git a/app/components/chat/MessageContents.tsx b/app/components/chat/Messages/components/MessageContents.tsx similarity index 94% rename from app/components/chat/MessageContents.tsx rename to app/components/chat/Messages/components/MessageContents.tsx index 8e5508d0..182f2350 100644 --- a/app/components/chat/MessageContents.tsx +++ b/app/components/chat/Messages/components/MessageContents.tsx @@ -2,7 +2,7 @@ * @ts-nocheck * Preventing TS checks with files presented in the video for a better presentation. */ -import { Markdown } from './Markdown'; +import { Markdown } from '~/components/chat/Markdown'; import type { Message } from '~/lib/persistence/message'; interface MessageContentsProps {