Show app building results (#107)

This commit is contained in:
Brian Hackett 2025-04-26 13:04:08 -07:00 committed by GitHub
parent f5cd0fd9f1
commit 5be90e492f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 369 additions and 38 deletions

View File

@ -0,0 +1,109 @@
.container {
width: 100%;
padding: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 1rem;
width: 100%;
}
.appItem {
display: flex;
flex-direction: column;
border-radius: 8px;
overflow: hidden;
background-color: var(--bg-card);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition:
transform 0.2s ease-in-out,
box-shadow 0.2s ease-in-out;
cursor: pointer;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
.appItemError {
border: 2px solid #ffd700;
}
.previewImage {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
background-color: var(--bg-subtle);
}
.placeholderImage {
width: 100%;
aspect-ratio: 16 / 9;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-subtle);
color: var(--text-secondary);
font-size: 0.9rem;
}
.appTitle {
padding: 0.75rem;
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.loading,
.error,
.empty {
width: 100%;
padding: 2rem;
text-align: center;
color: var(--text-secondary);
}
.error {
color: var(--text-error);
}
.buttonContainer {
display: flex;
justify-content: center;
width: 100%;
margin-top: 2rem;
}
.loadMoreButton {
padding: 0.75rem 1.5rem;
background-color: #22c55e;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: #16a34a;
}
}
@media (max-width: 768px) {
.grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(3, 1fr);
}
}
@media (max-width: 480px) {
.grid {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,92 @@
'use client';
import { useEffect, useState } from 'react';
import { type BuildAppResult, getRecentApps } from '~/lib/persistence/apps';
import styles from './ExampleLibraryApps.module.scss';
import { importChat } from '~/lib/persistence/useChatHistory';
export const ExampleLibraryApps = () => {
const [numApps, setNumApps] = useState<number>(6);
const [apps, setApps] = useState<BuildAppResult[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchRecentApps() {
try {
setLoading(true);
const recentApps = await getRecentApps(numApps);
setApps(recentApps);
setError(null);
} catch (err) {
console.error('Failed to fetch recent apps:', err);
setError('Failed to load recent apps');
} finally {
setLoading(false);
}
}
if (apps.length < numApps) {
fetchRecentApps();
}
}, [numApps]);
if (error) {
return <div className={styles.error}>{error}</div>;
}
if (apps.length === 0) {
if (loading) {
return <div className={styles.loading}>Loading recent apps...</div>;
}
return <div className={styles.empty}>No recent apps found</div>;
}
const displayApps = apps.slice(0, numApps);
return (
<div className={styles.container}>
<div className={styles.grid}>
{displayApps.map((app) => (
<div
key={app.appId}
className={`${styles.appItem} ${app.outcome !== 'success' ? styles.appItemError : ''}`}
onClick={() => {
importChat(
app.title ?? 'Untitled App',
app.messages.filter((msg) => {
// Workaround an issue where the messages in the database include images
// (used to generate the screenshots).
if (msg.role == 'assistant' && msg.type == 'image') {
return false;
}
return true;
}),
);
}}
>
{app.imageDataURL ? (
<img src={app.imageDataURL} alt={app.title || 'App preview'} className={styles.previewImage} />
) : (
<div className={styles.placeholderImage}>{app.title || 'No preview'}</div>
)}
<div className={styles.appTitle}>{app.title || 'Untitled App'}</div>
</div>
))}
</div>
{loading && <div className={styles.loading}>Loading recent apps...</div>}
{!loading && (
<div className={styles.buttonContainer}>
<button
className={styles.loadMoreButton}
onClick={() => {
setNumApps((prev) => prev + 12);
}}
>
Load More
</button>
</div>
)}
</div>
);
};

View File

@ -15,6 +15,7 @@ import * as Tooltip from '@radix-ui/react-tooltip';
import styles from './BaseChat.module.scss'; import styles from './BaseChat.module.scss';
import { ExamplePrompts } from '~/components/chat/ExamplePrompts'; import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
import { ExampleLibraryApps } from '~/components/app-library/ExampleLibraryApps';
import FilePreview from './FilePreview'; import FilePreview from './FilePreview';
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition'; import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
@ -358,7 +359,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</div> </div>
)} )}
<div <div
className={classNames('pt-6 px-2 sm:px-6', { className={classNames('px-2 sm:px-6', {
'h-full flex flex-col': chatStarted, 'h-full flex flex-col': chatStarted,
})} })}
> >
@ -377,7 +378,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</ClientOnly> </ClientOnly>
<div <div
className={classNames( className={classNames(
'bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt mb-6', 'bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt',
{ {
'sticky bottom-2': chatStarted, 'sticky bottom-2': chatStarted,
}, },
@ -438,8 +439,9 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
{!rejectFormOpen && messageInput} {!rejectFormOpen && messageInput}
</div> </div>
</div> </div>
{!chatStarted && {!chatStarted && (
ExamplePrompts((event, messageInput) => { <>
{ExamplePrompts((event: React.UIEvent, messageInput?: string) => {
if (hasPendingMessage) { if (hasPendingMessage) {
handleStop?.(); handleStop?.();
return; return;
@ -447,6 +449,15 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
handleSendMessage?.(event, messageInput); handleSendMessage?.(event, messageInput);
})} })}
<div className="text-2xl lg:text-4xl font-bold text-bolt-elements-textPrimary mt-8 mb-4 animate-fade-in text-center max-w-chat mx-auto">
Library
</div>
<div className="text-bolt-elements-textSecondary text-center max-w-chat mx-auto">
Browse these auto-generated apps for a place to start
</div>
<ExampleLibraryApps />
</>
)}
</div> </div>
<ClientOnly>{() => <Workbench chatStarted={chatStarted} />}</ClientOnly> <ClientOnly>{() => <Workbench chatStarted={chatStarted} />}</ClientOnly>
</div> </div>

View File

@ -7,7 +7,8 @@ import { useAnimate } from 'framer-motion';
import { memo, useEffect, useRef, useState } from 'react'; import { memo, useEffect, useRef, useState } from 'react';
import { cssTransition, toast, ToastContainer } from 'react-toastify'; import { cssTransition, toast, ToastContainer } from 'react-toastify';
import { useSnapScroll } from '~/lib/hooks'; import { useSnapScroll } from '~/lib/hooks';
import { database, handleChatTitleUpdate, useChatHistory, type ResumeChatInfo } from '~/lib/persistence'; import { handleChatTitleUpdate, useChatHistory, type ResumeChatInfo } from '~/lib/persistence';
import { database } from '~/lib/persistence/chats';
import { chatStore } from '~/lib/stores/chat'; import { chatStore } from '~/lib/stores/chat';
import { cubicEasingFn } from '~/utils/easings'; import { cubicEasingFn } from '~/utils/easings';
import { renderLogger } from '~/utils/logger'; import { renderLogger } from '~/utils/logger';

View File

@ -6,13 +6,12 @@ const EXAMPLE_PROMPTS = [
full: 'build an app to get turn by turn directions using the OpenStreetMap API. the directions should be in a clean and easy to read format showing a small map of the turn next to each step. do not show any complete map for the entire route. make sure the directions work on real locations, e.g. getting from santa cruz to san francisco should take about an hour and a half', full: 'build an app to get turn by turn directions using the OpenStreetMap API. the directions should be in a clean and easy to read format showing a small map of the turn next to each step. do not show any complete map for the entire route. make sure the directions work on real locations, e.g. getting from santa cruz to san francisco should take about an hour and a half',
}, },
{ text: 'Build a todo app' }, { text: 'Build a todo app' },
{ text: 'Build a simple blog' },
{ text: 'Make a space invaders game' }, { text: 'Make a space invaders game' },
]; ];
export function ExamplePrompts(sendMessage?: { (event: React.UIEvent, messageInput?: string): void | undefined }) { export function ExamplePrompts(sendMessage?: { (event: React.UIEvent, messageInput?: string): void | undefined }) {
return ( return (
<div id="examples" className="relative flex flex-col gap-9 w-full max-w-3xl mx-auto flex justify-center mt-6"> <div id="examples" className="relative flex flex-col gap-9 w-full max-w-3xl mx-auto flex justify-center mt-4">
<div <div
className="flex flex-wrap justify-center gap-2" className="flex flex-wrap justify-center gap-2"
style={{ style={{

View File

@ -5,7 +5,7 @@ import type { DeploySettingsDatabase } from '~/lib/replay/Deploy';
import { generateRandomId } from '~/lib/replay/ReplayProtocolClient'; import { generateRandomId } from '~/lib/replay/ReplayProtocolClient';
import { workbenchStore } from '~/lib/stores/workbench'; import { workbenchStore } from '~/lib/stores/workbench';
import { chatStore } from '~/lib/stores/chat'; import { chatStore } from '~/lib/stores/chat';
import { database } from '~/lib/persistence/db'; import { database } from '~/lib/persistence/chats';
import { deployRepository } from '~/lib/replay/Deploy'; import { deployRepository } from '~/lib/replay/Deploy';
ReactModal.setAppElement('#root'); ReactModal.setAppElement('#root');

View File

@ -1,7 +1,7 @@
import { useParams } from '@remix-run/react'; import { useParams } from '@remix-run/react';
import { classNames } from '~/utils/classNames'; import { classNames } from '~/utils/classNames';
import * as Dialog from '@radix-ui/react-dialog'; import * as Dialog from '@radix-ui/react-dialog';
import { type ChatContents } from '~/lib/persistence/db'; import { type ChatContents } from '~/lib/persistence/chats';
import WithTooltip from '~/components/ui/Tooltip'; import WithTooltip from '~/components/ui/Tooltip';
import { useEditChatTitle } from '~/lib/hooks/useEditChatDescription'; import { useEditChatTitle } from '~/lib/hooks/useEditChatDescription';
import { forwardRef, type ForwardedRef } from 'react'; import { forwardRef, type ForwardedRef } from 'react';

View File

@ -5,7 +5,7 @@ import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from
import { ThemeSwitch } from '~/components/ui/ThemeSwitch'; import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
import { SettingsWindow } from '~/components/settings/SettingsWindow'; import { SettingsWindow } from '~/components/settings/SettingsWindow';
import { SettingsButton } from '~/components/ui/SettingsButton'; import { SettingsButton } from '~/components/ui/SettingsButton';
import { database, type ChatContents } from '~/lib/persistence/db'; import { database, type ChatContents } from '~/lib/persistence/chats';
import { chatStore } from '~/lib/stores/chat'; import { chatStore } from '~/lib/stores/chat';
import { cubicEasingFn } from '~/utils/easings'; import { cubicEasingFn } from '~/utils/easings';
import { logger } from '~/utils/logger'; import { logger } from '~/utils/logger';

View File

@ -1,5 +1,5 @@
import { format, isAfter, isThisWeek, isThisYear, isToday, isYesterday, subDays } from 'date-fns'; import { format, isAfter, isThisWeek, isThisYear, isToday, isYesterday, subDays } from 'date-fns';
import type { ChatContents } from '~/lib/persistence/db'; import type { ChatContents } from '~/lib/persistence/chats';
type Bin = { category: string; items: ChatContents[] }; type Bin = { category: string; items: ChatContents[] };

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { chatStore } from '~/lib/stores/chat'; import { chatStore } from '~/lib/stores/chat';
import { database } from '~/lib/persistence/db'; import { database } from '~/lib/persistence/chats';
import { handleChatTitleUpdate } from '~/lib/persistence/useChatHistory'; import { handleChatTitleUpdate } from '~/lib/persistence/useChatHistory';
interface EditChatDescriptionOptions { interface EditChatDescriptionOptions {

View File

@ -1,6 +1,6 @@
import { useState, useMemo, useCallback } from 'react'; import { useState, useMemo, useCallback } from 'react';
import { debounce } from '~/utils/debounce'; import { debounce } from '~/utils/debounce';
import type { ChatContents } from '~/lib/persistence/db'; import type { ChatContents } from '~/lib/persistence/chats';
interface UseSearchFilterOptions { interface UseSearchFilterOptions {
items: ChatContents[]; items: ChatContents[];

View File

@ -0,0 +1,83 @@
// Functions for accessing the apps table in the database
import { getSupabase } from '~/lib/supabase/client';
import type { Message } from './message';
export enum BuildAppOutcome {
Success = 'success',
Error = 'error',
}
export interface BuildAppResult {
title: string | undefined;
elapsedMinutes: number;
totalPeanuts: number;
imageDataURL: string | undefined;
messages: Message[];
protocolChatId: string;
outcome: BuildAppOutcome;
appId: string;
}
function databaseRowToBuildAppResult(row: any): BuildAppResult {
// Determine the outcome based on the result field
let outcome = BuildAppOutcome.Error;
if (row.outcome === 'success') {
outcome = BuildAppOutcome.Success;
}
return {
title: row.title,
elapsedMinutes: row.elapsed_minutes || 0,
totalPeanuts: row.total_peanuts || 0,
imageDataURL: row.image_url,
messages: row.messages || [],
protocolChatId: row.protocol_chat_id,
outcome,
appId: row.app_id,
};
}
/**
* Get all apps created within the last X hours
* @param hours Number of hours to look back
* @returns Array of BuildAppResult objects
*/
async function getAppsCreatedInLastXHours(hours: number): Promise<BuildAppResult[]> {
try {
// Calculate the timestamp for X hours ago
const hoursAgo = new Date();
hoursAgo.setHours(hoursAgo.getHours() - hours);
const { data, error } = await getSupabase()
.from('apps')
.select('*')
.eq('deleted', false)
.gte('created_at', hoursAgo.toISOString())
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching recent apps:', error);
throw error;
}
// Ignore apps that don't have a title or image.
return data.map(databaseRowToBuildAppResult).filter((app) => app.title && app.imageDataURL);
} catch (error) {
console.error('Failed to get recent apps:', error);
throw error;
}
}
const HOUR_RANGES = [1, 2, 3, 6, 12, 24];
export async function getRecentApps(numApps: number): Promise<BuildAppResult[]> {
let apps: BuildAppResult[] = [];
for (const range of HOUR_RANGES) {
apps = await getAppsCreatedInLastXHours(range);
if (apps.length >= numApps) {
return apps.slice(0, numApps);
}
}
return apps;
}

View File

@ -1,2 +1,3 @@
export * from './db'; export * from './chats';
export * from './useChatHistory'; export * from './useChatHistory';
export * from './apps';

View File

@ -3,7 +3,7 @@ import { useState, useEffect } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { logStore } from '~/lib/stores/logs'; // Import logStore import { logStore } from '~/lib/stores/logs'; // Import logStore
import { chatStore } from '~/lib/stores/chat'; import { chatStore } from '~/lib/stores/chat';
import { database } from './db'; import { database } from './chats';
import { createMessagesForRepository, type Message } from './message'; import { createMessagesForRepository, type Message } from './message';
import { debounce } from '~/utils/debounce'; import { debounce } from '~/utils/debounce';
@ -12,14 +12,7 @@ export interface ResumeChatInfo {
protocolChatResponseId: string; protocolChatResponseId: string;
} }
export function useChatHistory() { export async function importChat(title: string, messages: Message[]) {
const { id: mixedId, repositoryId } = useLoaderData<{ id?: string; repositoryId?: string }>() ?? {};
const [initialMessages, setInitialMessages] = useState<Message[]>([]);
const [resumeChat, setResumeChat] = useState<ResumeChatInfo | undefined>(undefined);
const [ready, setReady] = useState<boolean>(!mixedId && !repositoryId);
const importChat = async (title: string, messages: Message[]) => {
try { try {
const chat = await database.createChat(title, messages); const chat = await database.createChat(title, messages);
window.location.href = `/chat/${chat.id}`; window.location.href = `/chat/${chat.id}`;
@ -31,7 +24,14 @@ export function useChatHistory() {
toast.error('Failed to import chat'); toast.error('Failed to import chat');
} }
} }
}; }
export function useChatHistory() {
const { id: mixedId, repositoryId } = useLoaderData<{ id?: string; repositoryId?: string }>() ?? {};
const [initialMessages, setInitialMessages] = useState<Message[]>([]);
const [resumeChat, setResumeChat] = useState<ResumeChatInfo | undefined>(undefined);
const [ready, setReady] = useState<boolean>(!mixedId && !repositoryId);
const loadRepository = async (repositoryId: string) => { const loadRepository = async (repositoryId: string) => {
const messages = createMessagesForRepository(`Repository: ${repositoryId}`, repositoryId); const messages = createMessagesForRepository(`Repository: ${repositoryId}`, repositoryId);

View File

@ -7,7 +7,7 @@ import { simulationDataVersion } from './SimulationData';
import { assert, generateRandomId, ProtocolClient } from './ReplayProtocolClient'; import { assert, generateRandomId, ProtocolClient } from './ReplayProtocolClient';
import { updateDevelopmentServer } from './DevelopmentServer'; import { updateDevelopmentServer } from './DevelopmentServer';
import type { Message } from '~/lib/persistence/message'; import type { Message } from '~/lib/persistence/message';
import { database } from '~/lib/persistence/db'; import { database } from '~/lib/persistence/chats';
import { chatStore } from '~/lib/stores/chat'; import { chatStore } from '~/lib/stores/chat';
import { debounce } from '~/utils/debounce'; import { debounce } from '~/utils/debounce';
import { getSupabase } from '~/lib/supabase/client'; import { getSupabase } from '~/lib/supabase/client';

View File

@ -4,7 +4,7 @@ import type { User, Session } from '@supabase/supabase-js';
import { logStore } from './logs'; import { logStore } from './logs';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { isAuthenticated } from '~/lib/supabase/client'; import { isAuthenticated } from '~/lib/supabase/client';
import { database } from '~/lib/persistence/db'; import { database } from '~/lib/persistence/chats';
export const userStore = atom<User | null>(null); export const userStore = atom<User | null>(null);
export const sessionStore = atom<Session | null>(null); export const sessionStore = atom<Session | null>(null);

View File

@ -1,5 +1,5 @@
import { atom } from 'nanostores'; import { atom } from 'nanostores';
import type { ChatContents } from '~/lib/persistence/db'; import type { ChatContents } from '~/lib/persistence/chats';
export class ChatStore { export class ChatStore {
currentChat = atom<ChatContents | undefined>(undefined); currentChat = atom<ChatContents | undefined>(undefined);

View File

@ -0,0 +1,35 @@
-- These tables aren't needed anymore.
DROP TABLE IF EXISTS public.problems;
DROP TABLE IF EXISTS public.problem_comments;
-- Create apps table
CREATE TABLE IF NOT EXISTS public.apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
title TEXT,
elapsed_minutes FLOAT,
total_peanuts INTEGER,
image_url TEXT,
messages JSONB,
protocol_chat_id UUID,
result TEXT,
app_id TEXT,
deleted BOOLEAN DEFAULT FALSE,
);
-- Create updated_at trigger for apps table
CREATE TRIGGER update_apps_updated_at
BEFORE UPDATE ON public.apps
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Enable Row Level Security
ALTER TABLE public.apps ENABLE ROW LEVEL SECURITY;
-- Create policy to allow all users full access
CREATE POLICY "Allow full access to all users" ON public.apps
FOR ALL
TO public
USING (true)
WITH CHECK (true);