From d00e1a605d0f34a58fbf66ec20c83f26d64e19f6 Mon Sep 17 00:00:00 2001 From: Brian Hackett Date: Mon, 9 Jun 2025 16:37:02 -0700 Subject: [PATCH] UI updates for AppSummary messages (#146) --- .../app-library/ExampleLibraryApps.tsx | 34 ++-- .../chat/Messages/Messages.client.tsx | 171 +++--------------- app/lib/persistence/message.ts | 119 +----------- app/lib/persistence/messageAppSummary.ts | 60 ++++++ 4 files changed, 107 insertions(+), 277 deletions(-) create mode 100644 app/lib/persistence/messageAppSummary.ts diff --git a/app/components/app-library/ExampleLibraryApps.tsx b/app/components/app-library/ExampleLibraryApps.tsx index 4364ee5b..d81202eb 100644 --- a/app/components/app-library/ExampleLibraryApps.tsx +++ b/app/components/app-library/ExampleLibraryApps.tsx @@ -3,8 +3,10 @@ import { useEffect, useState } from 'react'; import { type BuildAppResult, type BuildAppSummary, getAppById, getRecentApps } from '~/lib/persistence/apps'; import styles from './ExampleLibraryApps.module.scss'; -import { getMessagesRepositoryId, parseTestResultsMessage, TEST_RESULTS_CATEGORY } from '~/lib/persistence/message'; +import { getMessagesRepositoryId } from '~/lib/persistence/message'; import { classNames } from '~/utils/classNames'; +import { APP_SUMMARY_CATEGORY } from '~/lib/persistence/messageAppSummary'; +import { parseAppSummaryMessage } from '~/lib/persistence/messageAppSummary'; const formatDate = (date: Date) => { return new Intl.DateTimeFormat('en-US', { @@ -139,9 +141,9 @@ export const ExampleLibraryApps = ({ filterText }: ExampleLibraryAppsProps) => { ); }; - const getTestResults = (appContents: BuildAppResult) => { - const message = appContents.messages.findLast((message) => message.category === TEST_RESULTS_CATEGORY); - return message ? parseTestResultsMessage(message) : []; + const getAppSummary = (appContents: BuildAppResult) => { + const message = appContents.messages.findLast((message) => message.category === APP_SUMMARY_CATEGORY); + return message ? parseAppSummaryMessage(message) : null; }; const renderAppDetails = (appId: string, appContents: BuildAppResult | null) => { @@ -150,7 +152,7 @@ export const ExampleLibraryApps = ({ filterText }: ExampleLibraryAppsProps) => { return null; } - const testResults = appContents ? getTestResults(appContents) : null; + const appSummary = appContents ? getAppSummary(appContents) : null; return (
@@ -197,34 +199,34 @@ export const ExampleLibraryApps = ({ filterText }: ExampleLibraryAppsProps) => { {app.outcome.hasDatabase ? 'Present' : 'None'}
Test Results
- {testResults && ( + {appSummary?.tests.length && (
- {testResults.map((result) => ( -
+ {appSummary.tests.map((test) => ( +
- {result.recordingId ? ( + {test.recordingId ? ( - {result.title} + {test.title} ) : ( -
{result.title}
+
{test.title}
)}
))}
)} - {!testResults &&
Loading...
} + {!appSummary &&
Loading...
}
); diff --git a/app/components/chat/Messages/Messages.client.tsx b/app/components/chat/Messages/Messages.client.tsx index e40a979e..4de6d6ab 100644 --- a/app/components/chat/Messages/Messages.client.tsx +++ b/app/components/chat/Messages/Messages.client.tsx @@ -1,21 +1,10 @@ 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 { type Message, USER_RESPONSE_CATEGORY } from '~/lib/persistence/message'; import { MessageContents } from './components/MessageContents'; import { JumpToBottom } from './components/JumpToBottom'; +import { APP_SUMMARY_CATEGORY, parseAppSummaryMessage } from '~/lib/persistence/messageAppSummary'; interface MessagesProps { id?: string; @@ -25,40 +14,13 @@ 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; - } - } +function renderAppSummary(message: Message, index: number) { + const appSummary = parseAppSummaryMessage(message); - if (!appDescription) { + if (!appSummary) { 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}
- - )} +
{appSummary.description}
Features
- {appDescription.features.map((feature) => ( -
+ {appSummary.features.map((feature) => ( +
-
{feature}
+
{feature.description}
))} -
-
- ); -} - -function renderTestResults(message: Message, index: number) { - const testResults = parseTestResultsMessage(message); - - return ( -
-
-
Test Results
- {testResults.map((result) => ( -
+ {appSummary.tests.length > 0 &&
Test Results
} + {appSummary.tests.map((test) => ( +
- {result.recordingId ? ( + {test.recordingId ? ( - {result.title} + {test.title} ) : ( -
{result.title}
+
{test.title}
)}
))} @@ -135,27 +73,6 @@ function renderTestResults(message: Message, index: number) { ); } -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([]); @@ -226,36 +143,21 @@ export const Messages = React.forwardRef( return null; }; - // Return whether the test results at index are the last for the associated user response. - const isLastTestResults = (index: number) => { + // Return whether the app summary at index is the last for the associated user response. + const isLastAppSummary = (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) { + if (category === APP_SUMMARY_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; - } - // Only return on successful searches. Failed searches do not have - // a valid result. - if (category === SEARCH_ARBORETUM_CATEGORY && parseSearchArboretumResult(messages[i])) { - return true; - } - } - return false; - }; - const renderMessage = (message: Message, index: number) => { const { role, repositoryId } = message; const isUserMessage = role === 'user'; @@ -266,29 +168,12 @@ export const Messages = React.forwardRef( 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) { + if (message.category === APP_SUMMARY_CATEGORY) { + // The default view only shows the last app summary for each user response. + if (!isLastAppSummary(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); + return renderAppSummary(message, index); } if (!showDetails) { diff --git a/app/lib/persistence/message.ts b/app/lib/persistence/message.ts index 59fa8041..3a199afd 100644 --- a/app/lib/persistence/message.ts +++ b/app/lib/persistence/message.ts @@ -1,7 +1,6 @@ // 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'; @@ -31,22 +30,12 @@ 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 && !ignoreMessageRepositoryId(message)) { + if (message.repositoryId) { return message.repositoryId; } } @@ -85,109 +74,3 @@ export function createMessagesForRepository(title: string, repositoryId: string) // 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', - NotRun = 'NotRun', -} - -export interface PlaywrightTestResult { - title: string; - status: PlaywrightTestStatus; - recordingId?: string; -} - -// Message sent whenever tests have been run. -export const TEST_RESULTS_CATEGORY = 'TestResults'; - -export function parseTestResultsMessage(message: Message): PlaywrightTestResult[] { - if (message.type !== 'text') { - return []; - } - - const results: PlaywrightTestResult[] = []; - const lines = message.content.split('\n'); - for (const line of lines) { - const match = line.match(/TestResult (.*?) (.*?) (.*)/); - if (!match) { - continue; - } - const [status, recordingId, title] = match.slice(1); - results.push({ - status: status as PlaywrightTestStatus, - title, - recordingId: recordingId == 'NoRecording' ? undefined : recordingId, - }); - } - 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/persistence/messageAppSummary.ts b/app/lib/persistence/messageAppSummary.ts new file mode 100644 index 00000000..24154ae7 --- /dev/null +++ b/app/lib/persistence/messageAppSummary.ts @@ -0,0 +1,60 @@ +// Routines for parsing the current state of the app from backend messages. + +import { assert } from '~/lib/replay/ReplayProtocolClient'; +import type { Message } from './message'; + +// Message sent whenever the app summary is updated. +export const APP_SUMMARY_CATEGORY = 'AppSummary'; + +export interface AppFeature { + id: number; + description: string; + + // Set when the feature has been implemented and all tests pass. + done: boolean; +} + +export enum PlaywrightTestStatus { + Pass = 'Pass', + Fail = 'Fail', + NotRun = 'NotRun', +} + +export interface AppTest { + title: string; + featureId?: number; + status: PlaywrightTestStatus; + recordingId?: string; +} + +export interface AppAbstraction { + // Name of the abstraction as referred to in the abstracted description. + name: string; + + // Value in the original client messages which this abstraction represents. + representation: string; +} + +export interface AppSummary { + description: string; + abstractions: AppAbstraction[]; + features: AppFeature[]; + tests: AppTest[]; + + // Any planned feature for which initial code changes have been made but not + // all tests are passing yet. + inProgressFeatureId?: number; +} + +export function parseAppSummaryMessage(message: Message): AppSummary | undefined { + try { + assert(message.category === APP_SUMMARY_CATEGORY, 'Message is not an app summary message'); + assert(message.type === 'text', 'Message is not a text message'); + const appSummary = JSON.parse(message.content) as AppSummary; + assert(appSummary.description, 'Missing app description'); + return appSummary; + } catch (e) { + console.error('Failed to parse feature done message', e); + return undefined; + } +}