Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
34bfd35
feat: add restart sandbox button to toolbar
drfarrell Sep 15, 2025
804a1ea
fix: use reload icon and add toast notification for sandbox restart
drfarrell Sep 15, 2025
a3f45d2
refactor: address CodeRabbit review comments for restart sandbox feature
drfarrell Sep 15, 2025
fa834eb
feat: add loading state and delay to sandbox restart
drfarrell Sep 15, 2025
0025f42
feat: add error state indication for restart sandbox button
drfarrell Sep 15, 2025
da4ab4d
refactor: optimize error detection for massive performance improvement
drfarrell Sep 15, 2025
f897610
fix: remove non-existent hasTimedOut property to fix build error
drfarrell Sep 15, 2025
4cb140a
feat: improve spinner visibility and add startup grace period
drfarrell Sep 15, 2025
ab76b25
fix: use useMemo for proper memoization of hasSandboxError
drfarrell Sep 15, 2025
45456c8
fix: clear amber error state when sandbox successfully connects
drfarrell Sep 15, 2025
5d7cc0e
feat: detect actual 502 errors using 30-second timeout
drfarrell Sep 15, 2025
ccf112f
fix: reduce timeout detection to 5 seconds for faster user feedback
drfarrell Sep 15, 2025
cb0293e
perf: ultra-lightweight error detection with minimal resource usage
drfarrell Sep 15, 2025
eaaa718
fix: detect 502 errors on initial project load
drfarrell Sep 15, 2025
ebc7297
Merge branch 'main' into restart-sandbox-in-toolbar
Kitenite Sep 16, 2025
dcfada1
refactor and clean up
Kitenite Sep 16, 2025
367678f
finish loop
Kitenite Sep 16, 2025
1638260
clean up
Kitenite Sep 16, 2025
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
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
'use client';

import { useEditorEngine } from '@/components/store/editor';
import { Icons } from '@onlook/ui/icons';
import { toast } from '@onlook/ui/sonner';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@onlook/ui/tabs';
import { Tooltip, TooltipContent, TooltipTrigger } from '@onlook/ui/tooltip';
import { cn } from '@onlook/ui/utils';
import { observer } from 'mobx-react-lite';
import { motion } from 'motion/react';
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Terminal } from './terminal';

