feat(web): configurable browser defaults in Settings → Integrations#7082

merged8
main
browser-viewport-defaults
openedAug 15, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 3d ago
7 unresolved threads · Jump to file
Selected thread: apps/web/src/components/settings/IntegrationsSettings.tsx:182 — <!-- MURMUR_IGNORE --> `settings/IntegrationsSettings.tsx:182` The viewport control cluster can't shrink, so this row overflows on narrow widths. `SelectTrigger`'s default variant carries `min-w-36` (never overridden here), and the sized group next to it is `shrink-0` with two `w-24` fields plus the rotate button (~240px), giving the control a hard minimum of ~390px — more than a `SettingsRow` gets at phone widths or in a narrow desktop window, so the row spills horizontally once a non-fill default is configured. The comparable two-control row in `SettingsPanels.tsx` (font family + size) avoids this by making the flexible control `min-w-0 flex-1` next to a small fixed control. Allowing the cluster to wrap and letting the select shrink keeps the geometry intact: ```suggestion <div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end"> <Select value={viewportSelectValue(viewport)} onValueChange={selectViewport} disabled={disabled} > <SelectTrigger className="w-full min-w-0 sm:w-44" aria-label="Default browser viewport" > ``` No longer relevant as of bb0492d294a1597184c8f77ceb74fb2dfa097239
0 / 32 viewed32 files changedLine totals unavailable

packages/contracts/src/settings.ts · Earlier revision cca9dc83 · right 149

Original diff
@@ -111,7 +119,34 @@ export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationM
 export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200));
 export type FontFamilyPreference = typeof FontFamilyPreference.Type;
 
+/**
+ * Defaults for the in-app preview browser, applied whenever a tab is opened
+ * without an explicit viewport/zoom/appearance — by the user opening a browser
+ * tab, or by an agent calling `preview_open` with no size. Client-local
+ * because the Chromium guest they configure is desktop-local.
+ */
+export const DEFAULT_BROWSER_VIEWPORT: PreviewViewportSetting = FILL_PREVIEW_VIEWPORT;
+export const DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW = true;
+
 export const ClientSettingsSchema = Schema.Struct({
+  browserDefaultViewport: PreviewViewportSetting.pipe(
+    Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_VIEWPORT)),
+  ),
+  browserDefaultZoomFactor: PreviewZoomFactor.pipe(
+    Schema.withDecodingDefault(Effect.succeed(DEFAULT_PREVIEW_ZOOM_FACTOR)),
+  ),
+  browserDefaultAppearance: PreviewAppearancePreference.pipe(
+    Schema.withDecodingDefault(Effect.succeed(DEFAULT_PREVIEW_APPEARANCE)),
+  ),
+  /**
+   * Whether an agent opening a preview pops the floating mini player into
+   * view. Only applies when the agent didn't ask either way — an explicit
+   * `open`/`show` on `preview_open` still wins, since that is the agent
+   * deliberately showing or hiding its work.
+   */
+  browserAutoShowFloatingPreview: Schema.Boolean.pipe(
+    Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)),
+  ),
C
cursorBotAug 15, 2026

Restore defaults skips browser prefs

Medium Severity

General’s Restore defaults is meant to reset every non-legacy preference, but the new browserDefaultViewport, browserDefaultZoomFactor, browserDefaultAppearance, and browserAutoShowFloatingPreview fields are missing from both dirty tracking and the reset payload. Changing only Integrations → Browser leaves Restore idle, and a restore that does run leaves those values in place.

Triggered by learned rule: Restore defaults on General settings page is intentionally global — not scoped per-page

Reviewed by Cursor Bugbot for commit cca9dc83304dbee5c4ceed3fae636b729504521c. Configure here.

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 7e590424 · right 176–182

