# Files changed · PR \#7082 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/7082/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(web\): configurable browser defaults in Settings → Integrations](https://git.cafe/pingdotgg/t3code/pull/7082.md)

Version: 6

Head commit: 6489255f56d9ac7435a7189f827a687c1650ab93

Base commit: ad117235b544e23545fe39143812db2ddd41af1f

Comparison base: ad117235b544e23545fe39143812db2ddd41af1f

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/desktop/src/ipc/methods/preview.ts

Status: modified; +8; −3

```diff
@@ -14,6 +14,7 @@
   DesktopPreviewRegisterWebviewInputSchema,
   DesktopPreviewScreenshotArtifactSchema,
   DesktopPreviewSetColorSchemeInputSchema,
+  DesktopPreviewCreateTabInputSchema,
   DesktopPreviewTabInputSchema,
   DesktopPreviewWebviewConfigSchema,
   PreviewAnnotationSubmissionResultSchema,
@@ -48,11 +49,15 @@
 
 export const createTab = DesktopIpc.makeIpcMethod({
   channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL,
+  payload: DesktopPreviewCreateTabInputSchema,
-  payload: DesktopPreviewTabInputSchema,
   result: Schema.Void,
+  handler: Effect.fn("desktop.ipc.preview.createTab")(function* ({
+    tabId,
+    zoomFactor,
+    colorScheme,
+  }) {
-  handler: Effect.fn("desktop.ipc.preview.createTab")(function* ({ tabId }) {
     const manager = yield* PreviewManager.PreviewManager;
-    yield* manager.createTab(tabId);
+    yield* manager.createTab(tabId, { zoomFactor, colorScheme });
   }),
 });
 
```

## apps/desktop/src/preload.ts

Status: modified; +6; −1

```diff
@@ -163,7 +163,12 @@
     };
   },
   preview: {
+    createTab: (tabId, defaults) =>
+      ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, {
+        tabId,
+        zoomFactor: defaults?.zoomFactor,
+        colorScheme: defaults?.colorScheme,
+      }),
-    createTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, { tabId }),
     closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }),
     registerWebview: (tabId, webContentsId) =>
       ipcRenderer.invoke(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, { tabId, webContentsId }),
```

## apps/desktop/src/preview/Manager.ts

Status: modified; +28; −5

```diff
@@ -16,6 +16,7 @@
   DesktopPreviewRecordingArtifact,
   DesktopPreviewRecordingFrame,
   DesktopPreviewScreenshotArtifact,
+  DesktopPreviewTabDefaults,
   PreviewAutomationClickInput,
   PreviewAutomationActionEvent,
   PreviewAutomationConsoleEntry,
@@ -334,6 +335,21 @@
   return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1;
 };
 
+/**
+ * Clamp a client-supplied zoom factor onto the discrete ladder. The setting is
+ * chosen from the same ladder, but it arrives over IPC from a schema that only
+ * guarantees a positive number, so an out-of-band value snaps to the nearest
+ * step rather than leaving the guest at a zoom the zoom controls can't reach.
+ */
+const normalizeZoomFactor = (value: number | undefined): number => {
+  if (value === undefined || !Number.isFinite(value)) return DEFAULT_ZOOM_FACTOR;
+  let closest = ZOOM_LEVELS[0]!;
+  for (const level of ZOOM_LEVELS) {
+    if (Math.abs(level - value) < Math.abs(closest - value)) closest = level;
+  }
+  return closest;
+};
+
 const nextZoomLevel = (current: number, direction: "in" | "out"): number => {
   const step = findZoomStep(current);
   if (direction === "in") {
@@ -1614,6 +1630,7 @@
 
   const createTabUnlocked = Effect.fn("PreviewManager.createTabUnlocked")(function* (
     tabId: string,
+    defaults?: DesktopPreviewTabDefaults,
   ) {
     const updatedAt = yield* currentIso;
     const result = yield* SynchronizedRef.modify(
@@ -1632,9 +1649,9 @@
           navStatus: { kind: "Idle" },
           canGoBack: false,
           canGoForward: false,
-          zoomFactor: DEFAULT_ZOOM_FACTOR,
+          zoomFactor: normalizeZoomFactor(defaults?.zoomFactor),
           pictureInPicture: false,
-          colorScheme: "system",
+          colorScheme: defaults?.colorScheme ?? "system",
           controller: "none",
           updatedAt,
         };
@@ -1653,8 +1670,11 @@
     return result.state;
   });
 
-  const createTab = Effect.fn("PreviewManager.createTab")(function* (tabId: string) {
-    return yield* withTabLifecycleLock(tabId, createTabUnlocked(tabId));
+  const createTab = Effect.fn("PreviewManager.createTab")(function* (
+    tabId: string,
+    defaults?: DesktopPreviewTabDefaults,
+  ) {
+    return yield* withTabLifecycleLock(tabId, createTabUnlocked(tabId, defaults));
   });
 
   const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) {
@@ -3802,7 +3822,10 @@
     readonly setMainWindow: (window: BrowserWindow) => Effect.Effect<void, PreviewManagerError>;
     readonly getBrowserSession: (scope?: string) => Effect.Effect<Session, PreviewManagerError>;
     readonly isBrowserPartition: (partition: string) => boolean;
-    readonly createTab: (tabId: string) => Effect.Effect<PreviewTabState, PreviewManagerError>;
+    readonly createTab: (
+      tabId: string,
+      defaults?: DesktopPreviewTabDefaults,
+    ) => Effect.Effect<PreviewTabState, PreviewManagerError>;
     readonly closeTab: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
     readonly registerWebview: (
       tabId: string,
```

