diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index c4bbb5edd..e3e408b83 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -3,11 +3,13 @@ import { describe, expect, it } from "vitest"; import { AppSettingsSchema, + DEFAULT_BROWSER_PREVIEW_START_PAGE_URL, DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE, DEFAULT_SIDEBAR_FONT_SIZE, DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT, DEFAULT_SIDEBAR_SPACING, DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT, + resolveBrowserPreviewStartPageUrl, } from "./appSettings"; describe("AppSettingsSchema", () => { @@ -22,6 +24,7 @@ describe("AppSettingsSchema", () => { expect(settings.showNotificationDetails).toBe(false); expect(settings.includeDiagnosticsTipsInCopy).toBe(false); + expect(settings.browserPreviewStartPageUrl).toBe(""); }); it("defaults sidebar appearance controls", () => { @@ -57,3 +60,19 @@ describe("AppSettingsSchema", () => { expect(settings.prReviewRequestChangesTone).toBe(DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE); }); }); + +describe("resolveBrowserPreviewStartPageUrl", () => { + it("falls back to the default start page for blank or invalid values", () => { + expect(resolveBrowserPreviewStartPageUrl("")).toBe(DEFAULT_BROWSER_PREVIEW_START_PAGE_URL); + expect(resolveBrowserPreviewStartPageUrl("not-a-url")).toBe( + DEFAULT_BROWSER_PREVIEW_START_PAGE_URL, + ); + }); + + it("normalizes valid http and https URLs", () => { + expect(resolveBrowserPreviewStartPageUrl(" https://example.com ")).toBe("https://example.com/"); + expect(resolveBrowserPreviewStartPageUrl("http://localhost:3000/path")).toBe( + "http://localhost:3000/path", + ); + }); +}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index d72a06963..37daea8a9 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -11,6 +11,7 @@ import { normalizeModelSlug, resolveSelectableModel, } from "@okcode/shared/model"; +import { validateHttpPreviewUrl } from "@okcode/shared/preview"; import { APP_LOCALE_PREFERENCES } from "./i18n/types"; import { useLocalStorage } from "./hooks/useLocalStorage"; import { EnvMode } from "./components/BranchToolbar.logic"; @@ -32,6 +33,7 @@ export const DEFAULT_SIDEBAR_FONT_SIZE = 12; export const SIDEBAR_SPACING_MIN = 4; export const SIDEBAR_SPACING_MAX = 12; export const DEFAULT_SIDEBAR_SPACING = 8; +export const DEFAULT_BROWSER_PREVIEW_START_PAGE_URL = "https://www.google.com/"; export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; @@ -96,6 +98,9 @@ export const AppSettingsSchema = Schema.Struct({ includeDiagnosticsTipsInCopy: Schema.Boolean.pipe(withDefaults(() => false)), locale: AppLocale.pipe(withDefaults(() => DEFAULT_APP_LOCALE)), openLinksExternally: Schema.Boolean.pipe(withDefaults(() => false)), + browserPreviewStartPageUrl: Schema.String.check(Schema.isMaxLength(4096)).pipe( + withDefaults(() => ""), + ), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( withDefaults(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), ), @@ -227,6 +232,7 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, backgroundImageUrl: settings.backgroundImageUrl.trim(), + browserPreviewStartPageUrl: settings.browserPreviewStartPageUrl.trim(), backgroundImageOpacity: clampBackgroundOpacity(settings.backgroundImageOpacity), sidebarOpacity: clampOpacity(settings.sidebarOpacity), sidebarProjectRowHeight: clampSidebarProjectRowHeight(settings.sidebarProjectRowHeight), @@ -377,6 +383,16 @@ export function getProviderStartOptions( return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; } +export function resolveBrowserPreviewStartPageUrl(rawUrl: string | null | undefined): string { + const trimmedUrl = rawUrl?.trim() ?? ""; + if (trimmedUrl.length === 0) { + return DEFAULT_BROWSER_PREVIEW_START_PAGE_URL; + } + + const validatedUrl = validateHttpPreviewUrl(trimmedUrl); + return validatedUrl.ok ? validatedUrl.url : DEFAULT_BROWSER_PREVIEW_START_PAGE_URL; +} + export function useAppSettings() { const [settings, setSettings] = useLocalStorage( APP_SETTINGS_STORAGE_KEY, diff --git a/apps/web/src/components/PreviewPanel.tsx b/apps/web/src/components/PreviewPanel.tsx index e71748587..339c6f282 100644 --- a/apps/web/src/components/PreviewPanel.tsx +++ b/apps/web/src/components/PreviewPanel.tsx @@ -24,6 +24,7 @@ import { } from "lucide-react"; import { validateHttpPreviewUrl } from "@okcode/shared/preview"; +import { resolveBrowserPreviewStartPageUrl, useAppSettings } from "~/appSettings"; import { readDesktopPreviewBridge } from "~/desktopPreview"; import { type BrowserPresetId, @@ -145,6 +146,7 @@ function resolveViewportDimensions( } export function PreviewPanel({ projectId, threadId, onClose }: PreviewPanelProps) { + const { settings } = useAppSettings(); const previewBridge = readDesktopPreviewBridge(); const setProjectOpen = usePreviewStateStore((state) => state.setProjectOpen); const favoriteUrls = usePreviewStateStore((state) => state.favoriteUrls); @@ -412,8 +414,10 @@ export function PreviewPanel({ projectId, threadId, onClose }: PreviewPanelProps return; } } - // Create tab with a default page - void previewBridge?.createTab({ url: "https://www.google.com", threadId }); + void previewBridge?.createTab({ + url: resolveBrowserPreviewStartPageUrl(settings.browserPreviewStartPageUrl), + threadId, + }); }; const onClosePreview = () => { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index a4cb5657d..b1f821ac2 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -34,7 +34,9 @@ import { DEFAULT_GIT_TEXT_GENERATION_MODEL, } from "@okcode/contracts"; import { getModelOptions, normalizeModelSlug } from "@okcode/shared/model"; +import { validateHttpPreviewUrl } from "@okcode/shared/preview"; import { + DEFAULT_BROWSER_PREVIEW_START_PAGE_URL, DEFAULT_SIDEBAR_FONT_SIZE, DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT, DEFAULT_SIDEBAR_SPACING, @@ -46,6 +48,7 @@ import { MODEL_PROVIDER_SETTINGS, patchCustomModels, PrReviewRequestChangesTone, + resolveBrowserPreviewStartPageUrl, SIDEBAR_FONT_SIZE_MAX, SIDEBAR_FONT_SIZE_MIN, SIDEBAR_PROJECT_ROW_HEIGHT_MAX, @@ -758,6 +761,14 @@ function SettingsRouteView() { const { settings, defaults, updateSettings, resetSettings } = useAppSettings(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const queryClient = useQueryClient(); + const trimmedBrowserPreviewStartPageUrl = settings.browserPreviewStartPageUrl.trim(); + const browserPreviewStartPageValidation = + trimmedBrowserPreviewStartPageUrl.length > 0 + ? validateHttpPreviewUrl(trimmedBrowserPreviewStartPageUrl) + : null; + const effectiveBrowserPreviewStartPageUrl = resolveBrowserPreviewStartPageUrl( + settings.browserPreviewStartPageUrl, + ); const projects = useStore((state) => state.projects); const threads = useStore((state) => state.threads); const [selectedProjectId, setSelectedProjectId] = useState( @@ -2133,6 +2144,64 @@ function SettingsRouteView() { } /> + + Blank uses the default start page:{" "} + {DEFAULT_BROWSER_PREVIEW_START_PAGE_URL} + + ) : browserPreviewStartPageValidation?.ok ? ( + <> + New blank preview tabs will open at{" "} + {browserPreviewStartPageValidation.url}. + + ) : ( + <> + + Invalid URL. Falling back to{" "} + {DEFAULT_BROWSER_PREVIEW_START_PAGE_URL}. + + + Effective start page:{" "} + {effectiveBrowserPreviewStartPageUrl} + + + ) + } + resetAction={ + settings.browserPreviewStartPageUrl !== + defaults.browserPreviewStartPageUrl ? ( + + updateSettings({ + browserPreviewStartPageUrl: defaults.browserPreviewStartPageUrl, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + browserPreviewStartPageUrl: event.target.value, + }) + } + placeholder={DEFAULT_BROWSER_PREVIEW_START_PAGE_URL} + aria-label="Browser preview start page" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + className="w-full sm:w-72" + /> + } + /> +