import { DeviceHostsSettings } from "./DeviceHostsSettings";/** * Integrations settings - preferences for surfaces T3 Code embeds rather than * owns. Browser is the first section: the defaults a preview tab opens at, * applied to both hand-opened tabs and agent `preview_open` calls that don't * state their own size. * * @module IntegrationsSettings */import { BrowserImportFailureReason, BROWSER_PROFILE_MAX_COUNT, type BrowserLinkTarget, type BrowserProfile, type EnvironmentId, type SshDeviceHostConfig, BROWSER_PROFILE_NAME_MAX_LENGTH, BROWSER_RECORDING_FRAME_RATES, DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, DEFAULT_BROWSER_PROFILE_ID, DEFAULT_BROWSER_LINK_TARGET, DEFAULT_BROWSER_RECORDING_FRAME_RATE, DEFAULT_BROWSER_VIEWPORT, DEFAULT_PREVIEW_APPEARANCE, DEFAULT_PREVIEW_ZOOM_FACTOR, FILL_PREVIEW_VIEWPORT, PREVIEW_VIEWPORT_MAX_AREA, PREVIEW_VIEWPORT_MAX_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION, PREVIEW_ZOOM_LEVELS, findBrowserProfile, isBuiltInBrowserProfileId, resolveBrowserProfiles, type BrowserImportSource, type PreviewAppearancePreference, type PreviewViewportSetting,} from "@t3tools/contracts";import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";import { Link } from "@tanstack/react-router";import { MoreVertical, Plus as PlusIcon } from "lucide-react";import { useCallback, useRef, useState } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon";import { AnimatedHeight } from "~/components/AnimatedHeight";import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic";import { previewBridge } from "~/components/preview/previewBridge";import { cn, randomUUID } from "~/lib/utils";import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";import { deviceEnvironment, useDeviceState } from "~/state/device";import { useAtomCommand } from "~/state/use-atom-command";import { AgentDeviceSetupStatus, DeviceHubSetupStatus, PlatformStatus, platformSetupStatus, deviceHubDescription, agentDeviceDescription,} from "~/components/device/DeviceSetup";import { isElectron } from "../../env"; import { Badge } from "../ui/badge";import { Menu, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuSeparator, MenuTrigger,} from "../ui/menu";import { readLocalApi } from "~/localApi"; import { toastManager } from "../ui/toast";import { AlertDialog, AlertDialogClose, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogPopup, AlertDialogTitle,} from "../ui/alert-dialog";import { Button } from "../ui/button";import { DraftInput } from "../ui/draft-input";import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field";import { Select, SelectGroup, SelectGroupLabel, SelectItem, SelectPopup, SelectTrigger, SelectValue,} from "../ui/select";import { Switch } from "../ui/switch";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import { getClientSettings, persistClientSettingsUpdate, useClientSettings, useClientSettingsHydrated, useUpdatePrimarySettings,} from "~/hooks/useSettings"; import { SettingsUnavailableGroup, SettingResetButton, SettingsPageContainer, SettingsRow, SettingsSection,} from "./settingsLayout";import { searchableSetting } from "./settingsSearch";import { BrowserImportWizard, type WizardTarget } from "./BrowserImportWizard";import type { ImportOutcome } from "./browserImportWizard.logic"; const FILL_VALUE = "fill";const RESPONSIVE_VALUE = "responsive"; type BrowserProfileDataBridge = Pick< NonNullable<typeof previewBridge>, "clearCookies" | "clearCache">; export async function clearBrowserProfileData( bridge: BrowserProfileDataBridge | null, environmentIds: ReadonlyArray<EnvironmentId>, profileId: string,): Promise<void> { if (bridge === null || environmentIds.length === 0) { throw new Error("Browser profile data is not available to clear."); } await Promise.all( environmentIds.flatMap((environmentId) => [ bridge.clearCookies(environmentId, profileId), bridge.clearCache(environmentId, profileId), ]), );} export function browserProfileRemovalAvailable( bridgeAvailable: boolean, environmentsReady: boolean, environmentCount: number,): boolean { return bridgeAvailable && environmentsReady && environmentCount > 0;} /** * The size a "Responsive" default falls back to when the user switches away * from Fill and hasn't typed dimensions yet. Fill has no dimensions to carry * over, so the picker needs something concrete to seed the inputs with. */const RESPONSIVE_SEED_SIZE = { width: 1280, height: 800 } as const; const NO_GROUPING: Intl.NumberFormatOptions = { useGrouping: false }; const APPEARANCE_LABELS: Readonly<Record<PreviewAppearancePreference, string>> = { system: "System", light: "Light", dark: "Dark",}; const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`; /** * IPC flattens the failure to its message, so the reason token travels inside * it. Anything unrecognised reads as a plain read failure rather than leaking * the raw message into a toast. *//** Thrown from the post-import settings updater when the cap was hit meanwhile. */class ProfileLimitReachedError extends Error { constructor() { super("Browser profile limit reached."); this.name = "ProfileLimitReachedError"; }} export const importFailureReason = (cause: unknown): BrowserImportFailureReason => { const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); return ( BrowserImportFailureReason.literals.find((reason) => message.includes(`failed: ${reason}.`)) ?? "readFailed" );}; const viewportSelectValue = (viewport: PreviewViewportSetting): string => { if (viewport._tag === "fill") return FILL_VALUE; if ( viewport._tag === "preset" && PREVIEW_VIEWPORT_PRESETS.some((preset) => preset.id === viewport.presetId) ) { return viewport.presetId; } return RESPONSIVE_VALUE;}; /** * The trigger renders this rather than a bare `SelectValue`, which would fall * back to printing the raw stored value ("fill") because the options are built * inline instead of from an `items` map. */const viewportSelectLabel = (viewport: PreviewViewportSetting): string => { const value = viewportSelectValue(viewport); if (value === FILL_VALUE) return "Fill panel"; if (value === RESPONSIVE_VALUE) return "Responsive"; return PREVIEW_VIEWPORT_PRESETS.find((preset) => preset.id === value)?.label ?? "Responsive";}; const isValidDimension = (value: number) => Number.isInteger(value) && value >= PREVIEW_VIEWPORT_MIN_DIMENSION && value <= PREVIEW_VIEWPORT_MAX_DIMENSION; /** * A sized viewport with width and height swapped. Presets keep their identity * through a rotation — `resolvePreviewViewport` already stores rotated presets * as the preset id plus swapped dimensions — so a rotated iPad is still an * iPad, not an anonymous custom size. */const rotateViewport = ( viewport: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>,): PreviewViewportSetting => ({ ...viewport, width: viewport.height, height: viewport.width,}); function BrowserViewportSetting({ disabled }: { readonly disabled: boolean }) { const viewport = useClientSettings((settings) => settings.browserDefaultViewport); const updateSettings = useUpdatePrimarySettings(); const sized = viewport._tag === "fill" ? null : viewport; const presentedSize = { width: sized?.width ?? RESPONSIVE_SEED_SIZE.width, height: sized?.height ?? RESPONSIVE_SEED_SIZE.height, }; const selectViewport = (value: string | null) => { if (value === FILL_VALUE) { updateSettings({ browserDefaultViewport: FILL_PREVIEW_VIEWPORT }); return; } if (value === RESPONSIVE_VALUE) { updateSettings({ browserDefaultViewport: { _tag: "freeform", width: sized?.width ?? RESPONSIVE_SEED_SIZE.width, height: sized?.height ?? RESPONSIVE_SEED_SIZE.height, }, }); return; } const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === value); if (!preset) return; updateSettings({ browserDefaultViewport: { _tag: "preset", width: preset.width, height: preset.height, presetId: preset.id, }, }); }; // Committed on blur rather than per keystroke: typing "2560" passes through // "256", which is a legal dimension, so an onValueChange handler would // persist that intermediate size and churn the settings file on every key. const commitDimension = (axis: "width" | "height", value: number | null) => { if (value === null || !isValidDimension(value)) return; const next = { ...presentedSize, [axis]: value }; if (next.width * next.height > PREVIEW_VIEWPORT_MAX_AREA) return; if (sized && next.width === sized.width && next.height === sized.height) return; // Typing a size means the preset no longer describes it. updateSettings({ browserDefaultViewport: { _tag: "freeform", ...next } }); }; return ( <SettingsRow {...searchableSetting("browser-default-viewport")} description="Tab size for you and agents. Fill fits the panel; other sizes show the device toolbar." resetAction={ !disabled && viewport._tag !== DEFAULT_BROWSER_VIEWPORT._tag ? ( <SettingResetButton label="default browser viewport" onClick={() => updateSettings({ browserDefaultViewport: DEFAULT_BROWSER_VIEWPORT })} /> ) : null } control={ <div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto"> <Select value={viewportSelectValue(viewport)} onValueChange={selectViewport} disabled={disabled} > <SelectTrigger size="sm" className="w-full min-w-0 sm:w-44" aria-label="Default browser viewport" > <SelectValue>{viewportSelectLabel(viewport)}</SelectValue> </SelectTrigger> <SelectPopup align="end" alignItemWithTrigger={false} className="min-w-64"> <SelectItem value={FILL_VALUE}>Fill panel</SelectItem> <SelectItem value={RESPONSIVE_VALUE}>Responsive</SelectItem> <SelectGroup> <SelectGroupLabel>Standard</SelectGroupLabel> {PREVIEW_VIEWPORT_PRESETS.map((preset) => ( <SelectItem key={preset.id} value={preset.id}> <span className="flex w-full items-center justify-between gap-5"> <span>{preset.label}</span> <span className="text-xs tabular-nums text-muted-foreground"> {preset.detail} </span> </span> </SelectItem> ))} </SelectGroup> </SelectPopup> </Select> {sized ? ( <div className="flex min-w-0 items-center gap-1"> <NumberField value={presentedSize.width} min={PREVIEW_VIEWPORT_MIN_DIMENSION} max={PREVIEW_VIEWPORT_MAX_DIMENSION} disabled={disabled} // Pixel counts read as raw numbers; grouping would show "1,024". format={NO_GROUPING} size="sm" className="w-20" onValueCommitted={(value) => commitDimension("width", value)} > <NumberFieldGroup> <NumberFieldInput aria-label="Default viewport width" /> </NumberFieldGroup> </NumberField> <span className="text-xs text-muted-foreground">×</span> <NumberField value={presentedSize.height} min={PREVIEW_VIEWPORT_MIN_DIMENSION} max={PREVIEW_VIEWPORT_MAX_DIMENSION} disabled={disabled} format={NO_GROUPING} size="sm" className="w-20" onValueCommitted={(value) => commitDimension("height", value)} > <NumberFieldGroup> <NumberFieldInput aria-label="Default viewport height" /> </NumberFieldGroup> </NumberField> <Tooltip> <TooltipTrigger render={ <Button size="icon-sm" variant="ghost-muted" disabled={disabled} aria-label={`Rotate to ${ presentedSize.height >= presentedSize.width ? "landscape" : "portrait" }`} onClick={() => updateSettings({ browserDefaultViewport: rotateViewport(sized) }) } > <ScreenRotationIcon /> </Button> } /> <TooltipPopup side="top">Rotate</TooltipPopup> </Tooltip> </div> ) : null} </div> } /> );} function BrowserZoomSetting({ disabled }: { readonly disabled: boolean }) { const zoomFactor = useClientSettings((settings) => settings.browserDefaultZoomFactor); const updateSettings = useUpdatePrimarySettings(); return ( <SettingsRow {...searchableSetting("browser-default-zoom")} description="Page zoom applied to new browser tabs." resetAction={ !disabled && zoomFactor !== DEFAULT_PREVIEW_ZOOM_FACTOR ? ( <SettingResetButton label="default browser zoom" onClick={() => updateSettings({ browserDefaultZoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR }) } /> ) : null } control={ <Select disabled={disabled} value={String(zoomFactor)} onValueChange={(value) => { const next = PREVIEW_ZOOM_LEVELS.find((level) => String(level) === value); if (next !== undefined) updateSettings({ browserDefaultZoomFactor: next }); }} > <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Default browser zoom"> <SelectValue>{zoomLabel(zoomFactor)}</SelectValue> </SelectTrigger> <SelectPopup align="end" alignItemWithTrigger={false}> {PREVIEW_ZOOM_LEVELS.map((level) => ( <SelectItem hideIndicator key={level} value={String(level)}> {zoomLabel(level)} </SelectItem> ))} </SelectPopup> </Select> } /> );} function BrowserAppearanceSetting({ disabled }: { readonly disabled: boolean }) { const appearance = useClientSettings((settings) => settings.browserDefaultAppearance); const updateSettings = useUpdatePrimarySettings(); return ( <SettingsRow {...searchableSetting("browser-default-appearance")} description="The color scheme pages are told to prefer. System follows your OS setting." resetAction={ !disabled && appearance !== DEFAULT_PREVIEW_APPEARANCE ? ( <SettingResetButton label="default browser appearance" onClick={() => updateSettings({ browserDefaultAppearance: DEFAULT_PREVIEW_APPEARANCE })} /> ) : null } control={ <Select disabled={disabled} value={appearance} onValueChange={(value) => { if (value === "system" || value === "light" || value === "dark") { updateSettings({ browserDefaultAppearance: value }); } }} > <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Default browser appearance" > <SelectValue>{APPEARANCE_LABELS[appearance]}</SelectValue> </SelectTrigger> <SelectPopup align="end" alignItemWithTrigger={false}> {Object.entries(APPEARANCE_LABELS).map(([value, label]) => ( <SelectItem hideIndicator key={value} value={value}> {label} </SelectItem> ))} </SelectPopup> </Select> } /> );} function BrowserRecordingFrameRateSetting({ disabled }: { readonly disabled: boolean }) { const frameRate = useClientSettings((settings) => settings.browserRecordingFrameRate); const updateSettings = useUpdatePrimarySettings(); return ( <SettingsRow {...searchableSetting("browser-recording-frame-rate")} description="Maximum recording rate. 30 fps saves CPU and storage; 60 fps is smoother." resetAction={ !disabled && frameRate !== DEFAULT_BROWSER_RECORDING_FRAME_RATE ? ( <SettingResetButton label="browser recording frame rate" onClick={() => updateSettings({ browserRecordingFrameRate: DEFAULT_BROWSER_RECORDING_FRAME_RATE }) } /> ) : null } control={ <Select disabled={disabled} value={String(frameRate)} onValueChange={(value) => { const next = BROWSER_RECORDING_FRAME_RATES.find((rate) => String(rate) === value); if (next !== undefined) { updateSettings({ browserRecordingFrameRate: next }); } }} > <SelectTriggerLines 1–500 of 1358. Load more
import { DeviceHostsSettings } from "./DeviceHostsSettings"; /** * Integrations settings - preferences for surfaces T3 Code embeds rather than * owns. Browser is the first section: the defaults a preview tab opens at, * applied to both hand-opened tabs and agent `preview_open` calls that don't * state their own size. * * @module IntegrationsSettings */ import { BrowserImportFailureReason, BROWSER_PROFILE_MAX_COUNT, type BrowserLinkTarget, type BrowserProfile, type EnvironmentId, type SshDeviceHostConfig, BROWSER_PROFILE_NAME_MAX_LENGTH, BROWSER_RECORDING_FRAME_RATES, DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, DEFAULT_BROWSER_PROFILE_ID, DEFAULT_BROWSER_LINK_TARGET, DEFAULT_BROWSER_RECORDING_FRAME_RATE, DEFAULT_BROWSER_VIEWPORT, DEFAULT_PREVIEW_APPEARANCE, DEFAULT_PREVIEW_ZOOM_FACTOR, FILL_PREVIEW_VIEWPORT, PREVIEW_VIEWPORT_MAX_AREA, PREVIEW_VIEWPORT_MAX_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION, PREVIEW_ZOOM_LEVELS, findBrowserProfile, isBuiltInBrowserProfileId, resolveBrowserProfiles, type BrowserImportSource, type PreviewAppearancePreference, type PreviewViewportSetting, } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; import { Link } from "@tanstack/react-router"; import { MoreVertical, Plus as PlusIcon } from "lucide-react"; import { useCallback, useRef, useState } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; import { AnimatedHeight } from "~/components/AnimatedHeight"; import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic"; import { previewBridge } from "~/components/preview/previewBridge"; import { cn, randomUUID } from "~/lib/utils"; import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; import { deviceEnvironment, useDeviceState } from "~/state/device"; import { useAtomCommand } from "~/state/use-atom-command"; import { AgentDeviceSetupStatus, DeviceHubSetupStatus, PlatformStatus, platformSetupStatus, deviceHubDescription, agentDeviceDescription, } from "~/components/device/DeviceSetup"; import { isElectron } from "../../env"; import { Badge } from "../ui/badge"; import { Menu, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuSeparator, MenuTrigger, } from "../ui/menu"; import { readLocalApi } from "~/localApi"; import { toastManager } from "../ui/toast"; import { AlertDialog, AlertDialogClose, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogPopup, AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field"; import { Select, SelectGroup, SelectGroupLabel, SelectItem, SelectPopup, SelectTrigger, SelectValue, } from "../ui/select"; import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { getClientSettings, persistClientSettingsUpdate, useClientSettings, useClientSettingsHydrated, useUpdatePrimarySettings, } from "~/hooks/useSettings"; import { SettingsUnavailableGroup, SettingResetButton, SettingsPageContainer, SettingsRow, SettingsSection, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { BrowserImportWizard, type WizardTarget } from "./BrowserImportWizard"; import type { ImportOutcome } from "./browserImportWizard.logic"; const FILL_VALUE = "fill"; const RESPONSIVE_VALUE = "responsive"; type BrowserProfileDataBridge = Pick< NonNullable<typeof previewBridge>, "clearCookies" | "clearCache" >; export async function clearBrowserProfileData( bridge: BrowserProfileDataBridge | null, environmentIds: ReadonlyArray<EnvironmentId>, profileId: string, ): Promise<void> { if (bridge === null || environmentIds.length === 0) { throw new Error("Browser profile data is not available to clear."); } await Promise.all( environmentIds.flatMap((environmentId) => [ bridge.clearCookies(environmentId, profileId), bridge.clearCache(environmentId, profileId), ]), ); } export function browserProfileRemovalAvailable( bridgeAvailable: boolean, environmentsReady: boolean, environmentCount: number, ): boolean { return bridgeAvailable && environmentsReady && environmentCount > 0; } /** * The size a "Responsive" default falls back to when the user switches away * from Fill and hasn't typed dimensions yet. Fill has no dimensions to carry * over, so the picker needs something concrete to seed the inputs with. */ const RESPONSIVE_SEED_SIZE = { width: 1280, height: 800 } as const; const NO_GROUPING: Intl.NumberFormatOptions = { useGrouping: false }; const APPEARANCE_LABELS: Readonly<Record<PreviewAppearancePreference, string>> = { system: "System", light: "Light", dark: "Dark", }; const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`; /** * IPC flattens the failure to its message, so the reason token travels inside * it. Anything unrecognised reads as a plain read failure rather than leaking * the raw message into a toast. */ /** Thrown from the post-import settings updater when the cap was hit meanwhile. */ class ProfileLimitReachedError extends Error { constructor() { super("Browser profile limit reached."); this.name = "ProfileLimitReachedError"; } } export const importFailureReason = (cause: unknown): BrowserImportFailureReason => { const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); return ( BrowserImportFailureReason.literals.find((reason) => message.includes(`failed: ${reason}.`)) ?? "readFailed" ); }; const viewportSelectValue = (viewport: PreviewViewportSetting): string => { if (viewport._tag === "fill") return FILL_VALUE; if ( viewport._tag === "preset" && PREVIEW_VIEWPORT_PRESETS.some((preset) => preset.id === viewport.presetId) ) { return viewport.presetId; } return RESPONSIVE_VALUE; }; /** * The trigger renders this rather than a bare `SelectValue`, which would fall * back to printing the raw stored value ("fill") because the options are built * inline instead of from an `items` map. */ const viewportSelectLabel = (viewport: PreviewViewportSetting): string => { const value = viewportSelectValue(viewport); if (value === FILL_VALUE) return "Fill panel"; if (value === RESPONSIVE_VALUE) return "Responsive"; return PREVIEW_VIEWPORT_PRESETS.find((preset) => preset.id === value)?.label ?? "Responsive"; }; const isValidDimension = (value: number) => Number.isInteger(value) && value >= PREVIEW_VIEWPORT_MIN_DIMENSION && value <= PREVIEW_VIEWPORT_MAX_DIMENSION; /** * A sized viewport with width and height swapped. Presets keep their identity * through a rotation — `resolvePreviewViewport` already stores rotated presets * as the preset id plus swapped dimensions — so a rotated iPad is still an * iPad, not an anonymous custom size. */ const rotateViewport = ( viewport: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>, ): PreviewViewportSetting => ({ ...viewport, width: viewport.height, height: viewport.width, }); function BrowserViewportSetting({ disabled }: { readonly disabled: boolean }) { const viewport = useClientSettings((settings) => settings.browserDefaultViewport); const updateSettings = useUpdatePrimarySettings(); const sized = viewport._tag === "fill" ? null : viewport; const presentedSize = { width: sized?.width ?? RESPONSIVE_SEED_SIZE.width, height: sized?.height ?? RESPONSIVE_SEED_SIZE.height, }; const selectViewport = (value: string | null) => { if (value === FILL_VALUE) { updateSettings({ browserDefaultViewport: FILL_PREVIEW_VIEWPORT }); return; } if (value === RESPONSIVE_VALUE) { updateSettings({ browserDefaultViewport: { _tag: "freeform", width: sized?.width ?? RESPONSIVE_SEED_SIZE.width, height: sized?.height ?? RESPONSIVE_SEED_SIZE.height, }, }); return; } const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === value); if (!preset) return; updateSettings({ browserDefaultViewport: { _tag: "preset", width: preset.width, height: preset.height, presetId: preset.id, }, }); }; // Committed on blur rather than per keystroke: typing "2560" passes through // "256", which is a legal dimension, so an onValueChange handler would // persist that intermediate size and churn the settings file on every key. const commitDimension = (axis: "width" | "height", value: number | null) => { if (value === null || !isValidDimension(value)) return; const next = { ...presentedSize, [axis]: value }; if (next.width * next.height > PREVIEW_VIEWPORT_MAX_AREA) return; if (sized && next.width === sized.width && next.height === sized.height) return; // Typing a size means the preset no longer describes it. updateSettings({ browserDefaultViewport: { _tag: "freeform", ...next } }); }; return ( <SettingsRow {...searchableSetting("browser-default-viewport")} description="Tab size for you and agents. Fill fits the panel; other sizes show the device toolbar." resetAction={ !disabled && viewport._tag !== DEFAULT_BROWSER_VIEWPORT._tag ? ( <SettingResetButton label="default browser viewport" onClick={() => updateSettings({ browserDefaultViewport: DEFAULT_BROWSER_VIEWPORT })} /> ) : null } control={ <div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto"> <Select value={viewportSelectValue(viewport)} onValueChange={selectViewport} disabled={disabled} > <SelectTrigger size="sm" className="w-full min-w-0 sm:w-44" aria-label="Default browser viewport" > <SelectValue>{viewportSelectLabel(viewport)}</SelectValue> </SelectTrigger> <SelectPopup align="end" alignItemWithTrigger={false} className="min-w-64"> <SelectItem value={FILL_VALUE}>Fill panel</SelectItem> <SelectItem value={RESPONSIVE_VALUE}>Responsive</SelectItem> <SelectGroup> <SelectGroupLabel>Standard</SelectGroupLabel> {PREVIEW_VIEWPORT_PRESETS.map((preset) => ( <SelectItem key={preset.id} value={preset.id}> <span className="flex w-full items-center justify-between gap-5"> <span>{preset.label}</span> <span className="text-xs tabular-nums text-muted-foreground"> {preset.detail} </span> </span> </SelectItem> ))} </SelectGroup> </SelectPopup> </Select> {sized ? ( <div className="flex min-w-0 items-center gap-1"> <NumberField value={presentedSize.width} min={PREVIEW_VIEWPORT_MIN_DIMENSION} max={PREVIEW_VIEWPORT_MAX_DIMENSION} disabled={disabled} // Pixel counts read as raw numbers; grouping would show "1,024". format={NO_GROUPING} size="sm" className="w-20" onValueCommitted={(value) => commitDimension("width", value)} > <NumberFieldGroup> <NumberFieldInput aria-label="Default viewport width" /> </NumberFieldGroup> </NumberField> <span className="text-xs text-muted-foreground">×</span> <NumberField value={presentedSize.height} min={PREVIEW_VIEWPORT_MIN_DIMENSION} max={PREVIEW_VIEWPORT_MAX_DIMENSION} disabled={disabled} format={NO_GROUPING} size="sm" className="w-20" onValueCommitted={(value) => commitDimension("height", value)} > <NumberFieldGroup> <NumberFieldInput aria-label="Default viewport height" /> </NumberFieldGroup> </NumberField> <Tooltip> <TooltipTrigger render={ <Button size="icon-sm" variant="ghost-muted" disabled={disabled} aria-label={`Rotate to ${ presentedSize.height >= presentedSize.width ? "landscape" : "portrait" }`} onClick={() => updateSettings({ browserDefaultViewport: rotateViewport(sized) }) } > <ScreenRotationIcon /> </Button> } /> <TooltipPopup side="top">Rotate</TooltipPopup> </Tooltip> </div> ) : null} </div> } /> ); } function BrowserZoomSetting({ disabled }: { readonly disabled: boolean }) { const zoomFactor = useClientSettings((settings) => settings.browserDefaultZoomFactor); const updateSettings = useUpdatePrimarySettings(); return ( <SettingsRow {...searchableSetting("browser-default-zoom")} description="Page zoom applied to new browser tabs." resetAction={ !disabled && zoomFactor !== DEFAULT_PREVIEW_ZOOM_FACTOR ? ( <SettingResetButton label="default browser zoom" onClick={() => updateSettings({ browserDefaultZoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR }) } /> ) : null } control={ <Select disabled={disabled} value={String(zoomFactor)} onValueChange={(value) => { const next = PREVIEW_ZOOM_LEVELS.find((level) => String(level) === value); if (next !== undefined) updateSettings({ browserDefaultZoomFactor: next }); }} > <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Default browser zoom"> <SelectValue>{zoomLabel(zoomFactor)}</SelectValue> </SelectTrigger> <SelectPopup align="end" alignItemWithTrigger={false}> {PREVIEW_ZOOM_LEVELS.map((level) => ( <SelectItem hideIndicator key={level} value={String(level)}> {zoomLabel(level)} </SelectItem> ))} </SelectPopup> </Select> } /> ); } function BrowserAppearanceSetting({ disabled }: { readonly disabled: boolean }) { const appearance = useClientSettings((settings) => settings.browserDefaultAppearance); const updateSettings = useUpdatePrimarySettings(); return ( <SettingsRow {...searchableSetting("browser-default-appearance")} description="The color scheme pages are told to prefer. System follows your OS setting." resetAction={ !disabled && appearance !== DEFAULT_PREVIEW_APPEARANCE ? ( <SettingResetButton label="default browser appearance" onClick={() => updateSettings({ browserDefaultAppearance: DEFAULT_PREVIEW_APPEARANCE })} /> ) : null } control={ <Select disabled={disabled} value={appearance} onValueChange={(value) => { if (value === "system" || value === "light" || value === "dark") { updateSettings({ browserDefaultAppearance: value }); } }} > <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Default browser appearance" > <SelectValue>{APPEARANCE_LABELS[appearance]}</SelectValue> </SelectTrigger> <SelectPopup align="end" alignItemWithTrigger={false}> {Object.entries(APPEARANCE_LABELS).map(([value, label]) => ( <SelectItem hideIndicator key={value} value={value}> {label} </SelectItem> ))} </SelectPopup> </Select> } /> ); } function BrowserRecordingFrameRateSetting({ disabled }: { readonly disabled: boolean }) { const frameRate = useClientSettings((settings) => settings.browserRecordingFrameRate); const updateSettings = useUpdatePrimarySettings(); return ( <SettingsRow {...searchableSetting("browser-recording-frame-rate")} description="Maximum recording rate. 30 fps saves CPU and storage; 60 fps is smoother." resetAction={ !disabled && frameRate !== DEFAULT_BROWSER_RECORDING_FRAME_RATE ? ( <SettingResetButton label="browser recording frame rate" onClick={() => updateSettings({ browserRecordingFrameRate: DEFAULT_BROWSER_RECORDING_FRAME_RATE }) } /> ) : null } control={ <Select disabled={disabled} value={String(frameRate)} onValueChange={(value) => { const next = BROWSER_RECORDING_FRAME_RATES.find((rate) => String(rate) === value); if (next !== undefined) { updateSettings({ browserRecordingFrameRate: next }); } }} > <SelectTrigger