## apps/desktop/src/settings/DesktopClientSettings.test.ts

Status: modified; +4; −0

```diff
@@ -13,6 +13,10 @@
 import * as DesktopClientSettings from "./DesktopClientSettings.ts";
 
 const clientSettings: ClientSettings = {
+  browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
+  browserDefaultZoomFactor: 1.25,
+  browserDefaultAppearance: "dark",
+  browserAutoShowFloatingPreview: false,
   confirmQuit: true,
   confirmThreadArchive: true,
   confirmThreadDelete: false,
```

## apps/server/src/mcp/toolkits/preview/handlers.test.ts

Status: modified; +4; −6

```diff
@@ -3,12 +3,10 @@
 import { normalizePreviewOpenInput } from "./handlers.ts";
 
 describe("normalizePreviewOpenInput", () => {
-  it("opens the inline preview and reuses the current tab by default", () => {
-    expect(normalizePreviewOpenInput({})).toEqual({
-      open: true,
-      reuseExistingTab: true,
-      show: true,
+  it("leaves an unstated visibility for the client preference to decide", () => {
+    // Filling `open` in here would outrank `browserAutoShowFloatingPreview`,
+    // which is desktop-local and cannot be read from the server.
-    });
+    expect(normalizePreviewOpenInput({})).toEqual({ reuseExistingTab: true });
   });
 
   it("preserves an explicit background-only opt-out", () => {
```

## apps/server/src/mcp/toolkits/preview/handlers.ts

Status: modified; +10; −3

```diff
@@ -15,14 +15,21 @@
 import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
 import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts";
 
+/**
+ * Collapses the `show` alias onto `open` and defaults tab reuse.
+ *
+ * Deliberately leaves an unstated `open` unstated. Whether a preview the agent
+ * said nothing about surfaces is the user's `browserAutoShowFloatingPreview`
+ * preference, which is desktop-local and unreadable from here — filling in
+ * `true` would silently override it for every `preview_open`.
+ */
 export function normalizePreviewOpenInput(
   input: PreviewAutomationOpenInput,
 ): PreviewAutomationOpenInput {
+  const open = input.open ?? input.show;
-  const open = input.open ?? input.show ?? true;
   return {
     ...input,
-    open,
+    ...(open === undefined ? {} : { open, show: open }),
-    show: open,
     reuseExistingTab: input.reuseExistingTab ?? true,
   };
 }
```

## apps/server/src/preview/Manager.ts

Status: modified; +11; −3

