diff --git a/app/api/projects/[id]/environment/[envId]/route.ts b/app/api/projects/[id]/environment/[envId]/route.ts index 936e033..8ef190f 100644 --- a/app/api/projects/[id]/environment/[envId]/route.ts +++ b/app/api/projects/[id]/environment/[envId]/route.ts @@ -3,6 +3,10 @@ import { NextResponse } from 'next/server' import { verifyProjectAccess, withAuth } from '@/lib/api-auth' import { prisma } from '@/lib/db' +import { logger as baseLogger } from '@/lib/logger' +import { canUpdateResource } from '@/lib/util/action' + +const logger = baseLogger.child({ module: 'api/projects/[id]/environment/[envId]' }) type PutEnvironmentResponse = { error: string } | Environment @@ -27,6 +31,36 @@ export const PUT = withAuth(async (req, context, session return NextResponse.json({ error: 'Environment variable not found' }, { status: 404 }) } + // Check if project sandboxes can be updated + const project = await prisma.project.findUnique({ + where: { id: projectId }, + include: { + sandboxes: { + select: { id: true, status: true, name: true }, + }, + }, + }) + + if (!project) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }) + } + + // Check if all sandboxes can be updated + const nonUpdatableSandboxes = project.sandboxes.filter((sb) => !canUpdateResource(sb.status)) + + if (nonUpdatableSandboxes.length > 0) { + const statusList = nonUpdatableSandboxes.map((sb) => `${sb.name}: ${sb.status}`).join(', ') + logger.warn( + `Cannot update environment variable for project ${projectId}: some sandboxes cannot be updated (${statusList})` + ) + return NextResponse.json( + { + error: `Cannot update environment variable: some sandboxes are not in a state that allows updates. Only RUNNING sandboxes can be updated. Non-updatable sandboxes: ${statusList}`, + }, + { status: 400 } + ) + } + const body = await req.json() const { value } = body @@ -34,15 +68,31 @@ export const PUT = withAuth(async (req, context, session return NextResponse.json({ error: 'Value is required' }, { status: 400 }) } - // Update the environment variable in database only + // Update the environment variable in database const updated = await prisma.environment.update({ where: { id: envId }, data: { value }, }) + // Set all sandboxes to UPDATING status + if (project.sandboxes.length > 0) { + await prisma.sandbox.updateMany({ + where: { + projectId, + status: 'RUNNING', // Only update RUNNING sandboxes + }, + data: { + status: 'UPDATING', + }, + }) + logger.info( + `Set ${project.sandboxes.length} sandboxes to UPDATING status for project ${projectId}` + ) + } + return NextResponse.json(updated) } catch (error) { - console.error('Error updating environment variable:', error) + logger.error(`Error updating environment variable: ${error}`) return NextResponse.json({ error: 'Failed to update environment variable' }, { status: 500 }) } }) @@ -70,14 +120,60 @@ export const DELETE = withAuth(async (_req, context, return NextResponse.json({ error: 'Environment variable not found' }, { status: 404 }) } - // Delete the environment variable from database only + // Check if project sandboxes can be updated + const project = await prisma.project.findUnique({ + where: { id: projectId }, + include: { + sandboxes: { + select: { id: true, status: true, name: true }, + }, + }, + }) + + if (!project) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }) + } + + // Check if all sandboxes can be updated + const nonUpdatableSandboxes = project.sandboxes.filter((sb) => !canUpdateResource(sb.status)) + + if (nonUpdatableSandboxes.length > 0) { + const statusList = nonUpdatableSandboxes.map((sb) => `${sb.name}: ${sb.status}`).join(', ') + logger.warn( + `Cannot delete environment variable for project ${projectId}: some sandboxes cannot be updated (${statusList})` + ) + return NextResponse.json( + { + error: `Cannot delete environment variable: some sandboxes are not in a state that allows updates. Only RUNNING sandboxes can be updated. Non-updatable sandboxes: ${statusList}`, + }, + { status: 400 } + ) + } + + // Delete the environment variable from database await prisma.environment.delete({ where: { id: envId }, }) + // Set all sandboxes to UPDATING status + if (project.sandboxes.length > 0) { + await prisma.sandbox.updateMany({ + where: { + projectId, + status: 'RUNNING', // Only update RUNNING sandboxes + }, + data: { + status: 'UPDATING', + }, + }) + logger.info( + `Set ${project.sandboxes.length} sandboxes to UPDATING status for project ${projectId}` + ) + } + return NextResponse.json({ success: true }) } catch (error) { - console.error('Error deleting environment variable:', error) + logger.error(`Error deleting environment variable: ${error}`) return NextResponse.json({ error: 'Failed to delete environment variable' }, { status: 500 }) } }) diff --git a/app/api/projects/[id]/environment/route.ts b/app/api/projects/[id]/environment/route.ts index 47f92f1..cf7394a 100644 --- a/app/api/projects/[id]/environment/route.ts +++ b/app/api/projects/[id]/environment/route.ts @@ -4,6 +4,10 @@ import { NextResponse } from 'next/server' import { verifyProjectAccess, withAuth } from '@/lib/api-auth' import { EnvironmentCategory } from '@/lib/const' import { prisma } from '@/lib/db' +import { logger as baseLogger } from '@/lib/logger' +import { canUpdateResource } from '@/lib/util/action' + +const logger = baseLogger.child({ module: 'api/projects/[id]/environment' }) type GroupedEnvironments = Record @@ -58,6 +62,37 @@ export const POST = withAuth(async (req, context, sessi try { await verifyProjectAccess(projectId, session.user.id) + + // Check if project sandboxes can be updated + const project = await prisma.project.findUnique({ + where: { id: projectId }, + include: { + sandboxes: { + select: { id: true, status: true, name: true }, + }, + }, + }) + + if (!project) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }) + } + + // Check if all sandboxes can be updated + const nonUpdatableSandboxes = project.sandboxes.filter((sb) => !canUpdateResource(sb.status)) + + if (nonUpdatableSandboxes.length > 0) { + const statusList = nonUpdatableSandboxes.map((sb) => `${sb.name}: ${sb.status}`).join(', ') + logger.warn( + `Cannot update environment variables for project ${projectId}: some sandboxes cannot be updated (${statusList})` + ) + return NextResponse.json( + { + error: `Cannot update environment variables: some sandboxes are not in a state that allows updates. Only RUNNING sandboxes can be updated. Non-updatable sandboxes: ${statusList}`, + }, + { status: 400 } + ) + } + const body = await req.json() // Check if this is a single variable creation or batch update @@ -73,6 +108,22 @@ export const POST = withAuth(async (req, context, sessi }, }) + // Set all sandboxes to UPDATING status + if (project.sandboxes.length > 0) { + await prisma.sandbox.updateMany({ + where: { + projectId, + status: 'RUNNING', // Only update RUNNING sandboxes + }, + data: { + status: 'UPDATING', + }, + }) + logger.info( + `Set ${project.sandboxes.length} sandboxes to UPDATING status for project ${projectId}` + ) + } + return NextResponse.json(newVar) } else if (body.variables) { // Batch update (replace all variables) @@ -100,12 +151,28 @@ export const POST = withAuth(async (req, context, sessi const created = await Promise.all(envPromises) + // Set all sandboxes to UPDATING status + if (project.sandboxes.length > 0) { + await prisma.sandbox.updateMany({ + where: { + projectId, + status: 'RUNNING', // Only update RUNNING sandboxes + }, + data: { + status: 'UPDATING', + }, + }) + logger.info( + `Set ${project.sandboxes.length} sandboxes to UPDATING status for project ${projectId}` + ) + } + return NextResponse.json({ success: true, count: created.length }) } else { return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) } } catch (error) { - console.error('Error saving environment variables:', error) + logger.error(`Error saving environment variables: ${error}`) return NextResponse.json({ error: 'Failed to save environment variables' }, { status: 500 }) } }) diff --git a/app/api/projects/[id]/github/route.ts b/app/api/projects/[id]/github/route.ts deleted file mode 100644 index 011602c..0000000 --- a/app/api/projects/[id]/github/route.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' - -import { auth } from '@/lib/auth' -import { prisma } from '@/lib/db' - -// Connect GitHub repository to project -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await auth() - - if (!session) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: projectId } = await params - - try { - const project = await prisma.project.findFirst({ - where: { - id: projectId, - userId: session.user.id, - }, - }) - - if (!project) { - return NextResponse.json({ error: 'Project not found' }, { status: 404 }) - } - - const body = await request.json() - const { repoName } = body - - if (!repoName) { - return NextResponse.json({ error: 'Repository name is required' }, { status: 400 }) - } - - // Validate repository name format - if (!repoName.includes('/') || repoName.split('/').length !== 2) { - return NextResponse.json( - { error: 'Invalid repository format. Use: username/repository' }, - { status: 400 } - ) - } - - // Update project with GitHub repository - const updated = await prisma.project.update({ - where: { id: projectId }, - data: { githubRepo: repoName }, - }) - - return NextResponse.json({ - success: true, - githubRepo: updated.githubRepo, - }) - } catch (error) { - console.error('Error connecting GitHub repository:', error) - return NextResponse.json({ error: 'Failed to connect GitHub repository' }, { status: 500 }) - } -} - -// Disconnect GitHub repository from project -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const session = await auth() - - if (!session) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: projectId } = await params - - try { - const project = await prisma.project.findFirst({ - where: { - id: projectId, - userId: session.user.id, - }, - }) - - if (!project) { - return NextResponse.json({ error: 'Project not found' }, { status: 404 }) - } - - // Remove GitHub repository from project - await prisma.project.update({ - where: { id: projectId }, - data: { githubRepo: null }, - }) - - return NextResponse.json({ - success: true, - message: 'GitHub repository disconnected', - }) - } catch (error) { - console.error('Error disconnecting GitHub repository:', error) - return NextResponse.json({ error: 'Failed to disconnect GitHub repository' }, { status: 500 }) - } -} - -// Get GitHub repository info -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await auth() - - if (!session) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: projectId } = await params - - try { - const project = await prisma.project.findFirst({ - where: { - id: projectId, - userId: session.user.id, - }, - select: { - githubRepo: true, - }, - }) - - if (!project) { - return NextResponse.json({ error: 'Project not found' }, { status: 404 }) - } - - return NextResponse.json({ - githubRepo: project.githubRepo, - connected: !!project.githubRepo, - }) - } catch (error) { - console.error('Error getting GitHub repository info:', error) - return NextResponse.json({ error: 'Failed to get GitHub repository info' }, { status: 500 }) - } -} diff --git a/app/icon.svg b/app/icon.svg index ff95846..b811003 100644 --- a/app/icon.svg +++ b/app/icon.svg @@ -1,21 +1,24 @@ - - FullStack Agent — Full-Bleed App Icon - Rounded-square tile filled by a geometric comet for maximum size at small favicon scales. + FullStack Agent — App Icon + Rounded square container with a geometric comet mark. - + - - - - - - - - + + + + + + + + + + diff --git a/components/dialog/settings-dialog.tsx b/components/dialog/settings-dialog.tsx index 7892b62..568c355 100644 --- a/components/dialog/settings-dialog.tsx +++ b/components/dialog/settings-dialog.tsx @@ -4,6 +4,16 @@ import { useEffect, useState } from 'react'; import { Code, Database, Save, Terminal } from 'lucide-react'; import { toast } from 'sonner'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; @@ -76,6 +86,11 @@ export default function SettingsDialog({ const [isAnthropicLoading, setIsAnthropicLoading] = useState(false); const [isAnthropicInitialLoading, setIsAnthropicInitialLoading] = useState(true); + // Confirmation dialog state + const [showSystemPromptConfirm, setShowSystemPromptConfirm] = useState(false); + const [showSystemPromptResetConfirm, setShowSystemPromptResetConfirm] = useState(false); + const [showAnthropicConfirm, setShowAnthropicConfirm] = useState(false); + // Load data when dialog opens useEffect(() => { if (open) { @@ -145,7 +160,12 @@ export default function SettingsDialog({ } }; - const handleSaveSystemPrompt = async () => { + const handleSaveSystemPrompt = () => { + setShowSystemPromptConfirm(true); + }; + + const handleConfirmSaveSystemPrompt = async () => { + setShowSystemPromptConfirm(false); setIsSystemPromptLoading(true); try { await fetchClient.POST('/api/user/config/system-prompt', { @@ -193,12 +213,16 @@ export default function SettingsDialog({ } }; - const handleSaveAnthropicConfig = async () => { + const handleSaveAnthropicConfig = () => { if (!anthropicApiKey.trim() || !anthropicApiBaseUrl.trim()) { toast.error('Both API key and base URL are required'); return; } + setShowAnthropicConfirm(true); + }; + const handleConfirmSaveAnthropicConfig = async () => { + setShowAnthropicConfirm(false); setIsAnthropicLoading(true); try { await fetchClient.POST('/api/user/config/anthropic', { @@ -220,6 +244,11 @@ export default function SettingsDialog({ }; const handleResetSystemPrompt = () => { + setShowSystemPromptResetConfirm(true); + }; + + const handleConfirmResetSystemPrompt = () => { + setShowSystemPromptResetConfirm(false); setSystemPrompt(DEFAULT_SYSTEM_PROMPT); toast.success('Reset to default system prompt'); }; @@ -323,7 +352,10 @@ export default function SettingsDialog({
-
+ + {/* System Prompt Confirmation Dialog */} + + + + + Confirm Save System Prompt + + + These changes won't take effect until you manually restart the application. + Save now? + + + + + Cancel + + + Save + + + + + + {/* System Prompt Reset Confirmation Dialog */} + + + + + Reset System Prompt to Default + + + This will reset the system prompt to the default template. You'll need to + manually restart the application for this change to take effect. Continue? + + + + + Cancel + + + Reset to Default + + + + + + {/* Anthropic Config Confirmation Dialog */} + + + + + Confirm Save Anthropic Configuration + + + These changes won't take effect until you manually restart the application. + Save now? + + + + + Cancel + + + Save Configuration + + + + ); -} \ No newline at end of file +} diff --git a/components/features/projectList/ProjectCard.tsx b/components/features/projectList/ProjectCard.tsx index 88a8cbb..b0e3d47 100644 --- a/components/features/projectList/ProjectCard.tsx +++ b/components/features/projectList/ProjectCard.tsx @@ -28,6 +28,7 @@ const ProjectCard = memo(({ project }: ProjectCardProps) => { project.status === 'STARTING' && 'bg-yellow-600 dark:bg-yellow-500 animate-pulse', project.status === 'STOPPING' && 'bg-yellow-600 dark:bg-yellow-500 animate-pulse', project.status === 'CREATING' && 'bg-blue-600 dark:bg-blue-500 animate-pulse', + project.status === 'UPDATING' && 'bg-cyan-600 dark:bg-cyan-500 animate-pulse', project.status === 'TERMINATING' && 'bg-red-600 dark:bg-red-500 animate-pulse', project.status === 'ERROR' && 'bg-destructive', project.status === 'PARTIAL' && 'bg-orange-600 dark:bg-orange-500' diff --git a/components/terminal/terminal-container.tsx b/components/terminal/terminal-container.tsx index fa25fe7..73b9586 100644 --- a/components/terminal/terminal-container.tsx +++ b/components/terminal/terminal-container.tsx @@ -2,7 +2,8 @@ * TerminalContainer Component * * Main container that combines toolbar and display - * Manages tab state and terminal authentication + * Manages tab state and renders separate terminal instances for each tab + * Each tab gets its own iframe with independent WebSocket connection */ 'use client'; @@ -31,6 +32,7 @@ export interface TerminalContainerProps { /** * Terminal container with toolbar and display + * Renders separate terminal instances for each tab to ensure independent WebSocket connections */ export function TerminalContainer({ project, sandbox }: TerminalContainerProps) { // Tab management @@ -39,10 +41,10 @@ export function TerminalContainer({ project, sandbox }: TerminalContainerProps) // Tab operations const handleTabAdd = () => { - const newId = (tabs.length + 1).toString(); + const newId = Date.now().toString(); // Use timestamp for unique ID const newTab: Tab = { id: newId, - name: `Terminal ${newId}`, + name: `Terminal ${tabs.length + 1}`, }; setTabs([...tabs, newTab]); setActiveTabId(newId); @@ -76,9 +78,24 @@ export function TerminalContainer({ project, sandbox }: TerminalContainerProps) onTabAdd={handleTabAdd} /> - {/* Terminal Display */} -
- + {/* Terminal Displays - render all tabs but only show active one */} +
+ {tabs.map((tab) => ( +
+ +
+ ))}
); diff --git a/components/terminal/terminal-display.tsx b/components/terminal/terminal-display.tsx index 6380795..b1d09ee 100644 --- a/components/terminal/terminal-display.tsx +++ b/components/terminal/terminal-display.tsx @@ -17,15 +17,18 @@ export interface TerminalDisplayProps { ttydUrl?: string | null; /** Sandbox status */ status: string; + /** Unique tab ID for this terminal instance */ + tabId: string; } /** * Display terminal iframe or status message + * Each terminal tab gets its own iframe with unique key to ensure separate WebSocket connections */ -export function TerminalDisplay({ ttydUrl, status }: TerminalDisplayProps) { +export function TerminalDisplay({ ttydUrl, status, tabId }: TerminalDisplayProps) { const [iframeLoaded, setIframeLoaded] = useState(false); - // Show terminal iframe if running and URL is available + // Only show terminal iframe if status is RUNNING and URL is available if (status === 'RUNNING' && ttydUrl) { return (
@@ -39,8 +42,9 @@ export function TerminalDisplay({ ttydUrl, status }: TerminalDisplayProps) {
)} - {/* Terminal iframe */} + {/* Terminal iframe - unique key per tab ensures separate WebSocket connection */}