mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
Show app details when clicking in arboretum (#115)
This commit is contained in:
parent
8cb28b5651
commit
83380c2f5c
@ -6,7 +6,6 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
@ -138,7 +137,6 @@
|
||||
@media (max-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,3 +145,78 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.detailView {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1.5rem;
|
||||
background-color: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.detailHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.detailTitle {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detailActions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:first-child {
|
||||
background-color: #22c55e;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: #16a34a;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
background-color: #64748b;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: #475569;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.appDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.detailRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.detailLabel {
|
||||
font-weight: 500;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.detailValue {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { type BuildAppResult, getRecentApps } from '~/lib/persistence/apps';
|
||||
import { type BuildAppResult, type BuildAppSummary, getAppById, getRecentApps } from '~/lib/persistence/apps';
|
||||
import styles from './ExampleLibraryApps.module.scss';
|
||||
import { getMessagesRepositoryId, parseTestResultsMessage, TEST_RESULTS_CATEGORY } from '~/lib/persistence/message';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
@ -16,9 +18,40 @@ const formatDate = (date: Date) => {
|
||||
|
||||
export const ExampleLibraryApps = () => {
|
||||
const [numApps, setNumApps] = useState<number>(6);
|
||||
const [apps, setApps] = useState<BuildAppResult[]>([]);
|
||||
const [apps, setApps] = useState<BuildAppSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedAppId, setSelectedAppId] = useState<string | null>(null);
|
||||
const [selectedAppContents, setSelectedAppContents] = useState<BuildAppResult | null>(null);
|
||||
const [gridColumns, setGridColumns] = useState(1);
|
||||
|
||||
const computeGridColumns = () => {
|
||||
const width = window.innerWidth;
|
||||
if (width <= 480) {
|
||||
return 1;
|
||||
}
|
||||
if (width <= 768) {
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setGridColumns(computeGridColumns());
|
||||
|
||||
const handleResize = () => setGridColumns(computeGridColumns());
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (selectedAppId) {
|
||||
const app = await getAppById(selectedAppId);
|
||||
setSelectedAppContents(app);
|
||||
}
|
||||
})();
|
||||
}, [selectedAppId]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchRecentApps() {
|
||||
@ -53,37 +86,148 @@ export const ExampleLibraryApps = () => {
|
||||
|
||||
const displayApps = apps.slice(0, numApps);
|
||||
|
||||
let beforeApps = displayApps;
|
||||
let afterApps: BuildAppSummary[] = [];
|
||||
if (selectedAppId) {
|
||||
let selectedIndex = displayApps.findIndex((app) => app.id === selectedAppId);
|
||||
if (selectedIndex >= 0) {
|
||||
while ((selectedIndex + 1) % gridColumns != 0) {
|
||||
selectedIndex++;
|
||||
}
|
||||
beforeApps = displayApps.slice(0, selectedIndex + 1);
|
||||
afterApps = displayApps.slice(selectedIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const renderApp = (app: BuildAppSummary) => {
|
||||
return (
|
||||
<div
|
||||
key={app.id}
|
||||
onClick={() => {
|
||||
setSelectedAppId(app.id == selectedAppId ? null : app.id);
|
||||
setSelectedAppContents(null);
|
||||
}}
|
||||
className={`${styles.appItem} ${!app.outcome.testsPassed ? styles.appItemError : ''}`}
|
||||
>
|
||||
{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 className={styles.hoverOverlay}>
|
||||
<div className={styles.hoverContent}>
|
||||
<div className={styles.hoverInfo}>
|
||||
<div>
|
||||
Created at {formatDate(new Date(app.createdAt))} in {Math.round(app.elapsedMinutes)} minutes
|
||||
</div>
|
||||
<div>
|
||||
{app.totalPeanuts} peanuts{app.outcome.hasDatabase ? ' (has database)' : ''}
|
||||
</div>
|
||||
{!app.outcome.testsPassed && <div className={styles.warningText}>⚠️ Not all tests are passing</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getTestResults = (appContents: BuildAppResult) => {
|
||||
const message = appContents.messages.findLast((message) => message.category === TEST_RESULTS_CATEGORY);
|
||||
return message ? parseTestResultsMessage(message) : [];
|
||||
};
|
||||
|
||||
const renderAppDetails = (appId: string, appContents: BuildAppResult | null) => {
|
||||
const app = apps.find((app) => app.id === appId);
|
||||
if (!app) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const testResults = appContents ? getTestResults(appContents) : null;
|
||||
|
||||
return (
|
||||
<div className={styles.detailView}>
|
||||
<div className={styles.detailHeader}>
|
||||
<h3 className={styles.detailTitle}>{app.title}</h3>
|
||||
<div className={styles.detailActions}>
|
||||
<button
|
||||
className={styles.actionButton}
|
||||
onClick={async () => {
|
||||
const contents = appContents ?? (await getAppById(appId));
|
||||
const repositoryId = getMessagesRepositoryId(contents.messages);
|
||||
if (repositoryId) {
|
||||
window.open(`https://${repositoryId}.http.replay.io`, '_blank');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Load App
|
||||
</button>
|
||||
<button
|
||||
className={styles.actionButton}
|
||||
onClick={() => {
|
||||
window.open(`/app/${app.id}`, '_self');
|
||||
}}
|
||||
>
|
||||
Start Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.appDetails}>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Created:</span>
|
||||
<span className={styles.detailValue}>{new Date(app.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Time:</span>
|
||||
<span className={styles.detailValue}>{app.elapsedMinutes} minutes</span>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Peanuts:</span>
|
||||
<span className={styles.detailValue}>{app.totalPeanuts}</span>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Database:</span>
|
||||
<span className={styles.detailValue}>{app.outcome.hasDatabase ? 'Present' : 'None'}</span>
|
||||
</div>
|
||||
<div className="text-lg font-semibold mb-2">Test Results</div>
|
||||
{testResults && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{testResults.map((result) => (
|
||||
<div key={result.title} className="flex items-center gap-2">
|
||||
<div
|
||||
className={classNames('w-3 h-3 rounded-full border border-black', {
|
||||
'bg-green-500': result.status === 'Pass',
|
||||
'bg-red-500': result.status === 'Fail',
|
||||
'bg-gray-300': result.status === 'NotRun',
|
||||
})}
|
||||
/>
|
||||
{result.recordingId ? (
|
||||
<a
|
||||
href={`https://app.replay.io/recording/${result.recordingId}`}
|
||||
className="underline hover:text-blue-600"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{result.title}
|
||||
</a>
|
||||
) : (
|
||||
<div>{result.title}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!testResults && <div>Loading...</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.grid}>
|
||||
{displayApps.map((app) => (
|
||||
<a
|
||||
key={app.id}
|
||||
href={`/app/${app.id}`}
|
||||
className={`${styles.appItem} ${!app.outcome.testsPassed ? styles.appItemError : ''}`}
|
||||
>
|
||||
{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 className={styles.hoverOverlay}>
|
||||
<div className={styles.hoverContent}>
|
||||
<div className={styles.hoverInfo}>
|
||||
<div>
|
||||
Created at {formatDate(new Date(app.createdAt))} in {Math.round(app.elapsedMinutes)} minutes
|
||||
</div>
|
||||
<div>
|
||||
{app.totalPeanuts} peanuts{app.outcome.hasDatabase ? ' (has database)' : ''}
|
||||
</div>
|
||||
{!app.outcome.testsPassed && <div className={styles.warningText}>⚠️ Not all tests are passing</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.grid}>{beforeApps.map(renderApp)}</div>
|
||||
{selectedAppId && renderAppDetails(selectedAppId, selectedAppContents)}
|
||||
<div className={styles.grid}>{afterApps.map(renderApp)}</div>
|
||||
{loading && <div className={styles.loading}>Loading recent apps...</div>}
|
||||
{!loading && (
|
||||
<div className={styles.buttonContainer}>
|
||||
|
@ -1,9 +1,8 @@
|
||||
import React, { Suspense, useState } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
import { parseTestResultsMessage, type Message } from '~/lib/persistence/message';
|
||||
import { parseTestResultsMessage, type Message, TEST_RESULTS_CATEGORY } from '~/lib/persistence/message';
|
||||
import { MessageContents } from './MessageContents';
|
||||
import { assert } from '~/lib/replay/ReplayProtocolClient';
|
||||
|
||||
interface MessagesProps {
|
||||
id?: string;
|
||||
@ -37,8 +36,7 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
|
||||
};
|
||||
|
||||
const renderTestResults = (message: Message, index: number) => {
|
||||
assert(message.type === 'text');
|
||||
const testResults = parseTestResultsMessage(message.content);
|
||||
const testResults = parseTestResultsMessage(message);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -91,7 +89,7 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
|
||||
}
|
||||
const showDetails = showDetailMessageIds.includes(lastUserResponse.id);
|
||||
|
||||
if (message.category === 'TestResults') {
|
||||
if (message.category === TEST_RESULTS_CATEGORY) {
|
||||
// The default view only shows the last test results for each user response.
|
||||
if (!isLastTestResults(index) && !showDetails) {
|
||||
return null;
|
||||
|
@ -8,19 +8,23 @@ export interface BuildAppOutcome {
|
||||
hasDatabase?: boolean;
|
||||
}
|
||||
|
||||
export interface BuildAppResult {
|
||||
export interface BuildAppSummary {
|
||||
id: string;
|
||||
title: string | undefined;
|
||||
prompt: string | undefined;
|
||||
elapsedMinutes: number;
|
||||
totalPeanuts: number;
|
||||
imageDataURL: string | undefined;
|
||||
messages: Message[];
|
||||
protocolChatId: string;
|
||||
outcome: BuildAppOutcome;
|
||||
appId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BuildAppResult extends BuildAppSummary {
|
||||
messages: Message[];
|
||||
protocolChatId: string;
|
||||
}
|
||||
|
||||
function parseBuildAppOutcome(outcome: string): BuildAppOutcome {
|
||||
try {
|
||||
const json = JSON.parse(outcome);
|
||||
@ -45,30 +49,48 @@ function parseBuildAppOutcome(outcome: string): BuildAppOutcome {
|
||||
}
|
||||
}
|
||||
|
||||
function databaseRowToBuildAppResult(row: any): BuildAppResult {
|
||||
// Determine the outcome based on the result field
|
||||
const BUILD_APP_SUMMARY_COLUMNS = [
|
||||
'id',
|
||||
'title',
|
||||
'prompt',
|
||||
'elapsed_minutes',
|
||||
'total_peanuts',
|
||||
'image_url',
|
||||
'outcome',
|
||||
'app_id',
|
||||
'created_at',
|
||||
].join(',');
|
||||
|
||||
function databaseRowToBuildAppSummary(row: any): BuildAppSummary {
|
||||
const outcome = parseBuildAppOutcome(row.outcome);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
prompt: row.prompt,
|
||||
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,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function databaseRowToBuildAppResult(row: any): BuildAppResult {
|
||||
return {
|
||||
...databaseRowToBuildAppSummary(row),
|
||||
messages: row.messages || [],
|
||||
protocolChatId: row.protocol_chat_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[]> {
|
||||
async function getAppsCreatedInLastXHours(hours: number): Promise<BuildAppSummary[]> {
|
||||
try {
|
||||
// Calculate the timestamp for X hours ago
|
||||
const hoursAgo = new Date();
|
||||
@ -76,7 +98,7 @@ async function getAppsCreatedInLastXHours(hours: number): Promise<BuildAppResult
|
||||
|
||||
const { data, error } = await getSupabase()
|
||||
.from('apps')
|
||||
.select('*')
|
||||
.select(BUILD_APP_SUMMARY_COLUMNS)
|
||||
.eq('deleted', false)
|
||||
.gte('created_at', hoursAgo.toISOString())
|
||||
.order('created_at', { ascending: false });
|
||||
@ -87,7 +109,7 @@ async function getAppsCreatedInLastXHours(hours: number): Promise<BuildAppResult
|
||||
}
|
||||
|
||||
// Ignore apps that don't have a title or image.
|
||||
return data.map(databaseRowToBuildAppResult).filter((app) => app.title && app.imageDataURL);
|
||||
return data.map(databaseRowToBuildAppSummary).filter((app) => app.title && app.imageDataURL);
|
||||
} catch (error) {
|
||||
console.error('Failed to get recent apps:', error);
|
||||
throw error;
|
||||
@ -96,8 +118,8 @@ async function getAppsCreatedInLastXHours(hours: number): Promise<BuildAppResult
|
||||
|
||||
const HOUR_RANGES = [1, 2, 3, 6, 12, 24];
|
||||
|
||||
export async function getRecentApps(numApps: number): Promise<BuildAppResult[]> {
|
||||
let apps: BuildAppResult[] = [];
|
||||
export async function getRecentApps(numApps: number): Promise<BuildAppSummary[]> {
|
||||
let apps: BuildAppSummary[] = [];
|
||||
for (const range of HOUR_RANGES) {
|
||||
apps = await getAppsCreatedInLastXHours(range);
|
||||
if (apps.length >= numApps) {
|
||||
@ -108,7 +130,9 @@ export async function getRecentApps(numApps: number): Promise<BuildAppResult[]>
|
||||
}
|
||||
|
||||
export async function getAppById(id: string): Promise<BuildAppResult> {
|
||||
console.log('GetAppByIdStart', id);
|
||||
const { data, error } = await getSupabase().from('apps').select('*').eq('id', id).single();
|
||||
console.log('GetAppByIdDone', id);
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching app by id:', error);
|
||||
|
@ -81,9 +81,15 @@ export interface PlaywrightTestResult {
|
||||
recordingId?: string;
|
||||
}
|
||||
|
||||
export function parseTestResultsMessage(contents: string): PlaywrightTestResult[] {
|
||||
export const TEST_RESULTS_CATEGORY = 'TestResults';
|
||||
|
||||
export function parseTestResultsMessage(message: Message): PlaywrightTestResult[] {
|
||||
if (message.type !== 'text') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: PlaywrightTestResult[] = [];
|
||||
const lines = contents.split('\n');
|
||||
const lines = message.content.split('\n');
|
||||
for (const line of lines) {
|
||||
const match = line.match(/TestResult (.*?) (.*?) (.*)/);
|
||||
if (!match) {
|
||||
|
Loading…
Reference in New Issue
Block a user