```diff
@@ -24,6 +24,7 @@
   FILL_PREVIEW_VIEWPORT,
   PreviewSessionLookupError,
   type PreviewSessionSnapshot,
+  type PreviewViewportSetting,
 } from "@t3tools/contracts";
 import {
   isPreviewUrlNormalizationError,
@@ -121,6 +122,7 @@
   readonly tabId: string;
   readonly url: string;
   readonly title: string;
+  readonly viewport: PreviewViewportSetting;
   readonly updatedAt: string;
 }): PreviewSessionSnapshot => ({
   threadId: input.threadId,
@@ -128,13 +130,14 @@
   navStatus: { _tag: "Loading", url: input.url, title: input.title },
   canGoBack: false,
   canGoForward: false,
-  viewport: FILL_PREVIEW_VIEWPORT,
+  viewport: input.viewport,
   updatedAt: input.updatedAt,
 });
 
 const buildIdleSnapshot = (input: {
   readonly threadId: string;
   readonly tabId: string;
+  readonly viewport: PreviewViewportSetting;
   readonly updatedAt: string;
 }): PreviewSessionSnapshot => ({
   threadId: input.threadId,
@@ -142,7 +145,7 @@
   navStatus: { _tag: "Idle" },
   canGoBack: false,
   canGoForward: false,
+  viewport: input.viewport,
-  viewport: FILL_PREVIEW_VIEWPORT,
   updatedAt: input.updatedAt,
 });
 
@@ -215,15 +218,20 @@
     function* (input) {
       const tabId = newPreviewTabId();
       const updatedAt = yield* currentIsoTimestamp;
+      // Clients with a configured default send the viewport up front so the
+      // session is born at the right size; older clients omit it and keep the
+      // historical fill-panel behaviour.
+      const viewport = input.viewport ?? FILL_PREVIEW_VIEWPORT;
       const snapshot = input.url
         ? buildLoadingSnapshot({
             threadId: input.threadId,
             tabId,
             url: yield* normalizeUrl(input.url),
             title: "",
+            viewport,
             updatedAt,
           })
+        : buildIdleSnapshot({ threadId: input.threadId, tabId, viewport, updatedAt });
-        : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt });
       yield* SynchronizedRef.modifyEffect(stateRef, (state) =>
         Effect.gen(function* () {
           const revision = state.revision + 1;
```

## apps/web/src/browser/BrowserDeviceToolbar.tsx

Status: modified; +1; −20

```diff
@@ -25,6 +25,7 @@
 
 import { BROWSER_DEVICE_TOOLBAR_HEIGHT, resizeFreeformViewport } from "./browserViewportLayout";
 import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState";
+import { ScreenRotationIcon } from "./ScreenRotationIcon";
 
 const RESPONSIVE_VALUE = "responsive";
 const SELECT_ITEMS = [
@@ -32,26 +33,6 @@
   ...PREVIEW_VIEWPORT_PRESETS.map((preset) => ({ value: preset.id, label: preset.label })),
 ];
 
-function ScreenRotationIcon() {
-  return (
-    <svg
-      viewBox="0 0 24 24"
-      fill="none"
-      stroke="currentColor"
-      strokeWidth="2"
-      strokeLinecap="round"
-      strokeLinejoin="round"
-      aria-hidden="true"
-    >
-      <rect x="7.25" y="7.25" width="9.5" height="9.5" rx="1.4" transform="rotate(-45 12 12)" />
-      <path d="M12.5 2a10 10 0 0 1 8.4 5.4" />
-      <path d="M20.8 3.5v4h-4" />
-      <path d="M11.5 22a10 10 0 0 1-8.4-5.4" />
-      <path d="M3.2 20.5v-4h4" />
-    </svg>
-  );
-}
-
 interface Props {
   readonly setting: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>;
   readonly width: number;
```

## apps/web/src/browser/ScreenRotationIcon.tsx

Status: added; +26; −0

```diff
@@ -1,0 +1,26 @@
+/**
+ * Screen-rotation glyph shared by the in-browser device toolbar and the
+ * default-viewport setting, so the orientation control reads the same in both
+ * places.
+ *
+ * @module ScreenRotationIcon
+ */
+export function ScreenRotationIcon() {
+  return (
+    <svg
+      viewBox="0 0 24 24"
+      fill="none"
+      stroke="currentColor"
+      strokeWidth="2"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      aria-hidden="true"
+    >
+      <rect x="7.25" y="7.25" width="9.5" height="9.5" rx="1.4" transform="rotate(-45 12 12)" />
+      <path d="M12.5 2a10 10 0 0 1 8.4 5.4" />
+      <path d="M20.8 3.5v4h-4" />
+      <path d="M11.5 22a10 10 0 0 1-8.4-5.4" />
+      <path d="M3.2 20.5v-4h4" />
+    </svg>
+  );
+}
```

## apps/web/src/browser/browserDefaults.ts

