From c7cf72a57f072c4455d05e898ae10639c103fd5d Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Mon, 12 Jan 2026 23:36:15 +0000 Subject: [PATCH] Allow manual URL override in dev server preview (Vibe Kanban) (#1989) * The implementation is complete and tested. Here's a summary of the changes made: I've implemented an editable URL bar in the Preview Controls that allows users to override the auto-detected dev server URL on a per-workspace basis. | File | Changes | |------|---------| | `crates/db/src/models/scratch.rs` | Added `PreviewUrlOverrideData` struct and `PreviewUrlOverride` variant to `ScratchPayload` enum | | `crates/server/src/bin/generate_types.rs` | Registered `PreviewUrlOverrideData::decl()` for TypeScript type generation | | `shared/types.ts` | Auto-generated with new `PreviewUrlOverrideData` type and updated `ScratchPayload` union | | `frontend/src/hooks/usePreviewUrlOverride.ts` | **New file** - Custom hook for managing URL override persistence via scratch system | | `frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx` | Added URL override hook integration, merged auto-detected with override URL | | `frontend/src/components/ui-new/views/PreviewControls.tsx` | Replaced static `` with editable ``, added clear override button (X icon) | - **Editable URL bar**: Users can type a custom URL to override the auto-detected one - **Per-workspace persistence**: Override URL saved in the scratch system using workspace ID - **Debounced saves**: Changes are debounced (300ms) before saving to avoid excessive API calls - **Clear override button**: X button appears when override is active, allowing revert to auto-detected URL - **Visual distinction**: Override URLs show in normal text color, auto-detected in dim text * Cleanup script changes for workspace 3f3c315d-5cc2-43b0-9043-117c857f38b4 * The URL input fix is complete. Here's a summary of what was implemented: **Feature: Editable URL bar with per-workspace persistence** 1. **New scratch type** - Added `PreviewUrlOverride` to the Rust scratch system for persisting URL overrides per workspace 2. **New hook** - Created `usePreviewUrlOverride.ts` that wraps the scratch system with debounced saves (300ms delay) 3. **Container integration** - Updated `PreviewControlsContainer.tsx` to use the override URL when set, falling back to auto-detected URL 4. **View updates** - Updated `PreviewControls.tsx` with: - Editable input field instead of read-only display - Clear button (X icon) to revert to auto-detected URL - Local state management to prevent WebSocket updates from disrupting typing **Bug fix for typing issue:** - Added local state (`localValue`) and ref (`inputRef`) to track input value locally - The effect only syncs from server when input is not focused, preventing cursor jumping and character loss during typing * ESLint and Clippy both pass now. The fix moved the state management from the presentational view component (`PreviewControls.tsx`) to the container component (`PreviewControlsContainer.tsx`), which aligns with the codebase's architecture pattern of keeping views stateless. * I can see projects now. Let me click on the first project to access a workspace where I can test the URL override feature: * TypeScript check passes now. The fix was changing `RefObject` to `RefObject` in the props interface - the `null` is already implied in how React refs work. --- crates/db/src/models/scratch.rs | 7 ++ crates/server/src/bin/generate_types.rs | 1 + .../containers/PreviewBrowserContainer.tsx | 13 ++- .../containers/PreviewControlsContainer.tsx | 55 ++++++++++-- .../ui-new/views/PreviewControls.tsx | 43 +++++++++- frontend/src/hooks/usePreviewUrlOverride.ts | 86 +++++++++++++++++++ shared/types.ts | 6 +- 7 files changed, 193 insertions(+), 18 deletions(-) create mode 100644 frontend/src/hooks/usePreviewUrlOverride.ts 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 && ( + + )}