diff --git a/.yarn/cache/playroom-patch-2ff66375f4-fde81e710e.zip b/.yarn/cache/playroom-patch-251897556a-08df6bae9f.zip similarity index 98% rename from .yarn/cache/playroom-patch-2ff66375f4-fde81e710e.zip rename to .yarn/cache/playroom-patch-251897556a-08df6bae9f.zip index 2137fda93..af2c17e55 100644 Binary files a/.yarn/cache/playroom-patch-2ff66375f4-fde81e710e.zip and b/.yarn/cache/playroom-patch-251897556a-08df6bae9f.zip differ diff --git a/.yarn/patches/playroom-npm-0.39.0-24448abd08.patch b/.yarn/patches/playroom-npm-0.39.0-24448abd08.patch index 348f47c96..4d65b5f02 100644 --- a/.yarn/patches/playroom-npm-0.39.0-24448abd08.patch +++ b/.yarn/patches/playroom-npm-0.39.0-24448abd08.patch @@ -1,23 +1,49 @@ -diff --git a/src/Playroom/AiPanel/AiPanel.tsx b/src/Playroom/AiPanel/AiPanel.tsx +diff --git a/src/Playroom/AiPanel/AiPanel.css.ts b/src/Playroom/AiPanel/AiPanel.css.ts new file mode 100644 -index 0000000000000000000000000000000000000000..09c748db44dc2d25cc7f8396828f04c0c5adfc07 +index 0000000000000000000000000000000000000000..21d14ae14f317c051123f269242ee58dee262275 --- /dev/null -+++ b/src/Playroom/AiPanel/AiPanel.tsx -@@ -0,0 +1,344 @@ -+/* eslint-disable no-nested-ternary */ -+import * as React from 'react'; -+import { flushSync } from 'react-dom'; -+import { Heading } from '../Heading/Heading'; -+import { ToolbarPanel } from '../ToolbarPanel/ToolbarPanel'; ++++ b/src/Playroom/AiPanel/AiPanel.css.ts +@@ -0,0 +1,143 @@ ++import { keyframes, style } from '@vanilla-extract/css'; +import { colorPaletteVars } from '../sprinkles.css'; -+import { vars } from '../vars.css'; -+import { -+ type AiConversationMessage, -+ StoreContext, -+} from '../../StoreContext/StoreContext'; -+import { formatCode } from '../../utils/formatting'; ++import * as framesPanelStyles from '../FramesPanel/FramesPanel.css'; ++ ++export const panel = style({ ++ display: 'flex', ++ flexDirection: 'column', ++ height: '100%', ++ gap: 16, ++}); ++ ++export const conversationContainer = style({ ++ flex: 1, ++ display: 'flex', ++ flexDirection: 'column', ++ gap: 16, ++ overflowY: 'auto', ++ margin: '0 -20px', ++ padding: '0 20px', ++}); + -+const iconButtonStyles: React.CSSProperties = { ++export const emptyState = style({ ++ flex: 1, ++ display: 'flex', ++ flexDirection: 'column', ++ alignItems: 'center', ++ justifyContent: 'center', ++ gap: 16, ++ color: colorPaletteVars.foreground.secondary, ++ lineHeight: '1.3', ++}); ++ ++export const dragging = style({ ++ border: `2px dashed ${colorPaletteVars.border.standard}`, ++ padding: 12, ++ borderRadius: 8, ++ textAlign: 'center', ++}); ++ ++export const iconButton = style({ + display: 'inline-flex', + background: 'transparent', + border: 'none', @@ -28,7 +54,117 @@ index 0000000000000000000000000000000000000000..09c748db44dc2d25cc7f8396828f04c0 + WebkitAppearance: 'none', + padding: 0, + margin: 0, -+}; ++ ':disabled': { ++ cursor: 'default', ++ }, ++}); ++ ++const bubble = style({ ++ border: `1px solid ${colorPaletteVars.border.standard}`, ++ borderRadius: '16px', ++ padding: '8px', ++ width: 'fit-content', ++ lineHeight: '1.3', ++}); ++ ++export const userBubble = style([bubble, { alignSelf: 'flex-end' }]); ++export const assistantBubble = style([ ++ bubble, ++ { ++ fontStyle: 'italic', ++ display: 'flex', ++ alignItems: 'center', ++ gap: 8, ++ }, ++]); ++export const loadingBubble = style([ ++ bubble, ++ { ++ minWidth: '56px', ++ textAlign: 'center', ++ }, ++]); ++export const errorBubble = style([ ++ bubble, ++ { ++ minWidth: '56px', ++ color: 'red', ++ borderColor: 'red', ++ }, ++]); ++ ++export const regenerating = style({}); ++ ++export const assistantMessage = style({ ++ display: 'flex', ++ alignItems: 'center', ++ gap: 8, ++}); ++ ++export const disabledMessage = style({ ++ opacity: 0.5, ++}); ++ ++export const messageActions = style({ ++ display: 'none', ++ selectors: { ++ [`${regenerating} &`]: { ++ display: 'flex', ++ }, ++ [`${assistantMessage}:hover:not(${disabledMessage}) &`]: { ++ display: 'flex', ++ }, ++ }, ++}); ++ ++const spin = keyframes({ ++ from: { ++ transform: 'rotate(0deg)', ++ }, ++ to: { ++ transform: 'rotate(360deg)', ++ }, ++}); ++ ++export const regenerateIcon = style({ ++ selectors: { ++ [`${regenerating} &`]: { ++ animation: `${spin} 1s linear infinite`, ++ }, ++ }, ++}); ++ ++export const textarea = style([ ++ framesPanelStyles.textField, ++ { ++ width: '100%', ++ minWidth: '100%', ++ maxWidth: '100%', ++ ['fieldSizing' as any]: 'content', ++ minHeight: 'calc(3lh + 16px)', ++ maxHeight: 'calc(8lh + 16px)', ++ paddingTop: 12, ++ paddingBottom: 12, ++ }, ++]); +diff --git a/src/Playroom/AiPanel/AiPanel.tsx b/src/Playroom/AiPanel/AiPanel.tsx +new file mode 100644 +index 0000000000000000000000000000000000000000..c8049aac33ab6b4afbaa01dcdba48635c6ab2bf6 +--- /dev/null ++++ b/src/Playroom/AiPanel/AiPanel.tsx +@@ -0,0 +1,506 @@ ++/* eslint-disable no-nested-ternary */ ++import * as React from 'react'; ++import { flushSync } from 'react-dom'; ++import classNames from 'classnames'; ++import { Heading } from '../Heading/Heading'; ++import { ToolbarPanel } from '../ToolbarPanel/ToolbarPanel'; ++import { ++ type AiConversationMessage, ++ StoreContext, ++} from '../../StoreContext/StoreContext'; ++import { formatCode } from '../../utils/formatting'; ++import * as styles from './AiPanel.css'; + +const AnnimatedDots = () => { + const [dots, setDots] = React.useState('.'); @@ -86,12 +222,153 @@ index 0000000000000000000000000000000000000000..09c748db44dc2d25cc7f8396828f04c0 + .then((response) => response.json()) + .then((data) => data.conversationId); + ++const deleteConversation = (conversationId: string) => ++ fetchApi('/delete-conversation', { ++ body: JSON.stringify({ conversationId }), ++ }); ++ ++const regenerateMessage = (conversationId: string, messageIndex: number) => ++ fetchApi('/regenerate-message', { ++ body: JSON.stringify({ conversationId, messageIndex }), ++ }) ++ .then((response) => response.json()) ++ .then((data) => { ++ if (!data.success) { ++ throw new Error(data.error); ++ } ++ return data.source; ++ }); ++ ++export const EmptyState = ({ ++ onImagePaste, ++}: { ++ onImagePaste: (dataUrl: string) => void; ++}) => { ++ const [isDragging, setIsDragging] = React.useState(false); ++ ++ const dropAreaRefCallback = React.useCallback( ++ (node: HTMLDivElement | null) => { ++ if (!node) { ++ return; ++ } ++ const handleDragOver = (e: DragEvent) => { ++ e.preventDefault(); ++ setIsDragging(true); ++ }; ++ ++ const handleDragLeave = (e: DragEvent) => { ++ e.preventDefault(); ++ setIsDragging(false); ++ }; ++ ++ const handleDrop = (e: DragEvent) => { ++ e.preventDefault(); ++ setIsDragging(false); ++ const imageFile = e.dataTransfer?.files[0]; ++ if (!imageFile || !imageFile.type.startsWith('image/')) { ++ return; ++ } ++ const reader = new FileReader(); ++ reader.onload = () => { ++ onImagePaste(reader.result as string); ++ }; ++ reader.readAsDataURL(imageFile); ++ }; ++ ++ node.addEventListener('dragover', handleDragOver); ++ node.addEventListener('dragleave', handleDragLeave); ++ node.addEventListener('drop', handleDrop); ++ ++ return () => { ++ node.removeEventListener('dragover', handleDragOver); ++ node.removeEventListener('dragleave', handleDragLeave); ++ node.removeEventListener('drop', handleDrop); ++ }; ++ }, ++ [onImagePaste] ++ ); ++ ++ React.useEffect(() => { ++ const handlePaste = (e: ClipboardEvent) => { ++ const imageFile = e.clipboardData?.files[0]; ++ if (!imageFile || !imageFile.type.startsWith('image/')) { ++ return; ++ } ++ const canvas = document.createElement('canvas'); ++ const ctx = canvas.getContext('2d'); ++ if (!ctx) { ++ return; ++ } ++ const img = new Image(); ++ img.onload = () => { ++ canvas.width = img.width; ++ canvas.height = img.height; ++ ctx.drawImage(img, 0, 0); ++ const dataUrl = canvas.toDataURL('image/png'); ++ onImagePaste(dataUrl); ++ }; ++ img.src = URL.createObjectURL(imageFile); ++ }; ++ ++ document.addEventListener('paste', handlePaste); ++ return () => { ++ document.removeEventListener('paste', handlePaste); ++ }; ++ }, [onImagePaste]); ++ ++ return ( ++
++ {isDragging ? ( ++ 'Drop the image here' ++ ) : ( ++ <> ++

Use Mistica AI assistant to start building a UI.

++

++ Type what you want to build and the AI will generate the code for ++ you. ++

++

++ You can also paste an{' '} ++ ++ ++ ++ ++ {' '} ++ image with a design prototype here to start. ++

++ ++ )} ++
++ ); ++}; ++ +export default () => { + const [{ aiConversation }, dispatch] = React.useContext(StoreContext); + const [isWaitingForResponse, setIsWaitingForResponse] = + React.useState(false); ++ const [regeneratingIndex, setRegeneratingIndex] = React.useState< ++ number | null ++ >(null); + const [error, setError] = React.useState(null); + ++ const isLoading = isWaitingForResponse || regeneratingIndex !== null; ++ + const conversationContainerRef = React.useRef(null); + const textAreaRef = React.useRef(null); + @@ -112,14 +389,16 @@ index 0000000000000000000000000000000000000000..09c748db44dc2d25cc7f8396828f04c0 + scrollConversationToBottom(); + }; + -+ const sendMessage = async (message: string) => { -+ if (!message) { ++ const sendMessage = async ( ++ messageContent: AiConversationMessage['content'] ++ ) => { ++ if (messageContent.type === 'text' && !messageContent.text.trim()) { + return; + } + const prevAiMessageIdx = aiConversation.messages.length - 1; + addAiMessage({ + role: 'user', -+ content: { type: 'text', text: message }, ++ content: messageContent, + }); + setIsWaitingForResponse(true); + let conversationId = aiConversation.conversationId; @@ -131,7 +410,12 @@ index 0000000000000000000000000000000000000000..09c748db44dc2d25cc7f8396828f04c0 + payload: { conversationId }, + }); + } -+ const response = await sendMessageToAi(conversationId, message); ++ const response = await sendMessageToAi( ++ conversationId, ++ messageContent.type === 'text' ++ ? messageContent.text ++ : messageContent.url ++ ); + const { code: formattedCode } = formatCode({ + code: response, + cursor: { line: 0, ch: 0 }, @@ -160,6 +444,45 @@ index 0000000000000000000000000000000000000000..09c748db44dc2d25cc7f8396828f04c0 + } + }; + ++ const regenerate = async (messageIndex: number) => { ++ if (!aiConversation.conversationId) { ++ return; ++ } ++ setRegeneratingIndex(messageIndex); ++ try { ++ const response = await regenerateMessage( ++ aiConversation.conversationId, ++ messageIndex ++ ); ++ const { code: formattedCode } = formatCode({ ++ code: response, ++ cursor: { line: 0, ch: 0 }, ++ }); ++ ++ dispatch({ ++ type: 'updateCode', ++ payload: { code: formattedCode }, ++ }); ++ ++ dispatch({ ++ type: 'goBackToAiMessage', ++ payload: { index: messageIndex - 1 }, ++ }); ++ ++ addAiMessage({ ++ role: 'assistant', ++ content: { type: 'text', text: formattedCode }, ++ }); ++ } catch (e) { ++ setError((e as Error).message || 'Error'); ++ setTimeout(() => { ++ setError(null); ++ }, 5000); ++ } finally { ++ setRegeneratingIndex(null); ++ } ++ }; ++ + const restoreSnippet = (snippet: string) => { + dispatch({ + type: 'updateCode', @@ -168,6 +491,9 @@ index 0000000000000000000000000000000000000000..09c748db44dc2d25cc7f8396828f04c0 + }; + + const restartConversation = () => { ++ if (aiConversation.conversationId) { ++ deleteConversation(aiConversation.conversationId); ++ } + dispatch({ + type: 'resetAiMessages', + }); @@ -184,14 +510,7 @@ index 0000000000000000000000000000000000000000..09c748db44dc2d25cc7f8396828f04c0 + + return ( + -+
++
+
+
+ @@ -200,9 +519,9 @@ index 0000000000000000000000000000000000000000..09c748db44dc2d25cc7f8396828f04c0 +
+ {aiConversation.conversationId && ( +
+
++ {aiConversation.messages.length === 0 && !isLoading && ( ++ ++ sendMessage({ type: 'image', url: dataUrl }) ++ } ++ /> ++ )} + {aiConversation.messages.map((message, idx) => + message.role === 'assistant' ? ( +
-+
++
++ -+

Code generated

++ ++ ++ ++ ++
+
+ ) : ( +
-+ {message.content.text} ++ {message.content.type === 'text' ? ( ++ message.content.text ++ ) : ( ++ ++ )} +
+ ) + )} + {isWaitingForResponse && ( -+
++
+ +
+ )} -+ {error && ( -+
-+ {error} -+
-+ )} ++ {error &&
{error}
} +
+