Status: added; +114; −0

```diff
@@ -1,0 +1,114 @@
+/**
+ * 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 {
+  ensureClientSettingsHydrated,
+  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());
+}
+
+/**
+ * The defaults, once client settings have actually loaded.
+ *
+ * Opening a preview is asynchronous anyway, and before hydration the snapshot
+ * is the schema defaults rather than the user's — a tab opened in that window
+ * would be born at the wrong viewport, zoom and appearance and never corrected.
+ */
+export async function resolveBrowserDefaults(): Promise<BrowserDefaults> {
+  await ensureClientSettingsHydrated();
+  return getBrowserDefaults();
+}
+
+export function useBrowserDefaults(): BrowserDefaults {
+  return useClientSettings(toBrowserDefaults);
+}
+
+/**
+ * The zoom/appearance half of the defaults, in the shape `createTab` takes.
+ * Passing these at creation rather than after registration keeps the guest from
+ * painting a frame at 100%/system first.
+ */
+export function browserDefaultTabState(
+  defaults: BrowserDefaults = getBrowserDefaults(),
+): DesktopPreviewTabDefaults {
+  return { zoomFactor: defaults.zoomFactor, colorScheme: defaults.appearance };
+}
+
+/**
+ * The viewport a *newly opened* tab should start at. Sent with `preview.open`
+ * so the session is born at the configured size instead of being resized a
+ * frame later, which the user would see as a visible reflow.
+ */
+export function browserDefaultOpenViewport(
+  defaults: BrowserDefaults = getBrowserDefaults(),
+): PreviewViewportSetting {
+  return defaults.viewport;
+}
+
+/**
+ * The viewport to switch to when the user turns the device toolbar on for a tab
+ * currently in fill mode.
+ *
+ * A configured non-fill default is what the user said they want to look at, so
+ * it wins. When the default is fill there is no stated preference to honour, so
+ * fall back to fitting the panel — and only when the panel hasn't been measured
+ * yet does a fixed size apply.
+ */
+export const FALLBACK_RESPONSIVE_VIEWPORT_SIZE = { width: 1024, height: 768 } as const;
+
+export function browserResponsiveViewportForToggle(input: {
+  readonly defaults?: BrowserDefaults;
+  readonly panelRect: { readonly width: number; readonly height: number } | null;
+  readonly zoomFactor: number | undefined;
+}): PreviewViewportSetting {
+  const defaults = input.defaults ?? getBrowserDefaults();
+  if (defaults.viewport._tag !== "fill") return defaults.viewport;
+  const size = input.panelRect
+    ? resolveResponsiveBrowserViewportSize(input.panelRect, input.zoomFactor)
+    : FALLBACK_RESPONSIVE_VIEWPORT_SIZE;
+  return { _tag: "freeform", ...size };
+}
```

## apps/web/src/browser/desktopTabLifetime.test.ts

Status: modified; +18; −5

```diff
@@ -1,4 +1,9 @@
+import {
+  DEFAULT_PREVIEW_APPEARANCE,
-import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+  DEFAULT_PREVIEW_ZOOM_FACTOR,
+  EnvironmentId,
+  ThreadId,
+} from "@t3tools/contracts";
 import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
 
 const { closeTab, createTab, stopBrowserRecording } = vi.hoisted(() => ({
@@ -16,6 +21,12 @@
 }));
 
 import { acquireDesktopTab } from "./desktopTabLifetime";
+
+/** Client settings are unset in tests, so creation carries the schema defaults. */
+const DEFAULT_TAB_STATE = {
+  zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
+  colorScheme: DEFAULT_PREVIEW_APPEARANCE,
+};
 import { previewRuntimeTabId } from "./previewRuntimeTabId";
 
 describe("desktopTabLifetime", () => {
@@ -42,14 +53,16 @@
     const first = acquireDesktopTab("tab_readiness");
     const second = acquireDesktopTab("tab_readiness");
 
+    // Both leases share one creation, and it is still in flight: creation now
-    expect(createTab).toHaveBeenCalledOnce();
+    // waits for client settings to hydrate so the guest is born at the user's
+    // zoom and appearance rather than painting at the defaults first.
     expect(first.ready).toBe(second.ready);
 
     let ready = false;
     void first.ready.then(() => {
       ready = true;
     });
+    await vi.waitFor(() => expect(createTab).toHaveBeenCalledOnce());
-    await Promise.resolve();
     expect(ready).toBe(false);
 
     resolveCreation?.();
@@ -81,8 +94,8 @@
     const second = acquireDesktopTab(tabB);
     await Promise.all([first.ready, second.ready]);
 
+    expect(createTab).toHaveBeenCalledWith(tabA, DEFAULT_TAB_STATE);
+    expect(createTab).toHaveBeenCalledWith(tabB, DEFAULT_TAB_STATE);
-    expect(createTab).toHaveBeenCalledWith(tabA);
-    expect(createTab).toHaveBeenCalledWith(tabB);
     expect(createTab).toHaveBeenCalledTimes(2);
 
     first.release();
```

