Improve the preview functionality (vibe-kanban) (#2010)

* 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 `<Tooltip>` 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 `&times;`
- 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
This commit is contained in:
Louis Knight-Webb
2026-01-13 14:55:57 +00:00
committed by GitHub
parent 34e1b688b1
commit 33419c95f7
17 changed files with 919 additions and 378 deletions

View File

@@ -25,10 +25,16 @@ pub struct DraftFollowUpData {
pub variant: Option<String>,
}
/// 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<String>,
#[serde(default)]
pub responsive_width: Option<i32>,
#[serde(default)]
pub responsive_height: Option<i32>,
}
/// 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 {

View File

@@ -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(),

View File

@@ -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<HTMLInputElement>(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<HTMLDivElement>(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 (
<PreviewBrowser
url={iframeUrl}
autoDetectedUrl={urlInfo?.url}
urlInputValue={urlInputValue}
urlInputRef={urlInputRef}
isUsingOverride={hasOverride}
onUrlInputChange={handleUrlInputChange}
onClearOverride={handleClearOverride}
onCopyUrl={handleCopyUrl}
onOpenInNewTab={handleOpenInNewTab}
onRefresh={handleRefresh}
onStart={handleStart}
onStop={handleStop}
isStarting={isStarting}
isStopping={isStopping}
isServerRunning={runningDevServers.length > 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}
/>
);

View File

@@ -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<string | null>(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<HTMLInputElement>(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}
/>

View File

@@ -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 (
<button
type="button"
className={cn(
'flex items-center justify-center p-half rounded-sm transition-colors',
variantStyles,
className
)}
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
title={title}
>
<IconComponent className="size-icon-sm" weight="bold" />
</button>
);
}

View File

@@ -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 (
<div
className={cn(
'flex items-center rounded-sm border border-border overflow-hidden',
className
)}
>
{children}
</div>
);
}
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 = (
<button
type="button"
className={cn('p-half transition-colors', stateStyles, className)}
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
>
<IconComponent
className={cn('size-icon-sm', iconClassName)}
weight="bold"
/>
</button>
);
return title ? <Tooltip content={title}>{button}</Tooltip> : button;
}

View File

