diff --git a/crates/db/src/models/scratch.rs b/crates/db/src/models/scratch.rs index 0a9d379d..8af527cb 100644 --- a/crates/db/src/models/scratch.rs +++ b/crates/db/src/models/scratch.rs @@ -25,6 +25,12 @@ pub struct DraftFollowUpData { pub variant: Option, } +/// Data for a preview URL override scratch +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +pub struct PreviewUrlOverrideData { + pub url: String, +} + /// Data for a draft workspace scratch (new workspace creation) #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct DraftWorkspaceData { @@ -57,6 +63,7 @@ pub enum ScratchPayload { DraftTask(String), DraftFollowUp(DraftFollowUpData), DraftWorkspace(DraftWorkspaceData), + PreviewUrlOverride(PreviewUrlOverrideData), } impl ScratchPayload { diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index 8171fd9e..4d8350d4 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -39,6 +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::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 55c0572a..c6d47cdd 100644 --- a/frontend/src/components/ui-new/containers/PreviewBrowserContainer.tsx +++ b/frontend/src/components/ui-new/containers/PreviewBrowserContainer.tsx @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { PreviewBrowser } from '../views/PreviewBrowser'; 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'; @@ -19,7 +20,7 @@ export function PreviewBrowserContainer({ }: PreviewBrowserContainerProps) { const navigate = useNavigate(); const previewRefreshKey = useLayoutStore((s) => s.previewRefreshKey); - const { repos } = useWorkspaceContext(); + const { repos, workspaceId } = useWorkspaceContext(); const { start, isStarting, runningDevServers, devServerProcesses } = usePreviewDevServer(attemptId); @@ -28,13 +29,19 @@ export function PreviewBrowserContainer({ const { logs } = useLogStream(primaryDevServer?.id ?? ''); const urlInfo = usePreviewUrl(logs); + // URL override for this workspace + const { overrideUrl, hasOverride } = usePreviewUrlOverride(workspaceId); + + // Use override URL if set, otherwise fall back to auto-detected + const effectiveUrl = hasOverride ? overrideUrl : urlInfo?.url; + const handleStart = useCallback(() => { start(); }, [start]); // Use previewRefreshKey from store to force iframe reload - const iframeUrl = urlInfo?.url - ? `${urlInfo.url}${urlInfo.url.includes('?') ? '&' : '?'}_refresh=${previewRefreshKey}` + const iframeUrl = effectiveUrl + ? `${effectiveUrl}${effectiveUrl.includes('?') ? '&' : '?'}_refresh=${previewRefreshKey}` : undefined; const handleEditDevScript = () => { diff --git a/frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx b/frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx index 4259c3e3..3b8f1314 100644 --- a/frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx +++ b/frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx @@ -1,7 +1,8 @@ -import { useCallback, useState, useEffect } from 'react'; +import { useCallback, useState, useEffect, useRef } 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'; @@ -18,7 +19,7 @@ export function PreviewControlsContainer({ onViewProcessInPanel, className, }: PreviewControlsContainerProps) { - const { repos } = useWorkspaceContext(); + const { repos, workspaceId } = useWorkspaceContext(); const setLogsMode = useLayoutStore((s) => s.setLogsMode); const triggerPreviewRefresh = useLayoutStore((s) => s.triggerPreviewRefresh); @@ -49,6 +50,32 @@ export function PreviewControlsContainer({ 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; @@ -77,17 +104,21 @@ export function PreviewControlsContainer({ triggerPreviewRefresh(); }, [triggerPreviewRefresh]); + const handleClearOverride = useCallback(async () => { + await clearOverride(); + }, [clearOverride]); + const handleCopyUrl = useCallback(async () => { - if (urlInfo?.url) { - await navigator.clipboard.writeText(urlInfo.url); + if (effectiveUrl) { + await navigator.clipboard.writeText(effectiveUrl); } - }, [urlInfo?.url]); + }, [effectiveUrl]); const handleOpenInNewTab = useCallback(() => { - if (urlInfo?.url) { - window.open(urlInfo.url, '_blank'); + if (effectiveUrl) { + window.open(effectiveUrl, '_blank'); } - }, [urlInfo?.url]); + }, [effectiveUrl]); const handleFixScript = useCallback(() => { if (!attemptId || repos.length === 0) return; @@ -123,7 +154,13 @@ export function PreviewControlsContainer({ activeProcessId={activeProcess?.id ?? null} logs={logs} logsError={logsError} - url={urlInfo?.url} + url={effectiveUrl ?? undefined} + autoDetectedUrl={urlInfo?.url} + isUsingOverride={hasOverride} + urlInputValue={urlInputValue} + urlInputRef={urlInputRef} + onUrlInputChange={handleUrlInputChange} + onClearOverride={handleClearOverride} onViewFullLogs={handleViewFullLogs} onTabChange={handleTabChange} onStart={handleStart} diff --git a/frontend/src/components/ui-new/views/PreviewControls.tsx b/frontend/src/components/ui-new/views/PreviewControls.tsx index 31520cd0..01ada55f 100644 --- a/frontend/src/components/ui-new/views/PreviewControls.tsx +++ b/frontend/src/components/ui-new/views/PreviewControls.tsx @@ -1,3 +1,4 @@ +import type { RefObject } from 'react'; import { PlayIcon, StopIcon, @@ -6,6 +7,7 @@ import { SpinnerIcon, CopyIcon, WrenchIcon, + XIcon, } from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; @@ -24,6 +26,12 @@ interface PreviewControlsProps { logs: LogEntry[]; logsError: string | null; url?: string; + autoDetectedUrl?: string; + isUsingOverride?: boolean; + urlInputValue: string; + urlInputRef: RefObject; + onUrlInputChange: (value: string) => void; + onClearOverride?: () => void; onViewFullLogs: () => void; onTabChange: (processId: string) => void; onStart: () => void; @@ -44,6 +52,12 @@ export function PreviewControls({ logs, logsError, url, + autoDetectedUrl, + isUsingOverride, + urlInputValue, + urlInputRef, + onUrlInputChange, + onClearOverride, onViewFullLogs, onTabChange, onStart, @@ -73,11 +87,32 @@ export function PreviewControls({ contentClassName="flex flex-col flex-1 overflow-hidden" >
- {url && ( + {(url || autoDetectedUrl) && (
- - {url} - + 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 && ( + + )}