## apps/web/src/browser/desktopTabLifetime.ts

Status: modified; +6; −1

```diff
@@ -1,5 +1,6 @@
 import { previewBridge } from "~/components/preview/previewBridge";
 
+import { browserDefaultTabState, resolveBrowserDefaults } from "./browserDefaults";
 import { stopBrowserRecording } from "./browserRecording";
 
 interface DesktopTabLease {
@@ -41,7 +42,11 @@
     ({
       references: 0,
       closeTimer: null,
+      // Zoom/appearance defaults travel with creation so the guest never
+      // paints a frame at 100%/system before the preference is applied.
+      ready: enqueueDesktopTabOperation(tabId, async () =>
+        previewBridge?.createTab(tabId, browserDefaultTabState(await resolveBrowserDefaults())),
+      ),
-      ready: enqueueDesktopTabOperation(tabId, () => previewBridge?.createTab(tabId)),
     } satisfies DesktopTabLease);
   if (current.closeTimer !== null) window.clearTimeout(current.closeTimer);
   current.references += 1;
```

## apps/web/src/components/preview/PreviewAutomationHosts.tsx

Status: modified; +8; −1

```diff
@@ -38,6 +38,7 @@
 } from "~/browser/browserRecording";
 import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope";
 import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
+import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults";
 import { runBrowserViewportMutation } from "~/browser/browserViewportActions";
 import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
 import { isElectron } from "~/env";
@@ -380,6 +381,9 @@
                 input: {
                   threadId: request.threadId,
                   ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}),
+                  // An agent that didn't state a size gets the user's
+                  // configured default, same as a hand-opened tab.
+                  viewport: browserDefaultOpenViewport(await resolveBrowserDefaults()),
                 },
               });
               if (result._tag === "Failure") {
@@ -428,7 +432,10 @@
                 updatePreviewServerSnapshot(threadRef, resizeResult.value);
               }
             }
+            const shouldPresentPreview = shouldOpenPreviewMiniPlayer(
-            const shouldPresentPreview = shouldOpenPreviewMiniPlayer(input);
+              input,
+              (await resolveBrowserDefaults()).autoShowFloatingPreview,
+            );
             if (shouldPresentPreview) {
               usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId);
             }
```

## apps/web/src/components/preview/PreviewView.test.tsx

Status: modified; +35; −1

```diff
@@ -1,4 +1,10 @@
+import {
+  DEFAULT_PREVIEW_APPEARANCE,
+  DEFAULT_PREVIEW_ZOOM_FACTOR,
+  EnvironmentId,
+  FILL_PREVIEW_VIEWPORT,
-import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+  ThreadId,
+} from "@t3tools/contracts";
 import { renderToStaticMarkup } from "react-dom/server";
 import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
 
@@ -41,6 +47,34 @@
   readPreparedConnection: mocks.readPreparedConnection,
 }));
 
+// Stubbed at the direct dependency rather than letting the real module pull in
+// `useSettings` -> `state/server`, which would drag the whole settings and
+// connection graph into a test that only cares about the browser chrome.
+vi.mock("~/browser/browserDefaults", () => ({
+  useBrowserDefaults: () => ({
+    viewport: FILL_PREVIEW_VIEWPORT,
+    zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
+    appearance: DEFAULT_PREVIEW_APPEARANCE,
+    autoShowFloatingPreview: true,
+  }),
+  getBrowserDefaults: () => ({
+    viewport: FILL_PREVIEW_VIEWPORT,
+    zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
+    appearance: DEFAULT_PREVIEW_APPEARANCE,
+    autoShowFloatingPreview: true,
+  }),
+  browserDefaultOpenViewport: () => FILL_PREVIEW_VIEWPORT,
+  browserDefaultTabState: () => ({
+    zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
+    colorScheme: DEFAULT_PREVIEW_APPEARANCE,
+  }),
+  browserResponsiveViewportForToggle: () => ({
+    _tag: "freeform" as const,
+    width: 1024,
+    height: 768,
+  }),
+}));
+
 vi.mock("~/composerDraftStore", () => ({
   useComposerDraftStore: (
     select: (store: { addPreviewAnnotation: () => void; addImage: () => void }) => unknown,
```

