Skip to content

Commit 04f9e7e

Browse files
committed
Fix terminal command failures and app unselection issues
- Improve terminal command execution by using proper cwd parameter instead of cd commands - Enhanced command routing to default to frontend terminal for simple commands - Add better error logging for command failures - Prevent automatic app unselection when chats are temporarily unavailable - Keep selected app persistent during loading states and errors - Add retry logic for git operations with timeouts - Fix duplicate import in PreviewIframe component
1 parent 521ff78 commit 04f9e7e

File tree

6 files changed

+138
-31
lines changed

6 files changed

+138
-31
lines changed

assets/icon/logo.gif

121 KB
Loading

src/components/preview_panel/PreviewIframe.tsx

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from "@/atoms/appAtoms";
77
import { useAtomValue, useSetAtom, useAtom } from "jotai";
88
import { useEffect, useRef, useState } from "react";
9+
import { useRunApp } from "@/hooks/useRunApp";
910
import {
1011
ArrowLeft,
1112
ArrowRight,
@@ -39,7 +40,6 @@ import {
3940
TooltipProvider,
4041
TooltipTrigger,
4142
} from "@/components/ui/tooltip";
42-
import { useRunApp } from "@/hooks/useRunApp";
4343
import { useShortcut } from "@/hooks/useShortcut";
4444

4545
interface ErrorBannerProps {
@@ -135,7 +135,8 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
135135
const selectedChatId = useAtomValue(selectedChatIdAtom);
136136
const { streamMessage } = useStreamChat();
137137
const { routes: availableRoutes } = useParseRouter(selectedAppId);
138-
const { restartApp } = useRunApp();
138+
const { restartApp, loading: appLoading } = useRunApp();
139+
const [loadingStartTime, setLoadingStartTime] = useState<number | null>(null);
139140

140141
// Navigation state
141142
const [isComponentSelectorInitialized, setIsComponentSelectorInitialized] =
@@ -289,6 +290,31 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
289290
}
290291
}, [appUrl]);
291292

