Show app details when clicking in arboretum (#115)

This commit is contained in:
Brian Hackett 2025-05-06 13:36:22 -10:00 committed by GitHub
parent 8cb28b5651
commit 83380c2f5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 297 additions and 52 deletions

View File

@ -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);
}

View File

@ -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}>

View File

@ -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;

View File

@ -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);

View File

@ -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) {