UI updates for AppSummary messages (#146)

This commit is contained in:
Brian Hackett 2025-06-09 16:37:02 -07:00 committed by GitHub
parent 17684daf02
commit d00e1a605d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 107 additions and 277 deletions

View File

@ -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 (
<div className={styles.detailView}>
@ -197,34 +199,34 @@ export const ExampleLibraryApps = ({ filterText }: ExampleLibraryAppsProps) => {
<span className={styles.detailValue}>{app.outcome.hasDatabase ? 'Present' : 'None'}</span>
</div>
<div className="text-lg font-semibold mb-2 text-bolt-elements-textPrimary">Test Results</div>
{testResults && (
{appSummary?.tests.length && (
<div className="flex flex-col gap-2">
{testResults.map((result) => (
<div key={result.title} className="flex items-center gap-2">
{appSummary.tests.map((test) => (
<div key={test.title} className="flex items-center gap-2">
<div
className={classNames('w-3 h-3 rounded-full border border-bolt-elements-borderColor', {
'bg-green-500': result.status === 'Pass',
'bg-red-500': result.status === 'Fail',
'bg-gray-300': result.status === 'NotRun',
'bg-green-500': test.status === 'Pass',
'bg-red-500': test.status === 'Fail',
'bg-gray-300': test.status === 'NotRun',
})}
/>
{result.recordingId ? (
{test.recordingId ? (
<a
href={`https://app.replay.io/recording/${result.recordingId}`}
href={`https://app.replay.io/recording/${test.recordingId}`}
className="text-bolt-elements-textPrimary hover:text-bolt-elements-textSecondary"
target="_blank"
rel="noopener noreferrer"
>
{result.title}
{test.title}
</a>
) : (
<div className="text-bolt-elements-textPrimary">{result.title}</div>
<div className="text-bolt-elements-textPrimary">{test.title}</div>
)}
</div>
))}
</div>
)}
{!testResults && <div className="text-bolt-elements-textPrimary">Loading...</div>}
{!appSummary && <div className="text-bolt-elements-textPrimary">Loading...</div>}
</div>
</div>
);

View File

@ -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<string>();
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 (
<div
data-testid="message"
@ -69,64 +31,40 @@ function renderAppFeatures(allMessages: Message[], message: Message, index: numb
>
<div className="flex flex-col gap-2">
<div className="text-lg font-semibold mb-2">Development Plan</div>
<div>{appDescription.description}</div>
{arboretumDescription && (
<>
<div className="text-lg font-semibold mb-2">Prebuilt App</div>
<div>I found a prebuilt app that will be a good starting point:</div>
<div>{arboretumDescription.description}</div>
</>
)}
<div>{appSummary.description}</div>
<div className="text-lg font-semibold mb-2">Features</div>
{appDescription.features.map((feature) => (
<div key={feature} className="flex items-center gap-2">
{appSummary.features.map((feature) => (
<div key={feature.id} className="flex items-center gap-2">
<div
className={classNames('w-3 h-3 rounded-full border border-black', {
'bg-gray-300': !finishedFeatures.has(feature),
'bg-green-500': finishedFeatures.has(feature),
'bg-gray-300': !feature.done,
'bg-green-500': feature.done,
})}
/>
<div>{feature}</div>
<div>{feature.description}</div>
</div>
))}
</div>
</div>
);
}
function renderTestResults(message: Message, index: number) {
const testResults = parseTestResultsMessage(message);
return (
<div
data-testid="message"
key={index}
className={classNames(
'flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)] mt-4 bg-bolt-elements-messages-background text-bolt-elements-textPrimary',
)}
>
<div className="flex flex-col gap-2">
<div className="text-lg font-semibold mb-2">Test Results</div>
{testResults.map((result) => (
<div key={result.title} className="flex items-center gap-2">
{appSummary.tests.length > 0 && <div className="text-lg font-semibold mb-2">Test Results</div>}
{appSummary.tests.map((test) => (
<div key={test.title} className="flex items-center gap-2">
<div
className={classNames('w-3 h-3 rounded-full border border-black', {
'bg-green-500': result.status === 'Pass',
'bg-red-500': result.status === 'Fail',
'bg-gray-300': result.status === 'NotRun',
'bg-green-500': test.status === 'Pass',
'bg-red-500': test.status === 'Fail',
'bg-gray-300': test.status === 'NotRun',
})}
/>
{result.recordingId ? (
{test.recordingId ? (
<a
href={`https://app.replay.io/recording/${result.recordingId}`}
href={`https://app.replay.io/recording/${test.recordingId}`}
className="underline hover:text-blue-600"
target="_blank"
rel="noopener noreferrer"
>
{result.title}
{test.title}
</a>
) : (
<div>{result.title}</div>
<div>{test.title}</div>
)}
</div>
))}
@ -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 (
<div
data-testid="message"
key={index}
className={classNames(
'flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)] mt-4 bg-bolt-elements-messages-background text-bolt-elements-textPrimary',
)}
>
<div className="flex flex-col gap-2">
<div className="text-lg font-semibold mb-2">Feature Done</div>
<div>{result.featureDescription}</div>
</div>
</div>
);
}
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>(
({ messages = [], hasPendingMessage = false, pendingMessageStatus = '' }, ref) => {
const [showDetailMessageIds, setShowDetailMessageIds] = useState<string[]>([]);
@ -226,36 +143,21 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>(
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<HTMLDivElement, MessagesProps>(
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) {

View File

@ -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;
}
}

View File

@ -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;
}
}