Skip to content

Commit 949feb6

Browse files
feat(web): configurable browser defaults in Settings → Integrations (#7082)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ba46f92 commit 949feb6

32 files changed

Lines changed: 1066 additions & 64 deletions

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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

4950
export 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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
*/
344+
const 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+
337353
const 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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1313
import * as DesktopClientSettings from "./DesktopClientSettings.ts";
1414

1515
const 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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,10 @@ import { describe, expect, it } from "vite-plus/test";
33
import { normalizePreviewOpenInput } from "./handlers.ts";
44

55
describe("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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,21 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts";
1515
import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
1616
import { 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+
*/
1826
export 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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
FILL_PREVIEW_VIEWPORT,
2525
PreviewSessionLookupError,
2626
type PreviewSessionSnapshot,
27+
type PreviewViewportSetting,
2728
} from "@t3tools/contracts";
2829
import {
2930
isPreviewUrlNormalizationError,
@@ -121,28 +122,30 @@ 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,
127129
tabId: input.tabId,
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

135137
const buildIdleSnapshot = (input: {
136138
readonly threadId: string;
137139
readonly tabId: string;
140+
readonly viewport: PreviewViewportSetting;
138141
readonly updatedAt: string;
139142
}): PreviewSessionSnapshot => ({
140143
threadId: input.threadId,
141144
tabId: input.tabId,
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

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,33 +26,14 @@ import { cn } from "~/lib/utils";
2626

2727
import { BROWSER_DEVICE_TOOLBAR_HEIGHT, resizeFreeformViewport } from "./browserViewportLayout";
2828
import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState";
29+
import { ScreenRotationIcon } from "./ScreenRotationIcon";
2930

3031
const RESPONSIVE_VALUE = "responsive";
3132
const SELECT_ITEMS = [
3233
{ value: RESPONSIVE_VALUE, label: "Responsive" },
3334
...PREVIEW_VIEWPORT_PRESETS.map((preset) => ({ value: preset.id, label: preset.label })),
3435
];
3536

36-
function 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-
5637
interface Props {
5738
readonly setting: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>;
5839
readonly width: number;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
*/
8+
export 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+
}

0 commit comments

Comments
 (0)