mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-23 02:16:08 +00:00
UI updates for AppSummary messages (#146)
This commit is contained in:
parent
17684daf02
commit
d00e1a605d
@ -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>
|
||||
);
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
60
app/lib/persistence/messageAppSummary.ts
Normal file
60
app/lib/persistence/messageAppSummary.ts
Normal 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;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user