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 {...@@ -14,6 +14,7 @@ import {
14 DesktopPreviewRegisterWebviewInputSchema,14 DesktopPreviewRegisterWebviewInputSchema,
15 DesktopPreviewScreenshotArtifactSchema,15 DesktopPreviewScreenshotArtifactSchema,
16 DesktopPreviewSetColorSchemeInputSchema,16 DesktopPreviewSetColorSchemeInputSchema,
17 DesktopPreviewCreateTabInputSchema,
17 DesktopPreviewTabInputSchema,18 DesktopPreviewTabInputSchema,
18 DesktopPreviewWebviewConfigSchema,19 DesktopPreviewWebviewConfigSchema,
19 PreviewAnnotationSubmissionResultSchema,20 PreviewAnnotationSubmissionResultSchema,
...@@ -48,11 +49,15 @@ export const installPreviewEventForwarding = Effect.fn(...@@ -48,11 +49,15 @@ export const installPreviewEventForwarding = Effect.fn(
4849
49export const createTab = DesktopIpc.makeIpcMethod({50export const createTab = DesktopIpc.makeIpcMethod({
50 channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL,51 channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL,
51 payload: DesktopPreviewTabInputSchema,52 payload: DesktopPreviewCreateTabInputSchema,
52 result: Schema.Void,53 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 }) {
54 const manager = yield* PreviewManager.PreviewManager;59 const manager = yield* PreviewManager.PreviewManager;
55 yield* manager.createTab(tabId);60 yield* manager.createTab(tabId, { zoomFactor, colorScheme });
56 }),61 }),
57});62});
5863
apps/desktop/src/preload.ts+6-1
...@@ -163,7 +163,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -163,7 +163,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {
163 };163 };
164 },164 },
165 preview: {165 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 }),
167 closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }),172 closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }),
168 registerWebview: (tabId, webContentsId) =>173 registerWebview: (tabId, webContentsId) =>
169 ipcRenderer.invoke(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, { tabId, webContentsId }),174 ipcRenderer.invoke(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, { tabId, webContentsId }),
apps/desktop/src/preview/Manager.ts+28-5
...@@ -16,6 +16,7 @@ import type {...@@ -16,6 +16,7 @@ import type {
16 DesktopPreviewRecordingArtifact,16 DesktopPreviewRecordingArtifact,
17 DesktopPreviewRecordingFrame,17 DesktopPreviewRecordingFrame,
18 DesktopPreviewScreenshotArtifact,18 DesktopPreviewScreenshotArtifact,
19 DesktopPreviewTabDefaults,
19 PreviewAutomationClickInput,20 PreviewAutomationClickInput,
20 PreviewAutomationActionEvent,21 PreviewAutomationActionEvent,
21 PreviewAutomationConsoleEntry,22 PreviewAutomationConsoleEntry,
...@@ -334,6 +335,21 @@ const findZoomStep = (current: number): number => {...@@ -334,6 +335,21 @@ const findZoomStep = (current: number): number => {
334 return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1;335 return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1;
335};336};
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
337const nextZoomLevel = (current: number, direction: "in" | "out"): number => {353const nextZoomLevel = (current: number, direction: "in" | "out"): number => {
338 const step = findZoomStep(current);354 const step = findZoomStep(current);
339 if (direction === "in") {355 if (direction === "in") {
...@@ -1614,6 +1630,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1614,6 +1630,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
16141630
1615 const createTabUnlocked = Effect.fn("PreviewManager.createTabUnlocked")(function* (1631 const createTabUnlocked = Effect.fn("PreviewManager.createTabUnlocked")(function* (
1616 tabId: string,1632 tabId: string,
1633 defaults?: DesktopPreviewTabDefaults,
1617 ) {1634 ) {
1618 const updatedAt = yield* currentIso;1635 const updatedAt = yield* currentIso;
1619 const result = yield* SynchronizedRef.modify(1636 const result = yield* SynchronizedRef.modify(
...@@ -1632,9 +1649,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1632,9 +1649,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1632 navStatus: { kind: "Idle" },1649 navStatus: { kind: "Idle" },
1633 canGoBack: false,1650 canGoBack: false,
1634 canGoForward: false,1651 canGoForward: false,
1635 zoomFactor: DEFAULT_ZOOM_FACTOR,1652 zoomFactor: normalizeZoomFactor(defaults?.zoomFactor),
1636 pictureInPicture: false,1653 pictureInPicture: false,
1637 colorScheme: "system",1654 colorScheme: defaults?.colorScheme ?? "system",
1638 controller: "none",1655 controller: "none",
1639 updatedAt,1656 updatedAt,
1640 };1657 };
...@@ -1653,8 +1670,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1653,8 +1670,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1653 return result.state;1670 return result.state;
1654 });1671 });
16551672
1656 const createTab = Effect.fn("PreviewManager.createTab")(function* (tabId: string) {1673 const createTab = Effect.fn("PreviewManager.createTab")(function* (
1657 return yield* withTabLifecycleLock(tabId, createTabUnlocked(tabId));1674 tabId: string,
1675 defaults?: DesktopPreviewTabDefaults,
1676 ) {
1677 return yield* withTabLifecycleLock(tabId, createTabUnlocked(tabId, defaults));
1658 });1678 });
16591679
1660 const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) {1680 const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) {
...@@ -3802,7 +3822,10 @@ export class PreviewManager extends Context.Service<...@@ -3802,7 +3822,10 @@ export class PreviewManager extends Context.Service<
3802 readonly setMainWindow: (window: BrowserWindow) => Effect.Effect<void, PreviewManagerError>;3822 readonly setMainWindow: (window: BrowserWindow) => Effect.Effect<void, PreviewManagerError>;
3803 readonly getBrowserSession: (scope?: string) => Effect.Effect<Session, PreviewManagerError>;3823 readonly getBrowserSession: (scope?: string) => Effect.Effect<Session, PreviewManagerError>;
3804 readonly isBrowserPartition: (partition: string) => boolean;3824 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>;
3806 readonly closeTab: (tabId: string) => Effect.Effect<void, PreviewManagerError>;3829 readonly closeTab: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
3807 readonly registerWebview: (3830 readonly registerWebview: (
3808 tabId: string,3831 tabId: string,
apps/desktop/src/settings/DesktopClientSettings.test.ts+4
...@@ -13,6 +13,10 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";...@@ -13,6 +13,10 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
13import * as DesktopClientSettings from "./DesktopClientSettings.ts";13import * as DesktopClientSettings from "./DesktopClientSettings.ts";
1414
15const clientSettings: ClientSettings = {15const clientSettings: ClientSettings = {
16 browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
17 browserDefaultZoomFactor: 1.25,
18 browserDefaultAppearance: "dark",
19 browserAutoShowFloatingPreview: false,
16 confirmQuit: true,20 confirmQuit: true,
17 confirmThreadArchive: true,21 confirmThreadArchive: true,
18 confirmThreadDelete: false,22 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";...@@ -3,12 +3,10 @@ import { describe, expect, it } from "vite-plus/test";
3import { normalizePreviewOpenInput } from "./handlers.ts";3import { normalizePreviewOpenInput } from "./handlers.ts";
44
5describe("normalizePreviewOpenInput", () => {5describe("normalizePreviewOpenInput", () => {
6 it("opens the inline preview and reuses the current tab by default", () => {6 it("leaves an unstated visibility for the client preference to decide", () => {
7 expect(normalizePreviewOpenInput({})).toEqual({7 // Filling `open` in here would outrank `browserAutoShowFloatingPreview`,
8 open: true,8 // which is desktop-local and cannot be read from the server.
9 reuseExistingTab: true,9 expect(normalizePreviewOpenInput({})).toEqual({ reuseExistingTab: true });
10 show: true,
11 });
12 });10 });
1311
14 it("preserves an explicit background-only opt-out", () => {12 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";...@@ -15,14 +15,21 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts";
15import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";15import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
16import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts";16import { 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 */
18export function normalizePreviewOpenInput(26export function normalizePreviewOpenInput(
19 input: PreviewAutomationOpenInput,27 input: PreviewAutomationOpenInput,
20): PreviewAutomationOpenInput {28): PreviewAutomationOpenInput {
21 const open = input.open ?? input.show ?? true;29 const open = input.open ?? input.show;
22 return {30 return {
23 ...input,31 ...input,
24 open,32 ...(open === undefined ? {} : { open, show: open }),
25 show: open,
26 reuseExistingTab: input.reuseExistingTab ?? true,33 reuseExistingTab: input.reuseExistingTab ?? true,
27 };34 };
28}35}
apps/server/src/preview/Manager.ts+11-3
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
24 FILL_PREVIEW_VIEWPORT,24 FILL_PREVIEW_VIEWPORT,
25 PreviewSessionLookupError,25 PreviewSessionLookupError,
26 type PreviewSessionSnapshot,26 type PreviewSessionSnapshot,
27 type PreviewViewportSetting,
27} from "@t3tools/contracts";28} from "@t3tools/contracts";
28import {29import {
29 isPreviewUrlNormalizationError,30 isPreviewUrlNormalizationError,
...@@ -121,6 +122,7 @@ const buildLoadingSnapshot = (input: {...@@ -121,6 +122,7 @@ const buildLoadingSnapshot = (input: {
121 readonly tabId: string;122 readonly tabId: string;
122 readonly url: string;123 readonly url: string;
123 readonly title: string;124 readonly title: string;
125 readonly viewport: PreviewViewportSetting;
124 readonly updatedAt: string;126 readonly updatedAt: string;
125}): PreviewSessionSnapshot => ({127}): PreviewSessionSnapshot => ({
126 threadId: input.threadId,128 threadId: input.threadId,
...@@ -128,13 +130,14 @@ const buildLoadingSnapshot = (input: {...@@ -128,13 +130,14 @@ const buildLoadingSnapshot = (input: {
128 navStatus: { _tag: "Loading", url: input.url, title: input.title },130 navStatus: { _tag: "Loading", url: input.url, title: input.title },
129 canGoBack: false,131 canGoBack: false,
130 canGoForward: false,132 canGoForward: false,
131 viewport: FILL_PREVIEW_VIEWPORT,133 viewport: input.viewport,
132 updatedAt: input.updatedAt,134 updatedAt: input.updatedAt,
133});135});
134136
135const buildIdleSnapshot = (input: {137const buildIdleSnapshot = (input: {
136 readonly threadId: string;138 readonly threadId: string;
137 readonly tabId: string;139 readonly tabId: string;
140 readonly viewport: PreviewViewportSetting;
138 readonly updatedAt: string;141 readonly updatedAt: string;
139}): PreviewSessionSnapshot => ({142}): PreviewSessionSnapshot => ({
140 threadId: input.threadId,143 threadId: input.threadId,
...@@ -142,7 +145,7 @@ const buildIdleSnapshot = (input: {...@@ -142,7 +145,7 @@ const buildIdleSnapshot = (input: {
142 navStatus: { _tag: "Idle" },145 navStatus: { _tag: "Idle" },
143 canGoBack: false,146 canGoBack: false,
144 canGoForward: false,147 canGoForward: false,
145 viewport: FILL_PREVIEW_VIEWPORT,148 viewport: input.viewport,
146 updatedAt: input.updatedAt,149 updatedAt: input.updatedAt,
147});150});
148151
...@@ -215,15 +218,20 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -215,15 +218,20 @@ export const make = Effect.gen(function* PreviewManagerMake() {
215 function* (input) {218 function* (input) {
216 const tabId = newPreviewTabId();219 const tabId = newPreviewTabId();
217 const updatedAt = yield* currentIsoTimestamp;220 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;
218 const snapshot = input.url225 const snapshot = input.url
219 ? buildLoadingSnapshot({226 ? buildLoadingSnapshot({
220 threadId: input.threadId,227 threadId: input.threadId,
221 tabId,228 tabId,
222 url: yield* normalizeUrl(input.url),229 url: yield* normalizeUrl(input.url),
223 title: "",230 title: "",
231 viewport,
224 updatedAt,232 updatedAt,
225 })233 })
226 : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt });234 : buildIdleSnapshot({ threadId: input.threadId, tabId, viewport, updatedAt });
227 yield* SynchronizedRef.modifyEffect(stateRef, (state) =>235 yield* SynchronizedRef.modifyEffect(stateRef, (state) =>
228 Effect.gen(function* () {236 Effect.gen(function* () {
229 const revision = state.revision + 1;237 const revision = state.revision + 1;
apps/web/src/browser/BrowserDeviceToolbar.tsx+1-20
...@@ -26,6 +26,7 @@ import { cn } from "~/lib/utils";...@@ -26,6 +26,7 @@ import { cn } from "~/lib/utils";
2626
27import { BROWSER_DEVICE_TOOLBAR_HEIGHT, resizeFreeformViewport } from "./browserViewportLayout";27import { BROWSER_DEVICE_TOOLBAR_HEIGHT, resizeFreeformViewport } from "./browserViewportLayout";
28import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState";28import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState";
29import { ScreenRotationIcon } from "./ScreenRotationIcon";
2930
30const RESPONSIVE_VALUE = "responsive";31const RESPONSIVE_VALUE = "responsive";
31const SELECT_ITEMS = [32const SELECT_ITEMS = [
...@@ -33,26 +34,6 @@ const SELECT_ITEMS = [...@@ -33,26 +34,6 @@ const SELECT_ITEMS = [
33 ...PREVIEW_VIEWPORT_PRESETS.map((preset) => ({ value: preset.id, label: preset.label })),34 ...PREVIEW_VIEWPORT_PRESETS.map((preset) => ({ value: preset.id, label: preset.label })),
34];35];
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
56interface Props {37interface Props {
57 readonly setting: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>;38 readonly setting: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>;
58 readonly width: number;39 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 @@...@@ -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";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";7import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
38
4const { closeTab, createTab, stopBrowserRecording } = vi.hoisted(() => ({9const { closeTab, createTab, stopBrowserRecording } = vi.hoisted(() => ({
...@@ -16,6 +21,12 @@ vi.mock("./browserRecording", () => ({...@@ -16,6 +21,12 @@ vi.mock("./browserRecording", () => ({
16}));21}));
1722
18import { acquireDesktopTab } from "./desktopTabLifetime";23import { 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};
19import { previewRuntimeTabId } from "./previewRuntimeTabId";30import { previewRuntimeTabId } from "./previewRuntimeTabId";
2031
21describe("desktopTabLifetime", () => {32describe("desktopTabLifetime", () => {
...@@ -42,14 +53,16 @@ describe("desktopTabLifetime", () => {...@@ -42,14 +53,16 @@ describe("desktopTabLifetime", () => {
42 const first = acquireDesktopTab("tab_readiness");53 const first = acquireDesktopTab("tab_readiness");
43 const second = acquireDesktopTab("tab_readiness");54 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.
46 expect(first.ready).toBe(second.ready);59 expect(first.ready).toBe(second.ready);
4760
48 let ready = false;61 let ready = false;
49 void first.ready.then(() => {62 void first.ready.then(() => {
50 ready = true;63 ready = true;
51 });64 });
52 await Promise.resolve();65 await vi.waitFor(() => expect(createTab).toHaveBeenCalledOnce());
53 expect(ready).toBe(false);66 expect(ready).toBe(false);
5467
55 resolveCreation?.();68 resolveCreation?.();
...@@ -81,8 +94,8 @@ describe("desktopTabLifetime", () => {...@@ -81,8 +94,8 @@ describe("desktopTabLifetime", () => {
81 const second = acquireDesktopTab(tabB);94 const second = acquireDesktopTab(tabB);
82 await Promise.all([first.ready, second.ready]);95 await Promise.all([first.ready, second.ready]);
8396
84 expect(createTab).toHaveBeenCalledWith(tabA);97 expect(createTab).toHaveBeenCalledWith(tabA, DEFAULT_TAB_STATE);
85 expect(createTab).toHaveBeenCalledWith(tabB);98 expect(createTab).toHaveBeenCalledWith(tabB, DEFAULT_TAB_STATE);
86 expect(createTab).toHaveBeenCalledTimes(2);99 expect(createTab).toHaveBeenCalledTimes(2);
87100
88 first.release();101 first.release();
apps/web/src/browser/desktopTabLifetime.ts+6-1
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { previewBridge } from "~/components/preview/previewBridge";1import { previewBridge } from "~/components/preview/previewBridge";
22
3import { browserDefaultTabState, resolveBrowserDefaults } from "./browserDefaults";
3import { stopBrowserRecording } from "./browserRecording";4import { stopBrowserRecording } from "./browserRecording";
45
5interface DesktopTabLease {6interface DesktopTabLease {
...@@ -41,7 +42,11 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab {...@@ -41,7 +42,11 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab {
41 ({42 ({
42 references: 0,43 references: 0,
43 closeTimer: null,44 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 ),
45 } satisfies DesktopTabLease);50 } satisfies DesktopTabLease);
46 if (current.closeTimer !== null) window.clearTimeout(current.closeTimer);51 if (current.closeTimer !== null) window.clearTimeout(current.closeTimer);
47 current.references += 1;52 current.references += 1;
apps/web/src/components/preview/PreviewAutomationHosts.tsx+8-1
...@@ -38,6 +38,7 @@ import {...@@ -38,6 +38,7 @@ import {
38} from "~/browser/browserRecording";38} from "~/browser/browserRecording";
39import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope";39import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope";
40import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";40import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
41import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults";
41import { runBrowserViewportMutation } from "~/browser/browserViewportActions";42import { runBrowserViewportMutation } from "~/browser/browserViewportActions";
42import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";43import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
43import { isElectron } from "~/env";44import { isElectron } from "~/env";
...@@ -380,6 +381,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -380,6 +381,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
380 input: {381 input: {
381 threadId: request.threadId,382 threadId: request.threadId,
382 ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}),383 ...(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()),
383 },387 },
384 });388 });
385 if (result._tag === "Failure") {389 if (result._tag === "Failure") {
...@@ -428,7 +432,10 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -428,7 +432,10 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
428 updatePreviewServerSnapshot(threadRef, resizeResult.value);432 updatePreviewServerSnapshot(threadRef, resizeResult.value);
429 }433 }
430 }434 }
431 const shouldPresentPreview = shouldOpenPreviewMiniPlayer(input);435 const shouldPresentPreview = shouldOpenPreviewMiniPlayer(
436 input,
437 (await resolveBrowserDefaults()).autoShowFloatingPreview,
438 );
432 if (shouldPresentPreview) {439 if (shouldPresentPreview) {
433 usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId);440 usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId);
434 }441 }
apps/web/src/components/preview/PreviewView.test.tsx+35-1
...@@ -1,4 +1,10 @@...@@ -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";
2import { renderToStaticMarkup } from "react-dom/server";8import { renderToStaticMarkup } from "react-dom/server";
3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";9import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
410
...@@ -41,6 +47,34 @@ vi.mock("~/state/session", () => ({...@@ -41,6 +47,34 @@ vi.mock("~/state/session", () => ({
41 readPreparedConnection: mocks.readPreparedConnection,47 readPreparedConnection: mocks.readPreparedConnection,
42}));48}));
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
44vi.mock("~/composerDraftStore", () => ({78vi.mock("~/composerDraftStore", () => ({
45 useComposerDraftStore: (79 useComposerDraftStore: (
46 select: (store: { addPreviewAnnotation: () => void; addImage: () => void }) => unknown,80 select: (store: { addPreviewAnnotation: () => void; addImage: () => void }) => unknown,
apps/web/src/components/preview/PreviewView.tsx+10-7
...@@ -43,7 +43,7 @@ import {...@@ -43,7 +43,7 @@ import {
43 commitBrowserViewportChange,43 commitBrowserViewportChange,
44 subscribeBrowserViewportChange,44 subscribeBrowserViewportChange,
45} from "~/browser/browserViewportActions";45} from "~/browser/browserViewportActions";
46import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout";46import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults";
47import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";47import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
48import { PreviewUnreachable } from "./PreviewUnreachable";48import { PreviewUnreachable } from "./PreviewUnreachable";
49import { revealInFileExplorerLabel } from "./fileExplorerLabel";49import { revealInFileExplorerLabel } from "./fileExplorerLabel";
...@@ -144,6 +144,7 @@ export function PreviewView({...@@ -144,6 +144,7 @@ export function PreviewView({
144 const controller = desktopOverlay?.controller ?? "none";144 const controller = desktopOverlay?.controller ?? "none";
145 const loadProgress = useLoadingProgress(loading);145 const loadProgress = useLoadingProgress(loading);
146 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;146 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;
147 const browserDefaults = useBrowserDefaults();
147 const panelRect = useBrowserSurfaceStore((state) =>148 const panelRect = useBrowserSurfaceStore((state) =>
148 runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,149 runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,
149 );150 );
...@@ -249,12 +250,14 @@ export function PreviewView({...@@ -249,12 +250,14 @@ export function PreviewView({
249 return;250 return;
250 }251 }
251252
252 const responsiveSize = panelRect253 void commitBrowserViewportChange(
253 ? resolveResponsiveBrowserViewportSize(panelRect, desktopOverlay?.zoomFactor)254 runtimeTabId,
254 : { width: 1024, height: 768 };255 browserResponsiveViewportForToggle({
255 void commitBrowserViewportChange(runtimeTabId, { _tag: "freeform", ...responsiveSize }).catch(256 defaults: browserDefaults,
256 () => undefined,257 panelRect,
257 );258 zoomFactor: desktopOverlay?.zoomFactor,
259 }),
260 ).catch(() => undefined);
258 };261 };
259262
260 useEffect(() => {263 useEffect(() => {
apps/web/src/components/preview/addBrowserSurface.test.ts+10-2
...@@ -1,4 +1,9 @@...@@ -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";
2import { AsyncResult } from "effect/unstable/reactivity";7import { AsyncResult } from "effect/unstable/reactivity";
3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";8import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
49
...@@ -40,7 +45,10 @@ describe("addBrowserSurface", () => {...@@ -40,7 +45,10 @@ describe("addBrowserSurface", () => {
4045
41 await addBrowserSurface({ threadRef, openPreview: ({ input }) => openPreview(input) });46 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 });
44 expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]);52 expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]);
45 expect(53 expect(
46 selectThreadRightPanelState(54 selectThreadRightPanelState(
apps/web/src/components/preview/openPreviewSession.test.ts+15-3
...@@ -1,4 +1,9 @@...@@ -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";
2import * as Cause from "effect/Cause";7import * as Cause from "effect/Cause";
3import { AsyncResult } from "effect/unstable/reactivity";8import { AsyncResult } from "effect/unstable/reactivity";
4import { beforeEach, describe, expect, it, vi } from "vite-plus/test";9import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
...@@ -41,7 +46,10 @@ describe("openPreviewSession", () => {...@@ -41,7 +46,10 @@ describe("openPreviewSession", () => {
41 threadRef,46 threadRef,
42 });47 });
4348
44 expect(open).toHaveBeenCalledWith({ threadId: "thread-1" });49 expect(open).toHaveBeenCalledWith({
50 threadId: "thread-1",
51 viewport: FILL_PREVIEW_VIEWPORT,
52 });
45 expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot);53 expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot);
46 expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]);54 expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]);
47 });55 });
...@@ -55,7 +63,11 @@ describe("openPreviewSession", () => {...@@ -55,7 +63,11 @@ describe("openPreviewSession", () => {
55 url: "t3.chat",63 url: "t3.chat",
56 });64 });
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 });
59 expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot);71 expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot);
60 expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual(["https://t3.chat/"]);72 expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual(["https://t3.chat/"]);
61 });73 });
apps/web/src/components/preview/openPreviewSession.ts+5
...@@ -2,10 +2,12 @@ import type {...@@ -2,10 +2,12 @@ import type {
2 EnvironmentId,2 EnvironmentId,
3 PreviewOpenInput,3 PreviewOpenInput,
4 PreviewSessionSnapshot,4 PreviewSessionSnapshot,
5 PreviewViewportSetting,
5 ScopedThreadRef,6 ScopedThreadRef,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
7import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";8import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
89
10import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults";
9import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";11import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";
1012
11interface OpenPreviewSessionInput<E> {13interface OpenPreviewSessionInput<E> {
...@@ -15,6 +17,8 @@ interface OpenPreviewSessionInput<E> {...@@ -15,6 +17,8 @@ interface OpenPreviewSessionInput<E> {
15 }) => Promise<AtomCommandResult<PreviewSessionSnapshot, E>>;17 }) => Promise<AtomCommandResult<PreviewSessionSnapshot, E>>;
16 threadRef: ScopedThreadRef;18 threadRef: ScopedThreadRef;
17 url?: string;19 url?: string;
20 /** Overrides the configured default; automation passes an explicit size. */
21 viewport?: PreviewViewportSetting;
18}22}
1923
20export async function openPreviewSession<E>(24export async function openPreviewSession<E>(
...@@ -25,6 +29,7 @@ export async function openPreviewSession<E>(...@@ -25,6 +29,7 @@ export async function openPreviewSession<E>(
25 input: {29 input: {
26 threadId: input.threadRef.threadId,30 threadId: input.threadRef.threadId,
27 ...(input.url === undefined ? {} : { url: input.url }),31 ...(input.url === undefined ? {} : { url: input.url }),
32 viewport: input.viewport ?? browserDefaultOpenViewport(await resolveBrowserDefaults()),
28 },33 },
29 });34 });
30 if (result._tag === "Failure") {35 if (result._tag === "Failure") {
apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts+15
...@@ -77,3 +77,18 @@ describe("preview automation open readiness", () => {...@@ -77,3 +77,18 @@ describe("preview automation open readiness", () => {
77 ).toBeNull();77 ).toBeNull();
78 });78 });
79});79});
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 {...@@ -5,14 +5,28 @@ import {
5 type PreviewViewportSetting,5 type PreviewViewportSetting,
6} from "@t3tools/contracts";6} 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 */
8export const DEFAULT_PREVIEW_AUTOMATION_VIEWPORT = {14export const DEFAULT_PREVIEW_AUTOMATION_VIEWPORT = {
9 _tag: "freeform",15 _tag: "freeform",
10 width: 1280,16 width: 1280,
11 height: 800,17 height: 800,
12} as const satisfies PreviewViewportSetting;18} as const satisfies PreviewViewportSetting;
1319
14export function shouldOpenPreviewMiniPlayer(input: PreviewAutomationOpenInput): boolean {20/**
15 return input.open ?? input.show ?? true;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;
16}30}
1731
18export function previewAutomationOpenNeedsOverlay(32export function previewAutomationOpenNeedsOverlay(
...@@ -22,6 +36,13 @@ export function previewAutomationOpenNeedsOverlay(...@@ -22,6 +36,13 @@ export function previewAutomationOpenNeedsOverlay(
22 return input.url !== undefined || snapshot.navStatus._tag !== "Idle";36 return input.url !== undefined || snapshot.navStatus._tag !== "Idle";
23}37}
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 */
25export function previewAutomationDefaultViewport(46export function previewAutomationDefaultViewport(
26 reusedExistingTab: boolean,47 reusedExistingTab: boolean,
27 snapshot: PreviewSessionSnapshot,48 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 {...@@ -12,7 +12,9 @@ import {
12 backgroundActivitySharedPolicySettings,12 backgroundActivitySharedPolicySettings,
13 buildProviderInstanceUpdatePatch,13 buildProviderInstanceUpdatePatch,
14 formatDiagnosticsDescription,14 formatDiagnosticsDescription,
15 getChangedBrowserSettingLabels,
15 getChangedTypographySettingLabels,16 getChangedTypographySettingLabels,
17 isSamePreviewViewport,
16 hasChangedBackgroundActivitySettings,18 hasChangedBackgroundActivitySettings,
17 isProjectGroupingEnabled,19 isProjectGroupingEnabled,
18 projectGroupingModeFromToggle,20 projectGroupingModeFromToggle,
...@@ -242,3 +244,54 @@ describe("buildProviderInstanceUpdatePatch", () => {...@@ -242,3 +244,54 @@ describe("buildProviderInstanceUpdatePatch", () => {
242 expect(patch.providers).toBeUndefined();244 expect(patch.providers).toBeUndefined();
243 });245 });
244});246});
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 {...@@ -3,6 +3,7 @@ import type {
3 BackgroundActivitySettings,3 BackgroundActivitySettings,
4 ProviderDriverKind,4 ProviderDriverKind,
5 ProviderInstanceConfig,5 ProviderInstanceConfig,
6 PreviewViewportSetting,
6 ProviderInstanceId,7 ProviderInstanceId,
7 ServerSettings,8 ServerSettings,
8 SidebarProjectGroupingMode,9 SidebarProjectGroupingMode,
...@@ -108,6 +109,55 @@ export function getChangedTypographySettingLabels(settings: TypographySettings):...@@ -108,6 +109,55 @@ export function getChangedTypographySettingLabels(settings: TypographySettings):
108 ];109 ];
109}110}
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
111export function resolveBackgroundActivityProfileOption(161export function resolveBackgroundActivityProfileOption(
112 settings: ServerSettings,162 settings: ServerSettings,
113): BackgroundActivityProfile | "advanced" {163): BackgroundActivityProfile | "advanced" {
apps/web/src/components/settings/SettingsPanels.tsx+10
...@@ -122,6 +122,7 @@ import {...@@ -122,6 +122,7 @@ import {
122 backgroundActivitySharedPolicySettings,122 backgroundActivitySharedPolicySettings,
123 durationToSeconds,123 durationToSeconds,
124 formatDiagnosticsDescription,124 formatDiagnosticsDescription,
125 getChangedBrowserSettingLabels,
125 getChangedTypographySettingLabels,126 getChangedTypographySettingLabels,
126 normalizeIntervalSeconds,127 normalizeIntervalSeconds,
127 PROVIDER_HEALTH_INTERVAL_STEP_SECONDS,128 PROVIDER_HEALTH_INTERVAL_STEP_SECONDS,
...@@ -529,10 +530,15 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -529,10 +530,15 @@ export function useSettingsRestore(onRestored?: () => void) {
529 ? ["Quit confirmation"]530 ? ["Quit confirmation"]
530 : []),531 : []),
531 ...(isTextGenerationModelDirty ? ["Text generation model"] : []),532 ...(isTextGenerationModelDirty ? ["Text generation model"] : []),
533 ...getChangedBrowserSettingLabels(settings),
532 ],534 ],
533 [535 [
534 isTextGenerationModelDirty,536 isTextGenerationModelDirty,
535 isBackgroundActivityDirty,537 isBackgroundActivityDirty,
538 settings.browserDefaultViewport,
539 settings.browserDefaultZoomFactor,
540 settings.browserDefaultAppearance,
541 settings.browserAutoShowFloatingPreview,
536 settings.confirmQuit,542 settings.confirmQuit,
537 settings.confirmThreadArchive,543 settings.confirmThreadArchive,
538 settings.confirmThreadDelete,544 settings.confirmThreadDelete,
...@@ -657,6 +663,10 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -657,6 +663,10 @@ export function useSettingsRestore(onRestored?: () => void) {
657 fontSizePrompt: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt,663 fontSizePrompt: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt,
658 fontSizeCode: DEFAULT_UNIFIED_SETTINGS.fontSizeCode,664 fontSizeCode: DEFAULT_UNIFIED_SETTINGS.fontSizeCode,
659 fontSizeTerminal: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal,665 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,
660 });670 });
661 onRestored?.();671 onRestored?.();
662 }, [672 }, [
apps/web/src/components/settings/SettingsSidebarNav.tsx+2
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10import {10import {
11 ArchiveIcon,11 ArchiveIcon,
12 ArrowLeftIcon,12 ArrowLeftIcon,
13 BlocksIcon,
13 BotIcon,14 BotIcon,
14 GitBranchIcon,15 GitBranchIcon,
15 KeyboardIcon,16 KeyboardIcon,
...@@ -49,6 +50,7 @@ const SETTINGS_SECTION_ICONS: Readonly<...@@ -49,6 +50,7 @@ const SETTINGS_SECTION_ICONS: Readonly<
49 "/settings/appearance": PaletteIcon,50 "/settings/appearance": PaletteIcon,
50 "/settings/keybindings": KeyboardIcon,51 "/settings/keybindings": KeyboardIcon,
51 "/settings/providers": BotIcon,52 "/settings/providers": BotIcon,
53 "/settings/integrations": BlocksIcon,
52 "/settings/source-control": GitBranchIcon,54 "/settings/source-control": GitBranchIcon,
53 "/settings/connections": Link2Icon,55 "/settings/connections": Link2Icon,
54 "/settings/archived": ArchiveIcon,56 "/settings/archived": ArchiveIcon,
apps/web/src/components/settings/settingsSearch.ts+26
...@@ -5,6 +5,7 @@ export type SettingsPath =...@@ -5,6 +5,7 @@ export type SettingsPath =
5 | "/settings/appearance"5 | "/settings/appearance"
6 | "/settings/keybindings"6 | "/settings/keybindings"
7 | "/settings/providers"7 | "/settings/providers"
8 | "/settings/integrations"
8 | "/settings/source-control"9 | "/settings/source-control"
9 | "/settings/connections"10 | "/settings/connections"
10 | "/settings/archived";11 | "/settings/archived";
...@@ -28,6 +29,7 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {...@@ -28,6 +29,7 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
28 "/settings/appearance": "Appearance",29 "/settings/appearance": "Appearance",
29 "/settings/keybindings": "Keybindings",30 "/settings/keybindings": "Keybindings",
30 "/settings/providers": "Providers",31 "/settings/providers": "Providers",
32 "/settings/integrations": "Integrations",
31 "/settings/source-control": "Source Control",33 "/settings/source-control": "Source Control",
32 "/settings/connections": "Connections",34 "/settings/connections": "Connections",
33 "/settings/archived": "Archive",35 "/settings/archived": "Archive",
...@@ -195,6 +197,30 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -195,6 +197,30 @@ export const SETTINGS_SEARCH_ITEMS = [
195 title: "Providers",197 title: "Providers",
196 to: "/settings/providers",198 to: "/settings/providers",
197 },199 },
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 },
198 {224 {
199 id: "source-control",225 id: "source-control",
200 title: "Source control",226 title: "Source control",
apps/web/src/hooks/useSettings.ts+11
...@@ -185,6 +185,17 @@ export function getClientSettings(): ClientSettings {...@@ -185,6 +185,17 @@ export function getClientSettings(): ClientSettings {
185 return getClientSettingsSnapshot();185 return getClientSettingsSnapshot();
186}186}
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
188export function useClientSettingsHydrated(): boolean {199export function useClientSettingsHydrated(): boolean {
189 return useSyncExternalStore(200 return useSyncExternalStore(
190 subscribeClientSettingsHydration,201 subscribeClientSettingsHydration,
apps/web/src/routeTree.gen.ts+21
...@@ -18,6 +18,7 @@ import { Route as ChatIndexRouteImport } from './routes/_chat.index'...@@ -18,6 +18,7 @@ import { Route as ChatIndexRouteImport } from './routes/_chat.index'
18import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'18import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'
19import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'19import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'
20import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings'20import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings'
21import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations'
21import { Route as SettingsGeneralRouteImport } from './routes/settings.general'22import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
22import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'23import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'
23import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'24import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
...@@ -73,6 +74,11 @@ const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({...@@ -73,6 +74,11 @@ const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({
73 path: '/keybindings',74 path: '/keybindings',
74 getParentRoute: () => SettingsRoute,75 getParentRoute: () => SettingsRoute,
75} as any)76} as any)
77const SettingsIntegrationsRoute = SettingsIntegrationsRouteImport.update({
78 id: '/integrations',
79 path: '/integrations',
80 getParentRoute: () => SettingsRoute,
81} as any)
76const SettingsGeneralRoute = SettingsGeneralRouteImport.update({82const SettingsGeneralRoute = SettingsGeneralRouteImport.update({
77 id: '/general',83 id: '/general',
78 path: '/general',84 path: '/general',
...@@ -139,6 +145,7 @@ export interface FileRoutesByFullPath {...@@ -139,6 +145,7 @@ export interface FileRoutesByFullPath {
139 '/settings/connections': typeof SettingsConnectionsRoute145 '/settings/connections': typeof SettingsConnectionsRoute
140 '/settings/diagnostics': typeof SettingsDiagnosticsRoute146 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
141 '/settings/general': typeof SettingsGeneralRoute147 '/settings/general': typeof SettingsGeneralRoute
148 '/settings/integrations': typeof SettingsIntegrationsRoute
142 '/settings/keybindings': typeof SettingsKeybindingsRoute149 '/settings/keybindings': typeof SettingsKeybindingsRoute
143 '/settings/providers': typeof SettingsProvidersRoute150 '/settings/providers': typeof SettingsProvidersRoute
144 '/settings/source-control': typeof SettingsSourceControlRoute151 '/settings/source-control': typeof SettingsSourceControlRoute
...@@ -158,6 +165,7 @@ export interface FileRoutesByTo {...@@ -158,6 +165,7 @@ export interface FileRoutesByTo {
158 '/settings/connections': typeof SettingsConnectionsRoute165 '/settings/connections': typeof SettingsConnectionsRoute
159 '/settings/diagnostics': typeof SettingsDiagnosticsRoute166 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
160 '/settings/general': typeof SettingsGeneralRoute167 '/settings/general': typeof SettingsGeneralRoute
168 '/settings/integrations': typeof SettingsIntegrationsRoute
161 '/settings/keybindings': typeof SettingsKeybindingsRoute169 '/settings/keybindings': typeof SettingsKeybindingsRoute
162 '/settings/providers': typeof SettingsProvidersRoute170 '/settings/providers': typeof SettingsProvidersRoute
163 '/settings/source-control': typeof SettingsSourceControlRoute171 '/settings/source-control': typeof SettingsSourceControlRoute
...@@ -180,6 +188,7 @@ export interface FileRoutesById {...@@ -180,6 +188,7 @@ export interface FileRoutesById {
180 '/settings/connections': typeof SettingsConnectionsRoute188 '/settings/connections': typeof SettingsConnectionsRoute
181 '/settings/diagnostics': typeof SettingsDiagnosticsRoute189 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
182 '/settings/general': typeof SettingsGeneralRoute190 '/settings/general': typeof SettingsGeneralRoute
191 '/settings/integrations': typeof SettingsIntegrationsRoute
183 '/settings/keybindings': typeof SettingsKeybindingsRoute192 '/settings/keybindings': typeof SettingsKeybindingsRoute
184 '/settings/providers': typeof SettingsProvidersRoute193 '/settings/providers': typeof SettingsProvidersRoute
185 '/settings/source-control': typeof SettingsSourceControlRoute194 '/settings/source-control': typeof SettingsSourceControlRoute
...@@ -203,6 +212,7 @@ export interface FileRouteTypes {...@@ -203,6 +212,7 @@ export interface FileRouteTypes {
203 | '/settings/connections'212 | '/settings/connections'
204 | '/settings/diagnostics'213 | '/settings/diagnostics'
205 | '/settings/general'214 | '/settings/general'
215 | '/settings/integrations'
206 | '/settings/keybindings'216 | '/settings/keybindings'
207 | '/settings/providers'217 | '/settings/providers'
208 | '/settings/source-control'218 | '/settings/source-control'
...@@ -222,6 +232,7 @@ export interface FileRouteTypes {...@@ -222,6 +232,7 @@ export interface FileRouteTypes {
222 | '/settings/connections'232 | '/settings/connections'
223 | '/settings/diagnostics'233 | '/settings/diagnostics'
224 | '/settings/general'234 | '/settings/general'
235 | '/settings/integrations'
225 | '/settings/keybindings'236 | '/settings/keybindings'
226 | '/settings/providers'237 | '/settings/providers'
227 | '/settings/source-control'238 | '/settings/source-control'
...@@ -243,6 +254,7 @@ export interface FileRouteTypes {...@@ -243,6 +254,7 @@ export interface FileRouteTypes {
243 | '/settings/connections'254 | '/settings/connections'
244 | '/settings/diagnostics'255 | '/settings/diagnostics'
245 | '/settings/general'256 | '/settings/general'
257 | '/settings/integrations'
246 | '/settings/keybindings'258 | '/settings/keybindings'
247 | '/settings/providers'259 | '/settings/providers'
248 | '/settings/source-control'260 | '/settings/source-control'
...@@ -326,6 +338,13 @@ declare module '@tanstack/react-router' {...@@ -326,6 +338,13 @@ declare module '@tanstack/react-router' {
326 preLoaderRoute: typeof SettingsKeybindingsRouteImport338 preLoaderRoute: typeof SettingsKeybindingsRouteImport
327 parentRoute: typeof SettingsRoute339 parentRoute: typeof SettingsRoute
328 }340 }
341 '/settings/integrations': {
342 id: '/settings/integrations'
343 path: '/integrations'
344 fullPath: '/settings/integrations'
345 preLoaderRoute: typeof SettingsIntegrationsRouteImport
346 parentRoute: typeof SettingsRoute
347 }
329 '/settings/general': {348 '/settings/general': {
330 id: '/settings/general'349 id: '/settings/general'
331 path: '/general'350 path: '/general'
...@@ -421,6 +440,7 @@ interface SettingsRouteChildren {...@@ -421,6 +440,7 @@ interface SettingsRouteChildren {
421 SettingsConnectionsRoute: typeof SettingsConnectionsRoute440 SettingsConnectionsRoute: typeof SettingsConnectionsRoute
422 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute441 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute
423 SettingsGeneralRoute: typeof SettingsGeneralRoute442 SettingsGeneralRoute: typeof SettingsGeneralRoute
443 SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute
424 SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute444 SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute
425 SettingsProvidersRoute: typeof SettingsProvidersRoute445 SettingsProvidersRoute: typeof SettingsProvidersRoute
426 SettingsSourceControlRoute: typeof SettingsSourceControlRoute446 SettingsSourceControlRoute: typeof SettingsSourceControlRoute
...@@ -432,6 +452,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {...@@ -432,6 +452,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {
432 SettingsConnectionsRoute: SettingsConnectionsRoute,452 SettingsConnectionsRoute: SettingsConnectionsRoute,
433 SettingsDiagnosticsRoute: SettingsDiagnosticsRoute,453 SettingsDiagnosticsRoute: SettingsDiagnosticsRoute,
434 SettingsGeneralRoute: SettingsGeneralRoute,454 SettingsGeneralRoute: SettingsGeneralRoute,
455 SettingsIntegrationsRoute: SettingsIntegrationsRoute,
435 SettingsKeybindingsRoute: SettingsKeybindingsRoute,456 SettingsKeybindingsRoute: SettingsKeybindingsRoute,
436 SettingsProvidersRoute: SettingsProvidersRoute,457 SettingsProvidersRoute: SettingsProvidersRoute,
437 SettingsSourceControlRoute: SettingsSourceControlRoute,458 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({...@@ -956,6 +956,24 @@ export const DesktopPreviewTabInputSchema = Schema.Struct({
956 tabId: DesktopPreviewTabIdSchema,956 tabId: DesktopPreviewTabIdSchema,
957});957});
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
959export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({977export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({
960 tabId: DesktopPreviewTabIdSchema,978 tabId: DesktopPreviewTabIdSchema,
961 webContentsId: Schema.Int.check(Schema.isGreaterThan(0)),979 webContentsId: Schema.Int.check(Schema.isGreaterThan(0)),
...@@ -1109,7 +1127,7 @@ export interface DesktopBridge {...@@ -1109,7 +1127,7 @@ export interface DesktopBridge {
1109}1127}
11101128
1111export interface DesktopPreviewBridge {1129export interface DesktopPreviewBridge {
1112 createTab: (tabId: string) => Promise<void>;1130 createTab: (tabId: string, defaults?: DesktopPreviewTabDefaults) => Promise<void>;
1113 closeTab: (tabId: string) => Promise<void>;1131 closeTab: (tabId: string) => Promise<void>;
1114 registerWebview: (tabId: string, webContentsId: number) => Promise<void>;1132 registerWebview: (tabId: string, webContentsId: number) => Promise<void>;
1115 navigate: (tabId: string, url: string) => Promise<void>;1133 navigate: (tabId: string, url: string) => Promise<void>;
packages/contracts/src/preview.ts+31
...@@ -118,6 +118,30 @@ export const FILL_PREVIEW_VIEWPORT = {...@@ -118,6 +118,30 @@ export const FILL_PREVIEW_VIEWPORT = {
118 _tag: "fill",118 _tag: "fill",
119} as const satisfies PreviewViewportSetting;119} 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
121export const PreviewNavStatus = Schema.Union([145export const PreviewNavStatus = Schema.Union([
122 Schema.TaggedStruct("Idle", {}),146 Schema.TaggedStruct("Idle", {}),
123 Schema.TaggedStruct("Loading", {147 Schema.TaggedStruct("Loading", {
...@@ -153,6 +177,13 @@ export const PreviewOpenInput = Schema.Struct({...@@ -153,6 +177,13 @@ export const PreviewOpenInput = Schema.Struct({
153 threadId: ThreadId,177 threadId: ThreadId,
154 /** Omit to create an empty (Idle) tab the user can type into. */178 /** Omit to create an empty (Idle) tab the user can type into. */
155 url: Schema.optional(Url),179 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),
156});187});
157export type PreviewOpenInput = typeof PreviewOpenInput.Type;188export type PreviewOpenInput = typeof PreviewOpenInput.Type;
158189
packages/contracts/src/settings.ts+39
...@@ -10,6 +10,14 @@ import {...@@ -10,6 +10,14 @@ import {
10 ProviderOptionSelections,10 ProviderOptionSelections,
11} from "./model.ts";11} from "./model.ts";
12import { ModelSelection } from "./orchestration.ts";12import { 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";
13import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts";21import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts";
1422
15// ── Client Settings (local-only) ───────────────────────────────23// ── Client Settings (local-only) ───────────────────────────────
...@@ -111,7 +119,34 @@ export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationM...@@ -111,7 +119,34 @@ export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationM
111export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200));119export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200));
112export type FontFamilyPreference = typeof FontFamilyPreference.Type;120export 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
114export const ClientSettingsSchema = Schema.Struct({131export 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 ),
115 // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the150 // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the
116 // app quits; a quick tap only shows a hint. Browser clients ignore it.151 // app quits; a quick tap only shows a hint. Browser clients ignore it.
117 confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),152 confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
...@@ -759,6 +794,10 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -759,6 +794,10 @@ export const ServerSettingsPatch = Schema.Struct({
759export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;794export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
760795
761export const ClientSettingsPatch = Schema.Struct({796export 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),
762 confirmQuit: Schema.optionalKey(Schema.Boolean),801 confirmQuit: Schema.optionalKey(Schema.Boolean),
763 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),802 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
764 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),803 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),