/** * 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 { BROWSER_RECORDING_FRAME_RATES, DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, DEFAULT_BROWSER_RECORDING_FRAME_RATE, DEFAULT_BROWSER_VIEWPORT, DEFAULT_PREVIEW_APPEARANCE, DEFAULT_UNIFIED_SETTINGS, DEFAULT_PREVIEW_ZOOM_FACTOR, FILL_PREVIEW_VIEWPORT, PREVIEW_VIEWPORT_MAX_AREA, PREVIEW_VIEWPORT_MAX_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION, PREVIEW_ZOOM_LEVELS, type PreviewAppearancePreference, type PreviewViewportSetting, } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; import { InfoIcon } from "lucide-react"; import type { ReactNode } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; import { isElectron } from "../../env"; import { Button } from "../ui/button"; 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 { useClientSettings, usePrimarySettings, useUpdatePrimarySettings, } from "~/hooks/useSettings"; import { SettingResetButton, SettingsPageContainer, SettingsRow, SettingsSection, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; const FILL_VALUE = "fill"; const RESPONSIVE_VALUE = "responsive"; /** * 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> = { system: "System", light: "Light", dark: "Dark", }; const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`; 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 => ({ ...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 ( updateSettings({ browserDefaultViewport: DEFAULT_BROWSER_VIEWPORT })} /> ) : null } control={
{sized ? (
commitDimension("width", value)} > × commitDimension("height", value)} > = presentedSize.width ? "landscape" : "portrait" }`} onClick={() => updateSettings({ browserDefaultViewport: rotateViewport(sized) }) } > } /> Rotate
) : null}
} /> ); } function BrowserZoomSetting({ disabled }: { readonly disabled: boolean }) { const zoomFactor = useClientSettings((settings) => settings.browserDefaultZoomFactor); const updateSettings = useUpdatePrimarySettings(); return ( updateSettings({ browserDefaultZoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR }) } /> ) : null } control={ } /> ); } function BrowserAppearanceSetting({ disabled }: { readonly disabled: boolean }) { const appearance = useClientSettings((settings) => settings.browserDefaultAppearance); const updateSettings = useUpdatePrimarySettings(); return ( updateSettings({ browserDefaultAppearance: DEFAULT_PREVIEW_APPEARANCE })} /> ) : null } control={ } /> ); } function BrowserRecordingFrameRateSetting({ disabled }: { readonly disabled: boolean }) { const frameRate = useClientSettings((settings) => settings.browserRecordingFrameRate); const updateSettings = useUpdatePrimarySettings(); return ( updateSettings({ browserRecordingFrameRate: DEFAULT_BROWSER_RECORDING_FRAME_RATE }) } /> ) : null } control={ } /> ); } function AgentBrowserAccessSetting() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); return ( updateSettings({ enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess, }) } /> ) : null } control={ updateSettings({ enableAgentBrowserAccess: Boolean(checked) }) } aria-label="Allow agent browser access" /> } /> ); } function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled: boolean }) { const autoShow = useClientSettings((settings) => settings.browserAutoShowFloatingPreview); const updateSettings = useUpdatePrimarySettings(); return ( updateSettings({ browserAutoShowFloatingPreview: DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, }) } /> ) : null } control={ updateSettings({ browserAutoShowFloatingPreview: Boolean(checked) }) } aria-label="Auto-show floating preview" /> } /> ); } /** * Frames the client-local preview defaults as one unavailable block. * * Disabling each control on its own left the labels and descriptions at full * strength, so the group still read as editable. Boxing it puts the reason at * the top and dims everything it covers, which is also why the explanation * sits outside the dimmed area — the one part that must stay readable is the * part saying why the rest isn't. * * Disabled rather than hidden because these are *client* settings: editing * them from a browser tab would write preferences belonging to a different * client, reading as though the desktop app had been configured when it * hadn't. */ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode }) { return (

Only available in the desktop app.

{children}
); } export function IntegrationsSettingsPanel() { // Client-local preview defaults are editable only where the preview exists. const previewDefaultsDisabled = !isElectron; const previewDefaults = ( <> ); return ( {/* Server-authoritative, so it stays editable on every client and sits outside the block covering the desktop-only defaults. */} {previewDefaultsDisabled ? ( {previewDefaults} ) : ( previewDefaults )} ); }