mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
Remove problems UI (#94)
This commit is contained in:
parent
f39baaa5a9
commit
9f91b9fdde
@ -33,7 +33,7 @@ import { assert, generateRandomId } from '~/lib/replay/ReplayProtocolClient';
|
||||
import { getMessagesRepositoryId, getPreviousRepositoryId, type Message } from '~/lib/persistence/message';
|
||||
import { useAuthStatus } from '~/lib/stores/auth';
|
||||
import { debounce } from '~/utils/debounce';
|
||||
import { supabaseSubmitFeedback } from '~/lib/supabase/problems';
|
||||
import { supabaseSubmitFeedback } from '~/lib/supabase/feedback';
|
||||
|
||||
const toastAnimation = cssTransition({
|
||||
enter: 'animated fadeInRight',
|
||||
|
@ -1,113 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
import { assert } from '~/lib/replay/ReplayProtocolClient';
|
||||
import type { NutProblem } from '~/lib/replay/Problems';
|
||||
import { getProblem } from '~/lib/replay/Problems';
|
||||
import { createMessagesForRepository, type Message } from '~/lib/persistence/message';
|
||||
|
||||
interface LoadProblemButtonProps {
|
||||
className?: string;
|
||||
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function setLastLoadedProblem(problem: NutProblem) {
|
||||
localStorage.setItem('loadedProblemId', problem.problemId);
|
||||
}
|
||||
|
||||
export async function getOrFetchLastLoadedProblem(): Promise<NutProblem | null> {
|
||||
let problem: NutProblem | null = null;
|
||||
const problemId = localStorage.getItem('loadedProblemId');
|
||||
|
||||
if (!problemId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
problem = await getProblem(problemId);
|
||||
|
||||
if (!problem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
|
||||
export async function loadProblem(
|
||||
problemId: string,
|
||||
importChat: (description: string, messages: Message[]) => Promise<void>,
|
||||
) {
|
||||
const problem = await getProblem(problemId);
|
||||
|
||||
if (!problem) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLastLoadedProblem(problem);
|
||||
|
||||
const { repositoryId, title: problemTitle } = problem;
|
||||
|
||||
try {
|
||||
const messages = createMessagesForRepository(`Problem: ${problemTitle}`, repositoryId);
|
||||
await importChat(`Problem: ${problemTitle}`, messages);
|
||||
|
||||
logStore.logSystem('Problem loaded successfully', {
|
||||
problemId,
|
||||
});
|
||||
toast.success('Problem loaded successfully');
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to load problem', error);
|
||||
console.error('Failed to load problem:', error);
|
||||
toast.error('Failed to load problem');
|
||||
}
|
||||
}
|
||||
|
||||
export const LoadProblemButton: React.FC<LoadProblemButtonProps> = ({ className, importChat }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isInputOpen, setIsInputOpen] = useState(false);
|
||||
|
||||
const handleSubmit = async (_e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIsLoading(true);
|
||||
setIsInputOpen(false);
|
||||
|
||||
const problemId = (document.getElementById('problem-input') as HTMLInputElement)?.value;
|
||||
|
||||
assert(importChat, 'importChat is required');
|
||||
await loadProblem(problemId, importChat);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isInputOpen && (
|
||||
<input
|
||||
id="problem-input"
|
||||
type="text"
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
onChange={(_e) => {
|
||||
/* Input change handled by onKeyDown */
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit(e as any);
|
||||
}
|
||||
}}
|
||||
className="border border-gray-300 rounded px-2 py-1"
|
||||
{...({} as any)}
|
||||
/>
|
||||
)}
|
||||
{!isInputOpen && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsInputOpen(true);
|
||||
}}
|
||||
className={className}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<div className="i-ph:globe" />
|
||||
{isLoading ? 'Loading...' : 'Load Problem'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,7 +1,7 @@
|
||||
import { toast } from 'react-toastify';
|
||||
import ReactModal from 'react-modal';
|
||||
import { useState } from 'react';
|
||||
import { supabaseSubmitFeedback } from '~/lib/supabase/problems';
|
||||
import { supabaseSubmitFeedback } from '~/lib/supabase/feedback';
|
||||
import { getLastChatMessages } from '~/components/chat/Chat.client';
|
||||
|
||||
ReactModal.setAppElement('#root');
|
||||
|
@ -25,6 +25,9 @@ export function Header() {
|
||||
<img src="/logo-styled.svg" alt="logo" className="w-[40px] inline-block rotate-90" />
|
||||
</a>
|
||||
<Feedback />
|
||||
<a href="https://www.replay.io/discord" className="text-bolt-elements-accent underline hover:no-underline" target="_blank" rel="noopener noreferrer">
|
||||
<div className="i-ph:discord-logo-fill text-[1.3em]" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center ">
|
||||
|
@ -121,12 +121,6 @@ export const Menu = () => {
|
||||
<div className="h-[55px]" /> {/* Spacer for top margin */}
|
||||
<div className="flex-1 flex flex-col h-full w-full overflow-hidden">
|
||||
<div className="flex gap-2 px-2 mb-0 ml-2.5">
|
||||
<a
|
||||
href="/problems"
|
||||
className="flex gap-2 bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme"
|
||||
>
|
||||
Problems
|
||||
</a>
|
||||
<a
|
||||
href="/about"
|
||||
className="flex gap-2 bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme"
|
||||
|
@ -4,7 +4,6 @@ import { toast } from 'react-toastify';
|
||||
import { logStore } from '~/lib/stores/logs'; // Import logStore
|
||||
import { chatStore } from '~/lib/stores/chat';
|
||||
import { database } from './db';
|
||||
import { loadProblem } from '~/components/chat/LoadProblemButton';
|
||||
import { createMessagesForRepository, type Message } from './message';
|
||||
import { debounce } from '~/utils/debounce';
|
||||
|
||||
@ -14,15 +13,11 @@ export interface ResumeChatInfo {
|
||||
}
|
||||
|
||||
export function useChatHistory() {
|
||||
const {
|
||||
id: mixedId,
|
||||
problemId,
|
||||
repositoryId,
|
||||
} = useLoaderData<{ id?: string; problemId?: string; repositoryId?: string }>() ?? {};
|
||||
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 && !problemId && !repositoryId);
|
||||
const [ready, setReady] = useState<boolean>(!mixedId && !repositoryId);
|
||||
|
||||
const importChat = async (title: string, messages: Message[]) => {
|
||||
try {
|
||||
@ -73,9 +68,6 @@ export function useChatHistory() {
|
||||
const publicData = await database.getChatPublicData(mixedId);
|
||||
const messages = createMessagesForRepository(publicData.title, publicData.repositoryId);
|
||||
await importChat(publicData.title, messages);
|
||||
} else if (problemId) {
|
||||
await loadProblem(problemId, importChat);
|
||||
setReady(true);
|
||||
} else if (repositoryId) {
|
||||
await loadRepository(repositoryId);
|
||||
setReady(true);
|
||||
|
@ -1,135 +0,0 @@
|
||||
// Accessors for the API to access saved problems.
|
||||
|
||||
import { toast } from 'react-toastify';
|
||||
import { assert, sendCommandDedicatedClient } from './ReplayProtocolClient';
|
||||
import type { Message } from '~/lib/persistence/message';
|
||||
|
||||
// Add global declaration for the problem property
|
||||
declare global {
|
||||
interface Window {
|
||||
__currentProblem__?: NutProblem;
|
||||
}
|
||||
}
|
||||
|
||||
export interface NutProblemComment {
|
||||
id?: string;
|
||||
username?: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface NutProblemSolution {
|
||||
simulationData: any;
|
||||
messages: Message[];
|
||||
evaluator?: string;
|
||||
}
|
||||
|
||||
export enum NutProblemStatus {
|
||||
// Problem has been submitted but not yet reviewed.
|
||||
Pending = 'Pending',
|
||||
|
||||
// Problem has been reviewed and has not been solved yet.
|
||||
Unsolved = 'Unsolved',
|
||||
|
||||
// Nut automatically produces a suitable explanation for solving the problem.
|
||||
Solved = 'Solved',
|
||||
}
|
||||
|
||||
// Information about each problem stored in the index file.
|
||||
export interface NutProblemDescription {
|
||||
version: number;
|
||||
problemId: string;
|
||||
timestamp: number;
|
||||
title: string;
|
||||
description: string;
|
||||
status?: NutProblemStatus;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
export interface NutProblem extends NutProblemDescription {
|
||||
username?: string;
|
||||
user_id?: string;
|
||||
repositoryId: string;
|
||||
comments?: NutProblemComment[];
|
||||
solution?: NutProblemSolution;
|
||||
}
|
||||
|
||||
export type NutProblemInput = Omit<NutProblem, 'problemId' | 'timestamp'>;
|
||||
|
||||
export async function listAllProblems(): Promise<NutProblemDescription[]> {
|
||||
let problems: NutProblemDescription[] = [];
|
||||
|
||||
try {
|
||||
const rv = await sendCommandDedicatedClient({
|
||||
method: 'Recording.globalExperimentalCommand',
|
||||
params: {
|
||||
name: 'listBoltProblems',
|
||||
},
|
||||
});
|
||||
console.log('ListProblemsRval', rv);
|
||||
|
||||
problems = (rv as any).rval.problems.reverse();
|
||||
|
||||
const filteredProblems = problems.filter((problem) => {
|
||||
// if ?showAll=true is not in the url, filter out [test] problems
|
||||
if (window.location.search.includes('showAll=true')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !problem.title.includes('[test]');
|
||||
});
|
||||
|
||||
return filteredProblems;
|
||||
} catch (error) {
|
||||
console.error('Error fetching problems', error);
|
||||
toast.error('Failed to fetch problems');
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProblem(problemId: string): Promise<NutProblem | null> {
|
||||
let problem: NutProblem | null = null;
|
||||
|
||||
try {
|
||||
if (!problemId) {
|
||||
toast.error('Invalid problem ID');
|
||||
return null;
|
||||
}
|
||||
|
||||
const rv = await sendCommandDedicatedClient({
|
||||
method: 'Recording.globalExperimentalCommand',
|
||||
params: {
|
||||
name: 'fetchBoltProblem',
|
||||
params: { problemId },
|
||||
},
|
||||
});
|
||||
|
||||
problem = (rv as { rval: { problem: NutProblem } }).rval.problem;
|
||||
|
||||
if (!problem) {
|
||||
toast.error('Problem not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
assert(problem.repositoryId, 'Problem probably has outdated data format. Must have a repositoryId.');
|
||||
} catch (error) {
|
||||
console.error('Error fetching problem', error);
|
||||
|
||||
// Check for specific protocol error
|
||||
if (error instanceof Error && error.message.includes('Unknown problem ID')) {
|
||||
toast.error('Problem not found');
|
||||
} else {
|
||||
toast.error('Failed to fetch problem');
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Only used for testing
|
||||
*/
|
||||
if (problem) {
|
||||
window.__currentProblem__ = problem;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
30
app/lib/supabase/feedback.ts
Normal file
30
app/lib/supabase/feedback.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { getSupabase } from './client';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export async function supabaseSubmitFeedback(feedback: any) {
|
||||
const supabase = getSupabase();
|
||||
|
||||
// Get the current user ID if available
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
const userId = user?.id || null;
|
||||
|
||||
// Insert feedback into the feedback table
|
||||
const { data, error } = await supabase.from('feedback').insert({
|
||||
user_id: userId,
|
||||
description: feedback.description || feedback.text || JSON.stringify(feedback),
|
||||
metadata: feedback,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error submitting feedback to Supabase:', error);
|
||||
toast.error('Failed to submit feedback');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('Feedback submitted successfully:', data);
|
||||
|
||||
return true;
|
||||
}
|
@ -1,278 +0,0 @@
|
||||
// Supabase implementation of problem management functions
|
||||
|
||||
import { toast } from 'react-toastify';
|
||||
import { getSupabase, type Database } from './client';
|
||||
import type { NutProblem, NutProblemDescription, NutProblemInput, NutProblemStatus } from '~/lib/replay/Problems';
|
||||
import { getNutIsAdmin } from '~/lib/supabase/client';
|
||||
|
||||
async function downloadBlob(bucket: string, path: string) {
|
||||
const supabase = getSupabase();
|
||||
const { data, error } = await supabase.storage.from(bucket).download(path);
|
||||
|
||||
if (error) {
|
||||
console.error('Error downloading blob:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.text();
|
||||
}
|
||||
|
||||
export async function supabaseListAllProblems(): Promise<NutProblemDescription[]> {
|
||||
try {
|
||||
const { data, error } = await getSupabase()
|
||||
.from('problems')
|
||||
.select('id, created_at, updated_at, title, description, status, keywords, user_id')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const problems: NutProblemDescription[] = data.map((problem) => ({
|
||||
version: 1,
|
||||
problemId: problem.id,
|
||||
timestamp: new Date(problem.created_at).getTime(),
|
||||
title: problem.title,
|
||||
description: problem.description,
|
||||
status: problem.status,
|
||||
keywords: problem.keywords,
|
||||
}));
|
||||
|
||||
return problems;
|
||||
} catch (error) {
|
||||
console.error('Error fetching problems', error);
|
||||
toast.error('Failed to fetch problems');
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function supabaseGetProblem(problemId: string): Promise<NutProblem | null> {
|
||||
try {
|
||||
if (!problemId) {
|
||||
toast.error('Invalid problem ID');
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data, error } = await getSupabase()
|
||||
.from('problems')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
problem_comments (
|
||||
*
|
||||
)
|
||||
`,
|
||||
)
|
||||
.eq('id', problemId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
// More specific error message based on error code
|
||||
if (error.code === 'PGRST116') {
|
||||
toast.error('Problem not found');
|
||||
} else {
|
||||
toast.error(`Failed to fetch problem: ${error.message}`);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
toast.error('Problem not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch blob data from storage if paths are available
|
||||
let solution = data.solution;
|
||||
const prompt = data.prompt;
|
||||
|
||||
// Create a supabase instance for storage operations
|
||||
const supabase = getSupabase();
|
||||
|
||||
if (data.solution_path) {
|
||||
solution = JSON.parse((await downloadBlob('solutions', data.solution_path)) || '{}');
|
||||
}
|
||||
|
||||
// If the problem has a user_id, fetch the profile information
|
||||
let username = null;
|
||||
|
||||
if (data.user_id) {
|
||||
const { data: profileData, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('username')
|
||||
.eq('id', data.user_id)
|
||||
.single();
|
||||
|
||||
if (!profileError && profileData) {
|
||||
username = profileData.username;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
problemId: data.id,
|
||||
version: 1,
|
||||
timestamp: new Date(data.created_at).getTime(),
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
status: data.status as NutProblemStatus,
|
||||
keywords: data.keywords,
|
||||
repositoryId: data.repository_id,
|
||||
username,
|
||||
solution: solution || prompt,
|
||||
comments: data.problem_comments.map((comment: any) => ({
|
||||
id: comment.id,
|
||||
timestamp: comment.created_at,
|
||||
problemId: comment.problem_id,
|
||||
content: comment.content,
|
||||
username: comment.username,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching problem:', error);
|
||||
|
||||
// Don't show duplicate toast if we already showed one above
|
||||
if (!(error as any)?.code) {
|
||||
toast.error('Failed to fetch problem');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function supabaseSubmitProblem(problem: NutProblemInput): Promise<string | null> {
|
||||
try {
|
||||
const supabaseProblem = {
|
||||
id: undefined as any, // This will be set by Supabase
|
||||
title: problem.title,
|
||||
description: problem.description,
|
||||
status: problem.status as NutProblemStatus,
|
||||
keywords: problem.keywords || [],
|
||||
repository_id: problem.repositoryId,
|
||||
user_id: problem.user_id,
|
||||
};
|
||||
|
||||
const { data, error } = await getSupabase().from('problems').insert(supabaseProblem).select().single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data.id;
|
||||
} catch (error) {
|
||||
console.error('Error submitting problem', error);
|
||||
toast.error('Failed to submit problem');
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function supabaseDeleteProblem(problemId: string): Promise<void | undefined> {
|
||||
try {
|
||||
const { error: deleteError } = await getSupabase().from('problems').delete().eq('id', problemId);
|
||||
|
||||
if (deleteError) {
|
||||
throw deleteError;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.error('Error deleting problem', error);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function supabaseUpdateProblem(problemId: string, problem: NutProblemInput): Promise<void> {
|
||||
try {
|
||||
if (!getNutIsAdmin()) {
|
||||
toast.error('Admin user required');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Convert to Supabase format
|
||||
const updates: Database['public']['Tables']['problems']['Update'] = {
|
||||
title: problem.title,
|
||||
description: problem.description,
|
||||
status: problem.status,
|
||||
keywords: problem.keywords || [],
|
||||
repository_id: problem.repositoryId,
|
||||
solution_path: problem.solution ? `solutions/${problemId}.json` : undefined,
|
||||
};
|
||||
|
||||
// Update the problem
|
||||
const { error: updateError } = await getSupabase().from('problems').update(updates).eq('id', problemId);
|
||||
|
||||
if (updateError) {
|
||||
throw updateError;
|
||||
}
|
||||
|
||||
if (updates.solution_path) {
|
||||
const { error: solutionError } = await getSupabase()
|
||||
.storage.from('solutions')
|
||||
.upload(updates.solution_path, JSON.stringify(problem.solution), { upsert: true });
|
||||
|
||||
if (solutionError) {
|
||||
throw solutionError;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle comments if they exist
|
||||
if (problem.comments && problem.comments.length > 0) {
|
||||
const commentInserts = problem.comments
|
||||
.filter((comment) => !comment.id)
|
||||
.map((comment) => {
|
||||
return {
|
||||
problem_id: problemId,
|
||||
content: comment.content,
|
||||
username: comment.username || 'Anonymous',
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Use upsert with onConflict to avoid duplicates.
|
||||
* This will insert new comments and ignore existing ones based on created_at.
|
||||
*/
|
||||
const { error: commentsError } = await getSupabase().from('problem_comments').insert(commentInserts);
|
||||
|
||||
if (commentsError) {
|
||||
throw commentsError;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.error('Error updating problem', error);
|
||||
toast.error('Failed to update problem');
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function supabaseSubmitFeedback(feedback: any) {
|
||||
const supabase = getSupabase();
|
||||
|
||||
// Get the current user ID if available
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
const userId = user?.id || null;
|
||||
|
||||
// Insert feedback into the feedback table
|
||||
const { data, error } = await supabase.from('feedback').insert({
|
||||
user_id: userId,
|
||||
description: feedback.description || feedback.text || JSON.stringify(feedback),
|
||||
metadata: feedback,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error submitting feedback to Supabase:', error);
|
||||
toast.error('Failed to submit feedback');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('Feedback submitted successfully:', data);
|
||||
|
||||
return true;
|
||||
}
|
@ -15,31 +15,14 @@ function AboutPage() {
|
||||
<h1 className="text-4xl font-bold mb-8 text-gray-900 dark:text-gray-200">About Nut</h1>
|
||||
|
||||
<p className="mb-6">
|
||||
Nut is an{' '}
|
||||
<a
|
||||
href="https://github.com/replayio/bolt"
|
||||
className="text-bolt-elements-accent underline hover:no-underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
open source fork
|
||||
</a>{' '}
|
||||
of{' '}
|
||||
<a
|
||||
href="https://bolt.new"
|
||||
className="text-bolt-elements-accent underline hover:no-underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Bolt.new
|
||||
</a>{' '}
|
||||
for helping you develop full stack apps using AI. AI developers frequently struggle with fixing even simple
|
||||
bugs when they don't know the cause, and get stuck making ineffective changes over and over. We want to
|
||||
crack these tough nuts, so to speak, so you can get back to building.
|
||||
Nut is an agentic app builder for reliably developing full stack apps using AI.
|
||||
When you ask Nut to build or change an app, it will do its best to get the code
|
||||
changes right the first time. Afterwards it will check the app to make sure it's
|
||||
working as expected, writing tests and fixing problems those tests uncover.
|
||||
</p>
|
||||
|
||||
<p className="mb-6">
|
||||
When you ask Nut to fix a bug, it creates a{' '}
|
||||
You can also ask Nut to fix bugs. Nut will create a{' '}
|
||||
<a
|
||||
href="https://replay.io"
|
||||
className="text-bolt-elements-accent underline hover:no-underline"
|
||||
@ -54,13 +37,6 @@ function AboutPage() {
|
||||
</p>
|
||||
|
||||
<p className="mb-6">
|
||||
Nut.new is already pretty good at fixing problems, and we're working to make it better. We want it to
|
||||
reliably fix anything you're seeing, as long as it has a clear explanation and the problem isn't too
|
||||
complicated (AIs aren't magic). If it's doing poorly, let us know! Use the UI to leave us some private
|
||||
feedback or save your project to our public set of problems where AIs struggle.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Nut is being developed by the{' '}
|
||||
<a
|
||||
href="https://replay.io"
|
||||
@ -69,22 +45,21 @@ function AboutPage() {
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Replay.io
|
||||
</a>{' '}
|
||||
team. We're offering unlimited free access to Nut.new for early adopters who can give us feedback we'll use
|
||||
to improve Nut. Reach us at{' '}
|
||||
<a href="mailto:hi@replay.io" className="text-bolt-elements-accent underline hover:no-underline">
|
||||
hi@replay.io
|
||||
</a>{' '}
|
||||
or fill out our{' '}
|
||||
</a>{' '} team.
|
||||
We'd love to hear from you! Leave us some feedback at the top of the page,
|
||||
join our{' '}
|
||||
<a
|
||||
href="https://replay.io/contact"
|
||||
href="https://www.replay.io/discord"
|
||||
className="text-bolt-elements-accent underline hover:no-underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
contact form
|
||||
Discord
|
||||
</a>{' '}
|
||||
to join our early adopter program.
|
||||
or reach us at {' '}
|
||||
<a href="mailto:hi@replay.io" className="text-bolt-elements-accent underline hover:no-underline">
|
||||
hi@replay.io
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,8 +0,0 @@
|
||||
import { json, type LoaderFunctionArgs } from '~/lib/remix-types';
|
||||
import { default as IndexRoute } from './_index';
|
||||
|
||||
export async function loader(args: LoaderFunctionArgs) {
|
||||
return json({ problemId: args.params.id });
|
||||
}
|
||||
|
||||
export default IndexRoute;
|
@ -1,97 +0,0 @@
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { Header } from '~/components/header/Header';
|
||||
import { Menu } from '~/components/sidebar/Menu.client';
|
||||
import BackgroundRays from '~/components/ui/BackgroundRays';
|
||||
import { TooltipProvider } from '@radix-ui/react-tooltip';
|
||||
import { ToastContainerWrapper, Status, Keywords } from './problems';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { useParams } from '@remix-run/react';
|
||||
import { getProblem, NutProblemStatus } from '~/lib/replay/Problems';
|
||||
import type { NutProblem, NutProblemComment } from '~/lib/replay/Problems';
|
||||
|
||||
function Comments({ comments }: { comments: NutProblemComment[] }) {
|
||||
return (
|
||||
<div className="space-y-4 mt-6">
|
||||
{comments.map((comment, index) => (
|
||||
<div
|
||||
data-testid="problem-comment"
|
||||
key={index}
|
||||
className="bg-bolt-elements-background-depth-2 rounded-lg p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-bolt-text">{comment.username ?? 'Anonymous'}</span>
|
||||
<span className="text-sm text-bolt-text-secondary">
|
||||
{(() => {
|
||||
const date = new Date(comment.timestamp);
|
||||
return date && !isNaN(date.getTime()) ? date.toLocaleString() : 'Unknown date';
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-bolt-text whitespace-pre-wrap">{comment.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProblemViewer({ problem }: { problem: NutProblem }) {
|
||||
const { problemId, title, description, status = NutProblemStatus.Pending, keywords = [], comments = [] } = problem;
|
||||
|
||||
return (
|
||||
<div className="benchmark">
|
||||
<h1 className="text-xl4 font-semibold mb-2">{title}</h1>
|
||||
<p>{description}</p>
|
||||
<a
|
||||
href={`/load-problem/${problemId}`}
|
||||
className="load-button inline-block px-4 py-2 mt-3 mb-3 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors duration-200 font-medium"
|
||||
>
|
||||
Load Problem
|
||||
</a>
|
||||
<Status status={status} />
|
||||
<Keywords keywords={keywords} />
|
||||
<Comments comments={comments} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Nothing = () => null;
|
||||
|
||||
function ViewProblemPage() {
|
||||
const params = useParams();
|
||||
const problemId = params.id;
|
||||
|
||||
if (typeof problemId !== 'string') {
|
||||
throw new Error('Problem ID is required');
|
||||
}
|
||||
|
||||
const [problemData, setProblemData] = useState<NutProblem | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getProblem(problemId).then(setProblemData);
|
||||
}, [problemId]);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Nothing />}>
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col h-full min-h-screen w-full bg-bolt-elements-background-depth-1 text-gray-900 dark:text-gray-200">
|
||||
<BackgroundRays />
|
||||
<Header />
|
||||
<ClientOnly>{() => <Menu />}</ClientOnly>
|
||||
|
||||
<div className="p-6">
|
||||
{problemData === null ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
</div>
|
||||
) : (
|
||||
<ProblemViewer problem={problemData} />
|
||||
)}
|
||||
</div>
|
||||
<ToastContainerWrapper />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewProblemPage;
|
@ -1,177 +0,0 @@
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { Header } from '~/components/header/Header';
|
||||
import { Menu } from '~/components/sidebar/Menu.client';
|
||||
import BackgroundRays from '~/components/ui/BackgroundRays';
|
||||
import { TooltipProvider } from '@radix-ui/react-tooltip';
|
||||
import { cssTransition, ToastContainer } from 'react-toastify';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { listAllProblems, NutProblemStatus } from '~/lib/replay/Problems';
|
||||
import type { NutProblemDescription } from '~/lib/replay/Problems';
|
||||
|
||||
const toastAnimation = cssTransition({
|
||||
enter: 'animated fadeInRight',
|
||||
exit: 'animated fadeOutRight',
|
||||
});
|
||||
|
||||
export function ToastContainerWrapper() {
|
||||
return (
|
||||
<ToastContainer
|
||||
closeButton={({ closeToast }) => {
|
||||
return (
|
||||
<button className="Toastify__close-button" onClick={closeToast}>
|
||||
<div className="i-ph:x text-lg" />
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
icon={({ type }) => {
|
||||
/**
|
||||
* @todo Handle more types if we need them. This may require extra color palettes.
|
||||
*/
|
||||
switch (type) {
|
||||
case 'success': {
|
||||
return <div className="i-ph:check-bold text-bolt-elements-icon-success text-2xl" />;
|
||||
}
|
||||
case 'error': {
|
||||
return <div className="i-ph:warning-circle-bold text-bolt-elements-icon-error text-2xl" />;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}}
|
||||
position="bottom-right"
|
||||
pauseOnFocusLoss
|
||||
transition={toastAnimation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Status({ status }: { status: NutProblemStatus | undefined }) {
|
||||
if (!status) {
|
||||
status = NutProblemStatus.Pending;
|
||||
}
|
||||
|
||||
const statusColors: Record<NutProblemStatus, string> = {
|
||||
[NutProblemStatus.Pending]: 'bg-yellow-400 dark:text-yellow-400',
|
||||
[NutProblemStatus.Unsolved]: 'bg-orange-500 dark:text-orange-500',
|
||||
[NutProblemStatus.Solved]: 'bg-blue-500 dark:text-blue-500',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 my-2">
|
||||
<span className="font-semibold">Status:</span>
|
||||
<div
|
||||
className={`inline-flex items-center px-3 py-1 rounded-full bg-opacity-10 dark:bg-opacity-20 ${statusColors[status]}`}
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full mr-2 ${statusColors[status]} bg-opacity-100`}></span>
|
||||
<span className="font-medium">{status.charAt(0).toUpperCase() + status.slice(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Keywords({ keywords }: { keywords: string[] | undefined }) {
|
||||
if (!keywords?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{keywords.map((keyword, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1 text-sm rounded-full border border-bolt-elements-border bg-bolt-elements-background-depth-2 text-bolt-content-primary"
|
||||
>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getProblemStatus(problem: NutProblemDescription): NutProblemStatus {
|
||||
return problem.status ?? NutProblemStatus.Pending;
|
||||
}
|
||||
|
||||
const Nothing = () => null;
|
||||
|
||||
function ProblemsPage() {
|
||||
const [problems, setProblems] = useState<NutProblemDescription[] | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<NutProblemStatus | 'all'>(NutProblemStatus.Solved);
|
||||
|
||||
useEffect(() => {
|
||||
listAllProblems().then(setProblems);
|
||||
}, []);
|
||||
|
||||
const filteredProblems = problems?.filter((problem) => {
|
||||
return statusFilter === 'all' || getProblemStatus(problem) === statusFilter;
|
||||
});
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Nothing />}>
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col min-h-fit min-h-screen w-full bg-bolt-elements-background-depth-1 dark:bg-black text-gray-900 dark:text-gray-200">
|
||||
<BackgroundRays />
|
||||
<Header />
|
||||
<ClientOnly>{() => <Menu />}</ClientOnly>
|
||||
|
||||
<div className="p-6">
|
||||
{problems && (
|
||||
<div className="mb-4">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as NutProblemStatus | 'all')}
|
||||
className="appearance-none w-48 px-4 py-2.5 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-border text-bolt-content-primary hover:border-bolt-elements-border-hover focus:outline-none focus:ring-2 focus:ring-bolt-accent-primary/20 focus:border-bolt-accent-primary cursor-pointer relative pr-10"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'right 12px center',
|
||||
backgroundSize: '16px',
|
||||
}}
|
||||
>
|
||||
<option value="all">{`All Problems (${problems?.length ?? 0})`}</option>
|
||||
{Object.values(NutProblemStatus).map((status) => {
|
||||
const count = problems?.filter((problem) => getProblemStatus(problem) === status).length ?? 0;
|
||||
return (
|
||||
<option key={status} value={status}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1) + ` (${count})`}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{problems === null ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
</div>
|
||||
) : problems.length === 0 ? (
|
||||
<div className="text-center text-gray-600">No problems found</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{filteredProblems?.map((problem) => (
|
||||
<a
|
||||
href={`/problem/${problem.problemId}`}
|
||||
key={problem.problemId}
|
||||
className="p-4 rounded-lg bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 transition-colors cursor-pointer"
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-2">{problem.title}</h2>
|
||||
<p className="text-gray-700 dark:text-gray-200 mb-2">{problem.description}</p>
|
||||
<Status status={problem.status} />
|
||||
<Keywords keywords={problem.keywords} />
|
||||
<p className="text-sm text-gray-600 dark:text-gray-200">
|
||||
Time: {new Date(problem.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ToastContainerWrapper />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProblemsPage;
|
@ -1,98 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login, setLoginKey, openSidebar } from './setup/test-utils';
|
||||
|
||||
test('Should be able to load a problem', async ({ page }) => {
|
||||
await page.goto('/problems');
|
||||
|
||||
const combobox = page.getByRole('combobox');
|
||||
await expect(combobox).toBeVisible({ timeout: 30000 });
|
||||
await combobox.selectOption('all');
|
||||
|
||||
const problemLink = page.getByRole('link', { name: 'Contact book tiny search icon' }).first();
|
||||
await expect(problemLink).toBeVisible({ timeout: 30000 });
|
||||
await problemLink.click();
|
||||
|
||||
const loadProblemLink = page.getByRole('link', { name: 'Load Problem' });
|
||||
await expect(loadProblemLink).toBeVisible({ timeout: 30000 });
|
||||
await loadProblemLink.click();
|
||||
|
||||
await expect(page.getByText('Import the "problem" folder')).toBeVisible({ timeout: 30000 });
|
||||
});
|
||||
|
||||
// TODO: Unskip this test once we can make sure we get a repro for a problem.
|
||||
test.skip('Should be able to save a problem ', async ({ page }) => {
|
||||
await page.goto('/problems');
|
||||
await page.getByRole('link', { name: 'App goes blank getting' }).click();
|
||||
await page.getByRole('link', { name: 'Load Problem' }).click();
|
||||
|
||||
await expect(page.getByText('Import the "problem" folder')).toBeVisible({ timeout: 30000 });
|
||||
await login(page);
|
||||
|
||||
await expect(page.getByText('Import the "problem" folder')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await openSidebar(page);
|
||||
await page.getByRole('button', { name: 'Save Problem' }).click();
|
||||
|
||||
await page.locator('input[name="title"]').click();
|
||||
await page.locator('input[name="title"]').fill('[test] playwright');
|
||||
await page.locator('input[name="description"]').click();
|
||||
await page.locator('input[name="description"]').fill('...');
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
});
|
||||
|
||||
test('Should be able to update a problem', async ({ page }) => {
|
||||
await page.goto('/problems?showAll=true');
|
||||
await page.getByRole('combobox').selectOption('all');
|
||||
|
||||
await page.getByRole('link', { name: '[test] playwright' }).first().click();
|
||||
expect(await page.getByRole('textbox', { name: 'Set the title of the problem' })).not.toBeVisible();
|
||||
|
||||
await login(page);
|
||||
|
||||
const currentTime = new Date();
|
||||
const hours = currentTime.getHours().toString().padStart(2, '0');
|
||||
const minutes = currentTime.getMinutes().toString().padStart(2, '0');
|
||||
const timeString = `${hours}:${minutes}`;
|
||||
const title = `[test] playwright ${timeString}`;
|
||||
|
||||
await page.getByRole('textbox', { name: 'Set the title of the problem' }).click();
|
||||
await page.getByRole('textbox', { name: 'Set the title of the problem' }).fill(title);
|
||||
await page.getByRole('button', { name: 'Set Title' }).click();
|
||||
|
||||
await page.getByRole('heading', { name: title }).click();
|
||||
await page.getByRole('combobox').selectOption('Solved');
|
||||
await page.getByRole('button', { name: 'Set Status' }).click();
|
||||
await page.locator('span').filter({ hasText: 'Solved' }).click();
|
||||
await page.getByRole('combobox').selectOption('Pending');
|
||||
await page.getByRole('button', { name: 'Set Status' }).click();
|
||||
await page.locator('span').filter({ hasText: 'Pending' }).click();
|
||||
});
|
||||
|
||||
test('Should be able to add a comment to a problem', async ({ page }) => {
|
||||
await page.goto('/problems?showAll=true');
|
||||
await page.getByRole('combobox').selectOption('all');
|
||||
await page.getByRole('link', { name: '[test] playwright' }).first().click();
|
||||
|
||||
await login(page);
|
||||
|
||||
// Add a comment to the problem
|
||||
const comment = `test comment ${Date.now().toString()}`;
|
||||
await page.getByRole('textbox', { name: 'Add a comment...' }).click();
|
||||
await page.getByRole('textbox', { name: 'Add a comment...' }).fill(comment);
|
||||
await page.getByRole('button', { name: 'Add Comment' }).click();
|
||||
await expect(page.locator('[data-testid="problem-comment"]').filter({ hasText: comment })).toBeVisible();
|
||||
|
||||
// Reload the page and check that the comment is still visible
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-testid="problem-comment"]').filter({ hasText: comment })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Confirm that admins see the "Save Problem" button', async ({ page }) => {
|
||||
await page.goto('/problems?showAll=true');
|
||||
|
||||
await login(page);
|
||||
await openSidebar(page);
|
||||
await expect(page.getByRole('button', { name: 'Save Problem' })).toBeVisible();
|
||||
});
|
Loading…
Reference in New Issue
Block a user