diff --git a/.yarn/cache/playroom-patch-296f85d480-4519b01e0b.zip b/.yarn/cache/playroom-patch-296f85d480-4519b01e0b.zip new file mode 100644 index 000000000..5a4c13f4a Binary files /dev/null and b/.yarn/cache/playroom-patch-296f85d480-4519b01e0b.zip differ diff --git a/.yarn/patches/playroom-npm-0.39.0-24448abd08.patch b/.yarn/patches/playroom-npm-0.39.0-24448abd08.patch new file mode 100644 index 000000000..acd67d325 --- /dev/null +++ b/.yarn/patches/playroom-npm-0.39.0-24448abd08.patch @@ -0,0 +1,890 @@ +diff --git a/src/Playroom/AiPanel/AiPanel.css.ts b/src/Playroom/AiPanel/AiPanel.css.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..21d14ae14f317c051123f269242ee58dee262275 +--- /dev/null ++++ b/src/Playroom/AiPanel/AiPanel.css.ts +@@ -0,0 +1,143 @@ ++import { keyframes, style } from '@vanilla-extract/css'; ++import { colorPaletteVars } from '../sprinkles.css'; ++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', ++}); ++ ++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', ++ cursor: 'pointer', ++ width: 'auto', ++ color: 'inherit', ++ lineHeight: 'normal', ++ 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..57e123e4dfc28dbd4154d2c7bb62d47596ecbf25 +--- /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('.'); ++ ++ React.useEffect(() => { ++ const interval = setInterval(() => { ++ setDots((currentDots) => { ++ if (currentDots.length === 3) { ++ return '.'; ++ } ++ return `${currentDots}.`; ++ }); ++ }, 500); ++ return () => clearInterval(interval); ++ }, []); ++ ++ return <>{dots}; ++}; ++ ++// export const aiBaseUrl = `http://localhost:3000`; ++export const aiBaseUrl = `https://mistica-playroom-api.tooling-dev.svc.dev.tuenti.io`; ++ ++const fetchApi = (path: string, options?: RequestInit): Promise => ++ fetch(`${aiBaseUrl}${path}`, { ++ credentials: 'include', ++ method: 'POST', ++ headers: { ++ Accept: 'application/json', ++ 'Content-Type': 'application/json', ++ ...options?.headers, ++ }, ++ ...options, ++ }).then((response) => { ++ if (response.status === 401) { ++ window.location.assign('/oauth2/sign_in'); ++ return new Promise(() => {}); // never resolve, as we are redirecting to login ++ } ++ return response.json(); ++ }); ++ ++const sendMessageToAi = ( ++ conversationId: string, ++ message: string ++): Promise => ++ fetchApi('/send-message', { ++ body: JSON.stringify({ conversationId, message }), ++ }).then((data) => { ++ if (!data.success) { ++ throw new Error(data.error); ++ } ++ return data.source; ++ }); ++ ++const createConversation = (): Promise => ++ fetchApi('/create-conversation').then((data) => data.conversationId); ++ ++const deleteConversation = (conversationId: string): Promise => ++ fetchApi('/delete-conversation', { ++ body: JSON.stringify({ conversationId }), ++ }).then(() => {}); ++ ++const regenerateMessage = ( ++ conversationId: string, ++ messageIndex: number ++): Promise => ++ fetchApi('/regenerate-message', { ++ body: JSON.stringify({ conversationId, messageIndex }), ++ }).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); ++ ++ const scrollConversationToBottom = () => { ++ conversationContainerRef.current?.scrollTo({ ++ top: conversationContainerRef.current.scrollHeight, ++ behavior: 'smooth', ++ }); ++ }; ++ ++ const addAiMessage = (message: AiConversationMessage) => { ++ flushSync(() => { ++ dispatch({ ++ type: 'addAiMessage', ++ payload: { message }, ++ }); ++ }); ++ scrollConversationToBottom(); ++ }; ++ ++ const sendMessage = async ( ++ messageContent: AiConversationMessage['content'] ++ ) => { ++ if (messageContent.type === 'text' && !messageContent.text.trim()) { ++ return; ++ } ++ const prevAiMessageIdx = aiConversation.messages.length - 1; ++ addAiMessage({ ++ role: 'user', ++ content: messageContent, ++ }); ++ setIsWaitingForResponse(true); ++ let conversationId = aiConversation.conversationId; ++ try { ++ if (!conversationId) { ++ conversationId = await createConversation(); ++ dispatch({ ++ type: 'createAiConversation', ++ payload: { conversationId }, ++ }); ++ } ++ const response = await sendMessageToAi( ++ conversationId, ++ messageContent.type === 'text' ++ ? messageContent.text ++ : messageContent.url ++ ); ++ const { code: formattedCode } = formatCode({ ++ code: response, ++ cursor: { line: 0, ch: 0 }, ++ }); ++ ++ dispatch({ ++ type: 'updateCode', ++ payload: { code: formattedCode }, ++ }); ++ ++ addAiMessage({ ++ role: 'assistant', ++ content: { type: 'text', text: formattedCode }, ++ }); ++ } catch (e) { ++ setError((e as Error).message || 'Error'); ++ setTimeout(() => { ++ dispatch({ ++ type: 'goBackToAiMessage', ++ payload: { index: prevAiMessageIdx }, ++ }); ++ setError(null); ++ }, 5000); ++ } finally { ++ setIsWaitingForResponse(false); ++ } ++ }; ++ ++ 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', ++ payload: { code: snippet }, ++ }); ++ }; ++ ++ const restartConversation = () => { ++ if (aiConversation.conversationId) { ++ deleteConversation(aiConversation.conversationId); ++ } ++ dispatch({ ++ type: 'resetAiMessages', ++ }); ++ textAreaRef.current?.focus(); ++ }; ++ ++ React.useLayoutEffect(() => { ++ if (isWaitingForResponse) { ++ scrollConversationToBottom(); ++ } else { ++ textAreaRef.current?.focus(); ++ } ++ }, [isWaitingForResponse]); ++ ++ return ( ++ ++
++
++
++ ++ {error || 'AI Assistant'} ++ ++
++ {aiConversation.conversationId && ( ++ ++ )} ++
++
++ {aiConversation.messages.length === 0 && !isLoading && ( ++ ++ sendMessage({ type: 'image', url: dataUrl }) ++ } ++ /> ++ )} ++ {aiConversation.messages.map((message, idx) => ++ message.role === 'assistant' ? ( ++
++
++ ++

++ {regeneratingIndex === idx ++ ? 'Regenerating...' ++ : 'Code generated'} ++

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