mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
UX updates for building incrementally (#130)
This commit is contained in:
parent
def5efa2e4
commit
894e151811
@ -150,9 +150,12 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
data-chat-visible={showChat}
|
||||
>
|
||||
<ClientOnly>{() => <Menu />}</ClientOnly>
|
||||
<div ref={scrollRef} className={classNames("w-full h-full flex flex-col lg:flex-row", {
|
||||
"overflow-y-auto": !chatStarted,
|
||||
})}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={classNames('w-full h-full flex flex-col lg:flex-row', {
|
||||
'overflow-y-auto': !chatStarted,
|
||||
})}
|
||||
>
|
||||
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
|
||||
{!chatStarted && <IntroSection />}
|
||||
<div
|
||||
|
@ -196,7 +196,7 @@ const ChatImplementer = memo((props: ChatProps) => {
|
||||
});
|
||||
|
||||
await storeMessageHistory(newMessages);
|
||||
|
||||
|
||||
if (!chatStore.currentChat.get()) {
|
||||
toast.error('Failed to initialize chat');
|
||||
setPendingMessageId(undefined);
|
||||
|
@ -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<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"
|
||||
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">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 className="text-lg font-semibold mb-2">Features</div>
|
||||
{appDescription.features.map((feature) => (
|
||||
<div key={feature} 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),
|
||||
})}
|
||||
/>
|
||||
<div>{feature}</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">
|
||||
<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',
|
||||
})}
|
||||
/>
|
||||
{result.recordingId ? (
|
||||
<a
|
||||
href={`https://app.replay.io/recording/${result.recordingId}`}
|
||||
className="underline hover:text-blue-600"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{result.title}
|
||||
</a>
|
||||
) : (
|
||||
<div>{result.title}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>((props: MessagesProps, ref) => {
|
||||
const { id, hasPendingMessage = false, pendingMessageStatus = '', messages = [] } = props;
|
||||
const [showDetailMessageIds, setShowDetailMessageIds] = useState<string[]>([]);
|
||||
@ -20,7 +163,7 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((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<HTMLDivElement, MessagesProps>((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 (
|
||||
<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">
|
||||
<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',
|
||||
})}
|
||||
/>
|
||||
{result.recordingId ? (
|
||||
<a
|
||||
href={`https://app.replay.io/recording/${result.recordingId}`}
|
||||
className="underline hover:text-blue-600"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{result.title}
|
||||
</a>
|
||||
) : (
|
||||
<div>{result.title}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
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<HTMLDivElement, MessagesProps>((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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user