authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-17 01:59:36+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-17 01:59:36+02:00
log949feb61e4bfd96669ba0e8cf3dca7c6d7f885b3
tree1f19444d85bf8f9dd11e0a0ee5c008875d7497cf
parentba46f922a271942d42faf305ff833af89cb5c3a1
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

32 files changed, 1066 insertions(+), 64 deletions(-)

apps/desktop/src/ipc/methods/preview.ts+8-3
......@@ -14,6 +14,7 @@ import {
1414 DesktopPreviewRegisterWebviewInputSchema,
1515 DesktopPreviewScreenshotArtifactSchema,
1616 DesktopPreviewSetColorSchemeInputSchema,
17 DesktopPreviewCreateTabInputSchema,
1718 DesktopPreviewTabInputSchema,
1819 DesktopPreviewWebviewConfigSchema,
1920 PreviewAnnotationSubmissionResultSchema,
......@@ -48,11 +49,15 @@ export const installPreviewEventForwarding = Effect.fn(
4849
4950export const createTab = DesktopIpc.makeIpcMethod({
5051 channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL,
51 payload: DesktopPreviewTabInputSchema,
52 payload: DesktopPreviewCreateTabInputSchema,
5253 result: Schema.Void,
53 handler: Effect.fn("desktop.ipc.preview.createTab")(function* ({ tabId }) {
54 handler: Effect.fn("desktop.ipc.preview.createTab")(function* ({
55 tabId,
56 zoomFactor,
57 colorScheme,
58 }) {
5459 const manager = yield* PreviewManager.PreviewManager;
55 yield* manager.createTab(tabId);
60 yield* manager.createTab(tabId, { zoomFactor, colorScheme });
5661 }),
5762});
5863
apps/desktop/src/preload.ts+6-1
......@@ -163,7 +163,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {
163163 };
164164 },
165165 preview: {
166 createTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, { tabId }),
166 createTab: (tabId, defaults) =>
167 ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, {
168 tabId,
169 zoomFactor: defaults?.zoomFactor,
170 colorScheme: defaults?.colorScheme,
171 }),
167172 closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }),
168173 registerWebview: (tabId, webContentsId) =>
169174 ipcRenderer.invoke(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, { tabId, webContentsId }),
apps/desktop/src/preview/Manager.ts+28-5
......@@ -16,6 +16,7 @@ import type {
1616 DesktopPreviewRecordingArtifact,
1717 DesktopPreviewRecordingFrame,
1818 DesktopPreviewScreenshotArtifact,
19 DesktopPreviewTabDefaults,
1920 PreviewAutomationClickInput,
2021 PreviewAutomationActionEvent,
2122 PreviewAutomationConsoleEntry,
......@@ -334,6 +335,21 @@ const findZoomStep = (current: number): number => {
334335 return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1;
335336};
336337
338/**
339 * Clamp a client-supplied zoom factor onto the discrete ladder. The setting is
340 * chosen from the same ladder, but it arrives over IPC from a schema that only
341 * guarantees a positive number, so an out-of-band value snaps to the nearest
342 * step rather than leaving the guest at a zoom the zoom controls can't reach.
343 */
344const normalizeZoomFactor = (value: number | undefined): number => {
345 if (value === undefined || !Number.isFinite(value)) return DEFAULT_ZOOM_FACTOR;
346 let closest = ZOOM_LEVELS[0]!;
347 for (const level of ZOOM_LEVELS) {
348 if (Math.abs(level - value) < Math.abs(closest - value)) closest = level;
349 }
350 return closest;
351};
352
337353const nextZoomLevel = (current: number, direction: "in" | "out"): number => {
338354 const step = findZoomStep(current);
339355 if (direction === "in") {
......@@ -1614,6 +1630,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
16141630
16151631 const createTabUnlocked = Effect.fn("PreviewManager.createTabUnlocked")(function* (
16161632 tabId: string,
1633 defaults?: DesktopPreviewTabDefaults,
16171634 ) {
16181635 const updatedAt = yield* currentIso;
16191636 const result = yield* SynchronizedRef.modify(
......@@ -1632,9 +1649,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
16321649 navStatus: { kind: "Idle" },
16331650 canGoBack: false,
16341651 canGoForward: false,
1635 zoomFactor: DEFAULT_ZOOM_FACTOR,
1652 zoomFactor: normalizeZoomFactor(defaults?.zoomFactor),
16361653 pictureInPicture: false,
1637 colorScheme: "system",
1654 colorScheme: defaults?.colorScheme ?? "system",
16381655 controller: "none",
16391656 updatedAt,
16401657 };
......@@ -1653,8 +1670,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
16531670 return result.state;
16541671 });
16551672
1656 const createTab = Effect.fn("PreviewManager.createTab")(function* (tabId: string) {
1657 return yield* withTabLifecycleLock(tabId, createTabUnlocked(tabId));
1673 const createTab = Effect.fn("PreviewManager.createTab")(function* (
1674 tabId: string,
1675 defaults?: DesktopPreviewTabDefaults,
1676 ) {
1677 return yield* withTabLifecycleLock(tabId, createTabUnlocked(tabId, defaults));
16581678 });
16591679
16601680 const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) {
......@@ -3802,7 +3822,10 @@ export class PreviewManager extends Context.Service<
38023822 readonly setMainWindow: (window: BrowserWindow) => Effect.Effect<void, PreviewManagerError>;
38033823 readonly getBrowserSession: (scope?: string) => Effect.Effect<Session, PreviewManagerError>;
38043824 readonly isBrowserPartition: (partition: string) => boolean;
3805 readonly createTab: (tabId: string) => Effect.Effect<PreviewTabState, PreviewManagerError>;
3825 readonly createTab: (
3826 tabId: string,
3827 defaults?: DesktopPreviewTabDefaults,
3828 ) => Effect.Effect<PreviewTabState, PreviewManagerError>;
38063829 readonly closeTab: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
38073830 readonly registerWebview: (
38083831 tabId: string,
apps/desktop/src/settings/DesktopClientSettings.test.ts+4
......@@ -13,6 +13,10 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1313import * as DesktopClientSettings from "./DesktopClientSettings.ts";
1414
1515const clientSettings: ClientSettings = {
16 browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
17 browserDefaultZoomFactor: 1.25,
18 browserDefaultAppearance: "dark",
19 browserAutoShowFloatingPreview: false,
1620 confirmQuit: true,
1721 confirmThreadArchive: true,
1822 confirmThreadDelete: false,
apps/server/src/mcp/toolkits/preview/handlers.test.ts+4-6
......@@ -3,12 +3,10 @@ import { describe, expect, it } from "vite-plus/test";
33import { normalizePreviewOpenInput } from "./handlers.ts";
44
55describe("normalizePreviewOpenInput", () => {
6 it("opens the inline preview and reuses the current tab by default", () => {
7 expect(normalizePreviewOpenInput({})).toEqual({
8 open: true,
9 reuseExistingTab: true,
10 show: true,
11 });
6 it("leaves an unstated visibility for the client preference to decide", () => {
7 // Filling `open` in here would outrank `browserAutoShowFloatingPreview`,
8 // which is desktop-local and cannot be read from the server.
9 expect(normalizePreviewOpenInput({})).toEqual({ reuseExistingTab: true });
1210 });
1311
1412 it("preserves an explicit background-only opt-out", () => {
apps/server/src/mcp/toolkits/preview/handlers.ts+10-3
......@@ -15,14 +15,21 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts";
1515import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
1616import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts";
1717
18/**
19 * Collapses the `show` alias onto `open` and defaults tab reuse.
20 *
21 * Deliberately leaves an unstated `open` unstated. Whether a preview the agent
22 * said nothing about surfaces is the user's `browserAutoShowFloatingPreview`
23 * preference, which is desktop-local and unreadable from here — filling in
24 * `true` would silently override it for every `preview_open`.
25 */
1826export function normalizePreviewOpenInput(
1927 input: PreviewAutomationOpenInput,
2028): PreviewAutomationOpenInput {
21 const open = input.open ?? input.show ?? true;
29 const open = input.open ?? input.show;
2230 return {
2331 ...input,
24 open,
25 show: open,
32 ...(open === undefined ? {} : { open, show: open }),
2633 reuseExistingTab: input.reuseExistingTab ?? true,
2734 };
2835}
apps/server/src/preview/Manager.ts+11-3
......@@ -24,6 +24,7 @@ import {
2424 FILL_PREVIEW_VIEWPORT,
2525 PreviewSessionLookupError,
2626 type PreviewSessionSnapshot,
27 type PreviewViewportSetting,
2728} from "@t3tools/contracts";
2829import {
2930 isPreviewUrlNormalizationError,
......@@ -121,6 +122,7 @@ const buildLoadingSnapshot = (input: {
121122 readonly tabId: string;
122123 readonly url: string;
123124 readonly title: string;
125 readonly viewport: PreviewViewportSetting;
124126 readonly updatedAt: string;
125127}): PreviewSessionSnapshot => ({
126128 threadId: input.threadId,
......@@ -128,13 +130,14 @@ const buildLoadingSnapshot = (input: {
128130 navStatus: { _tag: "Loading", url: input.url, title: input.title },
129131 canGoBack: false,
130132 canGoForward: false,
131 viewport: FILL_PREVIEW_VIEWPORT,
133 viewport: input.viewport,
132134 updatedAt: input.updatedAt,
133135});
134136
135137const buildIdleSnapshot = (input: {
136138 readonly threadId: string;
137139 readonly tabId: string;
140 readonly viewport: PreviewViewportSetting;
138141 readonly updatedAt: string;
139142}): PreviewSessionSnapshot => ({
140143 threadId: input.threadId,
......@@ -142,7 +145,7 @@ const buildIdleSnapshot = (input: {
142145 navStatus: { _tag: "Idle" },
143146 canGoBack: false,
144147 canGoForward: false,
145 viewport: FILL_PREVIEW_VIEWPORT,
148 viewport: input.viewport,
146149 updatedAt: input.updatedAt,
147150});
148151
......@@ -215,15 +218,20 @@ export const make = Effect.gen(function* PreviewManagerMake() {
215218 function* (input) {
216219 const tabId = newPreviewTabId();
217220 const updatedAt = yield* currentIsoTimestamp;
221 // Clients with a configured default send the viewport up front so the
222 // session is born at the right size; older clients omit it and keep the
223 // historical fill-panel behaviour.
224 const viewport = input.viewport ?? FILL_PREVIEW_VIEWPORT;
218225 const snapshot = input.url
219226 ? buildLoadingSnapshot({
220227 threadId: input.threadId,
221228 tabId,
222229 url: yield* normalizeUrl(input.url),
223230 title: "",
231 viewport,
224232 updatedAt,
225233 })
226 : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt });
234 : buildIdleSnapshot({ threadId: input.threadId, tabId, viewport, updatedAt });
227235 yield* SynchronizedRef.modifyEffect(stateRef, (state) =>
228236 Effect.gen(function* () {
229237 const revision = state.revision + 1;
apps/web/src/browser/BrowserDeviceToolbar.tsx+1-20
......@@ -26,6 +26,7 @@ import { cn } from "~/lib/utils";
2626
2727import { BROWSER_DEVICE_TOOLBAR_HEIGHT, resizeFreeformViewport } from "./browserViewportLayout";
2828import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState";
29import { ScreenRotationIcon } from "./ScreenRotationIcon";
2930
3031const RESPONSIVE_VALUE = "responsive";
3132const SELECT_ITEMS = [
......@@ -33,26 +34,6 @@ const SELECT_ITEMS = [
3334 ...PREVIEW_VIEWPORT_PRESETS.map((preset) => ({ value: preset.id, label: preset.label })),
3435];
3536
36function ScreenRotationIcon() {
37 return (
38 <svg
39 viewBox="0 0 24 24"
40 fill="none"
41 stroke="currentColor"
42 strokeWidth="2"
43 strokeLinecap="round"
44 strokeLinejoin="round"
45 aria-hidden="true"
46 >
47 <rect x="7.25" y="7.25" width="9.5" height="9.5" rx="1.4" transform="rotate(-45 12 12)" />
48 <path d="M12.5 2a10 10 0 0 1 8.4 5.4" />
49 <path d="M20.8 3.5v4h-4" />
50 <path d="M11.5 22a10 10 0 0 1-8.4-5.4" />
51 <path d="M3.2 20.5v-4h4" />
52 </svg>
53 );
54}
55
5637interface Props {
5738 readonly setting: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>;
5839 readonly width: number;
apps/web/src/browser/ScreenRotationIcon.tsx created+26
......@@ -0,0 +1,26 @@
1/**
2 * Screen-rotation glyph shared by the in-browser device toolbar and the
3 * default-viewport setting, so the orientation control reads the same in both
4 * places.
5 *
6 * @module ScreenRotationIcon
7 */
8export function ScreenRotationIcon() {
9 return (
10 <svg
11 viewBox="0 0 24 24"
12 fill="none"
13 stroke="currentColor"
14 strokeWidth="2"
15 strokeLinecap="round"
16 strokeLinejoin="round"
17 aria-hidden="true"
18 >
19 <rect x="7.25" y="7.25" width="9.5" height="9.5" rx="1.4" transform="rotate(-45 12 12)" />
20 <path d="M12.5 2a10 10 0 0 1 8.4 5.4" />
21 <path d="M20.8 3.5v4h-4" />
22 <path d="M11.5 22a10 10 0 0 1-8.4-5.4" />
23 <path d="M3.2 20.5v-4h4" />
24 </svg>
25 );
26}
apps/web/src/browser/browserDefaults.ts created+114
......@@ -0,0 +1,114 @@
1/**
2 * Browser defaults - resolves the configured starting state for preview tabs.
3 *
4 * Settings → Integrations → Browser lets the user pick the viewport, zoom, and
5 * appearance a preview tab should open at. Those preferences apply to every
6 * entry point that opens a tab without stating its own: the user opening a
7 * browser panel, and agents calling `preview_open` with no size.
8 *
9 * The values live in client settings because the Chromium guest they configure
10 * is desktop-local, so this module reads them through the same external store
11 * the settings UI writes to, and exposes a non-hook accessor for the imperative
12 * open paths that run outside React.
13 *
14 * @module browserDefaults
15 */
16import type {
17 DesktopPreviewTabDefaults,
18 PreviewAppearancePreference,
19 PreviewViewportSetting,
20} from "@t3tools/contracts";
21
22import {
23 ensureClientSettingsHydrated,
24 getClientSettings,
25 useClientSettings,
26} from "~/hooks/useSettings";
27
28import { resolveResponsiveBrowserViewportSize } from "./browserViewportLayout";
29
30export interface BrowserDefaults {
31 readonly viewport: PreviewViewportSetting;
32 readonly zoomFactor: number;
33 readonly appearance: PreviewAppearancePreference;
34 readonly autoShowFloatingPreview: boolean;
35}
36
37const toBrowserDefaults = (settings: {
38 readonly browserDefaultViewport: PreviewViewportSetting;
39 readonly browserDefaultZoomFactor: number;
40 readonly browserDefaultAppearance: PreviewAppearancePreference;
41 readonly browserAutoShowFloatingPreview: boolean;
42}): BrowserDefaults => ({
43 viewport: settings.browserDefaultViewport,
44 zoomFactor: settings.browserDefaultZoomFactor,
45 appearance: settings.browserDefaultAppearance,
46 autoShowFloatingPreview: settings.browserAutoShowFloatingPreview,
47});
48
49/** Non-hook accessor for imperative open paths (menu actions, automation hosts). */
50export function getBrowserDefaults(): BrowserDefaults {
51 return toBrowserDefaults(getClientSettings());
52}
53
54/**
55 * The defaults, once client settings have actually loaded.
56 *
57 * Opening a preview is asynchronous anyway, and before hydration the snapshot
58 * is the schema defaults rather than the user's — a tab opened in that window
59 * would be born at the wrong viewport, zoom and appearance and never corrected.
60 */
61export async function resolveBrowserDefaults(): Promise<BrowserDefaults> {
62 await ensureClientSettingsHydrated();
63 return getBrowserDefaults();
64}
65
66export function useBrowserDefaults(): BrowserDefaults {
67 return useClientSettings(toBrowserDefaults);
68}
69
70/**
71 * The zoom/appearance half of the defaults, in the shape `createTab` takes.
72 * Passing these at creation rather than after registration keeps the guest from
73 * painting a frame at 100%/system first.
74 */
75export function browserDefaultTabState(
76 defaults: BrowserDefaults = getBrowserDefaults(),
77): DesktopPreviewTabDefaults {
78 return { zoomFactor: defaults.zoomFactor, colorScheme: defaults.appearance };
79}
80
81/**
82 * The viewport a *newly opened* tab should start at. Sent with `preview.open`
83 * so the session is born at the configured size instead of being resized a
84 * frame later, which the user would see as a visible reflow.
85 */
86export function browserDefaultOpenViewport(
87 defaults: BrowserDefaults = getBrowserDefaults(),
88): PreviewViewportSetting {
89 return defaults.viewport;
90}
91
92/**
93 * The viewport to switch to when the user turns the device toolbar on for a tab
94 * currently in fill mode.
95 *
96 * A configured non-fill default is what the user said they want to look at, so
97 * it wins. When the default is fill there is no stated preference to honour, so
98 * fall back to fitting the panel — and only when the panel hasn't been measured
99 * yet does a fixed size apply.
100 */
101export const FALLBACK_RESPONSIVE_VIEWPORT_SIZE = { width: 1024, height: 768 } as const;
102
103export function browserResponsiveViewportForToggle(input: {
104 readonly defaults?: BrowserDefaults;
105 readonly panelRect: { readonly width: number; readonly height: number } | null;
106 readonly zoomFactor: number | undefined;
107}): PreviewViewportSetting {
108 const defaults = input.defaults ?? getBrowserDefaults();
109 if (defaults.viewport._tag !== "fill") return defaults.viewport;
110 const size = input.panelRect
111 ? resolveResponsiveBrowserViewportSize(input.panelRect, input.zoomFactor)
112 : FALLBACK_RESPONSIVE_VIEWPORT_SIZE;
113 return { _tag: "freeform", ...size };
114}
apps/web/src/browser/desktopTabLifetime.test.ts+18-5
......@@ -1,4 +1,9 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
1import {
2 DEFAULT_PREVIEW_APPEARANCE,
3 DEFAULT_PREVIEW_ZOOM_FACTOR,
4 EnvironmentId,
5 ThreadId,
6} from "@t3tools/contracts";
27import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
38
49const { closeTab, createTab, stopBrowserRecording } = vi.hoisted(() => ({
......@@ -16,6 +21,12 @@ vi.mock("./browserRecording", () => ({
1621}));
1722
1823import { acquireDesktopTab } from "./desktopTabLifetime";
24
25/** Client settings are unset in tests, so creation carries the schema defaults. */
26const DEFAULT_TAB_STATE = {
27 zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
28 colorScheme: DEFAULT_PREVIEW_APPEARANCE,
29};
1930import { previewRuntimeTabId } from "./previewRuntimeTabId";
2031
2132describe("desktopTabLifetime", () => {
......@@ -42,14 +53,16 @@ describe("desktopTabLifetime", () => {
4253 const first = acquireDesktopTab("tab_readiness");
4354 const second = acquireDesktopTab("tab_readiness");
4455
45 expect(createTab).toHaveBeenCalledOnce();
56 // Both leases share one creation, and it is still in flight: creation now
57 // waits for client settings to hydrate so the guest is born at the user's
58 // zoom and appearance rather than painting at the defaults first.
4659 expect(first.ready).toBe(second.ready);
4760
4861 let ready = false;
4962 void first.ready.then(() => {
5063 ready = true;
5164 });
52 await Promise.resolve();
65 await vi.waitFor(() => expect(createTab).toHaveBeenCalledOnce());
5366 expect(ready).toBe(false);
5467
5568 resolveCreation?.();
......@@ -81,8 +94,8 @@ describe("desktopTabLifetime", () => {
8194 const second = acquireDesktopTab(tabB);
8295 await Promise.all([first.ready, second.ready]);
8396
84 expect(createTab).toHaveBeenCalledWith(tabA);
85 expect(createTab).toHaveBeenCalledWith(tabB);
97 expect(createTab).toHaveBeenCalledWith(tabA, DEFAULT_TAB_STATE);
98 expect(createTab).toHaveBeenCalledWith(tabB, DEFAULT_TAB_STATE);
8699 expect(createTab).toHaveBeenCalledTimes(2);
87100
88101 first.release();
apps/web/src/browser/desktopTabLifetime.ts+6-1
......@@ -1,5 +1,6 @@
11import { previewBridge } from "~/components/preview/previewBridge";
22
3import { browserDefaultTabState, resolveBrowserDefaults } from "./browserDefaults";
34import { stopBrowserRecording } from "./browserRecording";
45
56interface DesktopTabLease {
......@@ -41,7 +42,11 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab {
4142 ({
4243 references: 0,
4344 closeTimer: null,
44 ready: enqueueDesktopTabOperation(tabId, () => previewBridge?.createTab(tabId)),
45 // Zoom/appearance defaults travel with creation so the guest never
46 // paints a frame at 100%/system before the preference is applied.
47 ready: enqueueDesktopTabOperation(tabId, async () =>
48 previewBridge?.createTab(tabId, browserDefaultTabState(await resolveBrowserDefaults())),
49 ),
4550 } satisfies DesktopTabLease);
4651 if (current.closeTimer !== null) window.clearTimeout(current.closeTimer);
4752 current.references += 1;
apps/web/src/components/preview/PreviewAutomationHosts.tsx+8-1
......@@ -38,6 +38,7 @@ import {
3838} from "~/browser/browserRecording";
3939import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope";
4040import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
41import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults";
4142import { runBrowserViewportMutation } from "~/browser/browserViewportActions";
4243import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
4344import { isElectron } from "~/env";
......@@ -380,6 +381,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
380381 input: {
381382 threadId: request.threadId,
382383 ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}),
384 // An agent that didn't state a size gets the user's
385 // configured default, same as a hand-opened tab.
386 viewport: browserDefaultOpenViewport(await resolveBrowserDefaults()),
383387 },
384388 });
385389 if (result._tag === "Failure") {
......@@ -428,7 +432,10 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
428432 updatePreviewServerSnapshot(threadRef, resizeResult.value);
429433 }
430434 }
431 const shouldPresentPreview = shouldOpenPreviewMiniPlayer(input);
435 const shouldPresentPreview = shouldOpenPreviewMiniPlayer(
436 input,
437 (await resolveBrowserDefaults()).autoShowFloatingPreview,
438 );
432439 if (shouldPresentPreview) {
433440 usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId);
434441 }
apps/web/src/components/preview/PreviewView.test.tsx+35-1
......@@ -1,4 +1,10 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
1import {
2 DEFAULT_PREVIEW_APPEARANCE,
3 DEFAULT_PREVIEW_ZOOM_FACTOR,
4 EnvironmentId,
5 FILL_PREVIEW_VIEWPORT,
6 ThreadId,
7} from "@t3tools/contracts";
28import { renderToStaticMarkup } from "react-dom/server";
39import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
410
......@@ -41,6 +47,34 @@ vi.mock("~/state/session", () => ({
4147 readPreparedConnection: mocks.readPreparedConnection,
4248}));
4349
50// Stubbed at the direct dependency rather than letting the real module pull in
51// `useSettings` -> `state/server`, which would drag the whole settings and
52// connection graph into a test that only cares about the browser chrome.
53vi.mock("~/browser/browserDefaults", () => ({
54 useBrowserDefaults: () => ({
55 viewport: FILL_PREVIEW_VIEWPORT,
56 zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
57 appearance: DEFAULT_PREVIEW_APPEARANCE,
58 autoShowFloatingPreview: true,
59 }),
60 getBrowserDefaults: () => ({
61 viewport: FILL_PREVIEW_VIEWPORT,
62 zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
63 appearance: DEFAULT_PREVIEW_APPEARANCE,
64 autoShowFloatingPreview: true,
65 }),
66 browserDefaultOpenViewport: () => FILL_PREVIEW_VIEWPORT,
67 browserDefaultTabState: () => ({
68 zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
69 colorScheme: DEFAULT_PREVIEW_APPEARANCE,
70 }),
71 browserResponsiveViewportForToggle: () => ({
72 _tag: "freeform" as const,
73 width: 1024,
74 height: 768,
75 }),
76}));
77
4478vi.mock("~/composerDraftStore", () => ({
4579 useComposerDraftStore: (
4680 select: (store: { addPreviewAnnotation: () => void; addImage: () => void }) => unknown,
apps/web/src/components/preview/PreviewView.tsx+10-7
......@@ -43,7 +43,7 @@ import {
4343 commitBrowserViewportChange,
4444 subscribeBrowserViewportChange,
4545} from "~/browser/browserViewportActions";
46import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout";
46import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults";
4747import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
4848import { PreviewUnreachable } from "./PreviewUnreachable";
4949import { revealInFileExplorerLabel } from "./fileExplorerLabel";
......@@ -144,6 +144,7 @@ export function PreviewView({
144144 const controller = desktopOverlay?.controller ?? "none";
145145 const loadProgress = useLoadingProgress(loading);
146146 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;
147 const browserDefaults = useBrowserDefaults();
147148 const panelRect = useBrowserSurfaceStore((state) =>
148149 runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,
149150 );
......@@ -249,12 +250,14 @@ export function PreviewView({
249250 return;
250251 }
251252
252 const responsiveSize = panelRect
253 ? resolveResponsiveBrowserViewportSize(panelRect, desktopOverlay?.zoomFactor)
254 : { width: 1024, height: 768 };
255 void commitBrowserViewportChange(runtimeTabId, { _tag: "freeform", ...responsiveSize }).catch(
256 () => undefined,
257 );
253 void commitBrowserViewportChange(
254 runtimeTabId,
255 browserResponsiveViewportForToggle({
256 defaults: browserDefaults,
257 panelRect,
258 zoomFactor: desktopOverlay?.zoomFactor,
259 }),
260 ).catch(() => undefined);
258261 };
259262
260263 useEffect(() => {
apps/web/src/components/preview/addBrowserSurface.test.ts+10-2
......@@ -1,4 +1,9 @@
1import type { PreviewOpenInput, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
1import {
2 FILL_PREVIEW_VIEWPORT,
3 type PreviewOpenInput,
4 type PreviewSessionSnapshot,
5 type ScopedThreadRef,
6} from "@t3tools/contracts";
27import { AsyncResult } from "effect/unstable/reactivity";
38import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
49
......@@ -40,7 +45,10 @@ describe("addBrowserSurface", () => {
4045
4146 await addBrowserSurface({ threadRef, openPreview: ({ input }) => openPreview(input) });
4247
43 expect(openPreview).toHaveBeenCalledWith({ threadId: "thread-1" });
48 expect(openPreview).toHaveBeenCalledWith({
49 threadId: "thread-1",
50 viewport: FILL_PREVIEW_VIEWPORT,
51 });
4452 expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]);
4553 expect(
4654 selectThreadRightPanelState(
apps/web/src/components/preview/openPreviewSession.test.ts+15-3
......@@ -1,4 +1,9 @@
1import type { PreviewOpenInput, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
1import {
2 FILL_PREVIEW_VIEWPORT,
3 type PreviewOpenInput,
4 type PreviewSessionSnapshot,
5 type ScopedThreadRef,
6} from "@t3tools/contracts";
27import * as Cause from "effect/Cause";
38import { AsyncResult } from "effect/unstable/reactivity";
49import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
......@@ -41,7 +46,10 @@ describe("openPreviewSession", () => {
4146 threadRef,
4247 });
4348
44 expect(open).toHaveBeenCalledWith({ threadId: "thread-1" });
49 expect(open).toHaveBeenCalledWith({
50 threadId: "thread-1",
51 viewport: FILL_PREVIEW_VIEWPORT,
52 });
4553 expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot);
4654 expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]);
4755 });
......@@ -55,7 +63,11 @@ describe("openPreviewSession", () => {
5563 url: "t3.chat",
5664 });
5765
58 expect(open).toHaveBeenCalledWith({ threadId: "thread-1", url: "t3.chat" });
66 expect(open).toHaveBeenCalledWith({
67 threadId: "thread-1",
68 url: "t3.chat",
69 viewport: FILL_PREVIEW_VIEWPORT,
70 });
5971 expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot);
6072 expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual(["https://t3.chat/"]);
6173 });
apps/web/src/components/preview/openPreviewSession.ts+5
......@@ -2,10 +2,12 @@ import type {
22 EnvironmentId,
33 PreviewOpenInput,
44 PreviewSessionSnapshot,
5 PreviewViewportSetting,
56 ScopedThreadRef,
67} from "@t3tools/contracts";
78import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
89
10import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults";
911import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";
1012
1113interface OpenPreviewSessionInput<E> {
......@@ -15,6 +17,8 @@ interface OpenPreviewSessionInput<E> {
1517 }) => Promise<AtomCommandResult<PreviewSessionSnapshot, E>>;
1618 threadRef: ScopedThreadRef;
1719 url?: string;
20 /** Overrides the configured default; automation passes an explicit size. */
21 viewport?: PreviewViewportSetting;
1822}
1923
2024export async function openPreviewSession<E>(
......@@ -25,6 +29,7 @@ export async function openPreviewSession<E>(
2529 input: {
2630 threadId: input.threadRef.threadId,
2731 ...(input.url === undefined ? {} : { url: input.url }),
32 viewport: input.viewport ?? browserDefaultOpenViewport(await resolveBrowserDefaults()),
2833 },
2934 });
3035 if (result._tag === "Failure") {
apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts+15
......@@ -77,3 +77,18 @@ describe("preview automation open readiness", () => {
7777 ).toBeNull();
7878 });
7979});
80
81describe("shouldOpenPreviewMiniPlayer with the floating-preview preference", () => {
82 it("honours the preference when the agent said nothing either way", () => {
83 // `preview_open` no longer arrives with `open` pre-filled, so an agent
84 // that omitted it leaves the decision to the user's setting.
85 expect(shouldOpenPreviewMiniPlayer({}, false)).toBe(false);
86 expect(shouldOpenPreviewMiniPlayer({}, true)).toBe(true);
87 });
88
89 it("lets an explicit request outrank the preference in both directions", () => {
90 expect(shouldOpenPreviewMiniPlayer({ open: true }, false)).toBe(true);
91 expect(shouldOpenPreviewMiniPlayer({ open: false }, true)).toBe(false);
92 expect(shouldOpenPreviewMiniPlayer({ show: true }, false)).toBe(true);
93 });
94});
apps/web/src/components/preview/previewAutomationOpenReadiness.ts+23-2
......@@ -5,14 +5,28 @@ import {
55 type PreviewViewportSetting,
66} from "@t3tools/contracts";
77
8/**
9 * Viewport an agent-opened tab falls back to when the user has no configured
10 * browser default. Agents screenshot and assert against what they open, so a
11 * brand-new tab left in fill mode would be sized by whatever the panel happens
12 * to be — deterministic beats incidental here.
13 */
814export const DEFAULT_PREVIEW_AUTOMATION_VIEWPORT = {
915 _tag: "freeform",
1016 width: 1280,
1117 height: 800,
1218} as const satisfies PreviewViewportSetting;
1319
14export function shouldOpenPreviewMiniPlayer(input: PreviewAutomationOpenInput): boolean {
15 return input.open ?? input.show ?? true;
20/**
21 * An explicit `open`/`show` is the agent deliberately surfacing or suppressing
22 * its work, so it outranks the preference; the setting only decides what
23 * happens when the agent said nothing either way.
24 */
25export function shouldOpenPreviewMiniPlayer(
26 input: PreviewAutomationOpenInput,
27 autoShowFloatingPreview = true,
28): boolean {
29 return input.open ?? input.show ?? autoShowFloatingPreview;
1630}
1731
1832export function previewAutomationOpenNeedsOverlay(
......@@ -22,6 +36,13 @@ export function previewAutomationOpenNeedsOverlay(
2236 return input.url !== undefined || snapshot.navStatus._tag !== "Idle";
2337}
2438
39/**
40 * Whether a freshly opened automation tab still needs a viewport applied.
41 *
42 * A configured browser default is sent with `preview.open`, so the snapshot
43 * already carries it and nothing is needed here. Fill means the user has no
44 * stated preference, which is where the agent fallback applies.
45 */
2546export function previewAutomationDefaultViewport(
2647 reusedExistingTab: boolean,
2748 snapshot: PreviewSessionSnapshot,
apps/web/src/components/settings/IntegrationsSettings.tsx created+436
......@@ -0,0 +1,436 @@
1/**
2 * Integrations settings - preferences for surfaces T3 Code embeds rather than
3 * owns. Browser is the first section: the defaults a preview tab opens at,
4 * applied to both hand-opened tabs and agent `preview_open` calls that don't
5 * state their own size.
6 *
7 * @module IntegrationsSettings
8 */
9import {
10 DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
11 DEFAULT_BROWSER_VIEWPORT,
12 DEFAULT_PREVIEW_APPEARANCE,
13 DEFAULT_PREVIEW_ZOOM_FACTOR,
14 FILL_PREVIEW_VIEWPORT,
15 PREVIEW_VIEWPORT_MAX_AREA,
16 PREVIEW_VIEWPORT_MAX_DIMENSION,
17 PREVIEW_VIEWPORT_MIN_DIMENSION,
18 PREVIEW_ZOOM_LEVELS,
19 type PreviewAppearancePreference,
20 type PreviewViewportSetting,
21} from "@t3tools/contracts";
22import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";
23import { InfoIcon } from "lucide-react";
24import type { ReactNode } from "react";
25
26import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon";
27import { isElectron } from "../../env";
28
29import { Button } from "../ui/button";
30import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field";
31import {
32 Select,
33 SelectGroup,
34 SelectGroupLabel,
35 SelectItem,
36 SelectPopup,
37 SelectTrigger,
38 SelectValue,
39} from "../ui/select";
40import { Switch } from "../ui/switch";
41import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
42import { useClientSettings, useUpdatePrimarySettings } from "~/hooks/useSettings";
43
44import {
45 SettingResetButton,
46 SettingsPageContainer,
47 SettingsRow,
48 SettingsSection,
49} from "./settingsLayout";
50import { searchableSetting } from "./settingsSearch";
51
52const FILL_VALUE = "fill";
53const RESPONSIVE_VALUE = "responsive";
54
55/**
56 * The size a "Responsive" default falls back to when the user switches away
57 * from Fill and hasn't typed dimensions yet. Fill has no dimensions to carry
58 * over, so the picker needs something concrete to seed the inputs with.
59 */
60const RESPONSIVE_SEED_SIZE = { width: 1280, height: 800 } as const;
61
62const NO_GROUPING: Intl.NumberFormatOptions = { useGrouping: false };
63
64const APPEARANCE_LABELS: Readonly<Record<PreviewAppearancePreference, string>> = {
65 system: "System",
66 light: "Light",
67 dark: "Dark",
68};
69
70const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`;
71
72const viewportSelectValue = (viewport: PreviewViewportSetting): string => {
73 if (viewport._tag === "fill") return FILL_VALUE;
74 if (
75 viewport._tag === "preset" &&
76 PREVIEW_VIEWPORT_PRESETS.some((preset) => preset.id === viewport.presetId)
77 ) {
78 return viewport.presetId;
79 }
80 return RESPONSIVE_VALUE;
81};
82
83/**
84 * The trigger renders this rather than a bare `SelectValue`, which would fall
85 * back to printing the raw stored value ("fill") because the options are built
86 * inline instead of from an `items` map.
87 */
88const viewportSelectLabel = (viewport: PreviewViewportSetting): string => {
89 const value = viewportSelectValue(viewport);
90 if (value === FILL_VALUE) return "Fill panel";
91 if (value === RESPONSIVE_VALUE) return "Responsive";
92 return PREVIEW_VIEWPORT_PRESETS.find((preset) => preset.id === value)?.label ?? "Responsive";
93};
94
95const isValidDimension = (value: number) =>
96 Number.isInteger(value) &&
97 value >= PREVIEW_VIEWPORT_MIN_DIMENSION &&
98 value <= PREVIEW_VIEWPORT_MAX_DIMENSION;
99
100/**
101 * A sized viewport with width and height swapped. Presets keep their identity
102 * through a rotation — `resolvePreviewViewport` already stores rotated presets
103 * as the preset id plus swapped dimensions — so a rotated iPad is still an
104 * iPad, not an anonymous custom size.
105 */
106const rotateViewport = (
107 viewport: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>,
108): PreviewViewportSetting => ({
109 ...viewport,
110 width: viewport.height,
111 height: viewport.width,
112});
113
114function BrowserViewportSetting({ disabled }: { readonly disabled: boolean }) {
115 const viewport = useClientSettings((settings) => settings.browserDefaultViewport);
116 const updateSettings = useUpdatePrimarySettings();
117
118 const sized = viewport._tag === "fill" ? null : viewport;
119 const presentedSize = {
120 width: sized?.width ?? RESPONSIVE_SEED_SIZE.width,
121 height: sized?.height ?? RESPONSIVE_SEED_SIZE.height,
122 };
123
124 const selectViewport = (value: string | null) => {
125 if (value === FILL_VALUE) {
126 updateSettings({ browserDefaultViewport: FILL_PREVIEW_VIEWPORT });
127 return;
128 }
129 if (value === RESPONSIVE_VALUE) {
130 updateSettings({
131 browserDefaultViewport: {
132 _tag: "freeform",
133 width: sized?.width ?? RESPONSIVE_SEED_SIZE.width,
134 height: sized?.height ?? RESPONSIVE_SEED_SIZE.height,
135 },
136 });
137 return;
138 }
139 const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === value);
140 if (!preset) return;
141 updateSettings({
142 browserDefaultViewport: {
143 _tag: "preset",
144 width: preset.width,
145 height: preset.height,
146 presetId: preset.id,
147 },
148 });
149 };
150
151 // Committed on blur rather than per keystroke: typing "2560" passes through
152 // "256", which is a legal dimension, so an onValueChange handler would
153 // persist that intermediate size and churn the settings file on every key.
154 const commitDimension = (axis: "width" | "height", value: number | null) => {
155 if (value === null || !isValidDimension(value)) return;
156 const next = { ...presentedSize, [axis]: value };
157 if (next.width * next.height > PREVIEW_VIEWPORT_MAX_AREA) return;
158 if (sized && next.width === sized.width && next.height === sized.height) return;
159 // Typing a size means the preset no longer describes it.
160 updateSettings({ browserDefaultViewport: { _tag: "freeform", ...next } });
161 };
162
163 return (
164 <SettingsRow
165 {...searchableSetting("browser-default-viewport")}
166 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."
167 resetAction={
168 !disabled && viewport._tag !== DEFAULT_BROWSER_VIEWPORT._tag ? (
169 <SettingResetButton
170 label="default browser viewport"
171 onClick={() => updateSettings({ browserDefaultViewport: DEFAULT_BROWSER_VIEWPORT })}
172 />
173 ) : null
174 }
175 control={
176 <div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
177 <Select
178 value={viewportSelectValue(viewport)}
179 onValueChange={selectViewport}
180 disabled={disabled}
181 >
182 <SelectTrigger
183 size="sm"
184 className="w-full min-w-0 sm:w-44"
185 aria-label="Default browser viewport"
186 >
187 <SelectValue>{viewportSelectLabel(viewport)}</SelectValue>
188 </SelectTrigger>
189 <SelectPopup align="end" alignItemWithTrigger={false} className="min-w-64">
190 <SelectItem value={FILL_VALUE}>Fill panel</SelectItem>
191 <SelectItem value={RESPONSIVE_VALUE}>Responsive</SelectItem>
192 <SelectGroup>
193 <SelectGroupLabel>Standard</SelectGroupLabel>
194 {PREVIEW_VIEWPORT_PRESETS.map((preset) => (
195 <SelectItem key={preset.id} value={preset.id}>
196 <span className="flex w-full items-center justify-between gap-5">
197 <span>{preset.label}</span>
198 <span className="text-xs tabular-nums text-muted-foreground">
199 {preset.detail}
200 </span>
201 </span>
202 </SelectItem>
203 ))}
204 </SelectGroup>
205 </SelectPopup>
206 </Select>
207
208 {sized ? (
209 <div className="flex min-w-0 items-center gap-1">
210 <NumberField
211 value={presentedSize.width}
212 min={PREVIEW_VIEWPORT_MIN_DIMENSION}
213 max={PREVIEW_VIEWPORT_MAX_DIMENSION}
214 disabled={disabled}
215 // Pixel counts read as raw numbers; grouping would show "1,024".
216 format={NO_GROUPING}
217 size="sm"
218 className="w-20"
219 onValueCommitted={(value) => commitDimension("width", value)}
220 >
221 <NumberFieldGroup>
222 <NumberFieldInput aria-label="Default viewport width" />
223 </NumberFieldGroup>
224 </NumberField>
225 <span className="text-xs text-muted-foreground">×</span>
226 <NumberField
227 value={presentedSize.height}
228 min={PREVIEW_VIEWPORT_MIN_DIMENSION}
229 max={PREVIEW_VIEWPORT_MAX_DIMENSION}
230 disabled={disabled}
231 format={NO_GROUPING}
232 size="sm"
233 className="w-20"
234 onValueCommitted={(value) => commitDimension("height", value)}
235 >
236 <NumberFieldGroup>
237 <NumberFieldInput aria-label="Default viewport height" />
238 </NumberFieldGroup>
239 </NumberField>
240 <Tooltip>
241 <TooltipTrigger
242 render={
243 <Button
244 size="icon-sm"
245 variant="ghost-muted"
246 disabled={disabled}
247 aria-label={`Rotate to ${
248 presentedSize.height >= presentedSize.width ? "landscape" : "portrait"
249 }`}
250 onClick={() =>
251 updateSettings({ browserDefaultViewport: rotateViewport(sized) })
252 }
253 >
254 <ScreenRotationIcon />
255 </Button>
256 }
257 />
258 <TooltipPopup side="top">Rotate</TooltipPopup>
259 </Tooltip>
260 </div>
261 ) : null}
262 </div>
263 }
264 />
265 );
266}
267
268function BrowserZoomSetting({ disabled }: { readonly disabled: boolean }) {
269 const zoomFactor = useClientSettings((settings) => settings.browserDefaultZoomFactor);
270 const updateSettings = useUpdatePrimarySettings();
271
272 return (
273 <SettingsRow
274 {...searchableSetting("browser-default-zoom")}
275 description="Page zoom applied to new browser tabs."
276 resetAction={
277 !disabled && zoomFactor !== DEFAULT_PREVIEW_ZOOM_FACTOR ? (
278 <SettingResetButton
279 label="default browser zoom"
280 onClick={() =>
281 updateSettings({ browserDefaultZoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR })
282 }
283 />
284 ) : null
285 }
286 control={
287 <Select
288 disabled={disabled}
289 value={String(zoomFactor)}
290 onValueChange={(value) => {
291 const next = PREVIEW_ZOOM_LEVELS.find((level) => String(level) === value);
292 if (next !== undefined) updateSettings({ browserDefaultZoomFactor: next });
293 }}
294 >
295 <SelectTrigger className="w-full sm:w-40" aria-label="Default browser zoom">
296 <SelectValue>{zoomLabel(zoomFactor)}</SelectValue>
297 </SelectTrigger>
298 <SelectPopup align="end" alignItemWithTrigger={false}>
299 {PREVIEW_ZOOM_LEVELS.map((level) => (
300 <SelectItem hideIndicator key={level} value={String(level)}>
301 {zoomLabel(level)}
302 </SelectItem>
303 ))}
304 </SelectPopup>
305 </Select>
306 }
307 />
308 );
309}
310
311function BrowserAppearanceSetting({ disabled }: { readonly disabled: boolean }) {
312 const appearance = useClientSettings((settings) => settings.browserDefaultAppearance);
313 const updateSettings = useUpdatePrimarySettings();
314
315 return (
316 <SettingsRow
317 {...searchableSetting("browser-default-appearance")}
318 description="The color scheme pages are told to prefer. System follows your OS setting."
319 resetAction={
320 !disabled && appearance !== DEFAULT_PREVIEW_APPEARANCE ? (
321 <SettingResetButton
322 label="default browser appearance"
323 onClick={() => updateSettings({ browserDefaultAppearance: DEFAULT_PREVIEW_APPEARANCE })}
324 />
325 ) : null
326 }
327 control={
328 <Select
329 disabled={disabled}
330 value={appearance}
331 onValueChange={(value) => {
332 if (value === "system" || value === "light" || value === "dark") {
333 updateSettings({ browserDefaultAppearance: value });
334 }
335 }}
336 >
337 <SelectTrigger className="w-full sm:w-40" aria-label="Default browser appearance">
338 <SelectValue>{APPEARANCE_LABELS[appearance]}</SelectValue>
339 </SelectTrigger>
340 <SelectPopup align="end" alignItemWithTrigger={false}>
341 {Object.entries(APPEARANCE_LABELS).map(([value, label]) => (
342 <SelectItem hideIndicator key={value} value={value}>
343 {label}
344 </SelectItem>
345 ))}
346 </SelectPopup>
347 </Select>
348 }
349 />
350 );
351}
352
353function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled: boolean }) {
354 const autoShow = useClientSettings((settings) => settings.browserAutoShowFloatingPreview);
355 const updateSettings = useUpdatePrimarySettings();
356
357 return (
358 <SettingsRow
359 {...searchableSetting("browser-auto-show-floating-preview")}
360 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."
361 resetAction={
362 !disabled && autoShow !== DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW ? (
363 <SettingResetButton
364 label="auto-show floating preview"
365 onClick={() =>
366 updateSettings({
367 browserAutoShowFloatingPreview: DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
368 })
369 }
370 />
371 ) : null
372 }
373 control={
374 <Switch
375 disabled={disabled}
376 checked={autoShow}
377 onCheckedChange={(checked) =>
378 updateSettings({ browserAutoShowFloatingPreview: Boolean(checked) })
379 }
380 aria-label="Auto-show floating preview"
381 />
382 }
383 />
384 );
385}
386
387/**
388 * Frames the client-local preview defaults as one unavailable block.
389 *
390 * Disabling each control on its own left the labels and descriptions at full
391 * strength, so the group still read as editable. Boxing it puts the reason at
392 * the top and dims everything it covers, which is also why the explanation
393 * sits outside the dimmed area — the one part that must stay readable is the
394 * part saying why the rest isn't.
395 *
396 * Disabled rather than hidden because these are *client* settings: editing
397 * them from a browser tab would write preferences belonging to a different
398 * client, reading as though the desktop app had been configured when it
399 * hadn't.
400 */
401function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode }) {
402 return (
403 <div className="rounded-xl border border-border/60 bg-muted/20 py-1.5">
404 <div className="flex items-start gap-2 px-3 py-2 text-[12px] leading-relaxed text-muted-foreground sm:px-4">
405 <InfoIcon className="mt-0.5 size-3.5 shrink-0 text-warning" />
406 <p>Only available in the desktop app.</p>
407 </div>
408 <div className="[&_h3]:opacity-64 [&_p]:opacity-64">{children}</div>
409 </div>
410 );
411}
412
413export function IntegrationsSettingsPanel() {
414 // Client-local preview defaults are editable only where the preview exists.
415 const previewDefaultsDisabled = !isElectron;
416 const previewDefaults = (
417 <>
418 <BrowserViewportSetting disabled={previewDefaultsDisabled} />
419 <BrowserZoomSetting disabled={previewDefaultsDisabled} />
420 <BrowserAppearanceSetting disabled={previewDefaultsDisabled} />
421 <BrowserAutoShowFloatingPreviewSetting disabled={previewDefaultsDisabled} />
422 </>
423 );
424
425 return (
426 <SettingsPageContainer>
427 <SettingsSection id="browser" title="Browser">
428 {previewDefaultsDisabled ? (
429 <DesktopOnlyBrowserDefaults>{previewDefaults}</DesktopOnlyBrowserDefaults>
430 ) : (
431 previewDefaults
432 )}
433 </SettingsSection>
434 </SettingsPageContainer>
435 );
436}
apps/web/src/components/settings/SettingsPanels.logic.test.ts+53
......@@ -12,7 +12,9 @@ import {
1212 backgroundActivitySharedPolicySettings,
1313 buildProviderInstanceUpdatePatch,
1414 formatDiagnosticsDescription,
15 getChangedBrowserSettingLabels,
1516 getChangedTypographySettingLabels,
17 isSamePreviewViewport,
1618 hasChangedBackgroundActivitySettings,
1719 isProjectGroupingEnabled,
1820 projectGroupingModeFromToggle,
......@@ -242,3 +244,54 @@ describe("buildProviderInstanceUpdatePatch", () => {
242244 expect(patch.providers).toBeUndefined();
243245 });
244246});
247
248describe("getChangedBrowserSettingLabels", () => {
249 it("reports nothing for the defaults", () => {
250 expect(getChangedBrowserSettingLabels(DEFAULT_UNIFIED_SETTINGS)).toEqual([]);
251 });
252
253 it("treats a structurally equal viewport as unchanged", () => {
254 // The viewport is a tagged union, so identity comparison would report a
255 // freshly decoded copy of the default as dirty and offer to "restore" it.
256 expect(
257 getChangedBrowserSettingLabels({
258 ...DEFAULT_UNIFIED_SETTINGS,
259 browserDefaultViewport: { ...DEFAULT_UNIFIED_SETTINGS.browserDefaultViewport },
260 }),
261 ).toEqual([]);
262 });
263
264 it("labels each browser default that differs", () => {
265 expect(
266 getChangedBrowserSettingLabels({
267 ...DEFAULT_UNIFIED_SETTINGS,
268 browserDefaultViewport: { _tag: "freeform", width: 900, height: 600 },
269 browserDefaultZoomFactor: 1.5,
270 browserDefaultAppearance: "dark",
271 browserAutoShowFloatingPreview: !DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview,
272 }),
273 ).toEqual(["Browser viewport", "Browser zoom", "Browser appearance", "Floating preview"]);
274 });
275});
276
277describe("isSamePreviewViewport", () => {
278 it("separates presets that share a size", () => {
279 // Two presets can agree on width and height and still be different
280 // entries in the picker, so the id has to take part in the comparison.
281 expect(
282 isSamePreviewViewport(
283 { _tag: "preset", width: 390, height: 844, presetId: "iphone-12-pro" },
284 { _tag: "preset", width: 390, height: 844, presetId: "ipad-mini" },
285 ),
286 ).toBe(false);
287 });
288
289 it("separates a freeform viewport from a preset of the same size", () => {
290 expect(
291 isSamePreviewViewport(
292 { _tag: "freeform", width: 390, height: 844 },
293 { _tag: "preset", width: 390, height: 844, presetId: "iphone-12-pro" },
294 ),
295 ).toBe(false);
296 });
297});
apps/web/src/components/settings/SettingsPanels.logic.ts+50
......@@ -3,6 +3,7 @@ import type {
33 BackgroundActivitySettings,
44 ProviderDriverKind,
55 ProviderInstanceConfig,
6 PreviewViewportSetting,
67 ProviderInstanceId,
78 ServerSettings,
89 SidebarProjectGroupingMode,
......@@ -108,6 +109,55 @@ export function getChangedTypographySettingLabels(settings: TypographySettings):
108109 ];
109110}
110111
112export type BrowserDefaultSettings = Pick<
113 UnifiedSettings,
114 | "browserDefaultViewport"
115 | "browserDefaultZoomFactor"
116 | "browserDefaultAppearance"
117 | "browserAutoShowFloatingPreview"
118>;
119
120/**
121 * True when two viewport settings describe the same viewport.
122 *
123 * The setting is a tagged union rather than a scalar, so identity comparison
124 * reports every stored viewport as changed — including one that matches the
125 * default.
126 */
127export function isSamePreviewViewport(
128 left: PreviewViewportSetting,
129 right: PreviewViewportSetting,
130): boolean {
131 if (left._tag !== right._tag) return false;
132 if (left._tag === "fill" || right._tag === "fill") return true;
133 if (left.width !== right.width || left.height !== right.height) return false;
134 return left._tag === "preset" && right._tag === "preset"
135 ? left.presetId === right.presetId
136 : true;
137}
138
139/** Labels the browser-default rows that differ from the defaults. */
140export function getChangedBrowserSettingLabels(settings: BrowserDefaultSettings): string[] {
141 return [
142 ...(isSamePreviewViewport(
143 settings.browserDefaultViewport,
144 DEFAULT_UNIFIED_SETTINGS.browserDefaultViewport,
145 )
146 ? []
147 : ["Browser viewport"]),
148 ...(settings.browserDefaultZoomFactor !== DEFAULT_UNIFIED_SETTINGS.browserDefaultZoomFactor
149 ? ["Browser zoom"]
150 : []),
151 ...(settings.browserDefaultAppearance !== DEFAULT_UNIFIED_SETTINGS.browserDefaultAppearance
152 ? ["Browser appearance"]
153 : []),
154 ...(settings.browserAutoShowFloatingPreview !==
155 DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview
156 ? ["Floating preview"]
157 : []),
158 ];
159}
160
111161export function resolveBackgroundActivityProfileOption(
112162 settings: ServerSettings,
113163): BackgroundActivityProfile | "advanced" {
apps/web/src/components/settings/SettingsPanels.tsx+10
......@@ -122,6 +122,7 @@ import {
122122 backgroundActivitySharedPolicySettings,
123123 durationToSeconds,
124124 formatDiagnosticsDescription,
125 getChangedBrowserSettingLabels,
125126 getChangedTypographySettingLabels,
126127 normalizeIntervalSeconds,
127128 PROVIDER_HEALTH_INTERVAL_STEP_SECONDS,
......@@ -529,10 +530,15 @@ export function useSettingsRestore(onRestored?: () => void) {
529530 ? ["Quit confirmation"]
530531 : []),
531532 ...(isTextGenerationModelDirty ? ["Text generation model"] : []),
533 ...getChangedBrowserSettingLabels(settings),
532534 ],
533535 [
534536 isTextGenerationModelDirty,
535537 isBackgroundActivityDirty,
538 settings.browserDefaultViewport,
539 settings.browserDefaultZoomFactor,
540 settings.browserDefaultAppearance,
541 settings.browserAutoShowFloatingPreview,
536542 settings.confirmQuit,
537543 settings.confirmThreadArchive,
538544 settings.confirmThreadDelete,
......@@ -657,6 +663,10 @@ export function useSettingsRestore(onRestored?: () => void) {
657663 fontSizePrompt: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt,
658664 fontSizeCode: DEFAULT_UNIFIED_SETTINGS.fontSizeCode,
659665 fontSizeTerminal: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal,
666 browserDefaultViewport: DEFAULT_UNIFIED_SETTINGS.browserDefaultViewport,
667 browserDefaultZoomFactor: DEFAULT_UNIFIED_SETTINGS.browserDefaultZoomFactor,
668 browserDefaultAppearance: DEFAULT_UNIFIED_SETTINGS.browserDefaultAppearance,
669 browserAutoShowFloatingPreview: DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview,
660670 });
661671 onRestored?.();
662672 }, [
apps/web/src/components/settings/SettingsSidebarNav.tsx+2
......@@ -10,6 +10,7 @@ import {
1010import {
1111 ArchiveIcon,
1212 ArrowLeftIcon,
13 BlocksIcon,
1314 BotIcon,
1415 GitBranchIcon,
1516 KeyboardIcon,
......@@ -49,6 +50,7 @@ const SETTINGS_SECTION_ICONS: Readonly<
4950 "/settings/appearance": PaletteIcon,
5051 "/settings/keybindings": KeyboardIcon,
5152 "/settings/providers": BotIcon,
53 "/settings/integrations": BlocksIcon,
5254 "/settings/source-control": GitBranchIcon,
5355 "/settings/connections": Link2Icon,
5456 "/settings/archived": ArchiveIcon,
apps/web/src/components/settings/settingsSearch.ts+26
......@@ -5,6 +5,7 @@ export type SettingsPath =
55 | "/settings/appearance"
66 | "/settings/keybindings"
77 | "/settings/providers"
8 | "/settings/integrations"
89 | "/settings/source-control"
910 | "/settings/connections"
1011 | "/settings/archived";
......@@ -28,6 +29,7 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
2829 "/settings/appearance": "Appearance",
2930 "/settings/keybindings": "Keybindings",
3031 "/settings/providers": "Providers",
32 "/settings/integrations": "Integrations",
3133 "/settings/source-control": "Source Control",
3234 "/settings/connections": "Connections",
3335 "/settings/archived": "Archive",
......@@ -195,6 +197,30 @@ export const SETTINGS_SEARCH_ITEMS = [
195197 title: "Providers",
196198 to: "/settings/providers",
197199 },
200 {
201 id: "browser-default-viewport",
202 title: "Default browser viewport",
203 to: "/settings/integrations",
204 targetId: "browser",
205 },
206 {
207 id: "browser-default-zoom",
208 title: "Default browser zoom",
209 to: "/settings/integrations",
210 targetId: "browser",
211 },
212 {
213 id: "browser-default-appearance",
214 title: "Default browser appearance",
215 to: "/settings/integrations",
216 targetId: "browser",
217 },
218 {
219 id: "browser-auto-show-floating-preview",
220 title: "Auto-show floating preview",
221 to: "/settings/integrations",
222 targetId: "browser",
223 },
198224 {
199225 id: "source-control",
200226 title: "Source control",
apps/web/src/hooks/useSettings.ts+11
......@@ -185,6 +185,17 @@ export function getClientSettings(): ClientSettings {
185185 return getClientSettingsSnapshot();
186186}
187187
188/**
189 * Resolves once client settings have been read from disk.
190 *
191 * The pre-hydration snapshot is just the schema defaults, so imperative paths
192 * that open a preview must await this or they bake the built-in viewport, zoom
193 * and appearance into a tab that never picks up the user's saved values.
194 */
195export function ensureClientSettingsHydrated(): Promise<void> {
196 return hydrateClientSettings();
197}
198
188199export function useClientSettingsHydrated(): boolean {
189200 return useSyncExternalStore(
190201 subscribeClientSettingsHydration,
apps/web/src/routeTree.gen.ts+21
......@@ -18,6 +18,7 @@ import { Route as ChatIndexRouteImport } from './routes/_chat.index'
1818import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'
1919import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'
2020import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings'
21import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations'
2122import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
2223import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'
2324import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
......@@ -73,6 +74,11 @@ const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({
7374 path: '/keybindings',
7475 getParentRoute: () => SettingsRoute,
7576} as any)
77const SettingsIntegrationsRoute = SettingsIntegrationsRouteImport.update({
78 id: '/integrations',
79 path: '/integrations',
80 getParentRoute: () => SettingsRoute,
81} as any)
7682const SettingsGeneralRoute = SettingsGeneralRouteImport.update({
7783 id: '/general',
7884 path: '/general',
......@@ -139,6 +145,7 @@ export interface FileRoutesByFullPath {
139145 '/settings/connections': typeof SettingsConnectionsRoute
140146 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
141147 '/settings/general': typeof SettingsGeneralRoute
148 '/settings/integrations': typeof SettingsIntegrationsRoute
142149 '/settings/keybindings': typeof SettingsKeybindingsRoute
143150 '/settings/providers': typeof SettingsProvidersRoute
144151 '/settings/source-control': typeof SettingsSourceControlRoute
......@@ -158,6 +165,7 @@ export interface FileRoutesByTo {
158165 '/settings/connections': typeof SettingsConnectionsRoute
159166 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
160167 '/settings/general': typeof SettingsGeneralRoute
168 '/settings/integrations': typeof SettingsIntegrationsRoute
161169 '/settings/keybindings': typeof SettingsKeybindingsRoute
162170 '/settings/providers': typeof SettingsProvidersRoute
163171 '/settings/source-control': typeof SettingsSourceControlRoute
......@@ -180,6 +188,7 @@ export interface FileRoutesById {
180188 '/settings/connections': typeof SettingsConnectionsRoute
181189 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
182190 '/settings/general': typeof SettingsGeneralRoute
191 '/settings/integrations': typeof SettingsIntegrationsRoute
183192 '/settings/keybindings': typeof SettingsKeybindingsRoute
184193 '/settings/providers': typeof SettingsProvidersRoute
185194 '/settings/source-control': typeof SettingsSourceControlRoute
......@@ -203,6 +212,7 @@ export interface FileRouteTypes {
203212 | '/settings/connections'
204213 | '/settings/diagnostics'
205214 | '/settings/general'
215 | '/settings/integrations'
206216 | '/settings/keybindings'
207217 | '/settings/providers'
208218 | '/settings/source-control'
......@@ -222,6 +232,7 @@ export interface FileRouteTypes {
222232 | '/settings/connections'
223233 | '/settings/diagnostics'
224234 | '/settings/general'
235 | '/settings/integrations'
225236 | '/settings/keybindings'
226237 | '/settings/providers'
227238 | '/settings/source-control'
......@@ -243,6 +254,7 @@ export interface FileRouteTypes {
243254 | '/settings/connections'
244255 | '/settings/diagnostics'
245256 | '/settings/general'
257 | '/settings/integrations'
246258 | '/settings/keybindings'
247259 | '/settings/providers'
248260 | '/settings/source-control'
......@@ -326,6 +338,13 @@ declare module '@tanstack/react-router' {
326338 preLoaderRoute: typeof SettingsKeybindingsRouteImport
327339 parentRoute: typeof SettingsRoute
328340 }
341 '/settings/integrations': {
342 id: '/settings/integrations'
343 path: '/integrations'
344 fullPath: '/settings/integrations'
345 preLoaderRoute: typeof SettingsIntegrationsRouteImport
346 parentRoute: typeof SettingsRoute
347 }
329348 '/settings/general': {
330349 id: '/settings/general'
331350 path: '/general'
......@@ -421,6 +440,7 @@ interface SettingsRouteChildren {
421440 SettingsConnectionsRoute: typeof SettingsConnectionsRoute
422441 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute
423442 SettingsGeneralRoute: typeof SettingsGeneralRoute
443 SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute
424444 SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute
425445 SettingsProvidersRoute: typeof SettingsProvidersRoute
426446 SettingsSourceControlRoute: typeof SettingsSourceControlRoute
......@@ -432,6 +452,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {
432452 SettingsConnectionsRoute: SettingsConnectionsRoute,
433453 SettingsDiagnosticsRoute: SettingsDiagnosticsRoute,
434454 SettingsGeneralRoute: SettingsGeneralRoute,
455 SettingsIntegrationsRoute: SettingsIntegrationsRoute,
435456 SettingsKeybindingsRoute: SettingsKeybindingsRoute,
436457 SettingsProvidersRoute: SettingsProvidersRoute,
437458 SettingsSourceControlRoute: SettingsSourceControlRoute,
apps/web/src/routes/settings.integrations.tsx created+11
......@@ -0,0 +1,11 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { IntegrationsSettingsPanel } from "../components/settings/IntegrationsSettings";
4
5function SettingsIntegrationsRoute() {
6 return <IntegrationsSettingsPanel />;
7}
8
9export const Route = createFileRoute("/settings/integrations")({
10 component: SettingsIntegrationsRoute,
11});
packages/contracts/src/ipc.ts+19-1
......@@ -956,6 +956,24 @@ export const DesktopPreviewTabInputSchema = Schema.Struct({
956956 tabId: DesktopPreviewTabIdSchema,
957957});
958958
959/**
960 * Tab creation carries the client's configured browser defaults so the guest
961 * is born already zoomed and color-scheme-emulated. Applying them after
962 * creation instead would paint one frame at 100%/system first, which reads as
963 * a flash on every tab open. Both fields are optional so an older renderer
964 * still gets the historical defaults.
965 */
966export const DesktopPreviewCreateTabInputSchema = Schema.Struct({
967 tabId: DesktopPreviewTabIdSchema,
968 zoomFactor: Schema.optional(Schema.Number.check(Schema.isGreaterThan(0))),
969 colorScheme: Schema.optional(DesktopPreviewColorSchemeSchema),
970});
971
972export interface DesktopPreviewTabDefaults {
973 readonly zoomFactor?: number | undefined;
974 readonly colorScheme?: DesktopPreviewColorScheme | undefined;
975}
976
959977export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({
960978 tabId: DesktopPreviewTabIdSchema,
961979 webContentsId: Schema.Int.check(Schema.isGreaterThan(0)),
......@@ -1109,7 +1127,7 @@ export interface DesktopBridge {
11091127}
11101128
11111129export interface DesktopPreviewBridge {
1112 createTab: (tabId: string) => Promise<void>;
1130 createTab: (tabId: string, defaults?: DesktopPreviewTabDefaults) => Promise<void>;
11131131 closeTab: (tabId: string) => Promise<void>;
11141132 registerWebview: (tabId: string, webContentsId: number) => Promise<void>;
11151133 navigate: (tabId: string, url: string) => Promise<void>;
packages/contracts/src/preview.ts+31
......@@ -118,6 +118,30 @@ export const FILL_PREVIEW_VIEWPORT = {
118118 _tag: "fill",
119119} as const satisfies PreviewViewportSetting;
120120
121/**
122 * Discrete zoom levels mirroring Chrome's preset ladder. Zoom is applied by the
123 * desktop main process to the Chromium guest, but the ladder lives here so the
124 * settings UI can offer exactly the steps the zoom controls step through.
125 */
126export const PREVIEW_ZOOM_LEVELS = [
127 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0,
128] as const;
129
130export const PreviewZoomFactor = Schema.Literals(PREVIEW_ZOOM_LEVELS);
131export type PreviewZoomFactor = typeof PreviewZoomFactor.Type;
132
133export const DEFAULT_PREVIEW_ZOOM_FACTOR: PreviewZoomFactor = 1.0;
134
135/**
136 * Preferred `prefers-color-scheme` for preview guests. `system` clears the
137 * emulation override so the guest follows the OS. Structurally identical to
138 * `DesktopPreviewColorScheme`, which is the IPC-layer spelling of the same set.
139 */
140export const PreviewAppearancePreference = Schema.Literals(["system", "light", "dark"]);
141export type PreviewAppearancePreference = typeof PreviewAppearancePreference.Type;
142
143export const DEFAULT_PREVIEW_APPEARANCE: PreviewAppearancePreference = "system";
144
121145export const PreviewNavStatus = Schema.Union([
122146 Schema.TaggedStruct("Idle", {}),
123147 Schema.TaggedStruct("Loading", {
......@@ -153,6 +177,13 @@ export const PreviewOpenInput = Schema.Struct({
153177 threadId: ThreadId,
154178 /** Omit to create an empty (Idle) tab the user can type into. */
155179 url: Schema.optional(Url),
180 /**
181 * Initial viewport for the new tab. Omitting it keeps the historical
182 * fill-panel behaviour; clients that have a configured default send it here
183 * so the session is born at the right size instead of being resized a frame
184 * later (which the user would see as a visible reflow).
185 */
186 viewport: Schema.optional(PreviewViewportSetting),
156187});
157188export type PreviewOpenInput = typeof PreviewOpenInput.Type;
158189
packages/contracts/src/settings.ts+39
......@@ -10,6 +10,14 @@ import {
1010 ProviderOptionSelections,
1111} from "./model.ts";
1212import { ModelSelection } from "./orchestration.ts";
13import {
14 DEFAULT_PREVIEW_APPEARANCE,
15 DEFAULT_PREVIEW_ZOOM_FACTOR,
16 FILL_PREVIEW_VIEWPORT,
17 PreviewAppearancePreference,
18 PreviewViewportSetting,
19 PreviewZoomFactor,
20} from "./preview.ts";
1321import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts";
1422
1523// ── Client Settings (local-only) ───────────────────────────────
......@@ -111,7 +119,34 @@ export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationM
111119export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200));
112120export type FontFamilyPreference = typeof FontFamilyPreference.Type;
113121
122/**
123 * Defaults for the in-app preview browser, applied whenever a tab is opened
124 * without an explicit viewport/zoom/appearance — by the user opening a browser
125 * tab, or by an agent calling `preview_open` with no size. Client-local
126 * because the Chromium guest they configure is desktop-local.
127 */
128export const DEFAULT_BROWSER_VIEWPORT: PreviewViewportSetting = FILL_PREVIEW_VIEWPORT;
129export const DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW = true;
130
114131export const ClientSettingsSchema = Schema.Struct({
132 browserDefaultViewport: PreviewViewportSetting.pipe(
133 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_VIEWPORT)),
134 ),
135 browserDefaultZoomFactor: PreviewZoomFactor.pipe(
136 Schema.withDecodingDefault(Effect.succeed(DEFAULT_PREVIEW_ZOOM_FACTOR)),
137 ),
138 browserDefaultAppearance: PreviewAppearancePreference.pipe(
139 Schema.withDecodingDefault(Effect.succeed(DEFAULT_PREVIEW_APPEARANCE)),
140 ),
141 /**
142 * Whether an agent opening a preview pops the floating mini player into
143 * view. Only applies when the agent didn't ask either way — an explicit
144 * `open`/`show` on `preview_open` still wins, since that is the agent
145 * deliberately showing or hiding its work.
146 */
147 browserAutoShowFloatingPreview: Schema.Boolean.pipe(
148 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)),
149 ),
115150 // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the
116151 // app quits; a quick tap only shows a hint. Browser clients ignore it.
117152 confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
......@@ -759,6 +794,10 @@ export const ServerSettingsPatch = Schema.Struct({
759794export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
760795
761796export const ClientSettingsPatch = Schema.Struct({
797 browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting),
798 browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor),
799 browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),
800 browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean),
762801 confirmQuit: Schema.optionalKey(Schema.Boolean),
763802 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
764803 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),