diff --git a/app/components/chat/BaseChat/BaseChat.tsx b/app/components/chat/BaseChat/BaseChat.tsx index f01e0314..c222dbe0 100644 --- a/app/components/chat/BaseChat/BaseChat.tsx +++ b/app/components/chat/BaseChat/BaseChat.tsx @@ -150,9 +150,12 @@ export const BaseChat = React.forwardRef( data-chat-visible={showChat} > {() => } -
+
{!chatStarted && }
{ }); await storeMessageHistory(newMessages); - + if (!chatStore.currentChat.get()) { toast.error('Failed to initialize chat'); setPendingMessageId(undefined); diff --git a/app/components/chat/Messages.client.tsx b/app/components/chat/Messages.client.tsx index c7110d3b..df065d7a 100644 --- a/app/components/chat/Messages.client.tsx +++ b/app/components/chat/Messages.client.tsx @@ -1,7 +1,19 @@ import React, { Suspense, useState } from 'react'; import { classNames } from '~/utils/classNames'; import WithTooltip from '~/components/ui/Tooltip'; -import { parseTestResultsMessage, type Message, TEST_RESULTS_CATEGORY } from '~/lib/persistence/message'; +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 { @@ -12,6 +24,137 @@ interface MessagesProps { 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([]); @@ -20,7 +163,7 @@ export const Messages = React.forwardRef((props: // 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 === 'UserResponse') { + if (messages[i].category === USER_RESPONSE_CATEGORY) { return messages[i]; } if (messages[i].role === 'user') { @@ -35,55 +178,27 @@ export const Messages = React.forwardRef((props: let lastIndex = -1; for (let i = index; i < messages.length; i++) { const { category } = messages[i]; - if (category === 'UserResponse') { + if (category === USER_RESPONSE_CATEGORY) { return lastIndex === index; } - if (category === 'TestResults') { + if (category === TEST_RESULTS_CATEGORY) { lastIndex = i; } } return lastIndex === index; }; - const renderTestResults = (message: Message, index: number) => { - const testResults = parseTestResultsMessage(message); - - return ( -
-
-
Test Results
- {testResults.map((result) => ( -
-
- {result.recordingId ? ( - - {result.title} - - ) : ( -
{result.title}
- )} -
- ))} -
-
- ); + 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) => { @@ -92,17 +207,36 @@ export const Messages = React.forwardRef((props: const isFirst = index === 0; const isLast = index === messages.length - 1; - if (!isUserMessage && message.category && message.category !== 'UserResponse') { + 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); - } else if (!showDetails) { + } + + 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; } } diff --git a/app/lib/persistence/message.ts b/app/lib/persistence/message.ts index 383ffb55..68c1c21d 100644 --- a/app/lib/persistence/message.ts +++ b/app/lib/persistence/message.ts @@ -1,6 +1,7 @@ // Client messages match the format used by the Nut protocol. import { generateId } from '~/utils/fileUtils'; +import { assert } from '~/lib/replay/ReplayProtocolClient'; type MessageRole = 'user' | 'assistant'; @@ -28,12 +29,22 @@ export interface MessageImage extends MessageBase { export type Message = MessageText | MessageImage; +function ignoreMessageRepositoryId(message: Message) { + if (message.category === SEARCH_ARBORETUM_CATEGORY) { + // Repositories associated with Arboretum search results have details abstracted + // and shouldn't be displayed in the UI. We should get a new message shortly + // afterwards with the repository instantiated for details in this request. + return true; + } + return false; +} + // Get the repositoryId before any changes in the message at the given index. export function getPreviousRepositoryId(messages: Message[], index: number): string | undefined { for (let i = index - 1; i >= 0; i--) { const message = messages[i]; - if (message.repositoryId) { + if (message.repositoryId && !ignoreMessageRepositoryId(message)) { return message.repositoryId; } } @@ -69,6 +80,10 @@ export function createMessagesForRepository(title: string, repositoryId: string) return messages; } +// Category for the initial response made to every user message. +// All messages up to the next UserResponse are responding to this message. +export const USER_RESPONSE_CATEGORY = 'UserResponse'; + export enum PlaywrightTestStatus { Pass = 'Pass', Fail = 'Fail', @@ -81,6 +96,7 @@ export interface PlaywrightTestResult { recordingId?: string; } +// Message sent whenever tests have been run. export const TEST_RESULTS_CATEGORY = 'TestResults'; export function parseTestResultsMessage(message: Message): PlaywrightTestResult[] { @@ -104,3 +120,72 @@ export function parseTestResultsMessage(message: Message): PlaywrightTestResult[ } return results; } + +// Message sent after the initial user response to describe the app's features. +// Contents are a JSON-stringified AppDescription. +export const DESCRIBE_APP_CATEGORY = 'DescribeApp'; + +export interface AppDescription { + // Short description of the app's overall purpose. + description: string; + + // Short descriptions of each feature of the app, in the order they should be implemented. + features: string[]; +} + +export function parseDescribeAppMessage(message: Message): AppDescription | undefined { + try { + assert(message.type === 'text', 'Message is not a text message'); + const appDescription = JSON.parse(message.content) as AppDescription; + assert(appDescription.description, 'Missing description'); + assert(appDescription.features, 'Missing features'); + return appDescription; + } catch (e) { + console.error('Failed to parse describe app message', e); + return undefined; + } +} + +// Message sent when a match was found in the arboretum. +// Contents are a JSON-stringified ArboretumMatch. +export const SEARCH_ARBORETUM_CATEGORY = 'SearchArboretum'; + +export interface BestAppFeatureResult { + arboretumRepositoryId: string; + arboretumDescription: AppDescription; + revisedDescription: AppDescription; +} + +export function parseSearchArboretumResult(message: Message): BestAppFeatureResult | undefined { + try { + assert(message.type === 'text', 'Message is not a text message'); + const bestAppFeatureResult = JSON.parse(message.content) as BestAppFeatureResult; + assert(bestAppFeatureResult.arboretumRepositoryId, 'Missing arboretum repository id'); + assert(bestAppFeatureResult.arboretumDescription, 'Missing arboretum description'); + assert(bestAppFeatureResult.revisedDescription, 'Missing revised description'); + return bestAppFeatureResult; + } catch (e) { + console.error('Failed to parse best app feature result message', e); + return undefined; + } +} + +// Message sent when a feature has finished being implemented. +export const FEATURE_DONE_CATEGORY = 'FeatureDone'; + +export interface FeatureDoneResult { + implementedFeatureIndex: number; + featureDescription: string; +} + +export function parseFeatureDoneMessage(message: Message): FeatureDoneResult | undefined { + try { + assert(message.type === 'text', 'Message is not a text message'); + const featureDoneResult = JSON.parse(message.content) as FeatureDoneResult; + assert(featureDoneResult.featureDescription, 'Missing feature description'); + return featureDoneResult; + } catch (e) { + console.error('Failed to parse feature done message', e); + return undefined; + } +} diff --git a/app/lib/replay/ChatManager.ts b/app/lib/replay/ChatManager.ts index ab14ce3f..c71df69a 100644 --- a/app/lib/replay/ChatManager.ts +++ b/app/lib/replay/ChatManager.ts @@ -262,7 +262,7 @@ class ChatManager { await this.client.sendCommand({ method: 'Nut.sendChatMessage', - params: { chatId, responseId, messages, references }, + params: { chatId, responseId, mode: 'BuildAppIncremental', messages, references }, }); console.log('ChatMessageFinished', new Date().toISOString(), chatId);