Merge pull request #1 from replayio/recording-button

Add button to save recording, assorted other UX changes
This commit is contained in:
Brian Hackett
2025-01-07 05:56:18 -10:00
committed by GitHub
parent 1f938fca40
commit c3e1764da3
22 changed files with 1598 additions and 94 deletions

View File

@@ -22,11 +22,11 @@ if ! pnpm typecheck; then
fi
# Run lint
echo "Running lint..."
if ! pnpm lint; then
echo "❌ Linting failed! Run 'pnpm lint:fix' to fix the easy issues."
echo "Once you're done, don't forget to add your beautification to the commit! 🤩"
exit 1
fi
#echo "Running lint..."
#if ! pnpm lint; then
# echo "❌ Linting failed! Run 'pnpm lint:fix' to fix the easy issues."
# echo "Once you're done, don't forget to add your beautification to the commit! 🤩"
# exit 1
#fi
echo "👍 All checks passed! Committing changes..."

View File

@@ -7,7 +7,7 @@ interface AssistantMessageProps {
annotations?: JSONValue[];
}
export const AssistantMessage = memo(({ content, annotations }: AssistantMessageProps) => {
export function getAnnotationsTokensUsage(annotations: JSONValue[] | undefined) {
const filteredAnnotations = (annotations?.filter(
(annotation: JSONValue) => annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'),
) || []) as { type: string; value: any }[];
@@ -18,6 +18,12 @@ export const AssistantMessage = memo(({ content, annotations }: AssistantMessage
totalTokens: number;
} = filteredAnnotations.find((annotation) => annotation.type === 'usage')?.value;
return usage;
}
export const AssistantMessage = memo(({ content, annotations }: AssistantMessageProps) => {
const usage = getAnnotationsTokensUsage(annotations);
return (
<div className="overflow-hidden w-full">
{usage && (

View File

@@ -22,6 +22,8 @@ import { useSettings } from '~/lib/hooks/useSettings';
import type { ProviderInfo } from '~/types/model';
import { useSearchParams } from '@remix-run/react';
import { createSampler } from '~/utils/sampler';
import { saveProjectPrompt } from './Messages.client';
import { uint8ArrayToBase64 } from '../workbench/ReplayProtocolClient';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
@@ -314,6 +316,14 @@ export const ChatImpl = memo(
resetEnhancer();
textareaRef.current?.blur();
// The project contents are associated with the last message present when
// the user message is added.
const lastMessage = messages[messages.length - 1];
const { content, uniqueProjectName } = await workbenchStore.generateZip();
const buf = await content.arrayBuffer();
const contentBase64 = uint8ArrayToBase64(new Uint8Array(buf));
saveProjectPrompt(lastMessage.id, { content: contentBase64, uniqueProjectName, input: _input });
};
/**

View File

@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import type { Message } from 'ai';
import { toast } from 'react-toastify';
import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils';
import { createChatFromFolder } from '~/utils/folderImport';
import { createChatFromFolder, getFileArtifacts } from '~/utils/folderImport';
import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location
interface ImportFolderButtonProps {
@@ -73,7 +73,8 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
toast.info(`Skipping ${binaryFilePaths.length} binary files`);
}
const messages = await createChatFromFolder(textFiles, binaryFilePaths, folderName);
const textFileArtifacts = await getFileArtifacts(textFiles);
const messages = await createChatFromFolder(textFileArtifacts, binaryFilePaths, folderName);
if (importChat) {
await importChat(folderName, [...messages]);

View File

@@ -0,0 +1,114 @@
import React, { useState } from 'react';
import type { Message } from 'ai';
import { toast } from 'react-toastify';
import { createChatFromFolder, type FileArtifact } from '~/utils/folderImport';
import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location
import { sendCommandDedicatedClient } from '../workbench/ReplayProtocolClient';
import type { BoltProblem } from './Messages.client';
import JSZip from 'jszip';
interface LoadProblemButtonProps {
className?: string;
importChat?: (description: string, messages: Message[]) => Promise<void>;
}
export const LoadProblemButton: React.FC<LoadProblemButtonProps> = ({ className, importChat }) => {
const [isLoading, setIsLoading] = useState(false);
const [isInputOpen, setIsInputOpen] = useState(false);
const handleSubmit = async (e: React.ChangeEvent<HTMLInputElement>) => {
setIsLoading(true);
setIsInputOpen(false);
const problemId = (document.getElementById('problem-input') as HTMLInputElement)?.value;
let problem: BoltProblem | null = null;
try {
const rv = await sendCommandDedicatedClient({
method: "Recording.globalExperimentalCommand",
params: {
name: "fetchBoltProblem",
params: { problemId },
},
});
console.log("FetchProblemRval", rv);
problem = (rv as any).rval.problem;
} catch (error) {
console.error("Error fetching problem", error);
toast.error("Failed to fetch problem");
}
if (!problem) {
return;
}
console.log("Problem", problem);
const zip = new JSZip();
await zip.loadAsync(problem.prompt.content, { base64: true });
const fileArtifacts: FileArtifact[] = [];
for (const [key, object] of Object.entries(zip.files)) {
if (object.dir) continue;
fileArtifacts.push({
content: await object.async('text'),
path: key,
});
}
const folderName = problem.prompt.uniqueProjectName;
try {
const messages = await createChatFromFolder(fileArtifacts, [], folderName);
if (importChat) {
await importChat(folderName, [...messages]);
}
logStore.logSystem('Problem loaded successfully', {
problemId,
textFileCount: fileArtifacts.length,
});
toast.success('Problem loaded successfully');
} catch (error) {
logStore.logError('Failed to load problem', error);
console.error('Failed to load problem:', error);
toast.error('Failed to load problem');
} finally {
setIsLoading(false);
}
};
return (
<>
{isInputOpen && (
<input
id="problem-input"
type="text"
webkitdirectory=""
directory=""
onChange={() => {}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSubmit(e as any);
}
}}
className="border border-gray-300 rounded px-2 py-1"
{...({} as any)}
/>
)}
{!isInputOpen && (
<button
onClick={() => {
setIsInputOpen(true);
}}
className={className}
disabled={isLoading}
>
<div className="i-ph:globe" />
{isLoading ? 'Loading...' : 'Load Problem'}
</button>
)}
</>
);
};

View File

@@ -1,13 +1,17 @@
import type { Message } from 'ai';
import React from 'react';
import React, { useState } from 'react';
import { classNames } from '~/utils/classNames';
import { AssistantMessage } from './AssistantMessage';
import { AssistantMessage, getAnnotationsTokensUsage } from './AssistantMessage';
import { UserMessage } from './UserMessage';
import { useLocation } from '@remix-run/react';
import { db, chatId } from '~/lib/persistence/useChatHistory';
import { forkChat } from '~/lib/persistence/db';
import { toast } from 'react-toastify';
import WithTooltip from '~/components/ui/Tooltip';
import { assert, sendCommandDedicatedClient } from "~/components/workbench/ReplayProtocolClient";
import ReactModal from 'react-modal';
ReactModal.setAppElement('#root');
interface MessagesProps {
id?: string;
@@ -16,9 +20,44 @@ interface MessagesProps {
messages?: Message[];
}
// Combines information about the contents of a project along with a prompt
// from the user and any associated Replay data to accomplish a task. Together
// this information is enough that the model should be able to generate a
// suitable fix.
//
// Must be JSON serializable.
interface ProjectPrompt {
content: string; // base64 encoded
uniqueProjectName: string;
input: string;
}
export interface BoltProblem {
title: string;
description: string;
name: string;
email: string;
prompt: ProjectPrompt;
}
const gProjectPromptsByMessageId = new Map<string, ProjectPrompt>();
export function saveProjectPrompt(messageId: string, prompt: ProjectPrompt) {
gProjectPromptsByMessageId.set(messageId, prompt);
}
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
const { id, isStreaming = false, messages = [] } = props;
const location = useLocation();
const [isModalOpen, setIsModalOpen] = useState(false);
const [currentProjectPrompt, setCurrentProjectPrompt] = useState<ProjectPrompt | null>(null);
const [formData, setFormData] = useState({
title: '',
description: '',
name: '',
email: ''
});
const [problemId, setProblemId] = useState<string | null>(null);
const handleRewind = (messageId: string) => {
const searchParams = new URLSearchParams(location.search);
@@ -40,71 +79,223 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
}
};
return (
<div id={id} ref={ref} className={props.className}>
{messages.length > 0
? messages.map((message, index) => {
const { role, content, id: messageId } = message;
const isUserMessage = role === 'user';
const isFirst = index === 0;
const isLast = index === messages.length - 1;
const handleSaveProblem = (prompt: ProjectPrompt) => {
setCurrentProjectPrompt(prompt);
setIsModalOpen(true);
setFormData({
title: '',
description: '',
name: '',
email: '',
});
setProblemId(null);
};
return (
<div
key={index}
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
isStreaming && isLast,
'mt-4': !isFirst,
})}
>
{isUserMessage && (
<div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
<div className="i-ph:user-fill text-xl"></div>
</div>
)}
<div className="grid grid-col-1 w-full">
{isUserMessage ? (
<UserMessage content={content} />
) : (
<AssistantMessage content={content} annotations={message.annotations} />
const handleSubmitProblem = async (e: React.MouseEvent) => {
// Add validation here
if (!formData.title) {
toast.error('Please fill in title field');
return;
}
toast.info("Submitting problem...");
console.log("SubmitProblem", formData);
assert(currentProjectPrompt);
const problem: BoltProblem = {
title: formData.title,
description: formData.description,
name: formData.name,
email: formData.email,
prompt: currentProjectPrompt,
};
try {
const rv = await sendCommandDedicatedClient({
method: "Recording.globalExperimentalCommand",
params: {
name: "submitBoltProblem",
params: { problem },
},
});
console.log("SubmitProblemRval", rv);
setProblemId((rv as any).rval.problemId);
} catch (error) {
console.error("Error submitting problem", error);
toast.error("Failed to submit problem");
}
}
const getLastMessageProjectPrompt = (index: number) => {
// The message index is for the model response, and the project
// prompt will be associated with the last message present when
// the user prompt was sent to the model. So look back two messages
// for the associated prompt.
if (index < 2) {
return null;
}
const previousMessage = messages[index - 2];
return gProjectPromptsByMessageId.get(previousMessage.id);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
return (
<>
<div id={id} ref={ref} className={props.className}>
{messages.length > 0
? messages.map((message, index) => {
const { role, content, id: messageId } = message;
const isUserMessage = role === 'user';
const isFirst = index === 0;
const isLast = index === messages.length - 1;
return (
<div
key={index}
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
isStreaming && isLast,
'mt-4': !isFirst,
})}
>
{isUserMessage && (
<div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
<div className="i-ph:user-fill text-xl"></div>
</div>
)}
</div>
{!isUserMessage && (
<div className="flex gap-2 flex-col lg:flex-row">
{messageId && (
<WithTooltip tooltip="Revert to this message">
<div className="grid grid-col-1 w-full">
{isUserMessage ? (
<UserMessage content={content} />
) : (
<AssistantMessage content={content} annotations={message.annotations} />
)}
</div>
{!isUserMessage && (
<div className="flex gap-2 flex-col lg:flex-row">
{messageId && (
<WithTooltip tooltip="Revert to this message">
<button
onClick={() => handleRewind(messageId)}
key="i-ph:arrow-u-up-left"
className={classNames(
'i-ph:arrow-u-up-left',
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
)}
/>
</WithTooltip>
)}
<WithTooltip tooltip="Fork chat from this message">
<button
onClick={() => handleRewind(messageId)}
key="i-ph:arrow-u-up-left"
onClick={() => handleFork(messageId)}
key="i-ph:git-fork"
className={classNames(
'i-ph:arrow-u-up-left',
'i-ph:git-fork',
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
)}
/>
</WithTooltip>
)}
<WithTooltip tooltip="Fork chat from this message">
<button
onClick={() => handleFork(messageId)}
key="i-ph:git-fork"
className={classNames(
'i-ph:git-fork',
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
)}
/>
</WithTooltip>
</div>
)}
{getAnnotationsTokensUsage(message.annotations) &&
getLastMessageProjectPrompt(index) && (
<WithTooltip tooltip="Save prompt as new problem">
<button
onClick={() => {
const prompt = getLastMessageProjectPrompt(index);
assert(prompt);
handleSaveProblem(prompt);
}}
key="i-ph:export"
className={classNames(
'i-ph:export',
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
)}
/>
</WithTooltip>
)}
</div>
)}
</div>
);
})
: null}
{isStreaming && (
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
)}
</div>
<ReactModal
isOpen={isModalOpen}
onRequestClose={() => setIsModalOpen(false)}
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg p-6 max-w-2xl w-full z-50"
overlayClassName="fixed inset-0 bg-black bg-opacity-50 z-40"
>
{problemId && (
<>
<div className="text-center mb-2">Problem Submitted: {problemId}</div>
<div className="text-center">
<div className="flex justify-center gap-2 mt-4">
<button onClick={() => setIsModalOpen(false)} className="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400">Close</button>
</div>
);
})
: null}
{isStreaming && (
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
)}
</div>
</div>
</>
)}
{!problemId && (
<>
<div className="text-center">Save prompts as new problems when AI results are unsatisfactory.</div>
<div className="text-center">Problems are publicly visible and are used to improve AI performance.</div>
<div style={{ marginTop: "10px" }}>
<div className="grid grid-cols-[auto_1fr] gap-4 max-w-md mx-auto">
<div className="flex items-center">Title:</div>
<input type="text"
name="title"
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 w-full border border-gray-300"
value={formData.title}
onChange={handleInputChange}
/>
<div className="flex items-center">Description:</div>
<input type="text"
name="description"
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 w-full border border-gray-300"
value={formData.description}
onChange={handleInputChange}
/>
<div className="flex items-center">Name (optional):</div>
<input type="text"
name="name"
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 w-full border border-gray-300"
value={formData.name}
onChange={handleInputChange}
/>
<div className="flex items-center">Email (optional):</div>
<input type="text"
name="email"
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 w-full border border-gray-300"
value={formData.email}
onChange={handleInputChange}
/>
</div>
<div className="flex justify-center gap-2 mt-4">
<button onClick={handleSubmitProblem} className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">Submit</button>
<button onClick={() => setIsModalOpen(false)} className="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400">Cancel</button>
</div>
</div>
</>
)}
</ReactModal>
</>
);
});

View File

@@ -1,6 +1,7 @@
import type { Message } from 'ai';
import { toast } from 'react-toastify';
import { ImportFolderButton } from '~/components/chat/ImportFolderButton';
import { LoadProblemButton } from '~/components/chat/LoadProblemButton';
export function ImportButtons(importChat: ((description: string, messages: Message[]) => Promise<void>) | undefined) {
return (
@@ -63,6 +64,10 @@ export function ImportButtons(importChat: ((description: string, messages: Messa
importChat={importChat}
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
/>
<LoadProblemButton
importChat={importChat}
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
/>
</div>
</div>
</div>

View File

@@ -11,6 +11,7 @@ interface BaseIconButtonProps {
title?: string;
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
style?: React.CSSProperties;
}
type IconButtonWithoutChildrenProps = {
@@ -39,6 +40,7 @@ export const IconButton = memo(
title,
onClick,
children,
style,
}: IconButtonProps,
ref: ForwardedRef<HTMLButtonElement>,
) => {
@@ -61,6 +63,7 @@ export const IconButton = memo(
onClick?.(event);
}}
style={style}
>
{children ? children : <div className={classNames(icon, getIconSize(size), iconClassName)}></div>}
</button>

View File

@@ -0,0 +1,92 @@
import { memo, useCallback, useState } from 'react';
import { getMouseData } from './Recording';
interface PointSelectorProps {
isSelectionMode: boolean;
setIsSelectionMode: (mode: boolean) => void;
selectionPoint: { x: number; y: number } | null;
setSelectionPoint: (point: { x: number; y: number } | null) => void;
recordingSaved: boolean;
containerRef: React.RefObject<HTMLIFrameElement>;
}
export const PointSelector = memo(
(props: PointSelectorProps) => {
const {
isSelectionMode,
recordingSaved,
setIsSelectionMode,
selectionPoint,
setSelectionPoint,
containerRef,
} = props;
const [isCapturing, setIsCapturing] = useState(false);
const handleSelectionClick = useCallback(async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!isSelectionMode || !containerRef.current) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
setSelectionPoint({ x, y });
setIsCapturing(true);
const mouseData = await getMouseData(containerRef.current, { x, y });
console.log("MouseData", mouseData);
setIsCapturing(false);
setIsSelectionMode(false); // Turn off selection mode after capture
}, [isSelectionMode, containerRef, setIsSelectionMode]);
if (!isSelectionMode) {
if (recordingSaved) {
// Draw an overlay to prevent interactions with the iframe
// and to show the last point the user clicked (if there is one).
return (
<div
className="absolute inset-0"
onClick={(event) => event.preventDefault()}
>
{ selectionPoint && (
<div
style={{
position: 'absolute',
left: `${selectionPoint.x-8}px`,
top: `${selectionPoint.y-12}px`,
}}
>
&#10060;
</div>
)}
</div>
);
} else {
return null;
}
}
return (
<div
className="absolute inset-0"
onClick={handleSelectionClick}
style={{
backgroundColor: isCapturing ? 'transparent' : 'rgba(0, 0, 0, 0.1)',
userSelect: 'none',
WebkitUserSelect: 'none',
pointerEvents: 'all',
opacity: isCapturing ? 0 : 1,
zIndex: 50,
transition: 'opacity 0.1s ease-in-out',
}}
>
</div>
);
},
);

View File

@@ -3,7 +3,9 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { IconButton } from '~/components/ui/IconButton';
import { workbenchStore } from '~/lib/stores/workbench';
import { PortDropdown } from './PortDropdown';
import { ScreenshotSelector } from './ScreenshotSelector';
import { PointSelector } from './PointSelector';
import { saveReplayRecording } from './Recording';
import { assert } from './ReplayProtocolClient';
type ResizeSide = 'left' | 'right' | null;
@@ -22,6 +24,14 @@ export const Preview = memo(() => {
const [url, setUrl] = useState('');
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectionPoint, setSelectionPoint] = useState<{ x: number; y: number } | null>(null);
// Once a recording has been saved, the preview can no longer be interacted with.
// Reloading the preview or regenerating it after code changes will reset this.
const [recordingSaved, setRecordingSaved] = useState(false);
// The ID of the recording that was created. If a recording has been saved but
// no ID is set, the recording is still being created.
const [recordingId, setRecordingId] = useState<string | undefined>();
// Toggle between responsive mode and device mode
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
@@ -53,21 +63,46 @@ export const Preview = memo(() => {
setIframeUrl(baseUrl);
}, [activePreview]);
// Trim any long base URL from the start of the provided URL.
const displayUrl = (url: string) => {
if (!activePreview) {
return url;
}
const { baseUrl } = activePreview;
if (url.startsWith(baseUrl)) {
const trimmedUrl = url.slice(baseUrl.length);
if (trimmedUrl.startsWith('/')) {
return trimmedUrl;
}
return "/" + trimmedUrl;
}
return url;
}
const validateUrl = useCallback(
(value: string) => {
if (!activePreview) {
return false;
return null;
}
const { baseUrl } = activePreview;
if (value === baseUrl) {
return true;
return value;
} else if (value.startsWith(baseUrl)) {
return ['/', '?', '#'].includes(value.charAt(baseUrl.length));
if (['/', '?', '#'].includes(value.charAt(baseUrl.length))) {
return value;
}
}
return false;
if (value.startsWith('/')) {
return baseUrl + value;
}
return null;
},
[activePreview],
);
@@ -213,6 +248,16 @@ export const Preview = memo(() => {
</div>
);
const beginSaveRecording = () => {
assert(!recordingSaved);
setRecordingSaved(true);
assert(iframeRef.current);
saveReplayRecording(iframeRef.current).then((id) => {
setRecordingId(id);
});
};
return (
<div ref={containerRef} className="w-full h-full flex flex-col relative">
{isPortDropdownOpen && (
@@ -220,11 +265,20 @@ export const Preview = memo(() => {
)}
<div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
<IconButton
icon="i-ph:selection"
onClick={() => setIsSelectionMode(!isSelectionMode)}
className={isSelectionMode ? 'bg-bolt-elements-background-depth-3' : ''}
/>
{!recordingSaved && (
<IconButton
icon="i-ph:record-fill"
onClick={beginSaveRecording}
style={{ color: 'red' }}
/>
)}
{recordingSaved && (
<IconButton
icon="i-ph:cursor-click"
onClick={() => setIsSelectionMode(!isSelectionMode)}
className={isSelectionMode ? 'bg-bolt-elements-background-depth-3' : ''}
/>
)}
<div
className="flex items-center gap-1 flex-grow bg-bolt-elements-preview-addressBar-background border border-bolt-elements-borderColor text-bolt-elements-preview-addressBar-text rounded-full px-3 py-1 text-sm hover:bg-bolt-elements-preview-addressBar-backgroundHover hover:focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within:bg-bolt-elements-preview-addressBar-backgroundActive
focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive"
@@ -234,13 +288,14 @@ export const Preview = memo(() => {
ref={inputRef}
className="w-full bg-transparent outline-none"
type="text"
value={url}
value={displayUrl(url)}
onChange={(event) => {
setUrl(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && validateUrl(url)) {
setIframeUrl(url);
let newUrl;
if (event.key === 'Enter' && (newUrl = validateUrl(url))) {
setIframeUrl(newUrl);
if (inputRef.current) {
inputRef.current.blur();
@@ -296,9 +351,12 @@ export const Preview = memo(() => {
src={iframeUrl}
allowFullScreen
/>
<ScreenshotSelector
<PointSelector
isSelectionMode={isSelectionMode}
recordingSaved={recordingSaved}
setIsSelectionMode={setIsSelectionMode}
selectionPoint={selectionPoint}
setSelectionPoint={setSelectionPoint}
containerRef={iframeRef}
/>
</>

View File

@@ -0,0 +1,652 @@
// Manage state around recording Preview behavior for generating a Replay recording.
import { assert, sendCommandDedicatedClient, stringToBase64, uint8ArrayToBase64 } from "./ReplayProtocolClient";
interface RerecordResource {
url: string;
requestBodyBase64: string;
responseBodyBase64: string;
responseStatus: number;
responseHeaders: Record<string, string>;
}
enum RerecordInteractionKind {
Click = "click",
DblClick = "dblclick",
KeyDown = "keydown",
}
export interface RerecordInteraction {
kind: RerecordInteractionKind;
// Elapsed time when the interaction occurred.
time: number;
// Selector of the element associated with the interaction.
selector: string;
// For mouse interactions, dimensions and position within the
// element where the event occurred.
width?: number;
height?: number;
x?: number;
y?: number;
// For keydown interactions, the key pressed.
key?: string;
}
interface IndexedDBAccess {
kind: "get" | "put" | "add";
key?: any;
item?: any;
storeName: string;
databaseName: string;
databaseVersion: number;
}
interface LocalStorageAccess {
kind: "get" | "set";
key: string;
value?: string;
}
interface RerecordData {
// Contents of window.location.href.
locationHref: string;
// URL of the main document.
documentUrl: string;
// All resources accessed.
resources: RerecordResource[];
// All user interactions made.
interactions: RerecordInteraction[];
// All indexedDB accesses made.
indexedDBAccesses?: IndexedDBAccess[];
// All localStorage accesses made.
localStorageAccesses?: LocalStorageAccess[];
}
// This is in place to workaround some insane behavior where messages are being
// sent by iframes running older versions of the recording data logic, even after
// quitting and restarting the entire browser. Maybe related to webcontainers?
const RecordingDataVersion = 2;
export async function saveReplayRecording(iframe: HTMLIFrameElement) {
assert(iframe.contentWindow);
iframe.contentWindow.postMessage({ source: "recording-data-request" }, "*");
const data = await new Promise((resolve) => {
window.addEventListener("message", (event) => {
if (event.data?.source == "recording-data-response" &&
event.data?.version == RecordingDataVersion) {
const decoder = new TextDecoder();
const jsonString = decoder.decode(event.data.buffer);
const data = JSON.parse(jsonString) as RerecordData;
resolve(data);
}
});
});
console.log("RerecordData", JSON.stringify(data));
const rerecordRval = await sendCommandDedicatedClient({
method: "Recording.globalExperimentalCommand",
params: {
name: "rerecordGenerate",
params: {
rerecordData: data,
// FIXME the backend should not require an API key for this command.
// For now we use an API key used in Replay's devtools (which is public
// but probably shouldn't be).
apiKey: "rwk_b6mnJ00rI4pzlwkYmggmmmV1TVQXA0AUktRHoo4vGl9",
// FIXME the backend currently requires this but shouldn't.
recordingId: "dummy-recording-id",
},
},
});
console.log("RerecordRval", rerecordRval);
const recordingId = (rerecordRval as any).rval.rerecordedRecordingId as string;
console.log("CreatedRecording", recordingId);
return recordingId;
}
export async function getMouseData(iframe: HTMLIFrameElement, position: { x: number; y: number }) {
assert(iframe.contentWindow);
iframe.contentWindow.postMessage({ source: "mouse-data-request", position }, "*");
const mouseData = await new Promise((resolve) => {
window.addEventListener("message", (event) => {
if (event.data?.source == "mouse-data-response") {
resolve(event.data.mouseData);
}
});
});
return mouseData;
}
function addRecordingMessageHandler() {
const resources: Map<string, RerecordResource> = new Map();
const interactions: RerecordInteraction[] = [];
const indexedDBAccesses: IndexedDBAccess[] = [];
const localStorageAccesses: LocalStorageAccess[] = [];
// Promises which will resolve when all resources have been added.
const promises: Promise<void>[] = [];
// Set of URLs which are currently being fetched.
const pendingFetches = new Set<string>();
const startTime = Date.now();
function getScriptImports(text: string) {
// TODO: This should use a real parser.
const imports: string[] = [];
const lines = text.split("\n");
lines.forEach((line, index) => {
let match = line.match(/^import.*?['"]([^'")]+)/);
if (match) {
imports.push(match[1]);
}
match = line.match(/^export.*?from ['"]([^'")]+)/);
if (match) {
imports.push(match[1]);
}
if (line == "import {" || line == "export {") {
for (let i = index + 1; i < lines.length; i++) {
const match = lines[i].match(/} from ['"]([^'")]+)/);
if (match) {
imports.push(match[1]);
break;
}
}
}
});
return imports;
}
function addTextResource(path: string, text: string) {
const url = (new URL(path, window.location.href)).href;
if (resources.has(url)) {
return;
}
resources.set(url, {
url,
requestBodyBase64: "",
responseBodyBase64: stringToBase64(text),
responseStatus: 200,
responseHeaders: {},
});
}
async function fetchAndAddResource(path: string) {
pendingFetches.add(path);
const response = await baseFetch(path);
pendingFetches.delete(path);
const text = await response.text();
const responseHeaders = Object.fromEntries(response.headers.entries());
const url = (new URL(path, window.location.href)).href;
if (resources.has(url)) {
return;
}
resources.set(url, {
url,
requestBodyBase64: "",
responseBodyBase64: stringToBase64(text),
responseStatus: response.status,
responseHeaders,
});
const contentType = responseHeaders["content-type"];
// MIME types that can contain JS.
const JavaScriptMimeTypes = ["application/javascript", "text/javascript", "text/html"];
if (JavaScriptMimeTypes.includes(contentType)) {
const imports = getScriptImports(text);
for (const path of imports) {
promises.push(fetchAndAddResource(path));
}
}
if (contentType == "text/html") {
const parser = new DOMParser();
const doc = parser.parseFromString(text, "text/html");
const scripts = doc.querySelectorAll("script");
for (const script of scripts) {
promises.push(fetchAndAddResource(script.src));
}
}
}
async function getRerecordData(): Promise<RerecordData> {
// For now we only deal with cases where there is a single HTML page whose
// contents are expected to be filled in by the client code. We do this to
// avoid difficulties in exactly emulating the webcontainer's behavior when
// generating the recording.
let htmlContents = "<html><body>";
// Vite needs this to be set for the react plugin to work.
htmlContents += "<script>window.__vite_plugin_react_preamble_installed__ = true;</script>";
// Find all script elements and add them to the document.
const scriptElements = document.getElementsByTagName('script');
[...scriptElements].forEach((script, index) => {
let src = script.src;
if (src) {
promises.push(fetchAndAddResource(src));
} else {
assert(script.textContent, "Script element has no src and no text content");
const path = `script-${index}.js`;
addTextResource(path, script.textContent);
}
const { origin } = new URL(window.location.href);
if (src.startsWith(origin)) {
src = src.slice(origin.length);
}
htmlContents += `<script src="${src}" type="${script.type}"></script>`;
});
// Find all inline styles and add them to the document.
const cssElements = document.getElementsByTagName('style');
for (const style of cssElements) {
htmlContents += `<style>${style.textContent}</style>`;
}
// Find all stylesheet links and add them to the document.
const linkElements = document.getElementsByTagName('link');
for (const link of linkElements) {
if (link.rel === 'stylesheet' && link.href) {
promises.push(fetchAndAddResource(link.href));
htmlContents += `<link rel="stylesheet" href="${link.href}">`;
}
}
// React needs a root element to mount into.
htmlContents += "<div id='root'></div>";
htmlContents += "</body></html>";
addTextResource(window.location.href, htmlContents);
const interval = setInterval(() => {
console.log("PendingFetches", pendingFetches.size, pendingFetches);
}, 1000);
while (true) {
const length = promises.length;
await Promise.all(promises);
if (promises.length == length) {
break;
}
}
clearInterval(interval);
const data: RerecordData = {
locationHref: window.location.href,
documentUrl: window.location.href,
resources: Array.from(resources.values()),
interactions,
indexedDBAccesses,
localStorageAccesses,
};
return data;
}
window.addEventListener("message", async (event) => {
switch (event.data?.source) {
case "recording-data-request": {
const data = await getRerecordData();
const encoder = new TextEncoder();
const serializedData = encoder.encode(JSON.stringify(data));
const buffer = serializedData.buffer;
window.parent.postMessage({ source: "recording-data-response", buffer, version: RecordingDataVersion }, "*", [buffer]);
break;
}
case "mouse-data-request": {
const { x, y } = event.data.position;
const element = document.elementFromPoint(x, y);
assert(element);
const selector = computeSelector(element);
const rect = element.getBoundingClientRect();
const mouseData = {
selector,
width: rect.width,
height: rect.height,
x: x - rect.x,
y: y - rect.y,
};
window.parent.postMessage({ source: "mouse-data-response", mouseData }, "*");
break;
}
}
});
// Evaluated function to find the selector and associated data.
function getMouseEventData(event: MouseEvent) {
assert(event.target);
const target = event.target as Element;
const selector = computeSelector(target);
const rect = target.getBoundingClientRect();
return {
selector,
width: rect.width,
height: rect.height,
x: event.clientX - rect.x,
y: event.clientY - rect.y,
};
}
function getKeyboardEventData(event: KeyboardEvent) {
assert(event.target);
const target = event.target as Element;
const selector = computeSelector(target);
return {
selector,
key: event.key,
};
}
function computeSelector(target: Element): string {
// Build a unique selector by walking up the DOM tree
const path: string[] = [];
let current: Element | null = target;
while (current) {
// If element has an ID, use it as it's the most specific
if (current.id) {
path.unshift(`#${current.id}`);
break;
}
// Get the element's tag name
let selector = current.tagName.toLowerCase();
// Add nth-child if there are siblings
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children);
const index = siblings.indexOf(current) + 1;
if (siblings.filter(el => el.tagName === current!.tagName).length > 1) {
selector += `:nth-child(${index})`;
}
}
path.unshift(selector);
current = current.parentElement;
}
return path.join(" > ");
}
window.addEventListener("click", (event) => {
if (event.target) {
interactions.push({
kind: RerecordInteractionKind.Click,
time: Date.now() - startTime,
...getMouseEventData(event)
});
}
}, { capture: true, passive: true });
window.addEventListener("dblclick", (event) => {
if (event.target) {
interactions.push({
kind: RerecordInteractionKind.DblClick,
time: Date.now() - startTime,
...getMouseEventData(event)
});
}
}, { capture: true, passive: true });
window.addEventListener("keydown", (event) => {
if (event.key) {
interactions.push({
kind: RerecordInteractionKind.KeyDown,
time: Date.now() - startTime,
...getKeyboardEventData(event)
});
}
}, { capture: true, passive: true });
function onInterceptedOperation(name: string) {
console.log(`InterceptedOperation ${name}`);
}
function interceptProperty(
obj: object,
prop: string,
interceptor: (basevalue: any) => any
) {
const descriptor = Object.getOwnPropertyDescriptor(obj, prop);
assert(descriptor?.get, "Property must have a getter");
let interceptValue: any;
Object.defineProperty(obj, prop, {
...descriptor,
get() {
onInterceptedOperation(`Getter:${prop}`);
if (!interceptValue) {
const baseValue = (descriptor?.get as any).call(obj);
interceptValue = interceptor(baseValue);
}
return interceptValue;
},
});
}
const IDBFactoryMethods = {
_name: "IDBFactory",
open: (v: any) => createFunctionProxy(v, "open"),
};
const IDBOpenDBRequestMethods = {
_name: "IDBOpenDBRequest",
result: createProxy,
};
const IDBDatabaseMethods = {
_name: "IDBDatabase",
transaction: (v: any) => createFunctionProxy(v, "transaction"),
};
const IDBTransactionMethods = {
_name: "IDBTransaction",
objectStore: (v: any) => createFunctionProxy(v, "objectStore"),
};
function pushIndexedDBAccess(
request: IDBRequest,
kind: IndexedDBAccess["kind"],
key: any,
item: any
) {
indexedDBAccesses.push({
kind,
key,
item,
storeName: (request.source as any).name,
databaseName: (request.transaction as any).db.name,
databaseVersion: (request.transaction as any).db.version,
});
}
// Map "get" requests to their keys.
const getRequestKeys: Map<IDBRequest, any> = new Map();
const IDBObjectStoreMethods = {
_name: "IDBObjectStore",
get: (v: any) =>
createFunctionProxy(v, "get", (request, key) => {
// Wait to add the request until the value is known.
getRequestKeys.set(request, key);
return createProxy(request);
}),
put: (v: any) =>
createFunctionProxy(v, "put", (request, item, key) => {
pushIndexedDBAccess(request, "put", key, item);
return createProxy(request);
}),
add: (v: any) =>
createFunctionProxy(v, "add", (request, item, key) => {
pushIndexedDBAccess(request, "add", key, item);
return createProxy(request);
}),
};
const IDBRequestMethods = {
_name: "IDBRequest",
result: (value: any, target: any) => {
const key = getRequestKeys.get(target);
if (key) {
pushIndexedDBAccess(target, "get", key, value);
}
return value;
},
};
function pushLocalStorageAccess(
kind: LocalStorageAccess["kind"],
key: string,
value?: string
) {
localStorageAccesses.push({ kind, key, value });
}
const StorageMethods = {
_name: "Storage",
getItem: (v: any) =>
createFunctionProxy(v, "getItem", (value: string, key: string) => {
pushLocalStorageAccess("get", key, value);
return value;
}),
setItem: (v: any) =>
createFunctionProxy(v, "setItem", (_rv: undefined, key: string) => {
pushLocalStorageAccess("set", key);
}),
};
// Map Response to the triggering URL before redirects.
const responseToURL = new WeakMap<Response, string>();
const ResponseMethods = {
_name: "Response",
json: (v: any, response: Response) =>
createFunctionProxy(v, "json", async (promise: Promise<any>) => {
const json = await promise;
const url = responseToURL.get(response);
if (url) {
addTextResource(url, JSON.stringify(json));
}
return json;
}),
text: (v: any, response: Response) =>
createFunctionProxy(v, "text", async (promise: Promise<any>) => {
const text = await promise;
const url = responseToURL.get(response);
if (url) {
addTextResource(url, text);
}
return text;
}),
};
function createProxy(obj: any) {
let methods;
if (obj instanceof IDBFactory) {
methods = IDBFactoryMethods;
} else if (obj instanceof IDBOpenDBRequest) {
methods = IDBOpenDBRequestMethods;
} else if (obj instanceof IDBDatabase) {
methods = IDBDatabaseMethods;
} else if (obj instanceof IDBTransaction) {
methods = IDBTransactionMethods;
} else if (obj instanceof IDBObjectStore) {
methods = IDBObjectStoreMethods;
} else if (obj instanceof IDBRequest) {
methods = IDBRequestMethods;
} else if (obj instanceof Storage) {
methods = StorageMethods;
} else if (obj instanceof Response) {
methods = ResponseMethods;
}
assert(methods, "Unknown object for createProxy");
const name = methods._name;
return new Proxy(obj, {
get(target, prop) {
onInterceptedOperation(`ProxyGetter:${name}.${String(prop)}`);
let value = target[prop];
if (typeof value === "function") {
value = value.bind(target);
}
if (methods[prop]) {
value = methods[prop](value, target);
}
return value;
},
set(target, prop, value) {
onInterceptedOperation(`ProxySetter:${name}.${String(prop)}`);
target[prop] = value;
return true;
},
});
}
function createFunctionProxy(
fn: any,
name: string,
handler?: (v: any, ...args: any[]) => any
) {
return (...args: any[]) => {
onInterceptedOperation(`FunctionCall:${name}`);
const v = fn(...args);
return handler ? handler(v, ...args) : createProxy(v);
};
}
interceptProperty(window, "indexedDB", createProxy);
interceptProperty(window, "localStorage", createProxy);
const baseFetch = window.fetch;
window.fetch = async (info, options) => {
const rv = await baseFetch(info, options);
const url = info instanceof Request ? info.url : info.toString();
responseToURL.set(rv, url);
return createProxy(rv);
};
}
export function injectRecordingMessageHandler(content: string) {
const headTag = content.indexOf("<head>");
assert(headTag != -1, "No <head> tag found");
const headEnd = headTag + 6;
const text = `
<script>
${assert}
${stringToBase64}
${uint8ArrayToBase64}
(${addRecordingMessageHandler.toString().replace("RecordingDataVersion", `${RecordingDataVersion}`)})()
</script>
`;
return content.slice(0, headEnd) + text + content.slice(headEnd);
}

View File

@@ -0,0 +1,182 @@
const replayWsServer = "wss://dispatch.replay.io";
export function assert(condition: any, message: string = "Assertion failed!"): asserts condition {
if (!condition) {
throw new Error(message);
}
}
export function uint8ArrayToBase64(data: Uint8Array) {
let str = "";
for (const byte of data) {
str += String.fromCharCode(byte);
}
return btoa(str);
}
export function stringToBase64(inputString: string) {
if (typeof inputString !== "string") {
throw new TypeError("Input must be a string.");
}
const encoder = new TextEncoder();
const data = encoder.encode(inputString);
return uint8ArrayToBase64(data);
}
function logDebug(msg: string, tags: Record<string, any> = {}) {
console.log(msg, JSON.stringify(tags));
}
class ProtocolError extends Error {
protocolCode;
protocolMessage;
protocolData;
constructor(error: any) {
super(`protocol error ${error.code}: ${error.message}`);
this.protocolCode = error.code;
this.protocolMessage = error.message;
this.protocolData = error.data ?? {};
}
toString() {
return `Protocol error ${this.protocolCode}: ${this.protocolMessage}`;
}
}
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: any) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve: (value: T) => void;
let reject: (reason?: any) => void;
const promise = new Promise<T>((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return { promise, resolve: resolve!, reject: reject! };
}
type EventListener = (params: any) => void;
export class ProtocolClient {
openDeferred = createDeferred<void>();
eventListeners = new Map<string, Set<EventListener>>();
nextMessageId = 1;
pendingCommands = new Map<number, Deferred<any>>();
socket: WebSocket;
constructor() {
logDebug(`Creating WebSocket for ${replayWsServer}`);
this.socket = new WebSocket(replayWsServer);
this.socket.addEventListener("close", this.onSocketClose);
this.socket.addEventListener("error", this.onSocketError);
this.socket.addEventListener("open", this.onSocketOpen);
this.socket.addEventListener("message", this.onSocketMessage);
this.listenForMessage("Recording.sessionError", (error: any) => {
logDebug(`Session error ${error}`);
});
}
initialize() {
return this.openDeferred.promise;
}
close() {
this.socket.close();
}
listenForMessage(method: string, callback: (params: any) => void) {
let listeners = this.eventListeners.get(method);
if (listeners == null) {
listeners = new Set([callback]);
this.eventListeners.set(method, listeners);
} else {
listeners.add(callback);
}
return () => {
listeners.delete(callback);
};
}
sendCommand(args: { method: string; params: any; sessionId?: string }) {
const id = this.nextMessageId++;
const { method, params, sessionId } = args;
logDebug("Sending command", { id, method, params, sessionId });
const command = {
id,
method,
params,
sessionId,
};
this.socket.send(JSON.stringify(command));
const deferred = createDeferred();
this.pendingCommands.set(id, deferred);
return deferred.promise;
}
onSocketClose = () => {
logDebug("Socket closed");
};
onSocketError = (error: any) => {
logDebug(`Socket error ${error}`);
};
onSocketMessage = (event: MessageEvent) => {
const { error, id, method, params, result } = JSON.parse(String(event.data));
if (id) {
const deferred = this.pendingCommands.get(id);
assert(deferred, `Received message with unknown id: ${id}`);
this.pendingCommands.delete(id);
if (result) {
deferred.resolve(result);
} else if (error) {
console.error("ProtocolError", error);
deferred.reject(new ProtocolError(error));
} else {
deferred.reject(new Error("Channel error"));
}
} else if (this.eventListeners.has(method)) {
const callbacks = this.eventListeners.get(method);
if (callbacks) {
callbacks.forEach(callback => callback(params));
}
} else {
logDebug("Received message without a handler", { method, params });
}
};
onSocketOpen = async () => {
logDebug("Socket opened");
this.openDeferred.resolve();
};
}
// Send a single command with a one-use protocol client.
export async function sendCommandDedicatedClient(args: { method: string; params: any }) {
const client = new ProtocolClient();
await client.initialize();
try {
const rval = await client.sendCommand(args);
client.close();
return rval;
} finally {
client.close();
}
}

View File

@@ -120,6 +120,50 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
}
}, []);
const handleApplyChanges = useCallback(async () => {
try {
// Open file picker and get file handle
const [fileHandle] = await (window as any).showOpenFilePicker({
types: [
{
description: 'Text Files',
accept: {
'text/*': ['.txt', '.js', '.ts', '.jsx', '.tsx', '.json', '.html', '.css']
}
}
]
});
const changesFile = await fileHandle.getFile();
const changesContent = await changesFile.text();
let path = "";
let contents = "";
async function saveCurrentFile() {
if (path) {
await workbenchStore.saveFileContents("/home/project/src/" + path, contents);
}
}
for (const line of changesContent.split("\n")) {
const match = /^FILE (.*)/.exec(line);
if (match) {
await saveCurrentFile();
path = match[1];
contents = "";
continue;
}
contents += line + "\n";
}
await saveCurrentFile();
} catch (error) {
console.error('Error applying changes:', error);
toast.error('Failed to apply changes');
}
}, []);
return (
chatStarted && (
<motion.div
@@ -146,6 +190,13 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
<div className="ml-auto" />
{selectedView === 'code' && (
<div className="flex overflow-y-auto">
<PanelHeaderButton
className="mr-1 text-sm"
onClick={handleApplyChanges}
>
<div className="i-ph:code" />
Apply Changes
</PanelHeaderButton>
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {

View File

@@ -200,6 +200,10 @@ export async function streamText(props: {
systemPrompt = `${systemPrompt}\n\n ${codeContext}`;
}
const coreMessages = convertToCoreMessages(processedMessages as any);
console.log("QueryModel", JSON.stringify({ systemPrompt, coreMessages }));
return _streamText({
model: provider.getModelInstance({
model: currentModel,
@@ -209,7 +213,7 @@ export async function streamText(props: {
}),
system: systemPrompt,
maxTokens: dynamicMaxTokens,
messages: convertToCoreMessages(processedMessages as any),
messages: coreMessages,
...options,
});
}

View File

@@ -337,6 +337,52 @@ Here are some examples of correct usage of artifacts:
</assistant_response>
</example>
</examples>
ULTRA IMPORTANT: When asked to fix a bug, the results of console logs from the bug are available as comments in the source, in the format of "// Repro:Name console.log(xyz) == 123".
Use these comments to get a better understanding of the bug and how data flows through the code, and describe what is causing the bug before starting on any code changes. You must fix the bug.
Here are examples of how to use these comments:
<examples>
<example>
<user_query>The Summation component does not render its result correctly. Fix this bug.</user_query>
<code_state>
function computeSum(a, b) {
return a + b;
// Repro:computeSum console.log(rval) == NaN, console.log(a) == 3, console.log(b) == undefined
}
function Summation(props) {
const a = props.a;
const b = props.b;
const rval = computeSum(a, b);
return <div>{rval}</div>; // Repro:Summation console.log(rval) == NaN
}
function createSummation(a) {
return <Summation a={3} b={undefined} />; // Repro:createSummation undefined value used by computeSum.
}
function main() {
createSummation(3); // Repro:main 3 value used by computeSum.
}
</code_state>
<assistant_response>
1. From Repro:Summation I can see that the NaN value was produced at Repro:computeSum.
2. From Repro:computeSum I can see that a is 3 and b is undefined. The latter is incorrect, it was produced at Repro:createSummation.
I will fix this by not passing undefined to the Summation component in createSummation.
<boltAction type="file" filePath="components/Summation.js">
... code changes to fix bug ...
</boltAction>
</assistant_response>
</example>
</examples>
ULTRA IMPORTANT: NEVER add any logging. It is your responsibility to use the Repro console log comments to understand the bug. The user is not going to do this for you.
`;
export const CONTINUE_PROMPT = stripIndents`

View File

@@ -6,6 +6,7 @@ import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
import type { ActionCallbackData } from './message-parser';
import type { BoltShell } from '~/utils/shell';
import { injectRecordingMessageHandler } from '~/components/workbench/Recording';
const logger = createScopedLogger('ActionRunner');
@@ -219,7 +220,11 @@ export class ActionRunner {
}
try {
await webcontainer.fs.writeFile(relativePath, action.content);
let content = action.content;
if (relativePath == "../../index.html") {
content = injectRecordingMessageHandler(action.content);
}
await webcontainer.fs.writeFile(relativePath, content);
logger.debug(`File written ${relativePath}`);
} catch (error) {
logger.error('Failed to write file\n\n', error);

View File

@@ -93,7 +93,8 @@ export class FilesStore {
const oldContent = this.getFile(filePath)?.content;
if (!oldContent) {
unreachable('Expected content to be defined');
console.log("CurrentFiles", JSON.stringify(Object.keys(this.files.get())));
unreachable(`Cannot save unknown file ${filePath}`);
}
await webcontainer.fs.writeFile(relativePath, content);

View File

@@ -188,6 +188,10 @@ export class WorkbenchStore {
this.unsavedFiles.set(newUnsavedFiles);
}
async saveFileContents(filePath: string, contents: string) {
await this.#filesStore.saveFile(filePath, contents);
}
async saveCurrentDocument() {
const currentDocument = this.currentDocument.get();
@@ -339,7 +343,7 @@ export class WorkbenchStore {
return artifacts[id];
}
async downloadZip() {
async generateZip() {
const zip = new JSZip();
const files = this.files.get();
@@ -374,6 +378,11 @@ export class WorkbenchStore {
// Generate the zip file and save it
const content = await zip.generateAsync({ type: 'blob' });
return { content, uniqueProjectName };
}
async downloadZip() {
const { content, uniqueProjectName } = await this.generateZip();
saveAs(content, `${uniqueProjectName}.zip`);
}

View File

@@ -53,7 +53,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
const options: StreamingOptions = {
toolChoice: 'none',
onFinish: async ({ text: content, finishReason, usage }) => {
console.log('usage', usage);
console.log("QueryModelFinished", usage, content);
if (usage) {
cumulativeUsage.completionTokens += usage.completionTokens || 0;

View File

@@ -2,14 +2,15 @@ import type { Message } from 'ai';
import { generateId } from './fileUtils';
import { detectProjectCommands, createCommandsMessage } from './projectCommands';
export const createChatFromFolder = async (
files: File[],
binaryFiles: string[],
folderName: string,
): Promise<Message[]> => {
const fileArtifacts = await Promise.all(
export interface FileArtifact {
content: string;
path: string;
}
export async function getFileArtifacts(files: File[]): Promise<FileArtifact[]> {
return Promise.all(
files.map(async (file) => {
return new Promise<{ content: string; path: string }>((resolve, reject) => {
return new Promise<FileArtifact>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
@@ -25,7 +26,13 @@ export const createChatFromFolder = async (
});
}),
);
}
export const createChatFromFolder = async (
fileArtifacts: FileArtifact[],
binaryFiles: string[],
folderName: string,
): Promise<Message[]> => {
const commands = await detectProjectCommands(fileArtifacts);
const commandsMessage = createCommandsMessage(commands);

View File

@@ -91,6 +91,7 @@
"react-dom": "^18.3.1",
"react-hotkeys-hook": "^4.6.1",
"react-markdown": "^9.0.1",
"react-modal": "^3.16.3",
"react-resizable-panels": "^2.1.7",
"react-toastify": "^10.0.6",
"rehype-raw": "^7.0.0",
@@ -111,6 +112,7 @@
"@types/js-cookie": "^3.0.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-modal": "^3.16.3",
"fast-glob": "^3.3.2",
"husky": "9.1.7",
"is-ci": "^3.0.1",

67
pnpm-lock.yaml generated
View File

@@ -194,6 +194,9 @@ importers:
react-markdown:
specifier: ^9.0.1
version: 9.0.1(@types/react@18.3.12)(react@18.3.1)
react-modal:
specifier: ^3.16.3
version: 3.16.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-resizable-panels:
specifier: ^2.1.7
version: 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -249,6 +252,9 @@ importers:
'@types/react-dom':
specifier: ^18.3.1
version: 18.3.1
'@types/react-modal':
specifier: ^3.16.3
version: 3.16.3
fast-glob:
specifier: ^3.3.2
version: 3.3.2
@@ -2132,6 +2138,9 @@ packages:
'@types/react-dom@18.3.1':
resolution: {integrity: sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==}
'@types/react-modal@3.16.3':
resolution: {integrity: sha512-xXuGavyEGaFQDgBv4UVm8/ZsG+qxeQ7f77yNrW3n+1J6XAstUy5rYHeIHPh1KzsGc6IkCIdu6lQ2xWzu1jBTLg==}
'@types/react@18.3.12':
resolution: {integrity: sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==}
@@ -3160,6 +3169,9 @@ packages:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
exenv@1.2.2:
resolution: {integrity: sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==}
exit-hook@2.2.1:
resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==}
engines: {node: '>=6'}
@@ -4263,6 +4275,10 @@ packages:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
object-inspect@1.13.3:
resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==}
engines: {node: '>= 0.4'}
@@ -4557,6 +4573,9 @@ packages:
resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==}
engines: {node: '>=10'}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
property-information@6.5.0:
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
@@ -4623,12 +4642,24 @@ packages:
react: '>=16.8.1'
react-dom: '>=16.8.1'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-lifecycles-compat@3.0.4:
resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==}
react-markdown@9.0.1:
resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==}
peerDependencies:
'@types/react': '>=18'
react: '>=18'
react-modal@3.16.3:
resolution: {integrity: sha512-yCYRJB5YkeQDQlTt17WGAgFJ7jr2QYcWa1SHqZ3PluDmnKJ/7+tVU+E6uKyZ0nODaeEj+xCpK4LcSnKXLMC0Nw==}
peerDependencies:
react: ^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19
react-dom: ^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19
react-refresh@0.14.2:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'}
@@ -5620,6 +5651,9 @@ packages:
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
@@ -7542,6 +7576,10 @@ snapshots:
dependencies:
'@types/react': 18.3.12
'@types/react-modal@3.16.3':
dependencies:
'@types/react': 18.3.12
'@types/react@18.3.12':
dependencies:
'@types/prop-types': 15.7.13
@@ -8809,6 +8847,8 @@ snapshots:
signal-exit: 3.0.7
strip-final-newline: 2.0.0
exenv@1.2.2: {}
exit-hook@2.2.1: {}
expect-type@1.1.0: {}
@@ -10387,6 +10427,8 @@ snapshots:
dependencies:
path-key: 3.1.1
object-assign@4.1.1: {}
object-inspect@1.13.3: {}
object-is@1.1.6:
@@ -10669,6 +10711,12 @@ snapshots:
err-code: 2.0.3
retry: 0.12.0
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
property-information@6.5.0: {}
proxy-addr@2.0.7:
@@ -10746,6 +10794,10 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-is@16.13.1: {}
react-lifecycles-compat@3.0.4: {}
react-markdown@9.0.1(@types/react@18.3.12)(react@18.3.1):
dependencies:
'@types/hast': 3.0.4
@@ -10763,6 +10815,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
react-modal@3.16.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
exenv: 1.2.2
prop-types: 15.8.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-lifecycles-compat: 3.0.4
warning: 4.0.3
react-refresh@0.14.2: {}
react-remove-scroll-bar@2.3.6(@types/react@18.3.12)(react@18.3.1):
@@ -11841,6 +11902,10 @@ snapshots:
w3c-keyname@2.2.8: {}
warning@4.0.3:
dependencies:
loose-envify: 1.4.0
wcwidth@1.0.1:
dependencies:
defaults: 1.0.4
@@ -11957,4 +12022,4 @@ snapshots:
zod@3.23.8: {}
zwitch@2.0.4: {}
zwitch@2.0.4: {}