Original diff
@@ -0,0 +1,432 @@
+/**
+ * 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_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, 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 items-center gap-2 sm:w-auto">
+          <Select
+            value={viewportSelectValue(viewport)}
+            onValueChange={selectViewport}
+            disabled={disabled}
+          >
+            <SelectTrigger className="w-full sm:w-44" aria-label="Default browser viewport">

apps/web/src/components/settings/IntegrationsSettings.tsx · right 176–182

M
macroscopeappBotAug 15, 2026

settings/IntegrationsSettings.tsx:182

The viewport control cluster can't shrink, so this row overflows on narrow widths. SelectTrigger's default variant carries min-w-36 (never overridden here), and the sized group next to it is shrink-0 with two w-24 fields plus the rotate button (~240px), giving the control a hard minimum of ~390px — more than a SettingsRow gets at phone widths or in a narrow desktop window, so the row spills horizontally once a non-fill default is configured. The comparable two-control row in SettingsPanels.tsx (font family + size) avoids this by making the flexible control min-w-0 flex-1 next to a small fixed control. Allowing the cluster to wrap and letting the select shrink keeps the geometry intact:

suggestion
1 <div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
2 <Select
3 value={viewportSelectValue(viewport)}
4 onValueChange={selectViewport}
5 disabled={disabled}
6 >
7 <SelectTrigger
8 className="w-full min-w-0 sm:w-44"
9 aria-label="Default browser viewport"
10 >

No longer relevant as of bb0492d294a1597184c8f77ceb74fb2dfa097239

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision ab6e0eda · right 404

Original diff
@@ -0,0 +1,432 @@
+/**
+ * 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_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, 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 items-center gap-2 sm:w-auto">
+          <Select
+            value={viewportSelectValue(viewport)}
+            onValueChange={selectViewport}
+            disabled={disabled}
+          >
+            <SelectTrigger className="w-full 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 shrink-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-24"
+                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-24"
+                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 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="opacity-64">{children}</div>
M
macroscopeappBotAug 15, 2026

settings/IntegrationsSettings.tsx:404

The wrapper's opacity-64 compounds with the dimming each primitive already applies for its own disabled state — SelectTrigger has data-disabled:opacity-64, NumberFieldGroup has data-disabled:opacity-64, Button has disabled:opacity-64 — so in the browser build these controls composite to roughly 0.41 alpha, noticeably fainter than a disabled control anywhere else in Settings, while the labels/description only get the single 0.64 step.

The read-only block in ProviderSettingsPanel solves the same "dim the whole group" problem with inert + aria-disabled plus one wrapper opacity, which keeps a single dim level and still blocks focus and pointer interaction. Consider following that here (wrapper gets inert/aria-disabled, controls drop the disabled prop) so the group dims exactly once; note the resetAction guards currently keyed on !disabled would then need the same flag to stay hidden.

No longer relevant as of bb0492d294a1597184c8f77ceb74fb2dfa097239

apps/web/src/browser/browserDefaults.ts · Earlier revision ab6e0eda · right 47

Original diff
@@ -0,0 +1,98 @@
+/**
+ * Browser defaults - resolves the configured starting state for preview tabs.
+ *
+ * Settings → Integrations → Browser lets the user pick the viewport, zoom, and
+ * appearance a preview tab should open at. Those preferences apply to every
+ * entry point that opens a tab without stating its own: the user opening a
+ * browser panel, and agents calling `preview_open` with no size.
+ *
+ * The values live in client settings because the Chromium guest they configure
+ * is desktop-local, so this module reads them through the same external store
+ * the settings UI writes to, and exposes a non-hook accessor for the imperative
+ * open paths that run outside React.
+ *
+ * @module browserDefaults
+ */
+import type {
+  DesktopPreviewTabDefaults,
+  PreviewAppearancePreference,
+  PreviewViewportSetting,
+} from "@t3tools/contracts";
+
+import { getClientSettings, useClientSettings } from "~/hooks/useSettings";
+
+import { resolveResponsiveBrowserViewportSize } from "./browserViewportLayout";
+
+export interface BrowserDefaults {
+  readonly viewport: PreviewViewportSetting;
+  readonly zoomFactor: number;
+  readonly appearance: PreviewAppearancePreference;
+  readonly autoShowFloatingPreview: boolean;
+}
+
+const toBrowserDefaults = (settings: {
+  readonly browserDefaultViewport: PreviewViewportSetting;
+  readonly browserDefaultZoomFactor: number;
+  readonly browserDefaultAppearance: PreviewAppearancePreference;
+  readonly browserAutoShowFloatingPreview: boolean;
+}): BrowserDefaults => ({
+  viewport: settings.browserDefaultViewport,
+  zoomFactor: settings.browserDefaultZoomFactor,
+  appearance: settings.browserDefaultAppearance,
+  autoShowFloatingPreview: settings.browserAutoShowFloatingPreview,
+});
+
+/** Non-hook accessor for imperative open paths (menu actions, automation hosts). */
+export function getBrowserDefaults(): BrowserDefaults {
+  return toBrowserDefaults(getClientSettings());
M
macroscopeappBotAug 15, 2026

🟡 Medium browser/browserDefaults.ts:47

A preview opened before client-settings hydration completes is created with DEFAULT_CLIENT_SETTINGS for its viewport, zoom, and appearance, and never receives the user's persisted creation defaults. getBrowserDefaults() synchronously reads getClientSettings() while the snapshot still contains built-in values; wait for hydration before this one-shot creation path, or gate preview opening during startup.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/browser/browserDefaults.ts around line 47:
2
3A preview opened before client-settings hydration completes is created with `DEFAULT_CLIENT_SETTINGS` for its viewport, zoom, and appearance, and never receives the user's persisted creation defaults. `getBrowserDefaults()` synchronously reads `getClientSettings()` while the snapshot still contains built-in values; wait for hydration before this one-shot creation path, or gate preview opening during startup.

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision ab6e0eda · right 205

Original diff
@@ -0,0 +1,432 @@
+/**
+ * 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_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, 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 items-center gap-2 sm:w-auto">
+          <Select
+            value={viewportSelectValue(viewport)}
+            onValueChange={selectViewport}
+            disabled={disabled}
+          >
+            <SelectTrigger className="w-full 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 shrink-0 items-center gap-1">
M
macroscopeappBotAug 15, 2026

settings/IntegrationsSettings.tsx:205

The controls in this cluster resolve to different heights: the adjacent SelectTrigger uses the default size (min-h-9 sm:min-h-8, 36/32px) while these size="sm" number fields render 32/28px and the icon-sm rotate button 32/28px, so the select sits 4px taller than everything beside it in the same items-center row.

Suggest picking one height for the cluster — either size="sm" on the viewport SelectTrigger (which also lowers the row's minimum width), or default-size NumberFields with size="icon" on the rotate button to match the other rows' selects.

No longer relevant as of bb0492d294a1597184c8f77ceb74fb2dfa097239

apps/web/src/components/preview/PreviewAutomationHosts.tsx · Earlier revision ab6e0eda · right 435

Original diff
@@ -428,7 +432,10 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
                 updatePreviewServerSnapshot(threadRef, resizeResult.value);
               }
             }
-            const shouldPresentPreview = shouldOpenPreviewMiniPlayer(input);
+            const shouldPresentPreview = shouldOpenPreviewMiniPlayer(
M
macroscopeappBotAug 15, 2026

🟡 Medium preview/PreviewAutomationHosts.tsx:435

browserAutoShowFloatingPreview: false is ignored for normal preview_open requests, so the floating preview still opens when the agent omits visibility. normalizePreviewOpenInput fills omitted open with true before this handler runs, and shouldOpenPreviewMiniPlayer therefore never reaches the preference fallback; preserve whether open was omitted or apply the preference before normalization.

No longer relevant as of bb0492d294a1597184c8f77ceb74fb2dfa097239