Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion components/terminal/terminal-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,6 @@ export function TerminalContainer({ project, sandbox, isVisible = true }: Termin
<div className="flex flex-col flex-1 min-h-0 bg-background">
{/* Toolbar with tabs and operations */}
<TerminalToolbar
project={project}
sandbox={sandbox}
tabs={tabs}
activeTabId={activeTabId}
Expand Down
33 changes: 7 additions & 26 deletions components/terminal/toolbar/app-runner-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,63 +13,44 @@ interface AppRunnerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
sandboxUrl: string | null | undefined;
}

export function AppRunnerDialog({
open,
onOpenChange,
onConfirm,
sandboxUrl,
}: AppRunnerDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="bg-[#252526] border-[#3e3e42] text-white">
<AlertDialogHeader>
<AlertDialogTitle>Run Application & Keep Active?</AlertDialogTitle>
<AlertDialogTitle>Prepare Deployment Files?</AlertDialogTitle>
<AlertDialogDescription className="text-gray-400 space-y-3" asChild>
<div className="text-sm text-gray-400 space-y-3">
<div>
This will build and start your application by running:
This will invoke the deployment skill in the selected directory by running:
<br />
<code className="bg-[#1e1e1e] px-1.5 py-0.5 rounded text-xs border border-[#3e3e42] mt-1 inline-block font-mono text-blue-400">
pnpm build && pnpm start
claude -p &quot;/fulling-deploy&quot;
</code>
</div>

<div className="bg-[#1e1e1e]/50 rounded-md border border-[#3e3e42]/50 text-sm">
<div className="p-3 space-y-2">
<div className="flex gap-2.5 items-start">
<span className="text-blue-400 mt-0.5">•</span>
<span>App runs continuously in the background</span>
<span>Generate or reuse a Dockerfile for the current project</span>
</div>
<div className="flex gap-2.5 items-start">
<span className="text-blue-400 mt-0.5">•</span>
<span>Remains active even if you leave this page</span>
<span>Create a GitHub Actions workflow for image build and push</span>
</div>
<div className="flex gap-2.5 items-start">
<span className="text-blue-400 mt-0.5">•</span>
<span>
Can be stopped anytime by clicking this button again
</span>
<span>Let the skill commit and push the generated files to GitHub</span>
</div>
</div>

{sandboxUrl && (
<div className="px-3 pb-3 pt-2 border-t border-[#3e3e42]/30">
<div className="text-xs text-gray-500 mb-1">
Once running, your application will be available at:
</div>
<a
href={sandboxUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-[#3794ff] hover:text-[#4fc1ff] break-all underline underline-offset-2 hover:underline-offset-4 transition-all block"
>
{sandboxUrl}
</a>
</div>
)}
</div>
</div>
</AlertDialogDescription>
Expand All @@ -82,7 +63,7 @@ export function AppRunnerDialog({
onClick={onConfirm}
className="bg-[#007fd4] hover:bg-[#0060a0] text-white"
>
Confirm & Run
Confirm & Run Skill
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
Expand Down
57 changes: 19 additions & 38 deletions components/terminal/toolbar/app-runner.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { useState } from 'react';
import { MdPlayArrow, MdRefresh, MdStop } from 'react-icons/md';
import { MdRefresh, MdRocketLaunch } from 'react-icons/md';
import type { Prisma } from '@prisma/client';

import { useAppRunner } from '@/hooks/use-app-runner';
Expand All @@ -17,28 +17,20 @@ interface AppRunnerProps {
}

export function AppRunner({ sandbox }: AppRunnerProps) {
const [showStartConfirm, setShowStartConfirm] = useState(false);
const [showRunConfirm, setShowRunConfirm] = useState(false);
const [deployDirectory, setDeployDirectory] = useState('./');
const {
isStartingApp,
isStoppingApp,
isAppRunning,
startApp,
stopApp,
isRunningSkill,
runDeploySkill,
} = useAppRunner(sandbox?.id, deployDirectory);

// Toggle app start/stop
const handleToggleApp = () => {
if (isAppRunning) {
stopApp();
} else {
setShowStartConfirm(true); // Open confirmation modal
}
const handleRunSkill = () => {
setShowRunConfirm(true);
};

const handleConfirmStart = () => {
setShowStartConfirm(false);
startApp();
const handleConfirmRun = () => {
setShowRunConfirm(false);
runDeploySkill();
};

return (
Expand All @@ -55,30 +47,20 @@ export function AppRunner({ sandbox }: AppRunnerProps) {

{/* Run App Button */}
<button
onClick={handleToggleApp}
disabled={isStartingApp || isStoppingApp || !sandbox}
onClick={handleRunSkill}
disabled={isRunningSkill || !sandbox}
className={cn(
'px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 disabled:cursor-not-allowed',
isAppRunning
? 'text-green-400 hover:text-red-400 hover:bg-red-400/10 bg-green-400/10'
: 'text-foreground font-semibold hover:text-white hover:bg-zinc-800 disabled:opacity-50'
'text-foreground font-semibold hover:text-white hover:bg-zinc-800 disabled:opacity-50'
)}
title={
isAppRunning
? 'Click to stop. Your app will no longer be accessible.'
: 'Build and run your app in production mode. It will keep running even if you close this terminal.'
}
title="Generate deployment files and push changes via the /fulling-deploy skill."
>
{isStartingApp || isStoppingApp ? (
{isRunningSkill ? (
<MdRefresh className="h-3 w-3 animate-spin" />
) : isAppRunning ? (
<MdStop className="h-3 w-3" />
) : (
<MdPlayArrow className="h-3 w-3 text-green-500" />
<MdRocketLaunch className="h-3 w-3 text-blue-500" />
)}
<span>
{isStartingApp ? 'Starting...' : isStoppingApp ? 'Stopping...' : isAppRunning ? 'Running' : 'Run App'}
</span>
<span>{isRunningSkill ? 'Starting...' : 'Prepare Deploy'}</span>
</button>
</div>

Expand All @@ -87,10 +69,9 @@ export function AppRunner({ sandbox }: AppRunnerProps) {

{/* Confirmation Alert Dialog */}
<AppRunnerDialog
open={showStartConfirm}
onOpenChange={setShowStartConfirm}
onConfirm={handleConfirmStart}
sandboxUrl={sandbox?.publicUrl}
open={showRunConfirm}
onOpenChange={setShowRunConfirm}
onConfirm={handleConfirmRun}
/>
</>
);
Expand Down
9 changes: 0 additions & 9 deletions components/terminal/toolbar/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,9 @@ import { AppRunner } from './app-runner';
import { NetworkDialog } from './network-dialog';
import { type Tab,TerminalTabs } from './terminal-tabs';

type Project = Prisma.ProjectGetPayload<{
include: {
sandboxes: true;
databases: true;
};
}>;

type Sandbox = Prisma.SandboxGetPayload<object>;

export interface TerminalToolbarProps {
/** Project data */
project: Project;
/** Sandbox data */
sandbox: Sandbox | undefined;
/** Terminal tabs */
Expand Down
138 changes: 34 additions & 104 deletions hooks/use-app-runner.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { useCallback, useEffect, useMemo,useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner';

const BASE_DIR = '/home/fulling/next';

export function useAppRunner(sandboxId: string | undefined, deployDir: string = './') {
const [isStartingApp, setIsStartingApp] = useState(false);
const [isStoppingApp, setIsStoppingApp] = useState(false);
const [isAppRunning, setIsAppRunning] = useState(false);
function buildSkillPrompt(repoUrl?: string) {
void repoUrl;
return '/fulling-deploy';
}

export function useAppRunner(
sandboxId: string | undefined,
deployDir: string = './',
repoUrl?: string
) {
const [isRunningSkill, setIsRunningSkill] = useState(false);

// Calculate workdir based on deployDir
const workdir = useMemo(() => {
Expand All @@ -18,120 +25,43 @@ export function useAppRunner(sandboxId: string | undefined, deployDir: string =
return `${BASE_DIR}/${relativePath}`;
}, [deployDir]);

// Check app status on mount
useEffect(() => {
if (!sandboxId) return;

const checkStatus = async () => {
try {
const response = await fetch(`/api/sandbox/${sandboxId}/app-status`);
const data = await response.json();
setIsAppRunning(data.running);
} catch (error) {
console.error('Failed to check app status:', error);
}
};

checkStatus();
}, [sandboxId]);

// Start application in background
const startApp = useCallback(async () => {
if (!sandboxId || isStartingApp) return;

setIsStartingApp(true);

// Send exec command (fire and forget, don't wait for response)
fetch(`/api/sandbox/${sandboxId}/exec`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
command: 'pnpm run build && pnpm run start',
workdir,
}),
}).catch(() => {
// Ignore errors, we'll detect success via port polling
});

toast.info('Starting...', {
description: 'Building and starting your app. This may take a few minutes.',
});

// Poll for app status every 10 seconds, max 5 minutes
const maxAttempts = 30; // 30 * 10s = 5 minutes
let attempts = 0;
const runDeploySkill = useCallback(async () => {
if (!sandboxId || isRunningSkill) return;

const pollStatus = async (): Promise<boolean> => {
try {
const response = await fetch(`/api/sandbox/${sandboxId}/app-status`);
const data = await response.json();
return data.running;
} catch {
return false;
}
};

const poll = async () => {
while (attempts < maxAttempts) {
attempts++;
const running = await pollStatus();
if (running) {
setIsAppRunning(true);
setIsStartingApp(false);
toast.success('App Running', {
description: 'Your app is live in the background',
});
return;
}
// Wait 10 seconds before next check
await new Promise((resolve) => setTimeout(resolve, 10000));
}

// Timeout after max attempts
setIsStartingApp(false);
toast.error('Start Timeout', {
description: 'App did not start within 5 minutes. Check terminal for errors.',
});
};

poll();
}, [sandboxId, isStartingApp, workdir]);

// Stop application
const stopApp = useCallback(async () => {
if (!sandboxId || isStoppingApp) return;

setIsStoppingApp(true);
setIsRunningSkill(true);
try {
const response = await fetch(`/api/sandbox/${sandboxId}/app-status`, {
method: 'DELETE',
const prompt = buildSkillPrompt(repoUrl);
const response = await fetch(`/api/sandbox/${sandboxId}/exec`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
command: `claude -p ${JSON.stringify(prompt)}`,
workdir,
}),
});

const result = await response.json();

if (result.success) {
setIsAppRunning(false);
toast.success('App Stopped');
if (response.ok && result.success) {
toast.success('Deploy Prep Started', {
description: 'The /fulling-deploy skill is running in the sandbox background.',
});
} else {
toast.error('Stop Failed', {
toast.error('Failed to Start Deploy Prep', {
description: result.error || 'Unknown error',
});
}
} catch (error) {
console.error('Failed to stop app:', error);
toast.error('Stop Failed', {
console.error('Failed to start deploy prep skill:', error);
toast.error('Failed to Start Deploy Prep', {
description: 'Network error, please try again',
});
} finally {
setIsStoppingApp(false);
setIsRunningSkill(false);
}
}, [sandboxId, isStoppingApp]);
}, [sandboxId, isRunningSkill, repoUrl, workdir]);

return {
isStartingApp,
isStoppingApp,
isAppRunning,
startApp,
stopApp,
isRunningSkill,
runDeploySkill,
};
}
Loading