293+
// Track loading start time
294+
useEffect(() => {
295+
if (!appUrl && selectedAppId && !errorMessage) {
296+
setLoadingStartTime(Date.now());
297+
} else {
298+
setLoadingStartTime(null);
299+
}
300+
}, [appUrl, selectedAppId, errorMessage]);
301+
302+
// Helper function to get loading message based on duration
303+
const getLoadingMessage = () => {
304+
if (!loadingStartTime) return "Starting your app server...";
305+
306+
const elapsed = Date.now() - loadingStartTime;
307+
const seconds = Math.floor(elapsed / 1000);
308+
309+
if (seconds < 30) {
310+
return "Starting your app server...";
311+
} else if (seconds < 60) {
312+
return "Starting your app server... (This is taking longer than usual)";
313+
} else {
314+
return "Starting your app server... (Taking longer than expected. Check the terminal for any errors)";
315+
}
316+
};
317+
292318
// Function to activate component selector in the iframe
293319
const handleActivateComponentSelector = () => {
294320
if (iframeRef.current?.contentWindow) {
@@ -553,8 +579,8 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
553579
{!appUrl ? (
554580
<div className="absolute inset-0 flex flex-col items-center justify-center space-y-4 bg-gray-50 dark:bg-gray-950">
555581
<Loader2 className="w-8 h-8 animate-spin text-gray-400 dark:text-gray-500" />
556-
<p className="text-gray-600 dark:text-gray-300">
557-
Starting your app server...
582+
<p className="text-gray-600 dark:text-gray-300 text-center max-w-xs">
583+
{getLoadingMessage()}
558584
</p>
559585
</div>
560586
) : (

src/hooks/useRunApp.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback } from "react";
1+
import { useCallback, useEffect, useRef } from "react";
22
import { atom } from "jotai";
33
import { IpcClient } from "@/ipc/ipc_client";
44
import {
@@ -23,6 +23,24 @@ export function useRunApp() {
2323
const setPreviewPanelKey = useSetAtom(previewPanelKeyAtom);
2424
const appId = useAtomValue(selectedAppIdAtom);
2525
const setPreviewErrorMessage = useSetAtom(previewErrorMessageAtom);
26+
const startupTimeoutRef = useRef<NodeJS.Timeout | null>(null);
27+
28+
// Clear startup timeout when app URL is successfully set
29+
const clearStartupTimeout = useCallback(() => {
30+
if (startupTimeoutRef.current) {
31+
clearTimeout(startupTimeoutRef.current);
32+
startupTimeoutRef.current = null;
33+
}
34+
}, []);
35+
36+
// Set startup timeout to show error if app doesn't start within 2 minutes
37+
const setStartupTimeout = useCallback((appId: number) => {
38+
clearStartupTimeout(); // Clear any existing timeout
39+
startupTimeoutRef.current = setTimeout(() => {
40+
console.warn(`[useRunApp] App ${appId} startup timeout - no URL detected within 2 minutes`);
41+
setPreviewErrorMessage("App startup timed out. The server may have failed to start. Check the terminal output for errors.");
42+
}, 2 * 60 * 1000); // 2 minutes
43+
}, [clearStartupTimeout, setPreviewErrorMessage]);
2644

2745
const processProxyServerOutput = (output: AppOutput) => {
2846
const matchesProxyServerStart = output.message.includes(
@@ -38,13 +56,38 @@ export function useRunApp() {
3856
if (proxyUrlMatch && proxyUrlMatch[1]) {
3957
const proxyUrl = proxyUrlMatch[1];
4058
const originalUrl = originalUrlMatch && originalUrlMatch[1];
59+
console.log(`[useRunApp] Setting app URL: proxy=${proxyUrl}, original=${originalUrl}, appId=${output.appId}`);
60+
clearStartupTimeout();
4161
setAppUrlObj({
4262
appUrl: proxyUrl,
4363
appId: output.appId,
4464
originalUrl: originalUrl!,
4565
});
4666
}
4767
}
68+
69+
// Also check for server startup messages that might indicate the app is ready
70+
// This handles cases where the proxy server message format might be different
71+
const serverReadyPatterns = [
72+
/Local:\s+(http:\/\/localhost:\d+)/i,
73+
/Server running at (http:\/\/localhost:\d+)/i,
74+
/App is running on (http:\/\/localhost:\d+)/i,
75+
/Development server started.*(http:\/\/\S+)/i,
76+
];
77+
78+
for (const pattern of serverReadyPatterns) {
79+
const match = output.message.match(pattern);
80+
if (match && match[1]) {
81+
console.log(`[useRunApp] Detected server ready from pattern: ${pattern}, URL: ${match[1]}`);
82+
clearStartupTimeout();
83+
setAppUrlObj({
84+
appUrl: match[1],
85+
appId: output.appId,
86+
originalUrl: match[1],
87+
});
88+
break;
89+
}
90+
}
4891
};
4992

5093
const processAppOutput = useCallback(
@@ -76,6 +119,8 @@ export function useRunApp() {
76119
const runApp = useCallback(
77120
async (appId: number) => {
78121
setLoading(true);
122+
clearStartupTimeout(); // Clear any existing timeout
123+
setStartupTimeout(appId); // Set new timeout for this app startup
79124
try {
80125
const ipcClient = IpcClient.getInstance();
81126
console.debug("Running app", appId);
@@ -103,14 +148,15 @@ export function useRunApp() {
103148
setPreviewErrorMessage(undefined);
104149
} catch (error) {
105150
console.error(`Error running app ${appId}:`, error);
151+
clearStartupTimeout(); // Clear timeout on error
106152
setPreviewErrorMessage(
107153
error instanceof Error ? error.message : error?.toString(),
108154
);
109155
} finally {
110156
setLoading(false);
111157
}
112158
},
113-
[processAppOutput],
159+
[processAppOutput, clearStartupTimeout, setStartupTimeout],
114160
);
115161

116162
const stopApp = useCallback(async (appId: number) => {
@@ -149,6 +195,8 @@ export function useRunApp() {
149195
return;
150196
}
151197
setLoading(true);
198+
clearStartupTimeout(); // Clear any existing timeout
199+
setStartupTimeout(appId); // Set new timeout for this app restart
152200
try {
153201
const ipcClient = IpcClient.getInstance();
154202
console.debug(
@@ -188,6 +236,7 @@ export function useRunApp() {
188236
);
189237
} catch (error) {
190238
console.error(`Error restarting app ${appId}:`, error);
239+
clearStartupTimeout(); // Clear timeout on error
191240
setPreviewErrorMessage(
192241
error instanceof Error ? error.message : error?.toString(),
193242
);
@@ -204,13 +253,22 @@ export function useRunApp() {
204253
setPreviewPanelKey,
205254
processAppOutput,
206255
onHotModuleReload,
256+
clearStartupTimeout,
257+
setStartupTimeout,
207258
],
208259
);
209260

210261
const refreshAppIframe = useCallback(async () => {
211262
setPreviewPanelKey((prevKey) => prevKey + 1);
212263
}, [setPreviewPanelKey]);
213264

265+
// Cleanup timeout on unmount or appId change
266+
useEffect(() => {
267+
return () => {
268+
clearStartupTimeout();
269+
};
270+
}, [clearStartupTimeout]);
271+
214272
return {
215273
loading,
216274
runApp,

src/ipc/processors/response_processor.ts

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,38 @@ import { isServerFunction } from "../../supabase_admin/supabase_utils";
1919
import { UserSettings } from "../../lib/schemas";
2020
import { gitCommit } from "../utils/git_utils";
2121

22-
// Helper function to handle git operations with timeout
22+
// Helper function to handle git operations with timeout and retry
2323
function createSafeGitOperation(warnings: Output[], errors: Output[]) {
2424
return async function safeGitOperation(operation: () => Promise<any>, operationName: string, filePath?: string): Promise<any> {
25-
try {
26-
// Set a timeout for git operations (30 seconds)
27-
const timeoutPromise = new Promise((_, reject) => {
28-
setTimeout(() => reject(new Error(`${operationName} timed out after 30 seconds`)), 30000);
29-
});
25+
const maxRetries = 3;
26+
const retryDelay = 1000; // 1 second
3027

31-
const result = await Promise.race([operation(), timeoutPromise]);
32-
return result;
33-
} catch (error) {
34-
const errorMessage = `${operationName} failed${filePath ? ` for ${filePath}` : ''}: ${error}`;
35-
logger.warn(errorMessage);
36-
warnings.push({
37-
message: errorMessage,
38-
error: error,
39-
});
40-
return null;
28+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
29+
try {
30+
// Set a timeout for git operations (30 seconds)
31+
const timeoutPromise = new Promise((_, reject) => {
32+
setTimeout(() => reject(new Error(`${operationName} timed out after 30 seconds`)), 30000);
33+
});
34+
35+
const result = await Promise.race([operation(), timeoutPromise]);
36+
return result;
37+
} catch (error) {
38+
const errorMessage = `${operationName} failed${filePath ? ` for ${filePath}` : ''}: ${error}`;
39+
40+
// If this is the last attempt, add to warnings/errors and return null
41+
if (attempt === maxRetries) {
42+
logger.warn(`${errorMessage} (after ${maxRetries} attempts)`);
43+
warnings.push({
44+
message: `${errorMessage} (after ${maxRetries} attempts)`,
45+
error: error,
46+
});
47+
return null;
48+
}
49+
50+
// Otherwise, log the retry attempt and wait before retrying
51+
logger.warn(`${errorMessage} - Retrying attempt ${attempt + 1}/${maxRetries} in ${retryDelay}ms`);
52+
await new Promise(resolve => setTimeout(resolve, retryDelay));
53+
}
4154
}
4255
};
4356
}
@@ -235,7 +248,7 @@ export async function processFullResponseActions(
235248
// We need to import and use the backendTerminalOutputAtom
236249
// For now, we'll use a more direct approach by sending IPC messages to update the UI
237250

238-
const result = await runShellCommand(`cd "${cwd}" && ${cmdTag.command}`);
251+
const result = await runShellCommand(cmdTag.command, cwd);
239252

240253
if (result === null) {
241254
errors.push({
@@ -277,7 +290,7 @@ export async function processFullResponseActions(
277290

278291
logger.log(`Executing frontend terminal command: ${cmdTag.command} in ${cwd}`);
279292

280-
const result = await runShellCommand(`cd "${cwd}" && ${cmdTag.command}`);
293+
const result = await runShellCommand(cmdTag.command, cwd);
281294

282295
if (result === null) {
283296
errors.push({
@@ -343,7 +356,7 @@ export async function processFullResponseActions(
343356

344357
try {
345358
// Determine which terminal to route to based on command content and chat mode
346-
let terminalType: "frontend" | "backend" = "backend"; // default
359+
let terminalType: "frontend" | "backend" = "frontend"; // default to frontend for simple commands
347360
let cwd = cmdTag.cwd ? path.join(appPath, cmdTag.cwd) : appPath;
348361

349362
// Enhanced command detection with better patterns
@@ -429,7 +442,7 @@ export async function processFullResponseActions(
429442

430443
logger.log(`Executing general terminal command: ${cleanCommand} in ${cwd} (routing to ${terminalType} terminal) - isPython: ${isPythonCommand}, isNode: ${isNodeCommand}`);
431444

432-
const result = await runShellCommand(`cd "${cwd}" && ${cleanCommand}`);
445+
const result = await runShellCommand(cleanCommand, cwd);
433446

434447
if (result === null) {
435448
errors.push({

src/ipc/utils/runShellCommand.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,23 @@ import log from "electron-log";
33

44
const logger = log.scope("runShellCommand");
55

6-
export function runShellCommand(command: string): Promise<string | null> {
7-
logger.log(`Running command: ${command}`);
6+
export function runShellCommand(command: string, cwd?: string): Promise<string | null> {
7+
logger.log(`Running command: ${command}${cwd ? ` in ${cwd}` : ''}`);
88
return new Promise((resolve) => {
99
let output = "";
10+
let stderrOutput = "";
1011
const process = spawn(command, {
1112
shell: true,
1213
stdio: ["ignore", "pipe", "pipe"], // ignore stdin, pipe stdout/stderr
14+
cwd: cwd, // Set working directory if provided
1315
});
1416

1517
process.stdout?.on("data", (data) => {
1618
output += data.toString();
1719
});
1820

1921
process.stderr?.on("data", (data) => {
22+
stderrOutput += data.toString();
2023
// Log stderr but don't treat it as a failure unless the exit code is non-zero
2124
logger.warn(`Stderr from "${command}": ${data.toString().trim()}`);
2225
});
@@ -33,7 +36,7 @@ export function runShellCommand(command: string): Promise<string | null> {
3336
);
3437
resolve(output.trim()); // Command succeeded, return trimmed output
3538
} else {
36-
logger.error(`Command "${command}" failed with code ${code}`);
39+
logger.error(`Command "${command}" failed with code ${code}. Stderr: ${stderrOutput.trim()}`);
3740
resolve(null); // Command failed
3841
}
3942
});

src/pages/chat.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,17 @@ export default function ChatPage() {
2727
if (!chatId && chats.length && !loading) {
2828
// Not a real navigation, just a redirect, when the user navigates to /chat
2929
// without a chatId, we redirect to the first chat
30-
setSelectedAppId(chats[0].appId);
30+
// Only set the app if we don't already have one selected (to prevent clearing)
31+
if (!selectedAppId) {
32+
setSelectedAppId(chats[0].appId);
33+
}
3134
navigate({ to: "/chat", search: { id: chats[0].id }, replace: true });
35+
} else if (!chatId && !chats.length && !loading && selectedAppId) {
36+
// If we have a selected app but no chats for it, don't clear the selection
37+
// This prevents the app from being unselected when there are temporary issues
38+
console.log(`Keeping selected app ${selectedAppId} even though no chats found`);
3239
}
33-
}, [chatId, chats, loading, navigate]);
40+
}, [chatId, chats, loading, navigate, selectedAppId, setSelectedAppId]);
3441

3542
useEffect(() => {
3643
if (isPreviewOpen) {

0 commit comments

Comments
 (0)