From 83380c2f5ce3b32884025132f79af7173690da83 Mon Sep 17 00:00:00 2001 From: Brian Hackett Date: Tue, 6 May 2025 13:36:22 -1000 Subject: [PATCH] Show app details when clicking in arboretum (#115) --- .../ExampleLibraryApps.module.scss | 77 ++++++- .../app-library/ExampleLibraryApps.tsx | 206 +++++++++++++++--- app/components/chat/Messages.client.tsx | 8 +- app/lib/persistence/apps.ts | 48 +++- app/lib/persistence/message.ts | 10 +- 5 files changed, 297 insertions(+), 52 deletions(-) diff --git a/app/components/app-library/ExampleLibraryApps.module.scss b/app/components/app-library/ExampleLibraryApps.module.scss index e1d54b1f..d891aa34 100644 --- a/app/components/app-library/ExampleLibraryApps.module.scss +++ b/app/components/app-library/ExampleLibraryApps.module.scss @@ -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); +} diff --git a/app/components/app-library/ExampleLibraryApps.tsx b/app/components/app-library/ExampleLibraryApps.tsx index 4427f89d..51128e17 100644 --- a/app/components/app-library/ExampleLibraryApps.tsx +++ b/app/components/app-library/ExampleLibraryApps.tsx @@ -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(6); - const [apps, setApps] = useState([]); + const [apps, setApps] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [selectedAppId, setSelectedAppId] = useState(null); + const [selectedAppContents, setSelectedAppContents] = useState(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 ( +
{ + setSelectedAppId(app.id == selectedAppId ? null : app.id); + setSelectedAppContents(null); + }} + className={`${styles.appItem} ${!app.outcome.testsPassed ? styles.appItemError : ''}`} + > + {app.imageDataURL ? ( + {app.title + ) : ( +
{app.title || 'No preview'}
+ )} +
{app.title || 'Untitled App'}
+
+
+
+
+ Created at {formatDate(new Date(app.createdAt))} in {Math.round(app.elapsedMinutes)} minutes +
+
+ {app.totalPeanuts} peanuts{app.outcome.hasDatabase ? ' (has database)' : ''} +
+ {!app.outcome.testsPassed &&
⚠️ Not all tests are passing
} +
+
+
+
+ ); + }; + + 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 ( +
+
+

{app.title}

+
+ + +
+
+
+
+ Created: + {new Date(app.createdAt).toLocaleString()} +
+
+ Time: + {app.elapsedMinutes} minutes +
+
+ Peanuts: + {app.totalPeanuts} +
+
+ Database: + {app.outcome.hasDatabase ? 'Present' : 'None'} +
+
Test Results
+ {testResults && ( +
+ {testResults.map((result) => ( +
+
+ {result.recordingId ? ( + + {result.title} + + ) : ( +
{result.title}
+ )} +
+ ))} +
+ )} + {!testResults &&
Loading...
} +
+
+ ); + }; + return (
- +
{beforeApps.map(renderApp)}
+ {selectedAppId && renderAppDetails(selectedAppId, selectedAppContents)} +
{afterApps.map(renderApp)}
{loading &&
Loading recent apps...
} {!loading && (
diff --git a/app/components/chat/Messages.client.tsx b/app/components/chat/Messages.client.tsx index b4b8024c..0b5f183e 100644 --- a/app/components/chat/Messages.client.tsx +++ b/app/components/chat/Messages.client.tsx @@ -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((props: }; const renderTestResults = (message: Message, index: number) => { - assert(message.type === 'text'); - const testResults = parseTestResultsMessage(message.content); + const testResults = parseTestResultsMessage(message); return (
((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; diff --git a/app/lib/persistence/apps.ts b/app/lib/persistence/apps.ts index 9ad01ca9..e91b0de3 100644 --- a/app/lib/persistence/apps.ts +++ b/app/lib/persistence/apps.ts @@ -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 { +async function getAppsCreatedInLastXHours(hours: number): Promise { try { // Calculate the timestamp for X hours ago const hoursAgo = new Date(); @@ -76,7 +98,7 @@ async function getAppsCreatedInLastXHours(hours: number): Promise 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 { - let apps: BuildAppResult[] = []; +export async function getRecentApps(numApps: number): Promise { + 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 } export async function getAppById(id: string): Promise { + 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); diff --git a/app/lib/persistence/message.ts b/app/lib/persistence/message.ts index ff97360d..dc641705 100644 --- a/app/lib/persistence/message.ts +++ b/app/lib/persistence/message.ts @@ -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) {