## apps/web/src/components/preview/PreviewView.tsx

Status: modified; +10; −7

```diff
@@ -43,7 +43,7 @@
   commitBrowserViewportChange,
   subscribeBrowserViewportChange,
 } from "~/browser/browserViewportActions";
-import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout";
+import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults";
 import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
 import { PreviewUnreachable } from "./PreviewUnreachable";
 import { revealInFileExplorerLabel } from "./fileExplorerLabel";
@@ -144,6 +144,7 @@
   const controller = desktopOverlay?.controller ?? "none";
   const loadProgress = useLoadingProgress(loading);
   const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;
+  const browserDefaults = useBrowserDefaults();
   const panelRect = useBrowserSurfaceStore((state) =>
     runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,
   );
@@ -249,12 +250,14 @@
       return;
     }
 
+    void commitBrowserViewportChange(
+      runtimeTabId,
+      browserResponsiveViewportForToggle({
+        defaults: browserDefaults,
+        panelRect,
+        zoomFactor: desktopOverlay?.zoomFactor,
-    const responsiveSize = panelRect
-      ? resolveResponsiveBrowserViewportSize(panelRect, desktopOverlay?.zoomFactor)
-      : { width: 1024, height: 768 };
-    void commitBrowserViewportChange(runtimeTabId, { _tag: "freeform", ...responsiveSize }).catch(
-      () => undefined,
-    );
+      }),
+    ).catch(() => undefined);
   };
 
   useEffect(() => {
```

## apps/web/src/components/preview/addBrowserSurface.test.ts

Status: modified; +10; −2

```diff
@@ -1,4 +1,9 @@
+import {
+  FILL_PREVIEW_VIEWPORT,
-import type { PreviewOpenInput, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
+  type PreviewOpenInput,
+  type PreviewSessionSnapshot,
+  type ScopedThreadRef,
+} from "@t3tools/contracts";
 import { AsyncResult } from "effect/unstable/reactivity";
 import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
 
@@ -40,7 +45,10 @@
 
     await addBrowserSurface({ threadRef, openPreview: ({ input }) => openPreview(input) });
 
+    expect(openPreview).toHaveBeenCalledWith({
-    expect(openPreview).toHaveBeenCalledWith({ threadId: "thread-1" });
+      threadId: "thread-1",
+      viewport: FILL_PREVIEW_VIEWPORT,
+    });
     expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]);
     expect(
       selectThreadRightPanelState(
```

## apps/web/src/components/preview/openPreviewSession.test.ts

Status: modified; +15; −3

```diff
@@ -1,4 +1,9 @@
+import {
+  FILL_PREVIEW_VIEWPORT,
+  type PreviewOpenInput,
+  type PreviewSessionSnapshot,
+  type ScopedThreadRef,
-import type { PreviewOpenInput, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
+} from "@t3tools/contracts";
 import * as Cause from "effect/Cause";
 import { AsyncResult } from "effect/unstable/reactivity";
 import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
@@ -41,7 +46,10 @@
       threadRef,
     });
 
-    expect(open).toHaveBeenCalledWith({ threadId: "thread-1" });
+    expect(open).toHaveBeenCalledWith({
+      threadId: "thread-1",
+      viewport: FILL_PREVIEW_VIEWPORT,
+    });
     expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot);
     expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]);
   });
@@ -55,7 +63,11 @@
       url: "t3.chat",
     });
 
+    expect(open).toHaveBeenCalledWith({
+      threadId: "thread-1",
-    expect(open).toHaveBeenCalledWith({ threadId: "thread-1", url: "t3.chat" });
+      url: "t3.chat",
+      viewport: FILL_PREVIEW_VIEWPORT,
+    });
     expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot);
     expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual(["https://t3.chat/"]);
   });
```

