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 `<span>` with editable `<input>`, 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<HTMLInputElement | null>` to `RefObject<HTMLInputElement>` in the props interface - the `null` is already implied in how React refs work.
This commit is contained in:
Louis Knight-Webb
2026-01-12 23:36:15 +00:00
committed by GitHub
parent d9fa2d1fa5
commit c7cf72a57f
7 changed files with 193 additions and 18 deletions

View File

@@ -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 = () => {

View File

@@ -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<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;
@@ -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}

View File

@@ -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<HTMLInputElement>;
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"
>
<div className="flex items-center gap-half p-base">
{url && (
{(url || autoDetectedUrl) && (
<div className="flex items-center gap-half bg-panel rounded-sm px-base py-half flex-1 min-w-0">
<span className="flex-1 font-mono text-sm text-low truncate">
{url}
</span>
<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}

View File

@@ -0,0 +1,86 @@
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,
};
}