/** * 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_PROFILE_MAX_COUNT, type BrowserLinkTarget, type BrowserProfile, type EnvironmentId, 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_UNIFIED_SETTINGS, 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 PreviewAppearancePreference, type PreviewViewportSetting, } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; import { InfoIcon, Plus as PlusIcon, Trash2 as Trash2Icon } from "lucide-react"; import { useState } from "react"; import type { ReactNode } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; import { previewBridge } from "~/components/preview/previewBridge"; import { cn, randomUUID } from "~/lib/utils"; import { useEnvironments } from "~/state/environments"; import { isElectron } from "../../env"; import { Badge } from "../ui/badge"; 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, useClientSettings, useClientSettingsHydrated, usePrimarySettings, useUpdatePrimarySettings, } from "~/hooks/useSettings"; import { SettingResetButton, SettingsPageContainer, SettingsRow, SettingsSection, } from "./settingsLayout"; import { ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; import { searchableSetting } from "./settingsSearch"; const FILL_VALUE = "fill"; const RESPONSIVE_VALUE = "responsive"; type BrowserProfileDataBridge = Pick< NonNullable, "clearCookies" | "clearCache" >; export async function clearBrowserProfileData( bridge: BrowserProfileDataBridge | null, environmentIds: ReadonlyArray, profileId: string, ): Promise { 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> = { 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={ } /> ); } const LINK_TARGET_LABELS: Readonly> = { system: "Your default browser", app: "T3 Code", }; function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) { const linkTarget = useClientSettings((settings) => settings.browserLinkTarget); const updateSettings = useUpdatePrimarySettings(); return ( updateSettings({ browserLinkTarget: DEFAULT_BROWSER_LINK_TARGET })} /> ) : 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}
); } /** * Create, rename, and remove browser profiles. * * Built-ins render without controls: they are synthesized rather than stored, * so there is nothing to rename and removing them would strand every tab that * opened under them. */ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { const userProfiles = useClientSettings((settings) => settings.browserProfiles); const settingsHydrated = useClientSettingsHydrated(); const updateSettings = useUpdatePrimarySettings(); const { environments, isReady: environmentsReady } = useEnvironments(); const [profilePendingRemoval, setProfilePendingRemoval] = useState(null); const [profileRemovalError, setProfileRemovalError] = useState(null); const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false); const removalAvailable = browserProfileRemovalAvailable( previewBridge !== null, environmentsReady, environments.length, ); const profileWritesDisabled = disabled || !settingsHydrated; const addProfile = () => { if (!settingsHydrated) return; const currentProfiles = getClientSettings().browserProfiles; if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return; const taken = new Set(resolveBrowserProfiles(currentProfiles).map((profile) => profile.name)); let name = "New profile"; for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`; updateSettings({ browserProfiles: [ ...currentProfiles, { id: `profile-${randomUUID()}`, name, kind: "persistent" as const }, ], }); }; const renameProfile = (id: string, next: string) => { if (!settingsHydrated) return; const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH); if (name === "") return; const currentProfiles = getClientSettings().browserProfiles; updateSettings({ browserProfiles: currentProfiles.map((profile) => profile.id === id ? { ...profile, name } : profile, ), }); }; const removeProfile = async (id: string) => { if (!settingsHydrated) return; if (!removalAvailable) { setProfileRemovalError("Connect to an environment before removing this profile."); return; } setProfileRemovalError(null); setProfileRemovalInFlight(true); // Drop the partition's data too, otherwise a removed profile's cookies // stay on disk with nothing in the UI pointing at them. try { await clearBrowserProfileData( previewBridge, environmentsReady ? environments.map((environment) => environment.environmentId) : [], id, ); } catch { setProfileRemovalError("Profile data could not be deleted. Try again."); setProfileRemovalInFlight(false); return; } const currentSettings = getClientSettings(); updateSettings({ browserProfiles: currentSettings.browserProfiles.filter((profile) => profile.id !== id), // Reassign the default rather than leaving it pointing at nothing. ...(currentSettings.browserDefaultProfileId === id ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID } : {}), }); setProfileRemovalInFlight(false); setProfilePendingRemoval(null); }; return ( = BROWSER_PROFILE_MAX_COUNT} onClick={addProfile} > Add profile } > {/* Each profile is its own bounded row, and the list carries the bottom spacing `SettingsRow` leaves to its children (`pt-3 pb-1`). Bare rows stack on narrow viewports with a larger gap inside a row than between rows, which reads as the remove button belonging to the profile below. */}
{resolveBrowserProfiles(userProfiles).map((profile) => { const builtIn = isBuiltInBrowserProfileId(profile.id); return (
{builtIn ? ( // Dimmed here rather than on the list, which is the only // content in the row without a disabled treatment of its own: // a wrapper-level dim would stack with the rename field's and // the remove button's, landing them near 0.41 while every // other disabled control in the block sits at 0.64. {profile.name} {profile.kind === "incognito" ? "Ephemeral" : "Built-in"} ) : ( renameProfile(profile.id, next)} /> )} {builtIn ? null : ( } /> {removalAvailable ? "Remove profile and its data" : "Connect to an environment to remove this profile"} )}
); })}
{ if (!open && !profileRemovalInFlight) { setProfilePendingRemoval(null); setProfileRemovalError(null); } }} > Remove “{profilePendingRemoval?.name}”? Its cookies, logins, and cache are deleted with it. Tabs already open in this profile stay open until you close them. {profileRemovalError ? (

{profileRemovalError}

) : null} {!removalAvailable ? (

Connect to an environment to remove this profile and its data.

) : null}
} > Cancel
); } function BrowserDefaultProfileSetting({ disabled }: { readonly disabled: boolean }) { const userProfiles = useClientSettings((settings) => settings.browserProfiles); const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); const settingsHydrated = useClientSettingsHydrated(); const updateSettings = useUpdatePrimarySettings(); const profileWritesDisabled = disabled || !settingsHydrated; // Incognito is deliberately absent: as a default it would open every tab // into storage that is discarded on close. const profiles = resolveBrowserProfiles(userProfiles).filter( (profile) => profile.kind !== "incognito", ); const selected = findBrowserProfile(profiles, defaultProfileId) ?? profiles[0]; return ( { if (settingsHydrated) { updateSettings({ browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID }); } }} /> ) : null } control={ } /> ); } 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 any client anchored to a server; `serverScoped` covers the hosted app, which has none. It sits outside the block covering the desktop-only defaults. */} {previewDefaultsDisabled ? ( {previewDefaults} ) : ( previewDefaults )} ); }