/** * 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 { DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, 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<Record<PreviewAppearancePreference, string>> = { 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, { 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="The viewport a browser tab opens at, for both you and agents. Fill sizes the page to the panel; any other choice opens the device toolbar at that size." 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 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 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 AgentBrowserAccessSetting() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); return ( <SettingsRow {...searchableSetting("agent-browser-access")} description="Let agents open and drive the preview browser. When off, the browser tools and the instructions describing them are withheld from agent sessions. Your own browser panel is unaffected." status={ settings.enableAgentBrowserAccess ? undefined : "Applies to sessions started from now on; a running agent keeps the tools it was given." } resetAction={ settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess ? ( <SettingResetButton label="agent browser access" onClick={() => updateSettings({ enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess, }) } /> ) : null } control={ <Switch checked={settings.enableAgentBrowserAccess} onCheckedChange={(checked) => 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 ( <SettingsRow {...searchableSetting("browser-auto-show-floating-preview")} description="Pop the floating preview into view when an agent opens a browser. An agent that explicitly asks to show or hide its preview still gets what it asked for." resetAction={ !disabled && autoShow !== DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW ? ( <SettingResetButton label="auto-show floating preview" onClick={() => updateSettings({ browserAutoShowFloatingPreview: DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, }) } /> ) : null } control={ <Switch disabled={disabled} checked={autoShow} onCheckedChange={(checked) => 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 ( <div className="rounded-xl border border-border/60 bg-muted/20 py-1.5"> <div className="flex items-start gap-2 px-3 py-2 text-[12px] leading-relaxed text-muted-foreground sm:px-4"> <InfoIcon className="mt-0.5 size-3.5 shrink-0 text-warning" /> <p>Only available in the desktop app.</p> </div> <div className="[&_h3]:opacity-64 [&_p]:opacity-64">{children}</div> </div> );} export function IntegrationsSettingsPanel() { // Client-local preview defaults are editable only where the preview exists. const previewDefaultsDisabled = !isElectron; const previewDefaults = ( <> <BrowserViewportSetting disabled={previewDefaultsDisabled} /> <BrowserZoomSetting disabled={previewDefaultsDisabled} /> <BrowserAppearanceSetting disabled={previewDefaultsDisabled} /> <BrowserAutoShowFloatingPreviewSetting disabled={previewDefaultsDisabled} /> </> ); return ( <SettingsPageContainer> <SettingsSection id="browser" title="Browser"> {/* Server-authoritative, so it stays editable on every client and sits outside the block covering the desktop-only defaults. */} <AgentBrowserAccessSetting /> {previewDefaultsDisabled ? ( <DesktopOnlyBrowserDefaults>{previewDefaults}</DesktopOnlyBrowserDefaults> ) : ( previewDefaults )} </SettingsSection> </SettingsPageContainer> );}
/**
* 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 {
DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
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<Record<PreviewAppearancePreference, string>> = {
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, { 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="The viewport a browser tab opens at, for both you and agents. Fill sizes the page to the panel; any other choice opens the device toolbar at that size."
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 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 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 AgentBrowserAccessSetting() {
const settings = usePrimarySettings();
const updateSettings = useUpdatePrimarySettings();
return (
<SettingsRow
{...searchableSetting("agent-browser-access")}
description="Let agents open and drive the preview browser. When off, the browser tools and the instructions describing them are withheld from agent sessions. Your own browser panel is unaffected."
status={
settings.enableAgentBrowserAccess
? undefined
: "Applies to sessions started from now on; a running agent keeps the tools it was given."
}
resetAction={
settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess ? (
<SettingResetButton
label="agent browser access"
onClick={() =>
updateSettings({
enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess,
})
}
/>
) : null
}
control={
<Switch
checked={settings.enableAgentBrowserAccess}
onCheckedChange={(checked) =>
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 (
<SettingsRow
{...searchableSetting("browser-auto-show-floating-preview")}
description="Pop the floating preview into view when an agent opens a browser. An agent that explicitly asks to show or hide its preview still gets what it asked for."
resetAction={
!disabled && autoShow !== DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW ? (
<SettingResetButton
label="auto-show floating preview"
onClick={() =>
updateSettings({
browserAutoShowFloatingPreview: DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
})
}
/>
) : null
}
control={
<Switch
disabled={disabled}
checked={autoShow}
onCheckedChange={(checked) =>
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 (
<div className="rounded-xl border border-border/60 bg-muted/20 py-1.5">
<div className="flex items-start gap-2 px-3 py-2 text-[12px] leading-relaxed text-muted-foreground sm:px-4">
<InfoIcon className="mt-0.5 size-3.5 shrink-0 text-warning" />
<p>Only available in the desktop app.</p>
</div>
<div className="[&_h3]:opacity-64 [&_p]:opacity-64">{children}</div>
</div>
);
}
export function IntegrationsSettingsPanel() {
// Client-local preview defaults are editable only where the preview exists.
const previewDefaultsDisabled = !isElectron;
const previewDefaults = (
<>
<BrowserViewportSetting disabled={previewDefaultsDisabled} />
<BrowserZoomSetting disabled={previewDefaultsDisabled} />
<BrowserAppearanceSetting disabled={previewDefaultsDisabled} />
<BrowserAutoShowFloatingPreviewSetting disabled={previewDefaultsDisabled} />
</>
);
return (
<SettingsPageContainer>
<SettingsSection id="browser" title="Browser">
{/* Server-authoritative, so it stays editable on every client and sits
outside the block covering the desktop-only defaults. */}
<AgentBrowserAccessSetting />
{previewDefaultsDisabled ? (
<DesktopOnlyBrowserDefaults>{previewDefaults}</DesktopOnlyBrowserDefaults>
) : (
previewDefaults
)}
</SettingsSection>
</SettingsPageContainer>
);
}