Skip to content

Commit 21b65af

Browse files
authored
feat: Enhance App Running Experience with K8s Execution and Confirmation Dialog (#123)
* feat: add deploy button for persistent background app execution - Add execCommandInBackground method in sandbox-manager.ts for nohup execution - Add isPortListening and killProcessOnPort methods for app status detection - Create /api/sandbox/[id]/exec endpoint for background command execution - Create /api/sandbox/[id]/app-status endpoint for status check and stop - Add Deploy button in terminal-toolbar with polling-based status detection - Support start/stop toggle with visual feedback (Deploy -> Live -> Stop) Closes #116 * feat: refactor sandbox command execution to use ttyd WebSocket for background persistence and ignore `plan.md` * chore: ignore plan.md file * feat: Add ttyd basic authentication support by propagating the authorization parameter to ttyd execution functions. * fix: Replace direct ttyd-exec with K8s service for background command execution and add related test scripts. * feat: Introduce 'Run App' confirmation dialog with updated terminology and add internal scripts for background command execution testing. * style: fix lint issues in terminal toolbar * fix: resolve hydration error by replacing nested p with div in alert description
1 parent 754c54d commit 21b65af

4 files changed

Lines changed: 119 additions & 22 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,4 @@ next-env.d.ts
5757
/lib/generated/prisma
5858
/.idea
5959
.claude/settings.json
60+
plan.md

components/terminal/terminal-toolbar.tsx

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,23 @@ import type { Prisma } from '@prisma/client';
1111
import { Copy, Eye, EyeOff, Loader2, Network, Play, Plus, Square, Terminal as TerminalIcon, X } from 'lucide-react';
1212
import { toast } from 'sonner';
1313

14+
import {
15+
AlertDialog,
16+
AlertDialogAction,
17+
AlertDialogCancel,
18+
AlertDialogContent,
19+
AlertDialogDescription,
20+
AlertDialogFooter,
21+
AlertDialogHeader,
22+
AlertDialogTitle,
23+
} from '@/components/ui/alert-dialog';
1424
import {
1525
Dialog,
1626
DialogContent,
1727
DialogDescription,
1828
DialogHeader,
1929
DialogTitle,
2030
} from '@/components/ui/dialog';
21-
import { getStatusBgClasses } from '@/lib/util/status-colors';
2231
import { cn } from '@/lib/utils';
2332

2433
type Project = Prisma.ProjectGetPayload<{
@@ -61,7 +70,6 @@ export interface TerminalToolbarProps {
6170
* Terminal toolbar with tabs and operations
6271
*/
6372
export function TerminalToolbar({
64-
project,
6573
sandbox,
6674
tabs,
6775
activeTabId,
@@ -71,6 +79,7 @@ export function TerminalToolbar({
7179
fileBrowserCredentials,
7280
}: TerminalToolbarProps) {
7381
const [showNetworkDialog, setShowNetworkDialog] = useState(false);
82+
const [showStartConfirm, setShowStartConfirm] = useState(false);
7483
const [showPassword, setShowPassword] = useState(false);
7584
const [copiedField, setCopiedField] = useState<string | null>(null);
7685
const [isStartingApp, setIsStartingApp] = useState(false);
@@ -125,6 +134,7 @@ export function TerminalToolbar({
125134
if (!sandbox?.id || isStartingApp) return;
126135

127136
setIsStartingApp(true);
137+
setShowStartConfirm(false); // Close modal
128138

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

141-
toast.info('Deploying...', {
151+
toast.info('Starting...', {
142152
description: 'Building and starting your app. This may take a few minutes.',
143153
});
144154

@@ -163,8 +173,8 @@ export function TerminalToolbar({
163173
if (running) {
164174
setIsAppRunning(true);
165175
setIsStartingApp(false);
166-
toast.success('Deployed', {
167-
description: 'Your app is now live',
176+
toast.success('App Running', {
177+
description: 'Your app is live in the background',
168178
});
169179
return;
170180
}
@@ -174,7 +184,7 @@ export function TerminalToolbar({
174184

175185
// Timeout after max attempts
176186
setIsStartingApp(false);
177-
toast.error('Deploy Timeout', {
187+
toast.error('Start Timeout', {
178188
description: 'App did not start within 5 minutes. Check terminal for errors.',
179189
});
180190
};
@@ -217,7 +227,7 @@ export function TerminalToolbar({
217227
if (isAppRunning) {
218228
handleStopApp();
219229
} else {
220-
handleStartApp();
230+
setShowStartConfirm(true); // Open confirmation modal
221231
}
222232
};
223233

@@ -277,7 +287,7 @@ export function TerminalToolbar({
277287
<span>{project.status}</span>
278288
</div> */}
279289

280-
{/* Deploy Button */}
290+
{/* Run App Button (was Deploy) */}
281291
<button
282292
onClick={handleToggleApp}
283293
disabled={isStartingApp || isStoppingApp || !sandbox}
@@ -301,7 +311,7 @@ export function TerminalToolbar({
301311
<Play className="h-3 w-3" />
302312
)}
303313
<span>
304-
{isStartingApp ? 'Deploying...' : isStoppingApp ? 'Stopping...' : isAppRunning ? 'Live' : 'Deploy'}
314+
{isStartingApp ? 'Starting...' : isStoppingApp ? 'Stopping...' : isAppRunning ? 'Running' : 'Run App'}
305315
</span>
306316
</button>
307317

@@ -317,6 +327,61 @@ export function TerminalToolbar({
317327
</div>
318328
</div>
319329

330+
{/* Confirmation Alert Dialog */}
331+
<AlertDialog open={showStartConfirm} onOpenChange={setShowStartConfirm}>
332+
<AlertDialogContent className="bg-[#252526] border-[#3e3e42] text-white">
333+
<AlertDialogHeader>
334+
<AlertDialogTitle>Run Application & Keep Active?</AlertDialogTitle>
335+
<AlertDialogDescription className="text-gray-400 space-y-3" asChild>
336+
<div className="text-sm text-gray-400 space-y-3">
337+
<div>
338+
This will build and start your application by running:
339+
<br />
340+
<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>
341+
</div>
342+
343+
<div className="bg-[#1e1e1e]/50 rounded-md border border-[#3e3e42]/50 text-sm">
344+
<div className="p-3 space-y-2">
345+
<div className="flex gap-2.5 items-start">
346+
<span className="text-blue-400 mt-0.5"></span>
347+
<span>App runs continuously in the background</span>
348+
</div>
349+
<div className="flex gap-2.5 items-start">
350+
<span className="text-blue-400 mt-0.5"></span>
351+
<span>Remains active even if you leave this page</span>
352+
</div>
353+
<div className="flex gap-2.5 items-start">
354+
<span className="text-blue-400 mt-0.5"></span>
355+
<span>Can be stopped anytime by clicking this button again</span>
356+
</div>
357+
</div>
358+
359+
{sandbox?.publicUrl && (
360+
<div className="px-3 pb-3 pt-2 border-t border-[#3e3e42]/30">
361+
<div className="text-xs text-gray-500 mb-1">Once running, your application will be available at:</div>
362+
<a
363+
href={sandbox.publicUrl}
364+
target="_blank"
365+
rel="noopener noreferrer"
366+
className="text-xs text-[#3794ff] hover:text-[#4fc1ff] break-all underline underline-offset-2 hover:underline-offset-4 transition-all block"
367+
>
368+
{sandbox.publicUrl}
369+
</a>
370+
</div>
371+
)}
372+
</div>
373+
</div>
374+
</AlertDialogDescription>
375+
</AlertDialogHeader>
376+
<AlertDialogFooter>
377+
<AlertDialogCancel className="bg-transparent border-[#3e3e42] text-gray-300 hover:bg-[#37373d] hover:text-white">Cancel</AlertDialogCancel>
378+
<AlertDialogAction onClick={handleStartApp} className="bg-[#007fd4] hover:bg-[#0060a0] text-white">
379+
Confirm & Run
380+
</AlertDialogAction>
381+
</AlertDialogFooter>
382+
</AlertDialogContent>
383+
</AlertDialog>
384+
320385
{/* Network Dialog */}
321386
<Dialog open={showNetworkDialog} onOpenChange={setShowNetworkDialog}>
322387
<DialogContent className="bg-[#252526] border-[#3e3e42] text-white max-w-md">

lib/services/repoService.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,14 @@ async function getTtydContext(projectId: string, userId: string) {
4646

4747
// Parse the ttydUrl to get base URL (without query params)
4848
const ttydBaseUrl = new URL(sandbox.ttydUrl)
49+
50+
// Extract authorization param if present
51+
const authorization = ttydBaseUrl.searchParams.get('authorization') || undefined
52+
4953
ttydBaseUrl.search = '' // Remove query params
5054
const baseUrl = ttydBaseUrl.toString().replace(/\/$/, '')
5155

52-
return { baseUrl, accessToken: ttydAccessToken, project }
56+
return { baseUrl, accessToken: ttydAccessToken, authorization, project }
5357
}
5458

5559

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

6771
try {
68-
const { baseUrl, accessToken, project } = await getTtydContext(projectId, session.user.id)
72+
const { baseUrl, accessToken, authorization, project } = await getTtydContext(projectId, session.user.id)
6973

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

82-
await runInitCommand(baseUrl, accessToken)
86+
await runInitCommand(baseUrl, accessToken, authorization)
8387

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

98-
async function runInitCommand(baseUrl: string, accessToken: string) {
102+
async function runInitCommand(baseUrl: string, accessToken: string, authorization?: string) {
99103
return execCommand(
100104
baseUrl,
101105
accessToken,
102106
'git init -b main && git add . && claude -p "commit all staged changes with a descriptive message" --dangerously-skip-permissions',
103-
300000
107+
300000,
108+
authorization
104109
)
105110

106111
}
@@ -194,12 +199,14 @@ export async function commitChanges(projectId: string): Promise<RepoInitResult>
194199
}
195200

196201
try {
197-
const { baseUrl, accessToken } = await getTtydContext(projectId, session.user.id)
202+
const { baseUrl, accessToken, authorization } = await getTtydContext(projectId, session.user.id)
198203

199204
await execCommand(
200205
baseUrl,
201206
accessToken,
202207
'git add . && claude -p "commit all staged changes with a descriptive message" --dangerously-skip-permissions',
208+
undefined,
209+
authorization
203210
)
204211

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

234241
try {
235-
const { baseUrl, accessToken, project } = await getTtydContext(projectId, session.user.id)
242+
const { baseUrl, accessToken, authorization, project } = await getTtydContext(projectId, session.user.id)
236243

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

284-
await execCommand(baseUrl, accessToken, command, 300000)
291+
await execCommand(baseUrl, accessToken, command, 300000, authorization)
285292

286293
return { success: true, message: 'Code pushed to GitHub successfully' }
287294
} catch (error) {

lib/util/ttyd-exec.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ export interface TtydExecOptions {
9797
/** Optional session ID for multi-terminal support */
9898
sessionId?: string
9999

100+
/**
101+
* Optional authorization string for ttyd basic auth
102+
* Should be the base64 encoded "username:password" string
103+
*/
104+
authorization?: string
105+
100106
/** Timeout in milliseconds (default: 30000) */
101107
timeoutMs?: number
102108

@@ -158,7 +164,12 @@ function stripAnsiCodes(str: string): string {
158164
/**
159165
* Build WebSocket URL from ttyd HTTP URL
160166
*/
161-
function buildWsUrl(ttydUrl: string, accessToken: string, sessionId?: string): string {
167+
function buildWsUrl(
168+
ttydUrl: string,
169+
accessToken: string,
170+
sessionId?: string,
171+
authorization?: string
172+
): string {
162173
const url = new URL(ttydUrl)
163174
const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
164175
const wsPath = url.pathname.replace(/\/$/, '') + '/ws'
@@ -169,6 +180,9 @@ function buildWsUrl(ttydUrl: string, accessToken: string, sessionId?: string): s
169180
if (sessionId) {
170181
params.append('arg', sessionId)
171182
}
183+
if (authorization) {
184+
params.append('authorization', authorization)
185+
}
172186

173187
return `${wsProtocol}//${url.host}${wsPath}?${params.toString()}`
174188
}
@@ -230,6 +244,7 @@ export async function executeTtydCommand(options: TtydExecOptions): Promise<Ttyd
230244
accessToken,
231245
command,
232246
sessionId,
247+
authorization,
233248
timeoutMs = DEFAULT_TIMEOUT_MS,
234249
cols = DEFAULT_COLS,
235250
rows = DEFAULT_ROWS,
@@ -241,7 +256,7 @@ export async function executeTtydCommand(options: TtydExecOptions): Promise<Ttyd
241256
const endMarkerPattern = new RegExp(`${END_MARKER_PREFIX}${markerId}:(\\d+)___`)
242257

243258
// Build WebSocket URL
244-
const wsUrl = buildWsUrl(ttydUrl, accessToken, sessionId)
259+
const wsUrl = buildWsUrl(ttydUrl, accessToken, sessionId, authorization)
245260

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

393408
// Send initial terminal size
394-
const initMsg = JSON.stringify({ columns: cols, rows: rows })
409+
// Include AuthToken if authorization is provided (for ttyd basic auth)
410+
const initMsg = JSON.stringify({
411+
columns: cols,
412+
rows: rows,
413+
AuthToken: authorization,
414+
})
395415
ws.send(textEncoder.encode(initMsg))
396416

397417
// Wait a bit for shell to initialize, then send command
@@ -526,11 +546,13 @@ export async function execCommand(
526546
ttydUrl: string,
527547
accessToken: string,
528548
command: string,
529-
timeoutMs?: number
549+
timeoutMs?: number,
550+
authorization?: string
530551
): Promise<string> {
531552
const result = await executeTtydCommand({
532553
ttydUrl,
533554
accessToken,
555+
authorization,
534556
command,
535557
timeoutMs,
536558
})
@@ -553,12 +575,14 @@ export async function execCommand(
553575
export async function execCommandSuccess(
554576
ttydUrl: string,
555577
accessToken: string,
556-
command: string
578+
command: string,
579+
authorization?: string
557580
): Promise<boolean> {
558581
try {
559582
const result = await executeTtydCommand({
560583
ttydUrl,
561584
accessToken,
585+
authorization,
562586
command,
563587
})
564588
return result.exitCode === 0 && !result.timedOut

0 commit comments

Comments
 (0)