export const TerminalArea = observer(({ children }: { children: React.ReactNode }) => {
Expand Down Expand Up @@ -47,12 +50,146 @@ export const TerminalArea = observer(({ children }: { children: React.ReactNode
}

const [terminalHidden, setTerminalHidden] = useState(true);
const [restarting, setRestarting] = useState(false);

// Ultra-lightweight error detection using a single timer
const timeoutIdRef = useRef<NodeJS.Timeout | null>(null);
const [hasSandboxError, setHasSandboxError] = useState(false);
const mountTimeRef = useRef<number>(Date.now());

// Single effect that only sets/clears one timer - extremely efficient
useEffect(() => {
// Clear any existing timer first
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null;
}

const activeBranch = branches.activeBranch;
if (!activeBranch) {
setHasSandboxError(false);
return;
}

const branchData = branches.getBranchDataById(activeBranch.id);
const sandbox = branchData?.sandbox;

// Quick bailouts - no timer needed
if (!sandbox?.session) {
setHasSandboxError(false);
return;
}

// If we have a provider, we're connected - no error
if (sandbox.session.provider) {
setHasSandboxError(false);
return;
}

// Check if enough time has passed since mount (avoid false positives on initial load)
const timeSinceMount = Date.now() - mountTimeRef.current;
if (timeSinceMount < 5000) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using a named constant (e.g. GRACE_PERIOD_MS) instead of hardcoding 5000ms to improve readability and maintainability.

// Set timer to check after 5 seconds from mount
const delay = 5000 - timeSinceMount;
timeoutIdRef.current = setTimeout(() => {
// Check if still no provider (indicates 502 or failure)
const stillNoProvider = !branches.getBranchDataById(activeBranch.id)?.sandbox?.session?.provider;
if (stillNoProvider) {
setHasSandboxError(true);
}
}, delay);
} else {
// We're past the grace period - if no provider, it's an error
setHasSandboxError(true);
}

// Cleanup on unmount or deps change
return () => {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null;
}
};
}, [branches.activeBranch?.id, branches]); // Only re-run when branch ID changes

// Extract restart logic into a reusable function to follow DRY principles
const handleRestartSandbox = async () => {
const activeBranch = branches.activeBranch;
if (!activeBranch || restarting) return;

setRestarting(true);
setHasSandboxError(false); // Clear error state on restart

try {
const sandbox = branches.getSandboxById(activeBranch.id);
if (!sandbox?.session) {
toast.error('Sandbox session not available');
setRestarting(false);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Localize user‑facing text via next‑intl (repo guideline)

Strings are hardcoded; guidelines require using next-intl hooks/messages.

Illustrative diff (apply pattern to all messages/tooltips/labels):

@@
-import { toast } from '@onlook/ui/sonner';
+import { toast } from '@onlook/ui/sonner';
+import { useTranslations } from 'next-intl';
@@
 export const TerminalArea = observer(({ children }: { children: React.ReactNode }) => {
+    const t = useTranslations('TerminalArea');
@@
-                toast.error('Sandbox session not available');
+                toast.error(t('sandboxSessionUnavailable'));
@@
-                if (!opts?.silent) {
-                    toast.success('Sandbox restarted successfully', {
+                if (!opts?.silent) {
+                    toast.success(t('restartSuccess'), {
                         icon: <Icons.Cube className="h-4 w-4" />,
                     });
                 }
@@
-                if (!opts?.silent) toast.error('Failed to restart sandbox');
+                if (!opts?.silent) toast.error(t('restartFailed'));
@@
-            if (!opts?.silent) toast.error('An error occurred while restarting the sandbox');
+            if (!opts?.silent) toast.error(t('restartErrorGeneric'));
@@
-                        <TooltipContent sideOffset={5} hideArrow>Restart Sandbox</TooltipContent>
+                        <TooltipContent sideOffset={5} hideArrow>{t('restartSandbox')}</TooltipContent>
@@
-                        <TooltipContent sideOffset={5} hideArrow>Toggle Terminal</TooltipContent>
+                        <TooltipContent sideOffset={5} hideArrow>{t('toggleTerminal')}</TooltipContent>
@@
-                        <TooltipContent sideOffset={5} hideArrow>Restart Sandbox</TooltipContent>
+                        <TooltipContent sideOffset={5} hideArrow>{t('restartSandbox')}</TooltipContent>
@@
-                        <TooltipContent sideOffset={5} hideArrow>Toggle Terminal</TooltipContent>
+                        <TooltipContent sideOffset={5} hideArrow>{t('toggleTerminal')}</TooltipContent>
@@
-                        Terminal
+                        {t('terminal')}
@@
-                        <span className="text-sm">No terminal sessions available</span>
+                        <span className="text-sm">{t('noTerminalSessions')}</span>

Define the above keys in your messages file under the TerminalArea namespace.

Also applies to: 107-110, 125-126, 130-131, 164-165, 175-176, 218-219, 229-230, 274-275, 190-191, 3-5, 14-17

🤖 Prompt for AI Agents
In apps/web/client/src/app/project/[id]/_components/bottom-bar/terminal-area.tsx
around lines 100-101 (and also apply same change to lines 107-110, 125-126,
130-131, 164-165, 175-176, 190-191, 218-219, 229-230, 274-275, and header lines
3-5, 14-17), replace all hardcoded user-facing strings (toasts, button labels,
tooltips, etc.) with next-intl message lookups: import and use the next-intl
hook (e.g., const t = useTranslations('TerminalArea')) and call t('keyName') for
each string, add corresponding keys under the TerminalArea namespace in the
locale messages file, and ensure any dynamic values use t('key', { value }) or
template usage supported by next-intl; update tests/types if needed to expect
translated keys.

return;
}

const success = await sandbox.session.restartDevServer();
if (success) {
toast.success('Sandbox restarted successfully', {
icon: <Icons.Cube className="h-4 w-4" />,
});

// Wait 5 seconds before refreshing webviews to avoid 502 errors
setTimeout(() => {
const frames = editorEngine.frames.getAll();
frames.forEach(frame => {
try {
editorEngine.frames.reloadView(frame.frame.id);
} catch (frameError) {
console.error('Failed to reload frame:', frame.frame.id, frameError);
}
});
setRestarting(false);
}, 5000);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resource leak bug: The setTimeout creates a timer that is never cleaned up if the component unmounts before the 5-second delay completes. This can cause memory leaks and attempts to update unmounted components. The timer should be stored in a ref and cleared in a useEffect cleanup function.

Suggested change
setTimeout(() => {
const frames = editorEngine.frames.getAll();
frames.forEach(frame => {
try {
editorEngine.frames.reloadView(frame.frame.id);
} catch (frameError) {
console.error('Failed to reload frame:', frame.frame.id, frameError);
}
});
setRestarting(false);
}, 5000);
const timerRef = useRef<NodeJS.Timeout>();
useEffect(() => {
timerRef.current = setTimeout(() => {
const frames = editorEngine.frames.getAll();
frames.forEach(frame => {
try {
editorEngine.frames.reloadView(frame.frame.id);
} catch (frameError) {
console.error('Failed to reload frame:', frame.frame.id, frameError);
}
});
setRestarting(false);
}, 5000);
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, [editorEngine]);

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

} else {
toast.error('Failed to restart sandbox');
setRestarting(false);
}
} catch (error) {
console.error('Error restarting sandbox:', error);
toast.error('An error occurred while restarting the sandbox');
setRestarting(false);
}
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix memory leak and improve error handling.

The setTimeout creates a timer that is never cleaned up if the component unmounts during the 5-second delay. The restarting state management also needs improvement to prevent double submits.

Apply this diff to fix the memory leak and improve state management:

 const handleRestartSandbox = async () => {
     const activeBranch = branches.activeBranch;
     if (!activeBranch || restarting) return;

     setRestarting(true);
     setHasSandboxError(false); // Clear error state on restart
+    
+    let timeoutId: NodeJS.Timeout | null = null;
     
     try {
         const sandbox = branches.getSandboxById(activeBranch.id);
         if (!sandbox?.session) {
             toast.error('Sandbox session not available');
-            setRestarting(false);
             return;
         }

         const success = await sandbox.session.restartDevServer();
         if (success) {
             toast.success('Sandbox restarted successfully', {
                 icon: <Icons.Cube className="h-4 w-4" />,
             });
             
             // Wait 5 seconds before refreshing webviews to avoid 502 errors
-            setTimeout(() => {
-                const frames = editorEngine.frames.getAll();
-                frames.forEach(frame => {
-                    try {
-                        editorEngine.frames.reloadView(frame.frame.id);
-                    } catch (frameError) {
-                        console.error('Failed to reload frame:', frame.frame.id, frameError);
-                    }
-                });
-                setRestarting(false);
-            }, 5000);
+            await new Promise<void>((resolve) => {
+                timeoutId = setTimeout(() => {
+                    const frames = editorEngine.frames.getAll();
+                    frames.forEach(frame => {
+                        try {
+                            editorEngine.frames.reloadView(frame.frame.id);
+                        } catch (frameError) {
+                            console.error('Failed to reload frame:', frame.frame.id, frameError);
+                        }
+                    });
+                    resolve();
+                }, 5000);
+            });
         } else {
             toast.error('Failed to restart sandbox');
-            setRestarting(false);
         }
     } catch (error) {
         console.error('Error restarting sandbox:', error);
         toast.error('An error occurred while restarting the sandbox');
-        setRestarting(false);
+    } finally {
+        setRestarting(false);
     }
+
+    // Cleanup function to clear timeout if component unmounts
+    return () => {
+        if (timeoutId) {
+            clearTimeout(timeoutId);
+        }
+    };
 };

Store the cleanup function in a ref to handle component unmounting:

 const timeoutIdRef = useRef<NodeJS.Timeout | null>(null);
+const restartCleanupRef = useRef<(() => void) | null>(null);

+useEffect(() => {
+    return () => {
+        // Cleanup any pending restart timeouts on unmount
+        if (restartCleanupRef.current) {
+            restartCleanupRef.current();
+        }
+    };
+}, []);

Then in the handler:

-    return () => {
-        if (timeoutId) {
-            clearTimeout(timeoutId);
-        }
-    };
+    restartCleanupRef.current = () => {
+        if (timeoutId) {
+            clearTimeout(timeoutId);
+        }
+    };

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/web/client/src/app/project/[id]/_components/bottom-bar/terminal-area.tsx
around lines 116-158, the setTimeout used to delay frame reloads can leak if the
component unmounts and the restarting state handling allows duplicate submits;
store the timeout ID in a ref (e.g., restartTimeoutRef) and check restarting at
the top of the handler to prevent double submits, clear any existing timeout
before setting a new one, ensure setRestarting(false) is called in every exit
path (success, failure, and catch), and add a useEffect cleanup that clears the
stored timeout on unmount to avoid the memory leak.


return (
<>
{terminalHidden ? (
<motion.div layout className="flex items-center gap-1">
{children}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleRestartSandbox}
disabled={!branches.activeBranch || restarting}
className={cn(
"h-9 w-9 flex items-center justify-center rounded-md border border-transparent transition-colors",
hasSandboxError
? "bg-amber-900 text-amber-200 hover:bg-amber-800 hover:text-amber-100"
: restarting
? "text-foreground-tertiary bg-accent/30" // Keep visible during restart
: branches.activeBranch
? "hover:text-foreground-hover text-foreground-tertiary hover:bg-accent/50"
: "text-foreground-disabled cursor-not-allowed opacity-50"
)}
>
{restarting ? (
<Icons.LoadingSpinner className="h-4 w-4 animate-spin" />
) : (
<Icons.RestartSandbox className={cn(
"h-4 w-4",
hasSandboxError && "text-amber-200"
)} />
)}
</button>
</TooltipTrigger>
<TooltipContent sideOffset={5} hideArrow>Restart Sandbox</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
Expand Down Expand Up @@ -81,6 +218,34 @@ export const TerminalArea = observer(({ children }: { children: React.ReactNode
</motion.span>
<div className="flex items-center gap-1">
<motion.div layout>{/* <RunButton /> */}</motion.div>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleRestartSandbox}
disabled={!branches.activeBranch || restarting}
className={cn(
"h-9 w-9 flex items-center justify-center rounded-md border border-transparent transition-colors",
hasSandboxError
? "bg-amber-900 text-amber-200 hover:bg-amber-800 hover:text-amber-100"
: restarting
? "text-foreground-tertiary bg-accent/30" // Keep visible during restart
: branches.activeBranch
? "hover:text-foreground-hover text-foreground-tertiary hover:bg-accent/50"
: "text-foreground-disabled cursor-not-allowed opacity-50"
)}
>
{restarting ? (
<Icons.LoadingSpinner className="h-4 w-4 animate-spin" />
) : (
<Icons.RestartSandbox className={cn(
"h-4 w-4",
hasSandboxError && "text-amber-200"
)} />
)}
</button>
</TooltipTrigger>
<TooltipContent sideOffset={5} hideArrow>Restart Sandbox</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
Expand Down
26 changes: 26 additions & 0 deletions packages/ui/src/components/icons/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1668,6 +1668,32 @@ export const Icons = {

QuestionMarkCircled: QuestionMarkCircledIcon,
Reload: ReloadIcon,
RestartSandbox: ({ className, ...props }: IconProps) => (
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
{...props}
>
<path
d="M7.15756 7.497V13.354M7.15756 7.497L1.98945 4.59M7.15756 7.497L12.1619 4.68198M8.90971 12.9067L7.83305 13.5123C7.41355 13.7483 6.9013 13.7483 6.4818 13.5123L2.17514 11.0898C1.7412 10.8457 1.47266 10.3866 1.47266 9.88866V5.1053C1.47266 4.60742 1.7412 4.14825 2.17514 3.90416L6.4818 1.48167C6.9013 1.24569 7.41355 1.24569 7.83305 1.48167L12.1397 3.90416C12.5737 4.14825 12.8422 4.60742 12.8422 5.1053V6.30114V6.61615"
stroke="currentColor"
strokeWidth="0.9261"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M11.4963 7.89844L11.4984 9.99844M9.92344 8.83488C9.59671 9.20478 9.39844 9.69081 9.39844 10.2231C9.39844 11.3818 10.3377 12.321 11.4963 12.321C12.655 12.321 13.5942 11.3818 13.5942 10.2231C13.5942 9.69081 13.396 9.20478 13.0692 8.83488"
stroke="currentColor"
strokeWidth="0.84"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
Reset: ResetIcon,
RowSpacing: RowSpacingIcon,

Expand Down