## apps/web/src/components/preview/openPreviewSession.ts

Status: modified; +5; −0

```diff
@@ -2,10 +2,12 @@
   EnvironmentId,
   PreviewOpenInput,
   PreviewSessionSnapshot,
+  PreviewViewportSetting,
   ScopedThreadRef,
 } from "@t3tools/contracts";
 import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
 
+import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults";
 import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";
 
 interface OpenPreviewSessionInput<E> {
@@ -15,6 +17,8 @@
   }) => Promise<AtomCommandResult<PreviewSessionSnapshot, E>>;
   threadRef: ScopedThreadRef;
   url?: string;
+  /** Overrides the configured default; automation passes an explicit size. */
+  viewport?: PreviewViewportSetting;
 }
 
 export async function openPreviewSession<E>(
@@ -25,6 +29,7 @@
     input: {
       threadId: input.threadRef.threadId,
       ...(input.url === undefined ? {} : { url: input.url }),
+      viewport: input.viewport ?? browserDefaultOpenViewport(await resolveBrowserDefaults()),
     },
   });
   if (result._tag === "Failure") {
```

## apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts

Status: modified; +15; −0

```diff
@@ -77,3 +77,18 @@
     ).toBeNull();
   });
 });
+
+describe("shouldOpenPreviewMiniPlayer with the floating-preview preference", () => {
+  it("honours the preference when the agent said nothing either way", () => {
+    // `preview_open` no longer arrives with `open` pre-filled, so an agent
+    // that omitted it leaves the decision to the user's setting.
+    expect(shouldOpenPreviewMiniPlayer({}, false)).toBe(false);
+    expect(shouldOpenPreviewMiniPlayer({}, true)).toBe(true);
+  });
+
+  it("lets an explicit request outrank the preference in both directions", () => {
+    expect(shouldOpenPreviewMiniPlayer({ open: true }, false)).toBe(true);
+    expect(shouldOpenPreviewMiniPlayer({ open: false }, true)).toBe(false);
+    expect(shouldOpenPreviewMiniPlayer({ show: true }, false)).toBe(true);
+  });
+});
```

## apps/web/src/components/preview/previewAutomationOpenReadiness.ts

Status: modified; +23; −2

```diff
@@ -5,14 +5,28 @@
   type PreviewViewportSetting,
 } from "@t3tools/contracts";
 
+/**
+ * Viewport an agent-opened tab falls back to when the user has no configured
+ * browser default. Agents screenshot and assert against what they open, so a
+ * brand-new tab left in fill mode would be sized by whatever the panel happens
+ * to be — deterministic beats incidental here.
+ */
 export const DEFAULT_PREVIEW_AUTOMATION_VIEWPORT = {
   _tag: "freeform",
   width: 1280,
   height: 800,
 } as const satisfies PreviewViewportSetting;
 
+/**
+ * An explicit `open`/`show` is the agent deliberately surfacing or suppressing
+ * its work, so it outranks the preference; the setting only decides what
+ * happens when the agent said nothing either way.
+ */
+export function shouldOpenPreviewMiniPlayer(
+  input: PreviewAutomationOpenInput,
-export function shouldOpenPreviewMiniPlayer(input: PreviewAutomationOpenInput): boolean {
+  autoShowFloatingPreview = true,
-  return input.open ?? input.show ?? true;
+): boolean {
+  return input.open ?? input.show ?? autoShowFloatingPreview;
 }
 
 export function previewAutomationOpenNeedsOverlay(
@@ -22,6 +36,13 @@
   return input.url !== undefined || snapshot.navStatus._tag !== "Idle";
 }
 
+/**
+ * Whether a freshly opened automation tab still needs a viewport applied.
+ *
+ * A configured browser default is sent with `preview.open`, so the snapshot
+ * already carries it and nothing is needed here. Fill means the user has no
+ * stated preference, which is where the agent fallback applies.
+ */
 export function previewAutomationDefaultViewport(
   reusedExistingTab: boolean,
   snapshot: PreviewSessionSnapshot,
```

[Next page](https://git.cafe/pingdotgg/t3code/pull/7082/diffs?after=ImFwcHMvd2ViL3NyYy9jb21wb25lbnRzL3ByZXZpZXcvcHJldmlld0F1dG9tYXRpb25PcGVuUmVhZGluZXNzLnRzIg&expectedVersion=6&format=markdown)
