From 33419c95f72dcf7945c4e129143d6f8bf829949d Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Tue, 13 Jan 2026 14:55:57 +0000 Subject: [PATCH] Improve the preview functionality (vibe-kanban) (#2010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * preview styles * All tasks are complete. Let me provide a summary of the changes made: ## Summary I've implemented the preview functionality improvements as requested: ### Changes Made **1. Rust Types (`crates/db/src/models/scratch.rs`)** - Renamed `PreviewUrlOverrideData` → `PreviewSettingsData` - Added new fields: `screen_size`, `responsive_width`, `responsive_height` - Updated `ScratchPayload` and `ScratchType` enum to use `PreviewSettings` **2. New Hook (`frontend/src/hooks/usePreviewSettings.ts`)** - Created new `usePreviewSettings` hook (replaced `usePreviewUrlOverride`) - Manages both URL override and screen size settings - Persists screen size via the scratch system (per-workspace) - Exports `ScreenSize` and `ResponsiveDimensions` types **3. PreviewBrowserContainer (`frontend/src/components/ui-new/containers/PreviewBrowserContainer.tsx`)** - Added URL controls (input, copy, open in new tab, refresh, clear override) - Added stop button and related handlers - Added screen size state management - Added responsive dimensions resize handlers **4. PreviewBrowser View (`frontend/src/components/ui-new/views/PreviewBrowser.tsx`)** - Added floating toolbar above the iframe with: - URL input with action buttons - Screen size toggle (Desktop/Mobile/Responsive icons) - Dimensions display for responsive mode - Stop button - Implemented three screen modes: - **Desktop**: Full width/height (current behavior) - **Mobile**: 390×844px centered with shadow - **Responsive**: Draggable resize handles on right, bottom, and corner edges **5. PreviewControls View (`frontend/src/components/ui-new/views/PreviewControls.tsx`)** - Simplified to show only logs - Removed URL input row and all control buttons - Renamed section header to "Dev Server Logs" **6. PreviewControlsContainer (`frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx`)** - Removed all URL-related state and handlers - Now only manages logs display and process tab switching All type checks pass. The screen size preference is persisted per-workspace using the existing scratch system. * Cleanup script changes for workspace dd65dbe3-2e31-4897-8aa4-b2f8f7971ab7 * All ESLint and TypeScript checks pass. The implementation is complete. ## Summary of Changes **Files Modified:** 1. **`crates/db/src/models/scratch.rs`** - Renamed `PreviewUrlOverrideData` → `PreviewSettingsData` with new fields for screen size persistence 2. **`crates/server/src/bin/generate_types.rs`** - Updated type export to use new name 3. **`shared/types.ts`** - Regenerated with new types (`PreviewSettingsData`, `ScratchType.PREVIEW_SETTINGS`) 4. **`frontend/src/hooks/usePreviewSettings.ts`** (new) - Replaces `usePreviewUrlOverride.ts`, adds screen size management 5. **`frontend/src/hooks/usePreviewUrlOverride.ts`** - Deleted 6. **`frontend/src/components/ui-new/containers/PreviewBrowserContainer.tsx`** - Added URL controls, stop button, and resize state management 7. **`frontend/src/components/ui-new/views/PreviewBrowser.tsx`** - Added floating toolbar with: - URL input with copy/open/refresh/clear buttons - Screen size toggle (Desktop/Mobile/Responsive icons) - Responsive resize handles - Stop button 8. **`frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx`** - Simplified to logs only 9. **`frontend/src/components/ui-new/views/PreviewControls.tsx`** - Simplified to logs only * Cleanup script changes for workspace dd65dbe3-2e31-4897-8aa4-b2f8f7971ab7 * All checks pass. Here's a summary of the changes made: ## Summary of Changes ### 1. Fixed infinite loop bug (`usePreviewSettings.ts`) - Added `useMemo` to stabilize the `responsiveDimensions` object reference - This prevents the useEffect in the container from re-triggering on every render ### 2. Updated toolbar to always show with floating style (`PreviewBrowser.tsx`) - Toolbar is now always visible (not conditional on `isServerRunning && hasUrl`) - Changed styling from `border-b border-border` to `rounded-md shadow-md m-base` for a floating appearance - URL input and action buttons are disabled (with `opacity-50 cursor-not-allowed`) when no URL is available - Start/Stop button in toolbar changes based on server state: - Shows "Start" button when server is not running - Shows "Stop" button when server is running - Simplified the empty state content area to just show a message pointing to the toolbar's Start button * All checks pass. The fix: 1. **Changed from state to refs** for `isResizing` and `resizeDirection` - these now don't trigger re-renders when they change 2. **Added `localDimensionsRef`** to capture the current dimensions for the `handleEnd` callback without adding it to the effect's dependency array 3. **Effect now only depends on `setResponsiveDimensions`** (a stable callback), so event listeners are registered once on mount and cleaned up on unmount 4. **No more race conditions** - the same event handlers persist throughout the entire drag operation The resize should now: - Stop immediately when you release the mouse - Be smooth without choppiness (no effect re-runs during drag) * The responsive resize fix is complete. Here's a summary of the changes made: **In `PreviewBrowserContainer.tsx`:** 1. Added `isResizing` state to track when resizing is active (for UI purposes) 2. Added `setResponsiveDimensionsRef` to store the callback in a ref, preventing the resize effect from re-running when the callback identity changes 3. Changed the resize effect to use an empty dependency array (`[]`) since all external values are now accessed via refs 4. Updated `handleResizeStart` to set `isResizing(true)` 5. Updated `handleEnd` to set `isResizing(false)` and use `setResponsiveDimensionsRef.current` 6. Passed `isResizing` prop to the `PreviewBrowser` view **In `PreviewBrowser.tsx`:** 1. Added `isResizing` prop to the interface and destructured it 2. Applied `pointer-events-none` class to the iframe when `isResizing` is true - this prevents the iframe from capturing mouse events during resize, ensuring smooth dragging These changes fix both issues: - **Resize not stopping**: The effect no longer re-registers listeners when `setResponsiveDimensions` changes - **Choppy dragging**: The iframe no longer captures mouse events during resize * All checks pass. The fix is complete. The resize now uses delta-based calculation: 1. **`startPosRef`**: Captures the mouse position when resize starts 2. **`startDimensionsRef`**: Captures the iframe dimensions when resize starts 3. **`handleMove`**: Calculates delta (current position - start position) and applies it to the starting dimensions 4. **`handleEnd`**: Clears both refs when resize ends This approach works regardless of where the iframe is positioned in the container, fixing both: - The mouse jump offset (no longer tries to position based on container edge) - The 50% distance issue (delta is 1:1 with mouse movement) * Cleanup script changes for workspace dd65dbe3-2e31-4897-8aa4-b2f8f7971ab7 * Done. The resize should now move at 1:1 with the cursor. Since the centered element grows on both sides equally, doubling the delta ensures the edge under the cursor moves the full distance you drag. * Done! I've added a minimal dark phone frame around the iframe when in mobile mode. The changes: - **Mobile mode** now wraps the iframe in a phone skeleton with: - Dark bezel (`bg-gray-900`) - Rounded outer corners (`rounded-[3rem]`) - Padding for bezel width (`p-3`) - Shadow (`shadow-xl`) - Rounded inner screen area (`rounded-[2.5rem]`) - **Desktop and responsive modes** remain unchanged You can test it by running `pnpm run dev:qa` and switching to mobile view in the preview browser. * styles * I've completed the implementation. Here's a summary of the changes: ## Summary Created two new reusable button components and updated PreviewBrowser.tsx to standardize button styles: ### New Components 1. **`IconButton.tsx`** (`frontend/src/components/ui-new/primitives/IconButton.tsx`) - Standalone icon button with consistent styling - Supports `default` and `tertiary` variants - Includes proper hover states (`bg-secondary/50`), disabled states, and transitions 2. **`IconButtonGroup.tsx`** (`frontend/src/components/ui-new/primitives/IconButtonGroup.tsx`) - Container component for grouped icon buttons with shared border - `IconButtonGroupItem` for individual buttons with `active` state support - Consistent styling with border, padding, and hover/active states ### Updated PreviewBrowser.tsx - **URL action buttons** (Clear, Copy, Open in new tab, Refresh): Now use `IconButton` component with consistent hover backgrounds - **Screen size toggle** (Desktop, Mobile, Responsive): Now uses `IconButtonGroup` and `IconButtonGroupItem` with proper active state styling - **Start/Stop button**: Kept using `PrimaryButton` (already consistent) ### Visual Consistency Achieved All toolbar buttons now share: - Consistent padding (`p-half`) - Consistent hover states (`bg-secondary/50` background) - Consistent active states (`bg-secondary` background) for toggles - Consistent disabled states (`opacity-40 cursor-not-allowed`) - Consistent icon sizing (`size-icon-sm`) with `weight="bold"` - Consistent border radius (`rounded-sm`) * Done. The URL action buttons (Copy, Open in new tab, Refresh) are now grouped in an `IconButtonGroup` with a shared border, matching the visual style of the screen size toggle group. The toolbar now has three visually consistent button groups: 1. **URL input** with optional clear override button 2. **URL actions group** (Copy, Open in new tab, Refresh) - bordered group 3. **Screen size toggle group** (Desktop, Mobile, Responsive) - bordered group 4. **Start/Stop button** (PrimaryButton) * Done. All changes have been implemented: 1. **Added `iconClassName` prop to `IconButtonGroupItem`** - Supports passing custom classes to the icon (e.g., `animate-spin` for the spinner) 2. **Moved Clear button into URL Actions group** - The Clear Override button (X icon) is now inside the `IconButtonGroup` with Copy, Open, and Refresh buttons 3. **Replaced Pause button with `IconButtonGroupItem`** - The stop/pause button now uses the same `IconButtonGroup` styling as the other toolbar buttons, with spinner animation support when stopping The toolbar now has consistent visual styling with three button groups: - **URL Actions**: Clear (conditional), Copy, Open in new tab, Refresh - **Screen Size Toggle**: Desktop, Mobile, Responsive - **Stop button**: Pause icon (when server is running) - **Start button**: Kept as `PrimaryButton` since it has a text label * Cleanup script changes for workspace dd65dbe3-2e31-4897-8aa4-b2f8f7971ab7 * Done. The Start/Stop button is now a single `IconButtonGroup` with one `IconButtonGroupItem` that: - Shows Play icon when server is stopped, Pause icon when running - Shows Spinner icon with animation when starting or stopping - Has the same size as all other toolbar button groups * Cleanup script changes for workspace dd65dbe3-2e31-4897-8aa4-b2f8f7971ab7 * Done. Fixed the spinning Play icon bug by making the `iconClassName` logic match the icon selection logic exactly. Now `animate-spin` is only applied when actually showing the `SpinnerIcon`. * Done. The entire toolbar (URL input, URL actions, screen size toggle) is now disabled when the dev server isn't running. Only the Play/Start button remains enabled so users can start the server. * The changes compile correctly. Here's a summary of what was fixed: ## Summary Fixed the "Fix Script" button showing at the wrong time in `PreviewBrowser.tsx`. **Changes made:** 1. **`PreviewBrowser.tsx`** - Added `hasFailedDevServer?: boolean` prop and updated the condition on line 345 from `{handleFixDevScript && (` to `{hasFailedDevServer && handleFixDevScript && (` 2. **`PreviewBrowserContainer.tsx`** - Added failed dev server detection logic (lines 45-51) that checks for processes with `status === 'failed'` or `status === 'completed'` with non-zero exit code, and passed the `hasFailedDevServer` prop to the view The "Fix Script" button will now only appear when the last dev server process actually failed, not when it was successful or killed. * update styles * All tasks completed. Here's a summary of the changes made: ## Summary ### 1. `IconButtonGroup.tsx` (lines 3, 50-65) - Added import for `Tooltip` component - Modified `IconButtonGroupItem` to wrap buttons with `` when `title` prop is provided - Removed native `title` attribute from button (tooltip handles it now) ### 2. `en/tasks.json` (lines 125-134) Added new i18n keys under `preview.toolbar`: - `clearUrlOverride`: "Clear URL override" - `desktopView`: "Desktop view" - `mobileView`: "Mobile view (390x844)" - `responsiveView`: "Responsive view (resizable)" - `startDevServer`: "Start dev server" Added new `preview.loading` section: - `startingServer`: "Starting dev server..." - `waitingForServer`: "Waiting for server..." ### 3. `PreviewBrowser.tsx` (lines 160-254, 342-344) Replaced all hardcoded strings with i18n translations using `t()`: - URL action buttons (clear override, copy, open in tab, refresh) - Screen size toggle buttons (desktop, mobile, responsive) - Start/stop server button - Loading state messages All icon buttons now have proper accessible tooltips via the `Tooltip` component instead of native browser `title` attributes, and all strings are internationalized. * style * All i18n issues have been fixed. Here's a summary of the changes made: **1. Added missing translation keys to all non-English locales (es, ja, ko, zh-Hans, zh-Hant):** - `preview.loading.startingServer` - `preview.loading.waitingForServer` - `preview.toolbar.clearUrlOverride` - `preview.toolbar.desktopView` - `preview.toolbar.mobileView` - `preview.toolbar.responsiveView` - `preview.toolbar.startDevServer` **2. Fixed hardcoded strings in `PreviewBrowser.tsx`:** - Replaced dimension separator `x` with HTML entity `×` - Internationalized the "no dev script" setup message with 3 new i18n keys: - `preview.noServer.setupTitle` - `preview.noServer.editDevScript` - `preview.noServer.learnMore` **3. Added the 3 new keys to all locale files (en, es, ja, ko, zh-Hans, zh-Hant)** The `check-i18n.sh` script now passes with: - 128 violations (down from 131 on main - a net improvement!) - No duplicate keys - Translation keys consistent across all locales --- crates/db/src/models/scratch.rs | 12 +- crates/server/src/bin/generate_types.rs | 2 +- .../containers/PreviewBrowserContainer.tsx | 229 +++++++++++- .../containers/PreviewControlsContainer.tsx | 113 +----- .../ui-new/primitives/IconButton.tsx | 45 +++ .../ui-new/primitives/IconButtonGroup.tsx | 66 ++++ .../ui-new/views/PreviewBrowser.tsx | 346 ++++++++++++++++-- .../ui-new/views/PreviewControls.tsx | 127 +------ frontend/src/hooks/usePreviewSettings.ts | 169 +++++++++ frontend/src/hooks/usePreviewUrlOverride.ts | 86 ----- frontend/src/i18n/locales/en/tasks.json | 16 +- frontend/src/i18n/locales/es/tasks.json | 16 +- frontend/src/i18n/locales/ja/tasks.json | 16 +- frontend/src/i18n/locales/ko/tasks.json | 16 +- frontend/src/i18n/locales/zh-Hans/tasks.json | 16 +- frontend/src/i18n/locales/zh-Hant/tasks.json | 16 +- shared/types.ts | 6 +- 17 files changed, 919 insertions(+), 378 deletions(-) create mode 100644 frontend/src/components/ui-new/primitives/IconButton.tsx create mode 100644 frontend/src/components/ui-new/primitives/IconButtonGroup.tsx create mode 100644 frontend/src/hooks/usePreviewSettings.ts delete mode 100644 frontend/src/hooks/usePreviewUrlOverride.ts diff --git a/crates/db/src/models/scratch.rs b/crates/db/src/models/scratch.rs index 8af527cb..14931aff 100644 --- a/crates/db/src/models/scratch.rs +++ b/crates/db/src/models/scratch.rs @@ -25,10 +25,16 @@ pub struct DraftFollowUpData { pub variant: Option, } -/// Data for a preview URL override scratch +/// Data for preview settings scratch (URL override and screen size) #[derive(Debug, Clone, Serialize, Deserialize, TS)] -pub struct PreviewUrlOverrideData { +pub struct PreviewSettingsData { pub url: String, + #[serde(default)] + pub screen_size: Option, + #[serde(default)] + pub responsive_width: Option, + #[serde(default)] + pub responsive_height: Option, } /// Data for a draft workspace scratch (new workspace creation) @@ -63,7 +69,7 @@ pub enum ScratchPayload { DraftTask(String), DraftFollowUp(DraftFollowUpData), DraftWorkspace(DraftWorkspaceData), - PreviewUrlOverride(PreviewUrlOverrideData), + PreviewSettings(PreviewSettingsData), } impl ScratchPayload { diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index 4d8350d4..fc50cb42 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -39,7 +39,7 @@ fn generate_types_content() -> String { db::models::scratch::DraftFollowUpData::decl(), db::models::scratch::DraftWorkspaceData::decl(), db::models::scratch::DraftWorkspaceRepo::decl(), - db::models::scratch::PreviewUrlOverrideData::decl(), + db::models::scratch::PreviewSettingsData::decl(), db::models::scratch::ScratchPayload::decl(), db::models::scratch::ScratchType::decl(), db::models::scratch::Scratch::decl(), diff --git a/frontend/src/components/ui-new/containers/PreviewBrowserContainer.tsx b/frontend/src/components/ui-new/containers/PreviewBrowserContainer.tsx index c6d47cdd..428fbb32 100644 --- a/frontend/src/components/ui-new/containers/PreviewBrowserContainer.tsx +++ b/frontend/src/components/ui-new/containers/PreviewBrowserContainer.tsx @@ -1,14 +1,20 @@ -import { useCallback } from 'react'; +import { useCallback, useState, useEffect, useRef } from 'react'; import { PreviewBrowser } from '../views/PreviewBrowser'; import { usePreviewDevServer } from '../hooks/usePreviewDevServer'; import { usePreviewUrl } from '../hooks/usePreviewUrl'; -import { usePreviewUrlOverride } from '@/hooks/usePreviewUrlOverride'; +import { + usePreviewSettings, + type ScreenSize, +} from '@/hooks/usePreviewSettings'; import { useLogStream } from '@/hooks/useLogStream'; import { useLayoutStore } from '@/stores/useLayoutStore'; import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; import { useNavigate } from 'react-router-dom'; import { ScriptFixerDialog } from '@/components/dialogs/scripts/ScriptFixerDialog'; +const MIN_RESPONSIVE_WIDTH = 320; +const MIN_RESPONSIVE_HEIGHT = 480; + interface PreviewBrowserContainerProps { attemptId?: string; className?: string; @@ -20,25 +26,218 @@ export function PreviewBrowserContainer({ }: PreviewBrowserContainerProps) { const navigate = useNavigate(); const previewRefreshKey = useLayoutStore((s) => s.previewRefreshKey); + const triggerPreviewRefresh = useLayoutStore((s) => s.triggerPreviewRefresh); const { repos, workspaceId } = useWorkspaceContext(); - const { start, isStarting, runningDevServers, devServerProcesses } = - usePreviewDevServer(attemptId); + const { + start, + stop, + isStarting, + isStopping, + runningDevServers, + devServerProcesses, + } = usePreviewDevServer(attemptId); const primaryDevServer = runningDevServers[0]; const { logs } = useLogStream(primaryDevServer?.id ?? ''); const urlInfo = usePreviewUrl(logs); - // URL override for this workspace - const { overrideUrl, hasOverride } = usePreviewUrlOverride(workspaceId); + // Detect failed dev server process (failed status or completed with non-zero exit code) + const failedDevServerProcess = devServerProcesses.find( + (p) => + p.status === 'failed' || + (p.status === 'completed' && p.exit_code !== null && p.exit_code !== 0n) + ); + const hasFailedDevServer = Boolean(failedDevServerProcess); + + // Preview settings (URL override and screen size) + const { + overrideUrl, + hasOverride, + setOverrideUrl, + clearOverride, + screenSize, + responsiveDimensions, + setScreenSize, + setResponsiveDimensions, + } = usePreviewSettings(workspaceId); // Use override URL if set, otherwise fall back to auto-detected const effectiveUrl = hasOverride ? overrideUrl : urlInfo?.url; + // Local state for URL input to prevent updates from disrupting typing + const urlInputRef = useRef(null); + const [urlInputValue, setUrlInputValue] = useState(effectiveUrl ?? ''); + + // Sync from prop only when input is not focused + useEffect(() => { + if (document.activeElement !== urlInputRef.current) { + setUrlInputValue(effectiveUrl ?? ''); + } + }, [effectiveUrl]); + + // Responsive resize state - use refs for values that shouldn't trigger re-renders + const [localDimensions, setLocalDimensions] = useState(responsiveDimensions); + const [isResizing, setIsResizing] = useState(false); + const containerRef = useRef(null); + const isResizingRef = useRef(false); + const resizeDirectionRef = useRef<'right' | 'bottom' | 'corner' | null>(null); + const localDimensionsRef = useRef(localDimensions); + const startPosRef = useRef<{ x: number; y: number } | null>(null); + const startDimensionsRef = useRef<{ width: number; height: number } | null>( + null + ); + + // Store callback in ref to avoid effect re-runs when callback identity changes + const setResponsiveDimensionsRef = useRef(setResponsiveDimensions); + useEffect(() => { + setResponsiveDimensionsRef.current = setResponsiveDimensions; + }, [setResponsiveDimensions]); + + // Keep ref in sync with state for use in event handlers + useEffect(() => { + localDimensionsRef.current = localDimensions; + }, [localDimensions]); + + // Sync local dimensions with prop when not resizing + useEffect(() => { + if (!isResizingRef.current) { + setLocalDimensions(responsiveDimensions); + } + }, [responsiveDimensions]); + + // Handle resize events - register listeners once on mount + useEffect(() => { + const handleMove = (clientX: number, clientY: number) => { + if ( + !isResizingRef.current || + !startPosRef.current || + !startDimensionsRef.current + ) + return; + + const direction = resizeDirectionRef.current; + const deltaX = clientX - startPosRef.current.x; + const deltaY = clientY - startPosRef.current.y; + + setLocalDimensions(() => { + let newWidth = startDimensionsRef.current!.width; + let newHeight = startDimensionsRef.current!.height; + + if (direction === 'right' || direction === 'corner') { + // Double delta to compensate for centered element (grows on both sides) + newWidth = Math.max( + MIN_RESPONSIVE_WIDTH, + startDimensionsRef.current!.width + deltaX * 2 + ); + } + + if (direction === 'bottom' || direction === 'corner') { + // Double delta to compensate for centered element (grows on both sides) + newHeight = Math.max( + MIN_RESPONSIVE_HEIGHT, + startDimensionsRef.current!.height + deltaY * 2 + ); + } + + return { width: newWidth, height: newHeight }; + }); + }; + + const handleMouseMove = (e: MouseEvent) => { + handleMove(e.clientX, e.clientY); + }; + + const handleTouchMove = (e: TouchEvent) => { + const touch = e.touches[0]; + handleMove(touch.clientX, touch.clientY); + }; + + const handleEnd = () => { + if (isResizingRef.current) { + isResizingRef.current = false; + resizeDirectionRef.current = null; + startPosRef.current = null; + startDimensionsRef.current = null; + setIsResizing(false); + setResponsiveDimensionsRef.current(localDimensionsRef.current); + } + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleEnd); + document.addEventListener('touchmove', handleTouchMove); + document.addEventListener('touchend', handleEnd); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleEnd); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleEnd); + }; + }, []); // Empty deps - mount only, uses refs for all external values + + const handleResizeStart = useCallback( + (direction: 'right' | 'bottom' | 'corner') => + (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + isResizingRef.current = true; + resizeDirectionRef.current = direction; + setIsResizing(true); + + // Capture starting position and dimensions for delta-based resizing + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + startPosRef.current = { x: clientX, y: clientY }; + startDimensionsRef.current = { ...localDimensionsRef.current }; + }, + [] + ); + + const handleUrlInputChange = useCallback( + (value: string) => { + setUrlInputValue(value); + setOverrideUrl(value); + }, + [setOverrideUrl] + ); + const handleStart = useCallback(() => { start(); }, [start]); + const handleStop = useCallback(() => { + stop(); + }, [stop]); + + const handleRefresh = useCallback(() => { + triggerPreviewRefresh(); + }, [triggerPreviewRefresh]); + + const handleClearOverride = useCallback(async () => { + await clearOverride(); + setUrlInputValue(''); + }, [clearOverride]); + + const handleCopyUrl = useCallback(async () => { + if (effectiveUrl) { + await navigator.clipboard.writeText(effectiveUrl); + } + }, [effectiveUrl]); + + const handleOpenInNewTab = useCallback(() => { + if (effectiveUrl) { + window.open(effectiveUrl, '_blank'); + } + }, [effectiveUrl]); + + const handleScreenSizeChange = useCallback( + (size: ScreenSize) => { + setScreenSize(size); + }, + [setScreenSize] + ); + // Use previewRefreshKey from store to force iframe reload const iframeUrl = effectiveUrl ? `${effectiveUrl}${effectiveUrl.includes('?') ? '&' : '?'}_refresh=${previewRefreshKey}` @@ -70,14 +269,32 @@ export function PreviewBrowserContainer({ return ( 0} + screenSize={screenSize} + localDimensions={localDimensions} + onScreenSizeChange={handleScreenSizeChange} + onResizeStart={handleResizeStart} + isResizing={isResizing} + containerRef={containerRef} repos={repos} handleEditDevScript={handleEditDevScript} handleFixDevScript={ attemptId && repos.length > 0 ? handleFixDevScript : undefined } + hasFailedDevServer={hasFailedDevServer} className={className} /> ); diff --git a/frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx b/frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx index 3b8f1314..302f546b 100644 --- a/frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx +++ b/frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx @@ -1,12 +1,9 @@ -import { useCallback, useState, useEffect, useRef } from 'react'; +import { useCallback, useState, useEffect } from 'react'; import { PreviewControls } from '../views/PreviewControls'; import { usePreviewDevServer } from '../hooks/usePreviewDevServer'; -import { usePreviewUrl } from '../hooks/usePreviewUrl'; -import { usePreviewUrlOverride } from '@/hooks/usePreviewUrlOverride'; import { useLogStream } from '@/hooks/useLogStream'; import { useLayoutStore } from '@/stores/useLayoutStore'; import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; -import { ScriptFixerDialog } from '@/components/dialogs/scripts/ScriptFixerDialog'; interface PreviewControlsContainerProps { attemptId?: string; @@ -19,18 +16,11 @@ export function PreviewControlsContainer({ onViewProcessInPanel, className, }: PreviewControlsContainerProps) { - const { repos, workspaceId } = useWorkspaceContext(); + const { repos } = useWorkspaceContext(); const setLogsMode = useLayoutStore((s) => s.setLogsMode); - const triggerPreviewRefresh = useLayoutStore((s) => s.triggerPreviewRefresh); - const { - start, - stop, - isStarting, - isStopping, - runningDevServers, - devServerProcesses, - } = usePreviewDevServer(attemptId); + const { isStarting, runningDevServers, devServerProcesses } = + usePreviewDevServer(attemptId); const [activeProcessId, setActiveProcessId] = useState(null); @@ -46,36 +36,6 @@ export function PreviewControlsContainer({ const { logs, error: logsError } = useLogStream(activeProcess?.id ?? ''); - const primaryDevServer = runningDevServers[0]; - const { logs: primaryLogs } = useLogStream(primaryDevServer?.id ?? ''); - const urlInfo = usePreviewUrl(primaryLogs); - - // URL override for this workspace - const { overrideUrl, setOverrideUrl, clearOverride, hasOverride } = - usePreviewUrlOverride(workspaceId); - - // Use override URL if set, otherwise fall back to auto-detected - const effectiveUrl = hasOverride ? overrideUrl : urlInfo?.url; - - // Local state for URL input to prevent WebSocket updates from disrupting typing - const urlInputRef = useRef(null); - const [urlInputValue, setUrlInputValue] = useState(effectiveUrl ?? ''); - - // Sync from prop only when input is not focused - useEffect(() => { - if (document.activeElement !== urlInputRef.current) { - setUrlInputValue(effectiveUrl ?? ''); - } - }, [effectiveUrl]); - - const handleUrlInputChange = useCallback( - (value: string) => { - setUrlInputValue(value); - setOverrideUrl(value); - }, - [setOverrideUrl] - ); - const handleViewFullLogs = useCallback( (processId?: string) => { const targetId = processId ?? activeProcess?.id; @@ -92,57 +52,10 @@ export function PreviewControlsContainer({ setActiveProcessId(processId); }, []); - const handleStart = useCallback(() => { - start(); - }, [start]); - - const handleStop = useCallback(() => { - stop(); - }, [stop]); - - const handleRefresh = useCallback(() => { - triggerPreviewRefresh(); - }, [triggerPreviewRefresh]); - - const handleClearOverride = useCallback(async () => { - await clearOverride(); - }, [clearOverride]); - - const handleCopyUrl = useCallback(async () => { - if (effectiveUrl) { - await navigator.clipboard.writeText(effectiveUrl); - } - }, [effectiveUrl]); - - const handleOpenInNewTab = useCallback(() => { - if (effectiveUrl) { - window.open(effectiveUrl, '_blank'); - } - }, [effectiveUrl]); - - const handleFixScript = useCallback(() => { - if (!attemptId || repos.length === 0) return; - - // Get session ID from the latest dev server process - const sessionId = devServerProcesses[0]?.session_id; - - ScriptFixerDialog.show({ - scriptType: 'dev_server', - repos, - workspaceId: attemptId, - sessionId, - initialRepoId: repos.length === 1 ? repos[0].id : undefined, - }); - }, [attemptId, repos, devServerProcesses]); - const hasDevScript = repos.some( (repo) => repo.dev_server_script && repo.dev_server_script.trim() !== '' ); - // Only show "Fix Script" button when the latest dev server process failed - const latestDevServerFailed = - devServerProcesses.length > 0 && devServerProcesses[0]?.status === 'failed'; - // Don't render if no repos have dev server scripts configured if (!hasDevScript) { return null; @@ -154,27 +67,9 @@ export function PreviewControlsContainer({ activeProcessId={activeProcess?.id ?? null} logs={logs} logsError={logsError} - url={effectiveUrl ?? undefined} - autoDetectedUrl={urlInfo?.url} - isUsingOverride={hasOverride} - urlInputValue={urlInputValue} - urlInputRef={urlInputRef} - onUrlInputChange={handleUrlInputChange} - onClearOverride={handleClearOverride} onViewFullLogs={handleViewFullLogs} onTabChange={handleTabChange} - onStart={handleStart} - onStop={handleStop} - onRefresh={handleRefresh} - onCopyUrl={handleCopyUrl} - onOpenInNewTab={handleOpenInNewTab} - onFixScript={ - attemptId && repos.length > 0 && latestDevServerFailed - ? handleFixScript - : undefined - } isStarting={isStarting} - isStopping={isStopping} isServerRunning={runningDevServers.length > 0} className={className} /> diff --git a/frontend/src/components/ui-new/primitives/IconButton.tsx b/frontend/src/components/ui-new/primitives/IconButton.tsx new file mode 100644 index 00000000..4b066b9c --- /dev/null +++ b/frontend/src/components/ui-new/primitives/IconButton.tsx @@ -0,0 +1,45 @@ +import { cn } from '@/lib/utils'; +import type { Icon } from '@phosphor-icons/react'; + +interface IconButtonProps { + icon: Icon; + onClick?: () => void; + disabled?: boolean; + variant?: 'default' | 'tertiary'; + 'aria-label': string; + title?: string; + className?: string; +} + +export function IconButton({ + icon: IconComponent, + onClick, + disabled, + variant = 'default', + 'aria-label': ariaLabel, + title, + className, +}: IconButtonProps) { + const variantStyles = disabled + ? 'opacity-40 cursor-not-allowed' + : variant === 'default' + ? 'text-low hover:text-normal hover:bg-secondary/50' + : 'bg-panel hover:bg-secondary text-normal'; + + return ( + + ); +} diff --git a/frontend/src/components/ui-new/primitives/IconButtonGroup.tsx b/frontend/src/components/ui-new/primitives/IconButtonGroup.tsx new file mode 100644 index 00000000..d22674b3 --- /dev/null +++ b/frontend/src/components/ui-new/primitives/IconButtonGroup.tsx @@ -0,0 +1,66 @@ +import { cn } from '@/lib/utils'; +import type { Icon } from '@phosphor-icons/react'; +import { Tooltip } from './Tooltip'; + +interface IconButtonGroupProps { + children: React.ReactNode; + className?: string; +} + +export function IconButtonGroup({ children, className }: IconButtonGroupProps) { + return ( +
+ {children} +
+ ); +} + +interface IconButtonGroupItemProps { + icon: Icon; + iconClassName?: string; + onClick?: () => void; + disabled?: boolean; + active?: boolean; + 'aria-label': string; + title?: string; + className?: string; +} + +export function IconButtonGroupItem({ + icon: IconComponent, + iconClassName, + onClick, + disabled, + active, + 'aria-label': ariaLabel, + title, + className, +}: IconButtonGroupItemProps) { + const stateStyles = disabled + ? 'opacity-40 cursor-not-allowed' + : active + ? 'bg-secondary text-normal' + : 'text-low hover:text-normal hover:bg-secondary/50'; + + const button = ( + + ); + + return title ? {button} : button; +} diff --git a/frontend/src/components/ui-new/views/PreviewBrowser.tsx b/frontend/src/components/ui-new/views/PreviewBrowser.tsx index ce005f67..d74492d3 100644 --- a/frontend/src/components/ui-new/views/PreviewBrowser.tsx +++ b/frontend/src/components/ui-new/views/PreviewBrowser.tsx @@ -1,28 +1,90 @@ -import { PlayIcon, SpinnerIcon, WrenchIcon } from '@phosphor-icons/react'; +import type { RefObject } from 'react'; +import { + PlayIcon, + SpinnerIcon, + WrenchIcon, + ArrowSquareOutIcon, + ArrowClockwiseIcon, + CopyIcon, + XIcon, + MonitorIcon, + DeviceMobileIcon, + ArrowsOutCardinalIcon, + PauseIcon, +} from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { PrimaryButton } from '../primitives/PrimaryButton'; -import { Repo } from 'shared/types'; +import { + IconButtonGroup, + IconButtonGroupItem, +} from '../primitives/IconButtonGroup'; +import type { Repo } from 'shared/types'; +import type { + ScreenSize, + ResponsiveDimensions, +} from '@/hooks/usePreviewSettings'; + +const MOBILE_WIDTH = 390; +const MOBILE_HEIGHT = 844; interface PreviewBrowserProps { url?: string; + autoDetectedUrl?: string; + urlInputValue: string; + urlInputRef: RefObject; + isUsingOverride?: boolean; + onUrlInputChange: (value: string) => void; + onClearOverride?: () => void; + onCopyUrl: () => void; + onOpenInNewTab: () => void; + onRefresh: () => void; onStart: () => void; + onStop: () => void; isStarting: boolean; + isStopping: boolean; isServerRunning: boolean; + screenSize: ScreenSize; + localDimensions: ResponsiveDimensions; + onScreenSizeChange: (size: ScreenSize) => void; + onResizeStart: ( + direction: 'right' | 'bottom' | 'corner' + ) => (e: React.MouseEvent | React.TouchEvent) => void; + isResizing: boolean; + containerRef: RefObject; repos: Repo[]; handleEditDevScript: () => void; handleFixDevScript?: () => void; + hasFailedDevServer?: boolean; className?: string; } export function PreviewBrowser({ url, + autoDetectedUrl, + urlInputValue, + urlInputRef, + isUsingOverride, + onUrlInputChange, + onClearOverride, + onCopyUrl, + onOpenInNewTab, + onRefresh, onStart, + onStop, isStarting, + isStopping, isServerRunning, + screenSize, + localDimensions, + onScreenSizeChange, + onResizeStart, + isResizing, + containerRef, repos, handleEditDevScript, handleFixDevScript, + hasFailedDevServer, className, }: PreviewBrowserProps) { const { t } = useTranslation(['tasks', 'common']); @@ -33,23 +95,244 @@ export function PreviewBrowser({ (repo) => repo.dev_server_script && repo.dev_server_script.trim() !== '' ); + const getIframeContainerStyle = (): React.CSSProperties => { + switch (screenSize) { + case 'mobile': + return { + width: MOBILE_WIDTH, + height: MOBILE_HEIGHT, + }; + case 'responsive': + return { + width: localDimensions.width, + height: localDimensions.height, + }; + case 'desktop': + default: + return { + width: '100%', + height: '100%', + }; + } + }; + return (
+ {/* Floating Toolbar */} +
+
+ {/* URL Input */} +
+ onUrlInputChange(e.target.value)} + placeholder={autoDetectedUrl ?? 'Enter URL...'} + disabled={!isServerRunning} + className={cn( + 'flex-1 font-mono text-sm bg-transparent border-none outline-none min-w-0', + isUsingOverride + ? 'text-normal' + : 'text-low placeholder:text-low', + !isServerRunning && 'cursor-not-allowed' + )} + /> +
+ + {/* URL Actions */} + + {isUsingOverride && ( + + )} + + + + + + {/* Screen Size Toggle */} + + onScreenSizeChange('desktop')} + active={screenSize === 'desktop'} + disabled={!isServerRunning} + aria-label={t('preview.toolbar.desktopView')} + title={t('preview.toolbar.desktopView')} + /> + onScreenSizeChange('mobile')} + active={screenSize === 'mobile'} + disabled={!isServerRunning} + aria-label={t('preview.toolbar.mobileView')} + title={t('preview.toolbar.mobileView')} + /> + onScreenSizeChange('responsive')} + active={screenSize === 'responsive'} + disabled={!isServerRunning} + aria-label={t('preview.toolbar.responsiveView')} + title={t('preview.toolbar.responsiveView')} + /> + + + {/* Dimensions display for responsive mode */} + {screenSize === 'responsive' && ( + + {Math.round(localDimensions.width)} ×{' '} + {Math.round(localDimensions.height)} + + )} + + {/* Start/Stop Button */} + + + +
+
+ {/* Content area */} -
+
{showIframe ? ( -