Skip to content
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,4 @@ next-env.d.ts
/lib/generated/prisma
/.idea
.claude/settings.json
plan.md
83 changes: 74 additions & 9 deletions components/terminal/terminal-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,23 @@ import type { Prisma } from '@prisma/client';
import { Copy, Eye, EyeOff, Loader2, Network, Play, Plus, Square, Terminal as TerminalIcon, X } from 'lucide-react';
import { toast } from 'sonner';

import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { getStatusBgClasses } from '@/lib/util/status-colors';
import { cn } from '@/lib/utils';

type Project = Prisma.ProjectGetPayload<{
Expand Down Expand Up @@ -61,7 +70,6 @@ export interface TerminalToolbarProps {
* Terminal toolbar with tabs and operations
*/
export function TerminalToolbar({
project,
sandbox,
tabs,
activeTabId,
Expand All @@ -71,6 +79,7 @@ export function TerminalToolbar({
fileBrowserCredentials,
}: TerminalToolbarProps) {
const [showNetworkDialog, setShowNetworkDialog] = useState(false);
const [showStartConfirm, setShowStartConfirm] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [copiedField, setCopiedField] = useState<string | null>(null);
const [isStartingApp, setIsStartingApp] = useState(false);
Expand Down Expand Up @@ -125,6 +134,7 @@ export function TerminalToolbar({
if (!sandbox?.id || isStartingApp) return;

setIsStartingApp(true);
setShowStartConfirm(false); // Close modal

// Send exec command (fire and forget, don't wait for response)
fetch(`/api/sandbox/${sandbox.id}/exec`, {
Expand All @@ -138,7 +148,7 @@ export function TerminalToolbar({
// Ignore errors, we'll detect success via port polling
});

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

Expand All @@ -163,8 +173,8 @@ export function TerminalToolbar({
if (running) {
setIsAppRunning(true);
setIsStartingApp(false);
toast.success('Deployed', {
description: 'Your app is now live',
toast.success('App Running', {
description: 'Your app is live in the background',
});
return;
}
Expand All @@ -174,7 +184,7 @@ export function TerminalToolbar({

// Timeout after max attempts
setIsStartingApp(false);
toast.error('Deploy Timeout', {
toast.error('Start Timeout', {
description: 'App did not start within 5 minutes. Check terminal for errors.',
});
};
Expand Down Expand Up @@ -217,7 +227,7 @@ export function TerminalToolbar({
if (isAppRunning) {
handleStopApp();
} else {
handleStartApp();
setShowStartConfirm(true); // Open confirmation modal
}
};

Expand Down Expand Up @@ -277,7 +287,7 @@ export function TerminalToolbar({
<span>{project.status}</span>
</div> */}

{/* Deploy Button */}
{/* Run App Button (was Deploy) */}
<button
onClick={handleToggleApp}
disabled={isStartingApp || isStoppingApp || !sandbox}
Expand All @@ -301,7 +311,7 @@ export function TerminalToolbar({
<Play className="h-3 w-3" />
)}
<span>
{isStartingApp ? 'Deploying...' : isStoppingApp ? 'Stopping...' : isAppRunning ? 'Live' : 'Deploy'}
{isStartingApp ? 'Starting...' : isStoppingApp ? 'Stopping...' : isAppRunning ? 'Running' : 'Run App'}
</span>
</button>

Expand All @@ -317,6 +327,61 @@ export function TerminalToolbar({
</div>
</div>

{/* Confirmation Alert Dialog */}
<AlertDialog open={showStartConfirm} onOpenChange={setShowStartConfirm}>
<AlertDialogContent className="bg-[#252526] border-[#3e3e42] text-white">
<AlertDialogHeader>
<AlertDialogTitle>Run Application & Keep Active?</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:
<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</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>
</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>
</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>
</div>
</div>

{sandbox?.publicUrl && (
<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={sandbox.publicUrl}
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"
>
{sandbox.publicUrl}
</a>
</div>
)}
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="bg-transparent border-[#3e3e42] text-gray-300 hover:bg-[#37373d] hover:text-white">Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleStartApp} className="bg-[#007fd4] hover:bg-[#0060a0] text-white">
Confirm & Run
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

{/* Network Dialog */}
<Dialog open={showNetworkDialog} onOpenChange={setShowNetworkDialog}>
<DialogContent className="bg-[#252526] border-[#3e3e42] text-white max-w-md">
Expand Down
23 changes: 15 additions & 8 deletions lib/services/repoService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,14 @@ async function getTtydContext(projectId: string, userId: string) {

// Parse the ttydUrl to get base URL (without query params)
const ttydBaseUrl = new URL(sandbox.ttydUrl)

// Extract authorization param if present
const authorization = ttydBaseUrl.searchParams.get('authorization') || undefined

ttydBaseUrl.search = '' // Remove query params
const baseUrl = ttydBaseUrl.toString().replace(/\/$/, '')

return { baseUrl, accessToken: ttydAccessToken, project }
return { baseUrl, accessToken: ttydAccessToken, authorization, project }
}


Expand All @@ -65,7 +69,7 @@ export async function initializeRepo(projectId: string): Promise<RepoInitResult>
}

try {
const { baseUrl, accessToken, project } = await getTtydContext(projectId, session.user.id)
const { baseUrl, accessToken, authorization, project } = await getTtydContext(projectId, session.user.id)

// Create GitHub repo first
const repoResult = await createGithubRepo(project.name)
Expand All @@ -79,7 +83,7 @@ export async function initializeRepo(projectId: string): Promise<RepoInitResult>
data: { githubRepo: repoResult.repoUrl },
})

await runInitCommand(baseUrl, accessToken)
await runInitCommand(baseUrl, accessToken, authorization)

// Push the initial code to GitHub
const pushResult = await pushToGithub(projectId)
Expand All @@ -95,12 +99,13 @@ export async function initializeRepo(projectId: string): Promise<RepoInitResult>
}
}

async function runInitCommand(baseUrl: string, accessToken: string) {
async function runInitCommand(baseUrl: string, accessToken: string, authorization?: string) {
return execCommand(
baseUrl,
accessToken,
'git init -b main && git add . && claude -p "commit all staged changes with a descriptive message" --dangerously-skip-permissions',
300000
300000,
authorization
)

}
Expand Down Expand Up @@ -194,12 +199,14 @@ export async function commitChanges(projectId: string): Promise<RepoInitResult>
}

try {
const { baseUrl, accessToken } = await getTtydContext(projectId, session.user.id)
const { baseUrl, accessToken, authorization } = await getTtydContext(projectId, session.user.id)

await execCommand(
baseUrl,
accessToken,
'git add . && claude -p "commit all staged changes with a descriptive message" --dangerously-skip-permissions',
undefined,
authorization
)

// Push changes to GitHub
Expand Down Expand Up @@ -232,7 +239,7 @@ export async function pushToGithub(projectId: string): Promise<RepoInitResult> {
}

try {
const { baseUrl, accessToken, project } = await getTtydContext(projectId, session.user.id)
const { baseUrl, accessToken, authorization, project } = await getTtydContext(projectId, session.user.id)

if (!project.githubRepo) {
return { success: false, message: 'No GitHub repository linked to this project' }
Expand Down Expand Up @@ -281,7 +288,7 @@ export async function pushToGithub(projectId: string): Promise<RepoInitResult> {
git push -u origin main
`.replace(/\n/g, ' ').trim()

await execCommand(baseUrl, accessToken, command, 300000)
await execCommand(baseUrl, accessToken, command, 300000, authorization)

return { success: true, message: 'Code pushed to GitHub successfully' }
} catch (error) {
Expand Down
34 changes: 29 additions & 5 deletions lib/util/ttyd-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ export interface TtydExecOptions {
/** Optional session ID for multi-terminal support */
sessionId?: string

/**
* Optional authorization string for ttyd basic auth
* Should be the base64 encoded "username:password" string
*/
authorization?: string

/** Timeout in milliseconds (default: 30000) */
timeoutMs?: number

Expand Down Expand Up @@ -158,7 +164,12 @@ function stripAnsiCodes(str: string): string {
/**
* Build WebSocket URL from ttyd HTTP URL
*/
function buildWsUrl(ttydUrl: string, accessToken: string, sessionId?: string): string {
function buildWsUrl(
ttydUrl: string,
accessToken: string,
sessionId?: string,
authorization?: string
): string {
const url = new URL(ttydUrl)
const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
const wsPath = url.pathname.replace(/\/$/, '') + '/ws'
Expand All @@ -169,6 +180,9 @@ function buildWsUrl(ttydUrl: string, accessToken: string, sessionId?: string): s
if (sessionId) {
params.append('arg', sessionId)
}
if (authorization) {
params.append('authorization', authorization)
}

return `${wsProtocol}//${url.host}${wsPath}?${params.toString()}`
}
Expand Down Expand Up @@ -230,6 +244,7 @@ export async function executeTtydCommand(options: TtydExecOptions): Promise<Ttyd
accessToken,
command,
sessionId,
authorization,
timeoutMs = DEFAULT_TIMEOUT_MS,
cols = DEFAULT_COLS,
rows = DEFAULT_ROWS,
Expand All @@ -241,7 +256,7 @@ export async function executeTtydCommand(options: TtydExecOptions): Promise<Ttyd
const endMarkerPattern = new RegExp(`${END_MARKER_PREFIX}${markerId}:(\\d+)___`)

// Build WebSocket URL
const wsUrl = buildWsUrl(ttydUrl, accessToken, sessionId)
const wsUrl = buildWsUrl(ttydUrl, accessToken, sessionId, authorization)

// Text encoder/decoder
const textEncoder = new TextEncoder()
Expand Down Expand Up @@ -391,7 +406,12 @@ export async function executeTtydCommand(options: TtydExecOptions): Promise<Ttyd
if (!ws) return

// Send initial terminal size
const initMsg = JSON.stringify({ columns: cols, rows: rows })
// Include AuthToken if authorization is provided (for ttyd basic auth)
const initMsg = JSON.stringify({
columns: cols,
rows: rows,
AuthToken: authorization,
})
ws.send(textEncoder.encode(initMsg))

// Wait a bit for shell to initialize, then send command
Expand Down Expand Up @@ -526,11 +546,13 @@ export async function execCommand(
ttydUrl: string,
accessToken: string,
command: string,
timeoutMs?: number
timeoutMs?: number,
authorization?: string
): Promise<string> {
const result = await executeTtydCommand({
ttydUrl,
accessToken,
authorization,
command,
timeoutMs,
})
Expand All @@ -553,12 +575,14 @@ export async function execCommand(
export async function execCommandSuccess(
ttydUrl: string,
accessToken: string,
command: string
command: string,
authorization?: string
): Promise<boolean> {
try {
const result = await executeTtydCommand({
ttydUrl,
accessToken,
authorization,
command,
})
return result.exitCode === 0 && !result.timedOut
Expand Down
Loading