diff --git a/apps/web/components/resizable-split.tsx b/apps/web/components/resizable-split.tsx deleted file mode 100644 index 3c29d8f39f..0000000000 --- a/apps/web/components/resizable-split.tsx +++ /dev/null @@ -1,113 +0,0 @@ -'use client'; - -import { ReactNode, useCallback, useRef, useState } from 'react'; -import { cn } from '@/lib/utils'; - -type Direction = 'horizontal' | 'vertical'; - -interface ResizableSplitProps { - direction: Direction; - initialSizes?: [number, number]; - minSizes?: [number, number]; - className?: string; - handleClassName?: string; - children: [ReactNode, ReactNode]; -} - -export function ResizableSplit({ - direction, - initialSizes = [50, 50], - minSizes = [20, 20], - className, - handleClassName, - children, -}: ResizableSplitProps) { - const containerRef = useRef(null); - const [sizes, setSizes] = useState<[number, number]>(initialSizes); - const [isDragging, setIsDragging] = useState(false); - - const clampSizes = useCallback( - (nextFirst: number) => { - const minFirst = minSizes[0]; - const minSecond = minSizes[1]; - const clampedFirst = Math.min(100 - minSecond, Math.max(minFirst, nextFirst)); - return [clampedFirst, 100 - clampedFirst] as [number, number]; - }, - [minSizes] - ); - - const updateSizes = useCallback( - (event: PointerEvent | React.PointerEvent) => { - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return; - const total = direction === 'horizontal' ? rect.width : rect.height; - if (!total) return; - const offset = - direction === 'horizontal' ? event.clientX - rect.left : event.clientY - rect.top; - const nextFirst = (offset / total) * 100; - setSizes(clampSizes(nextFirst)); - }, - [clampSizes, direction] - ); - - const handlePointerDown = useCallback( - (event: React.PointerEvent) => { - event.preventDefault(); - setIsDragging(true); - updateSizes(event); - - const handleMove = (moveEvent: PointerEvent) => { - updateSizes(moveEvent); - }; - const handleUp = (upEvent: PointerEvent) => { - updateSizes(upEvent); - setIsDragging(false); - window.removeEventListener('pointermove', handleMove); - window.removeEventListener('pointerup', handleUp); - }; - - window.addEventListener('pointermove', handleMove); - window.addEventListener('pointerup', handleUp); - }, - [updateSizes] - ); - - const isHorizontal = direction === 'horizontal'; - - return ( -
-
- {children[0]} -
-
-
- {children[1]} -
-
- ); -} diff --git a/apps/web/components/task/preview/preview-panel.tsx b/apps/web/components/task/preview/preview-panel.tsx index 9efdc15864..9558477771 100644 --- a/apps/web/components/task/preview/preview-panel.tsx +++ b/apps/web/components/task/preview/preview-panel.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { IconRefresh } from '@tabler/icons-react'; +import { IconRefresh, IconExternalLink, IconLoader2 } from '@tabler/icons-react'; import { Button } from '@kandev/ui/button'; import { Input } from '@kandev/ui/input'; import { usePreviewPanel } from '@/hooks/use-preview-panel'; @@ -23,6 +23,8 @@ export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) { setPreviewUrlDraft, isStopping, handleStop, + detectedUrl, + isRunning, } = usePreviewPanel({ sessionId, hasDevScript }); const closeLayoutPreview = useLayoutStore((state) => state.closePreview); const setPreviewOpen = useAppStore((state) => state.setPreviewOpen); @@ -50,14 +52,21 @@ export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) { const devProcess = useAppStore((state) => devProcessId ? state.processes.processesById[devProcessId] : undefined ); - const devOutput = useAppStore ((state) => + const devOutput = useAppStore((state) => devProcessId ? state.processes.outputsByProcessId[devProcessId] ?? '' : '' ); const [refreshKey, setRefreshKey] = useState(0); + const [showIframe, setShowIframe] = useState(false); + const [allowManualUrl, setAllowManualUrl] = useState(false); + const [showLoadingSpinner, setShowLoadingSpinner] = useState(true); const isProcessFailed = devProcess?.status === 'failed'; const isProcessExited = devProcess?.status === 'exited'; + // Check if we're waiting for a URL (for button spinner) + // Show spinner on "Preview" button when on output view and waiting for URL + const isWaitingForUrl = isRunning && previewView === 'output' && !previewUrl && showLoadingSpinner; + // Sync the view from localStorage to store if needed useEffect(() => { if (!sessionId) return; @@ -71,6 +80,54 @@ export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) { } }, [sessionId, appStoreApi, setPreviewView]); + // 10-second timeout to enable manual URL entry and stop loading spinner when no URL detected + useEffect(() => { + if (!isRunning || detectedUrl) { + // Clear immediately using setTimeout to avoid synchronous setState in effect + const immediate = setTimeout(() => { + setAllowManualUrl(false); + setShowLoadingSpinner(true); + }, 0); + return () => clearTimeout(immediate); + } + + // Reset loading spinner when starting to wait + const resetTimer = setTimeout(() => setShowLoadingSpinner(true), 0); + + // After 10 seconds, enable manual URL, stop showing loading spinner, and switch to preview view + const timer = setTimeout(() => { + setAllowManualUrl(true); + setShowLoadingSpinner(false); + // Switch to preview view to show the "No URL detected" message + if (sessionId) { + setPreviewView(sessionId, 'preview'); + } + }, 10000); + + return () => { + clearTimeout(resetTimer); + clearTimeout(timer); + }; + }, [isRunning, detectedUrl, sessionId, setPreviewView]); + + // 2-second delay before showing iframe after URL detection + useEffect(() => { + if (!previewUrl) { + // Clear immediately using setTimeout to avoid synchronous setState in effect + const immediate = setTimeout(() => setShowIframe(false), 0); + return () => clearTimeout(immediate); + } + + // Start hidden, then show after delay + const hideTimer = setTimeout(() => setShowIframe(false), 0); + const showTimer = setTimeout(() => setShowIframe(true), 2000); + + return () => { + clearTimeout(hideTimer); + clearTimeout(showTimer); + }; + }, [previewUrl, refreshKey]); + if (!sessionId) { return (
@@ -101,28 +158,61 @@ export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) { await handleStop(); }; + const handleUrlSubmit = () => { + if (!sessionId) return; + const trimmed = previewUrlDraft.trim(); + if (trimmed) { + setPreviewUrl(sessionId, trimmed); + } + }; + + const handleOpenInTab = () => { + if (!sessionId || !previewUrl) return; + + // Open URL in new tab + window.open(previewUrl, '_blank', 'noopener,noreferrer'); + + // Close the preview panel but keep dev server running + setPreviewOpen(sessionId, false); + setPreviewStage(sessionId, 'closed'); + setPreviewView(sessionId, 'preview'); + setPreviewUrl(sessionId, ''); + setPreviewUrlDraft(sessionId, ''); + closeLayoutPreview(sessionId); + }; + return (
setPreviewUrlDraft(sessionId, event.target.value)} - onBlur={() => setPreviewUrl(sessionId, previewUrlDraft)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); - setPreviewUrl(sessionId, previewUrlDraft); + handleUrlSubmit(); } }} - placeholder="http://localhost:3000" - className="h-8 flex-1 min-w-[240px]" + placeholder={detectedUrl || "http://localhost:3000"} + className="h-6 flex-1 min-w-[240px]" /> + @@ -143,6 +233,9 @@ export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) { setPreviewView(sessionId, previewView === 'output' ? 'preview' : 'output') } > + {isWaitingForUrl && ( + + )} {previewView === 'output' ? 'Preview' : 'Logs'}
@@ -152,7 +245,7 @@ export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) {
- ) : previewUrl ? ( + ) : showIframe && previewUrl ? (