@@ -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<HTMLInputElement>;
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<HTMLDivElement>;
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,16 +95,189 @@ 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 (
<div
className={cn(
'w-full h-full bg-secondary flex flex-col overflow-hidden',
'bg-brand/20 w-full h-full flex flex-col overflow-hidden',
className
)}
>
{/* Floating Toolbar */}
<div className="p-double">
<div className="backdrop-blur-sm bg-primary/80 border border-brand/20 flex items-center gap-base p-base rounded-md shadow-md shrink-0">
{/* URL Input */}
<div
className={cn(
'flex items-center gap-half rounded-sm px-base py-half flex-1 min-w-0',
!isServerRunning && 'opacity-50'
)}
>
<input
ref={urlInputRef}
type="text"
value={urlInputValue}
onChange={(e) => 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'
)}
/>
</div>
{/* URL Actions */}
<IconButtonGroup>
{isUsingOverride && (
<IconButtonGroupItem
icon={XIcon}
onClick={onClearOverride}
disabled={!isServerRunning}
aria-label={t('preview.toolbar.clearUrlOverride')}
title={t('preview.toolbar.resetUrl')}
/>
)}
<IconButtonGroupItem
icon={CopyIcon}
onClick={onCopyUrl}
disabled={!isServerRunning}
aria-label={t('preview.toolbar.copyUrl')}
title={t('preview.toolbar.copyUrl')}
/>
<IconButtonGroupItem
icon={ArrowSquareOutIcon}
onClick={onOpenInNewTab}
disabled={!isServerRunning}
aria-label={t('preview.toolbar.openInTab')}
title={t('preview.toolbar.openInTab')}
/>
<IconButtonGroupItem
icon={ArrowClockwiseIcon}
onClick={onRefresh}
disabled={!isServerRunning}
aria-label={t('preview.toolbar.refresh')}
title={t('preview.toolbar.refresh')}
/>
</IconButtonGroup>
{/* Screen Size Toggle */}
<IconButtonGroup>
<IconButtonGroupItem
icon={MonitorIcon}
onClick={() => onScreenSizeChange('desktop')}
active={screenSize === 'desktop'}
disabled={!isServerRunning}
aria-label={t('preview.toolbar.desktopView')}
title={t('preview.toolbar.desktopView')}
/>
<IconButtonGroupItem
icon={DeviceMobileIcon}
onClick={() => onScreenSizeChange('mobile')}
active={screenSize === 'mobile'}
disabled={!isServerRunning}
aria-label={t('preview.toolbar.mobileView')}
title={t('preview.toolbar.mobileView')}
/>
<IconButtonGroupItem
icon={ArrowsOutCardinalIcon}
onClick={() => onScreenSizeChange('responsive')}
active={screenSize === 'responsive'}
disabled={!isServerRunning}
aria-label={t('preview.toolbar.responsiveView')}
title={t('preview.toolbar.responsiveView')}
/>
</IconButtonGroup>
{/* Dimensions display for responsive mode */}
{screenSize === 'responsive' && (
<span className="text-xs text-low font-mono whitespace-nowrap">
{Math.round(localDimensions.width)} &times;{' '}
{Math.round(localDimensions.height)}
</span>
)}
{/* Start/Stop Button */}
<IconButtonGroup>
<IconButtonGroupItem
icon={
isServerRunning
? isStopping
? SpinnerIcon
: PauseIcon
: isStarting
? SpinnerIcon
: PlayIcon
}
iconClassName={
(isServerRunning && isStopping) ||
(!isServerRunning && isStarting)
? 'animate-spin'
: undefined
}
onClick={isServerRunning ? onStop : onStart}
disabled={
isServerRunning ? isStopping : isStarting || !hasDevScript
}
aria-label={
isServerRunning
? t('preview.toolbar.stopDevServer')
: t('preview.toolbar.startDevServer')
}
title={
isServerRunning
? t('preview.toolbar.stopDevServer')
: t('preview.toolbar.startDevServer')
}
/>
</IconButtonGroup>
</div>
</div>
{/* Content area */}
<div className="flex-1 min-h-0 relative">
<div
ref={containerRef}
className="flex-1 min-h-0 relative overflow-auto px-double pb-double"
>
{showIframe ? (
<div
className={cn(
'h-full',
screenSize === 'desktop'
? ''
: 'flex items-center justify-center p-double'
)}
>
{screenSize === 'mobile' ? (
// Phone frame for mobile mode
<div className="bg-primary rounded-[2rem] p-3 shadow-xl">
<div
className="rounded-[1.5rem] overflow-hidden"
style={{ width: MOBILE_WIDTH, height: MOBILE_HEIGHT }}
>
<iframe
src={url}
title={t('preview.browser.title')}
@@ -50,6 +285,54 @@ export function PreviewBrowser({
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
referrerPolicy="no-referrer"
/>
</div>
</div>
) : (
// Desktop and responsive modes
<div
className={cn(
'rounded-sm border overflow-hidden relative',
screenSize === 'responsive' && 'shadow-lg'
)}
style={getIframeContainerStyle()}
>
<iframe
src={url}
title={t('preview.browser.title')}
className={cn(
'w-full h-full border-0',
isResizing && 'pointer-events-none'
)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
referrerPolicy="no-referrer"
/>
{/* Resize handles for responsive mode */}
{screenSize === 'responsive' && (
<>
{/* Right edge handle */}
<div
className="absolute top-0 right-0 w-2 h-full cursor-ew-resize hover:bg-brand/30 transition-colors"
onMouseDown={onResizeStart('right')}
onTouchStart={onResizeStart('right')}
/>
{/* Bottom edge handle */}
<div
className="absolute bottom-0 left-0 w-full h-2 cursor-ns-resize hover:bg-brand/30 transition-colors"
onMouseDown={onResizeStart('bottom')}
onTouchStart={onResizeStart('bottom')}
/>
{/* Corner handle */}
<div
className="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize hover:bg-brand/30 transition-colors"
onMouseDown={onResizeStart('corner')}
onTouchStart={onResizeStart('corner')}
/>
</>
)}
</div>
)}
</div>
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-base text-low">
{isLoading ? (
@@ -57,47 +340,40 @@ export function PreviewBrowser({
<SpinnerIcon className="size-icon-lg animate-spin text-brand" />
<p className="text-sm">
{isStarting
? 'Starting dev server...'
: 'Waiting for server...'}
? t('preview.loading.startingServer')
: t('preview.loading.waitingForServer')}
</p>
</>
) : hasDevScript ? (
<>
<p className="text-sm">{t('preview.noServer.title')}</p>
<div className="flex gap-base">
<PrimaryButton
value={t('preview.browser.startButton')}
actionIcon={PlayIcon}
onClick={onStart}
disabled={isStarting}
/>
{handleFixDevScript && (
<p>{t('preview.noServer.title')}</p>
{hasFailedDevServer && handleFixDevScript ? (
<PrimaryButton
variant="tertiary"
value={t('scriptFixer.fixScript')}
actionIcon={WrenchIcon}
onClick={handleFixDevScript}
/>
) : (
<PrimaryButton
value={t('attempt.actions.startDevServer')}
actionIcon={PlayIcon}
onClick={onStart}
/>
)}
</div>
</>
) : (
<div className="flex flex-col gap-double p-double max-w-md">
<div className="flex flex-col gap-base">
<p className="text-xl text-high max-w-xs">
You must set up a dev server script to use the preview
feature
</p>
<p>
Vibe Kanban can run dev servers to help you test your
changes. You can set up a dev server script in the
repository section of the settings page.
{t('preview.noServer.setupTitle')}
</p>
<p>{t('preview.noServer.setupPrompt')}</p>
</div>
<div className="flex flex-col gap-base">
<div>
<PrimaryButton
value="Edit Dev Server Script"
value={t('preview.noServer.editDevScript')}
onClick={handleEditDevScript}
/>
</div>
@@ -107,7 +383,7 @@ export function PreviewBrowser({
rel="noopener noreferrer"
className="text-brand hover:text-brand-hover underline"
>
Learn more about testing applications
{t('preview.noServer.learnMore')}
</a>
</div>
</div>

View File

@@ -1,18 +1,7 @@
import type { RefObject } from 'react';
import {
PlayIcon,
StopIcon,
ArrowSquareOutIcon,
ArrowClockwiseIcon,
SpinnerIcon,
CopyIcon,
WrenchIcon,
XIcon,
} from '@phosphor-icons/react';
import { ArrowSquareOutIcon, SpinnerIcon } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { CollapsibleSectionHeader } from '../primitives/CollapsibleSectionHeader';
import { PrimaryButton } from '../primitives/PrimaryButton';
import { VirtualizedProcessLogs } from '../VirtualizedProcessLogs';
import { PERSIST_KEYS } from '@/stores/useUiPreferencesStore';
import { getDevServerWorkingDir } from '@/lib/devServerUtils';
@@ -25,23 +14,9 @@ interface PreviewControlsProps {
activeProcessId: string | null;
logs: LogEntry[];
logsError: string | null;
url?: string;
autoDetectedUrl?: string;
isUsingOverride?: boolean;
urlInputValue: string;
urlInputRef: RefObject<HTMLInputElement>;
onUrlInputChange: (value: string) => void;
onClearOverride?: () => void;
onViewFullLogs: () => void;
onTabChange: (processId: string) => void;
onStart: () => void;
onStop: () => void;
onRefresh: () => void;
onCopyUrl: () => void;
onOpenInNewTab: () => void;
onFixScript?: () => void;
isStarting: boolean;
isStopping: boolean;
isServerRunning: boolean;
className?: string;
}
@@ -51,28 +26,14 @@ export function PreviewControls({
activeProcessId,
logs,
logsError,
url,
autoDetectedUrl,
isUsingOverride,
urlInputValue,
urlInputRef,
onUrlInputChange,
onClearOverride,
onViewFullLogs,
onTabChange,
onStart,
onStop,
onRefresh,
onCopyUrl,
onOpenInNewTab,
onFixScript,
isStarting,
isStopping,
isServerRunning,
className,
}: PreviewControlsProps) {
const { t } = useTranslation(['tasks', 'common']);
const isLoading = isStarting || (isServerRunning && !url);
const isLoading = isStarting || isServerRunning;
return (
<div
@@ -82,92 +43,12 @@ export function PreviewControls({
)}
>
<CollapsibleSectionHeader
title="Dev Server"
title="Dev Server Logs"
persistKey={PERSIST_KEYS.devServerSection}
contentClassName="flex flex-col flex-1 overflow-hidden"
>
<div className="flex items-center gap-half p-base">
{(url || autoDetectedUrl) && (
<div className="flex items-center gap-half bg-panel rounded-sm px-base py-half flex-1 min-w-0">
<input
ref={urlInputRef}
type="text"
value={urlInputValue}
onChange={(e) => onUrlInputChange(e.target.value)}
placeholder={autoDetectedUrl ?? 'Enter URL...'}
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'
)}
/>
{isUsingOverride && (
<button
type="button"
onClick={onClearOverride}
className="text-low hover:text-normal"
aria-label="Clear URL override"
title="Revert to auto-detected URL"
>
<XIcon className="size-icon-sm" />
</button>
)}
<button
type="button"
onClick={onCopyUrl}
className="text-low hover:text-normal"
aria-label="Copy URL"
>
<CopyIcon className="size-icon-sm" />
</button>
<button
type="button"
onClick={onOpenInNewTab}
className="text-low hover:text-normal"
aria-label="Open in new tab"
>
<ArrowSquareOutIcon className="size-icon-sm" />
</button>
<button
type="button"
onClick={onRefresh}
className="text-low hover:text-normal"
aria-label="Refresh"
>
<ArrowClockwiseIcon className="size-icon-sm" />
</button>
</div>
)}
{isServerRunning ? (
<PrimaryButton
variant="tertiary"
value={t('preview.browser.stopButton')}
actionIcon={isStopping ? 'spinner' : StopIcon}
onClick={onStop}
disabled={isStopping}
/>
) : (
<PrimaryButton
value={t('preview.browser.startingButton')}
actionIcon={isStarting ? 'spinner' : PlayIcon}
onClick={onStart}
disabled={isStarting}
/>
)}
{onFixScript && (
<PrimaryButton
variant="tertiary"
value={t('scriptFixer.fixScript')}
actionIcon={WrenchIcon}
onClick={onFixScript}
/>
)}
</div>
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between px-base pb-half">
<div className="flex items-center justify-between px-base py-half">
<span className="text-xs font-medium text-low">
{t('preview.logs.label')}
</span>

View File

@@ -0,0 +1,169 @@
import { useCallback, useMemo } from 'react';
import { useScratch } from './useScratch';
import { useDebouncedCallback } from './useDebouncedCallback';
import {
ScratchType,
type PreviewSettingsData,
type ScratchPayload,
} from 'shared/types';
export type ScreenSize = 'desktop' | 'mobile' | 'responsive';
export interface ResponsiveDimensions {
width: number;
height: number;
}
interface UsePreviewSettingsResult {
// URL override
overrideUrl: string | null;
hasOverride: boolean;
setOverrideUrl: (url: string) => void;
clearOverride: () => Promise<void>;
// Screen size
screenSize: ScreenSize;
responsiveDimensions: ResponsiveDimensions;
setScreenSize: (size: ScreenSize) => void;
setResponsiveDimensions: (dimensions: ResponsiveDimensions) => void;
isLoading: boolean;
}
const DEFAULT_RESPONSIVE_DIMENSIONS: ResponsiveDimensions = {
width: 800,
height: 600,
};
/**
* Hook to manage per-workspace preview settings (URL override and screen size).
* Uses the scratch system for persistence.
*/
export function usePreviewSettings(
workspaceId: string | undefined
): UsePreviewSettingsResult {
const enabled = !!workspaceId;
const {
scratch,
updateScratch,
deleteScratch,
isLoading: isScratchLoading,
} = useScratch(ScratchType.PREVIEW_SETTINGS, workspaceId ?? '', {
enabled,
});
// Extract settings from scratch data
const payload = scratch?.payload as ScratchPayload | undefined;
const scratchData: PreviewSettingsData | undefined =
payload?.type === 'PREVIEW_SETTINGS' ? payload.data : undefined;
const overrideUrl = scratchData?.url ?? null;
const hasOverride = overrideUrl !== null && overrideUrl.trim() !== '';
const screenSize: ScreenSize =
(scratchData?.screen_size as ScreenSize) ?? 'desktop';
const responsiveDimensions: ResponsiveDimensions = useMemo(
() => ({
width:
scratchData?.responsive_width ?? DEFAULT_RESPONSIVE_DIMENSIONS.width,
height:
scratchData?.responsive_height ?? DEFAULT_RESPONSIVE_DIMENSIONS.height,
}),
[scratchData?.responsive_width, scratchData?.responsive_height]
);
// Helper to save settings
const saveSettings = useCallback(
async (updates: Partial<PreviewSettingsData>) => {
if (!workspaceId) return;
try {
await updateScratch({
payload: {
type: 'PREVIEW_SETTINGS',
data: {
url: updates.url ?? overrideUrl ?? '',
screen_size: updates.screen_size ?? screenSize,
responsive_width:
updates.responsive_width ?? responsiveDimensions.width,
responsive_height:
updates.responsive_height ?? responsiveDimensions.height,
},
},
});
} catch (e) {
console.error('[usePreviewSettings] Failed to save:', e);
}
},
[
workspaceId,
updateScratch,
overrideUrl,
screenSize,
responsiveDimensions.width,
responsiveDimensions.height,
]
);
// Debounced save for URL changes (frequent typing)
const { debounced: debouncedSaveUrl } = useDebouncedCallback(
async (url: string) => {
await saveSettings({ url });
},
300
);
// Debounced save for responsive dimensions (frequent dragging)
const { debounced: debouncedSaveDimensions } = useDebouncedCallback(
async (dimensions: ResponsiveDimensions) => {
await saveSettings({
responsive_width: dimensions.width,
responsive_height: dimensions.height,
});
},
300
);
const setOverrideUrl = useCallback(
(url: string) => {
debouncedSaveUrl(url);
},
[debouncedSaveUrl]
);
const setScreenSize = useCallback(
(size: ScreenSize) => {
saveSettings({ screen_size: size });
},
[saveSettings]
);
const setResponsiveDimensions = useCallback(
(dimensions: ResponsiveDimensions) => {
debouncedSaveDimensions(dimensions);
},
[debouncedSaveDimensions]
);
const clearOverride = useCallback(async () => {
try {
await deleteScratch();
} catch (e) {
// Ignore 404 errors when scratch doesn't exist
console.error('[usePreviewSettings] Failed to clear:', e);
}
}, [deleteScratch]);
return {
overrideUrl,
hasOverride,
setOverrideUrl,
clearOverride,
screenSize,
responsiveDimensions,
setScreenSize,
setResponsiveDimensions,
isLoading: isScratchLoading,
};
}

View File

@@ -1,86 +0,0 @@
import { useCallback } from 'react';
import { useScratch } from './useScratch';
import { useDebouncedCallback } from './useDebouncedCallback';
import {
ScratchType,
type PreviewUrlOverrideData,
type ScratchPayload,
} from 'shared/types';
interface UsePreviewUrlOverrideResult {
overrideUrl: string | null;
isLoading: boolean;
setOverrideUrl: (url: string) => void;
clearOverride: () => Promise<void>;
hasOverride: boolean;
}
/**
* Hook to manage a per-workspace preview URL override.
* Uses the scratch system for persistence.
*/
export function usePreviewUrlOverride(
workspaceId: string | undefined
): UsePreviewUrlOverrideResult {
const enabled = !!workspaceId;
const {
scratch,
updateScratch,
deleteScratch,
isLoading: isScratchLoading,
} = useScratch(ScratchType.PREVIEW_URL_OVERRIDE, workspaceId ?? '', {
enabled,
});
// Extract override URL from scratch data
const payload = scratch?.payload as ScratchPayload | undefined;
const scratchData: PreviewUrlOverrideData | undefined =
payload?.type === 'PREVIEW_URL_OVERRIDE' ? payload.data : undefined;
const overrideUrl = scratchData?.url ?? null;
const hasOverride = overrideUrl !== null && overrideUrl.trim() !== '';
// Debounced save to scratch
const { debounced: debouncedSave } = useDebouncedCallback(
async (url: string) => {
if (!workspaceId) return;
try {
await updateScratch({
payload: {
type: 'PREVIEW_URL_OVERRIDE',
data: { url },
},
});
} catch (e) {
console.error('[usePreviewUrlOverride] Failed to save:', e);
}
},
300
);
const setOverrideUrl = useCallback(
(url: string) => {
debouncedSave(url);
},
[debouncedSave]
);
const clearOverride = useCallback(async () => {
try {
await deleteScratch();
} catch (e) {
// Ignore 404 errors when scratch doesn't exist
console.error('[usePreviewUrlOverride] Failed to clear:', e);
}
}, [deleteScratch]);
return {
overrideUrl,
isLoading: isScratchLoading,
setOverrideUrl,
clearOverride,
hasOverride,
};
}

View File

@@ -85,13 +85,16 @@
"noServer": {
"title": "No dev server running",
"startPrompt": "Please start a dev server to see the preview",
"setupTitle": "You must set up a dev server script to use the preview feature",
"setupPrompt": "To use the live preview and click-to-edit, please add a dev server script to this project.",
"companionPrompt": "For click-to-edit functionality, add the browser companion to your project.",
"companionLink": "View installation guide",
"startButton": "Start Dev Server",
"configureButton": "Configure",
"stopAndEditButton": "Stop Dev Server & Resolve Issues",
"fixScript": "Fix Dev Script"
"fixScript": "Fix Dev Script",
"editDevScript": "Edit Dev Server Script",
"learnMore": "Learn more about testing applications"
},
"devScript": {
"saveAndStart": "Save & Start",
@@ -121,7 +124,16 @@
"openInTab": "Open in new tab",
"stopDevServer": "Stop dev server",
"clickToEdit": "Click to edit URL",
"resetUrl": "Reset to detected URL"
"resetUrl": "Reset to detected URL",
"clearUrlOverride": "Clear URL override",
"desktopView": "Desktop view",
"mobileView": "Mobile view (390x844)",
"responsiveView": "Responsive view (resizable)",
"startDevServer": "Start dev server"
},
"loading": {
"startingServer": "Starting dev server...",
"waitingForServer": "Waiting for server..."
},
"browser": {
"title": "Dev Server Preview",

View File

@@ -381,12 +381,15 @@
"companionLink": "Ver guía de instalación",
"companionPrompt": "Para la funcionalidad de clic y editar, agrega el complemento del navegador a tu proyecto.",
"configureButton": "Configurar",
"setupTitle": "Debes configurar un script de servidor de desarrollo para usar la función de vista previa",
"setupPrompt": "Para usar la vista previa en vivo y la función de hacer clic y editar, por favor agrega un script de servidor de desarrollo a este proyecto.",
"startButton": "Iniciar Servidor de Desarrollo",
"startPrompt": "Por favor inicia un servidor de desarrollo para ver la vista previa",
"stopAndEditButton": "Detener Servidor de Desarrollo y Resolver Problemas",
"title": "No hay servidor de desarrollo en ejecución",
"fixScript": "Corregir Script de Desarrollo"
"fixScript": "Corregir Script de Desarrollo",
"editDevScript": "Editar Script de Servidor de Desarrollo",
"learnMore": "Más información sobre cómo probar aplicaciones"
},
"selectAttempt": "Select an attempt to see preview",
"title": "Preview",
@@ -396,7 +399,16 @@
"refresh": "Actualizar vista previa",
"stopDevServer": "Detener servidor de desarrollo",
"clickToEdit": "Clic para editar URL",
"resetUrl": "Restablecer a URL detectada"
"resetUrl": "Restablecer a URL detectada",
"clearUrlOverride": "Limpiar anulación de URL",
"desktopView": "Vista de escritorio",
"mobileView": "Vista móvil (390x844)",
"responsiveView": "Vista adaptable (redimensionable)",
"startDevServer": "Iniciar servidor de desarrollo"
},
"loading": {
"startingServer": "Iniciando servidor de desarrollo...",
"waitingForServer": "Esperando servidor..."
},
"troubleAlert": {
"item1": "¿Se inició correctamente el servidor de desarrollo? Puede haber un error que necesites resolver, o quizás sea necesario instalar dependencias.",

View File

@@ -381,12 +381,15 @@
"companionLink": "インストールガイドを表示",
"companionPrompt": "クリック編集機能のために、プロジェクトにブラウザコンパニオンを追加してください。",
"configureButton": "設定",
"setupTitle": "プレビュー機能を使用するには、開発サーバースクリプトを設定する必要があります",
"setupPrompt": "ライブプレビューとクリックして編集機能を使用するには、このプロジェクトに開発サーバースクリプトを追加してください。",
"startButton": "開発サーバーを開始",
"startPrompt": "プレビューを表示するには開発サーバーを起動してください",
"stopAndEditButton": "開発サーバーを停止して問題を解決",
"title": "開発サーバーが実行されていません",
"fixScript": "開発スクリプトを修正"
"fixScript": "開発スクリプトを修正",
"editDevScript": "開発サーバースクリプトを編集",
"learnMore": "アプリケーションのテストについてもっと詳しく"
},
"selectAttempt": "Select an attempt to see preview",
"title": "Preview",
@@ -396,7 +399,16 @@
"refresh": "プレビューを更新",
"stopDevServer": "開発サーバーを停止",
"clickToEdit": "クリックしてURLを編集",
"resetUrl": "検出されたURLにリセット"
"resetUrl": "検出されたURLにリセット",
"clearUrlOverride": "URL オーバーライドをクリア",
"desktopView": "デスクトップ表示",
"mobileView": "モバイル表示 (390x844)",
"responsiveView": "レスポンシブ表示(リサイズ可能)",
"startDevServer": "開発サーバーを開始"
},
"loading": {
"startingServer": "開発サーバーを起動中...",
"waitingForServer": "サーバーを待機中..."
},
"troubleAlert": {
"item1": "開発サーバーが正常に起動しましたか?解決すべきバグがあるか、依存関係のインストールが必要な可能性があります。",

View File

@@ -373,12 +373,15 @@
"companionLink": "설치 가이드 보기",
"companionPrompt": "클릭하여 편집 기능을 사용하려면 프로젝트에 브라우저 컴패니언을 추가하세요.",
"configureButton": "구성",
"setupTitle": "미리보기 기능을 사용하려면 개발 서버 스크립트를 설정해야 합니다",
"setupPrompt": "라이브 미리보기 및 클릭하여 편집을 사용하려면 이 프로젝트에 개발 서버 스크립트를 추가하세요.",
"startButton": "개발 서버 시작",
"startPrompt": "미리보기를 보려면 개발 서버를 시작하세요",
"stopAndEditButton": "개발 서버 중지 및 문제 해결",
"title": "실행 중인 개발 서버 없음",
"fixScript": "개발 스크립트 수정"
"fixScript": "개발 스크립트 수정",
"editDevScript": "개발 서버 스크립트 편집",
"learnMore": "애플리케이션 테스트에 대해 자세히 알아보기"
},
"selectAttempt": "Select an attempt to see preview",
"title": "Preview",
@@ -388,7 +391,16 @@
"refresh": "미리보기 새로고침",
"stopDevServer": "개발 서버 중지",
"clickToEdit": "URL 편집하려면 클릭",
"resetUrl": "감지된 URL로 재설정"
"resetUrl": "감지된 URL로 재설정",
"clearUrlOverride": "URL 오버라이드 지우기",
"desktopView": "데스크탑 보기",
"mobileView": "모바일 보기 (390x844)",
"responsiveView": "반응형 보기 (크기 조절 가능)",
"startDevServer": "개발 서버 시작"
},
"loading": {
"startingServer": "개발 서버 시작 중...",
"waitingForServer": "서버 대기 중..."
},
"troubleAlert": {
"item1": "개발 서버가 성공적으로 시작되었나요? 해결해야 할 버그가 있거나 종속성을 설치해야 할 수 있습니다.",

View File

@@ -98,13 +98,16 @@
"noServer": {
"title": "没有运行开发服务器",
"startPrompt": "请启动开发服务器以查看预览",
"setupTitle": "必须设置开发服务器脚本才能使用预览功能",
"setupPrompt": "要使用实时预览和点击编辑,请为此项目添加开发服务器脚本。",
"companionPrompt": "要使用点击编辑功能,请将浏览器伴侣添加到您的项目。",
"companionLink": "查看安装指南",
"startButton": "启动开发服务器",
"configureButton": "配置",
"stopAndEditButton": "停止开发服务器并解决问题",
"fixScript": "修复开发脚本"
"fixScript": "修复开发脚本",
"editDevScript": "编辑开发服务器脚本",
"learnMore": "了解更多关于测试应用程序的信息"
},
"devScript": {
"saveAndStart": "保存并启动",
@@ -140,7 +143,16 @@
"openInTab": "在新标签页中打开",
"stopDevServer": "停止开发服务器",
"clickToEdit": "点击编辑 URL",
"resetUrl": "重置为检测到的 URL"
"resetUrl": "重置为检测到的 URL",
"clearUrlOverride": "清除 URL 覆盖",
"desktopView": "桌面视图",
"mobileView": "移动视图 (390x844)",
"responsiveView": "响应式视图(可调整大小)",
"startDevServer": "启动开发服务器"
},
"loading": {
"startingServer": "正在启动开发服务器...",
"waitingForServer": "正在等待服务器..."
}
},
"diff": {

View File

@@ -98,13 +98,16 @@
"noServer": {
"title": "沒有執行中的開發伺服器",
"startPrompt": "請啟動開發伺服器以查看預覽",
"setupTitle": "必須設定開發伺服器腳本才能使用預覽功能",
"setupPrompt": "要使用即時預覽與點擊編輯,請為此專案新增開發伺服器腳本。",
"companionPrompt": "要使用點擊編輯功能,請將瀏覽器 Companion 加到您的專案。",
"companionLink": "查看安裝指南",
"startButton": "啟動開發伺服器",
"configureButton": "設定",
"stopAndEditButton": "停止開發伺服器並解決問題",
"fixScript": "修復開發腳本"
"fixScript": "修復開發腳本",
"editDevScript": "編輯開發伺服器腳本",
"learnMore": "了解更多關於測試應用程式的資訊"
},
"devScript": {
"saveAndStart": "儲存並啟動",
@@ -140,7 +143,16 @@
"openInTab": "在新分頁開啟",
"stopDevServer": "停止開發伺服器",
"clickToEdit": "點擊以編輯 URL",
"resetUrl": "重設為偵測到的 URL"
"resetUrl": "重設為偵測到的 URL",
"clearUrlOverride": "清除 URL 覆寫",
"desktopView": "桌面檢視",
"mobileView": "行動裝置檢視 (390x844)",
"responsiveView": "響應式檢視(可調整大小)",
"startDevServer": "啟動開發伺服器"
},
"loading": {
"startingServer": "正在啟動開發伺服器...",
"waitingForServer": "正在等待伺服器..."
}
},
"diff": {

View File

@@ -64,11 +64,11 @@ export type DraftWorkspaceData = { message: string, project_id: string | null, r
export type DraftWorkspaceRepo = { repo_id: string, target_branch: string, };
export type PreviewUrlOverrideData = { url: string, };
export type PreviewSettingsData = { url: string, screen_size: string | null, responsive_width: number | null, responsive_height: number | null, };
export type ScratchPayload = { "type": "DRAFT_TASK", "data": string } | { "type": "DRAFT_FOLLOW_UP", "data": DraftFollowUpData } | { "type": "DRAFT_WORKSPACE", "data": DraftWorkspaceData } | { "type": "PREVIEW_URL_OVERRIDE", "data": PreviewUrlOverrideData };
export type ScratchPayload = { "type": "DRAFT_TASK", "data": string } | { "type": "DRAFT_FOLLOW_UP", "data": DraftFollowUpData } | { "type": "DRAFT_WORKSPACE", "data": DraftWorkspaceData } | { "type": "PREVIEW_SETTINGS", "data": PreviewSettingsData };
export enum ScratchType { DRAFT_TASK = "DRAFT_TASK", DRAFT_FOLLOW_UP = "DRAFT_FOLLOW_UP", DRAFT_WORKSPACE = "DRAFT_WORKSPACE", PREVIEW_URL_OVERRIDE = "PREVIEW_URL_OVERRIDE" }
export enum ScratchType { DRAFT_TASK = "DRAFT_TASK", DRAFT_FOLLOW_UP = "DRAFT_FOLLOW_UP", DRAFT_WORKSPACE = "DRAFT_WORKSPACE", PREVIEW_SETTINGS = "PREVIEW_SETTINGS" }
export type Scratch = { id: string, payload: ScratchPayload, created_at: string, updated_at: string, };