| author | |
| committer | |
| log | 949feb61e4bfd96669ba0e8cf3dca7c6d7f885b3 |
| tree | 1f19444d85bf8f9dd11e0a0ee5c008875d7497cf |
| parent | ba46f922a271942d42faf305ff833af89cb5c3a1 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 14 | DesktopPreviewRegisterWebviewInputSchema, |
| 15 | 15 | DesktopPreviewScreenshotArtifactSchema, |
| 16 | 16 | DesktopPreviewSetColorSchemeInputSchema, |
| 17 | DesktopPreviewCreateTabInputSchema, | |
| 17 | 18 | DesktopPreviewTabInputSchema, |
| 18 | 19 | DesktopPreviewWebviewConfigSchema, |
| 19 | 20 | PreviewAnnotationSubmissionResultSchema, |
| ... | ... | @@ -48,11 +49,15 @@ export const installPreviewEventForwarding = Effect.fn( |
| 48 | 49 | |
| 49 | 50 | export const createTab = DesktopIpc.makeIpcMethod({ |
| 50 | 51 | channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, |
| 51 | payload: DesktopPreviewTabInputSchema, | |
| 52 | payload: DesktopPreviewCreateTabInputSchema, | |
| 52 | 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 | 59 | const manager = yield* PreviewManager.PreviewManager; |
| 55 | yield* manager.createTab(tabId); | |
| 60 | yield* manager.createTab(tabId, { zoomFactor, colorScheme }); | |
| 56 | 61 | }), |
| 57 | 62 | }); |
| 58 | 63 |
apps/desktop/src/preload.ts+6-1| ... | ... | @@ -163,7 +163,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 163 | 163 | }; |
| 164 | 164 | }, |
| 165 | 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 | 172 | closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }), |
| 168 | 173 | registerWebview: (tabId, webContentsId) => |
| 169 | 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 | 16 | DesktopPreviewRecordingArtifact, |
| 17 | 17 | DesktopPreviewRecordingFrame, |
| 18 | 18 | DesktopPreviewScreenshotArtifact, |
| 19 | DesktopPreviewTabDefaults, | |
| 19 | 20 | PreviewAutomationClickInput, |
| 20 | 21 | PreviewAutomationActionEvent, |
| 21 | 22 | PreviewAutomationConsoleEntry, |
| ... | ... | @@ -334,6 +335,21 @@ const findZoomStep = (current: number): number => { |
| 334 | 335 | return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1; |
| 335 | 336 | }; |
| 336 | 337 | |
| 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 | ||
| 337 | 353 | const nextZoomLevel = (current: number, direction: "in" | "out"): number => { |
| 338 | 354 | const step = findZoomStep(current); |
| 339 | 355 | if (direction === "in") { |
| ... | ... | @@ -1614,6 +1630,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1614 | 1630 | |
| 1615 | 1631 | const createTabUnlocked = Effect.fn("PreviewManager.createTabUnlocked")(function* ( |
| 1616 | 1632 | tabId: string, |
| 1633 | defaults?: DesktopPreviewTabDefaults, | |
| 1617 | 1634 | ) { |
| 1618 | 1635 | const updatedAt = yield* currentIso; |
| 1619 | 1636 | const result = yield* SynchronizedRef.modify( |
| ... | ... | @@ -1632,9 +1649,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1632 | 1649 | navStatus: { kind: "Idle" }, |
| 1633 | 1650 | canGoBack: false, |
| 1634 | 1651 | canGoForward: false, |
| 1635 | zoomFactor: DEFAULT_ZOOM_FACTOR, | |
| 1652 | zoomFactor: normalizeZoomFactor(defaults?.zoomFactor), | |
| 1636 | 1653 | pictureInPicture: false, |
| 1637 | colorScheme: "system", | |
| 1654 | colorScheme: defaults?.colorScheme ?? "system", | |
| 1638 | 1655 | controller: "none", |
| 1639 | 1656 | updatedAt, |
| 1640 | 1657 | }; |
| ... | ... | @@ -1653,8 +1670,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1653 | 1670 | return result.state; |
| 1654 | 1671 | }); |
| 1655 | 1672 | |
| 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)); | |
| 1658 | 1678 | }); |
| 1659 | 1679 | |
| 1660 | 1680 | const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) { |
| ... | ... | @@ -3802,7 +3822,10 @@ export class PreviewManager extends Context.Service< |
| 3802 | 3822 | readonly setMainWindow: (window: BrowserWindow) => Effect.Effect<void, PreviewManagerError>; |
| 3803 | 3823 | readonly getBrowserSession: (scope?: string) => Effect.Effect<Session, PreviewManagerError>; |
| 3804 | 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 | 3829 | readonly closeTab: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 3807 | 3830 | readonly registerWebview: ( |
| 3808 | 3831 | tabId: string, |
apps/desktop/src/settings/DesktopClientSettings.test.ts+4| ... | ... | @@ -13,6 +13,10 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 13 | 13 | import * as DesktopClientSettings from "./DesktopClientSettings.ts"; |
| 14 | 14 | |
| 15 | 15 | const clientSettings: ClientSettings = { |
| 16 | browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" }, | |
| 17 | browserDefaultZoomFactor: 1.25, | |
| 18 | browserDefaultAppearance: "dark", | |
| 19 | browserAutoShowFloatingPreview: false, | |
| 16 | 20 | confirmQuit: true, |
| 17 | 21 | confirmThreadArchive: true, |
| 18 | 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 | 3 | import { normalizePreviewOpenInput } from "./handlers.ts"; |
| 4 | 4 | |
| 5 | 5 | 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 }); | |
| 12 | 10 | }); |
| 13 | 11 | |
| 14 | 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 | 15 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; |
| 16 | 16 | import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; |
| 17 | 17 | |
| 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 | */ | |
| 18 | 26 | export function normalizePreviewOpenInput( |
| 19 | 27 | input: PreviewAutomationOpenInput, |
| 20 | 28 | ): PreviewAutomationOpenInput { |
| 21 | const open = input.open ?? input.show ?? true; | |
| 29 | const open = input.open ?? input.show; | |
| 22 | 30 | return { |
| 23 | 31 | ...input, |
| 24 | open, | |
| 25 | show: open, | |
| 32 | ...(open === undefined ? {} : { open, show: open }), | |
| 26 | 33 | reuseExistingTab: input.reuseExistingTab ?? true, |
| 27 | 34 | }; |
| 28 | 35 | } |
apps/server/src/preview/Manager.ts+11-3| ... | ... | @@ -24,6 +24,7 @@ import { |
| 24 | 24 | FILL_PREVIEW_VIEWPORT, |
| 25 | 25 | PreviewSessionLookupError, |
| 26 | 26 | type PreviewSessionSnapshot, |
| 27 | type PreviewViewportSetting, | |
| 27 | 28 | } from "@t3tools/contracts"; |
| 28 | 29 | import { |
| 29 | 30 | isPreviewUrlNormalizationError, |
| ... | ... | @@ -121,6 +122,7 @@ const buildLoadingSnapshot = (input: { |
| 121 | 122 | readonly tabId: string; |
| 122 | 123 | readonly url: string; |
| 123 | 124 | readonly title: string; |
| 125 | readonly viewport: PreviewViewportSetting; | |
| 124 | 126 | readonly updatedAt: string; |
| 125 | 127 | }): PreviewSessionSnapshot => ({ |
| 126 | 128 | threadId: input.threadId, |
| ... | ... | @@ -128,13 +130,14 @@ const buildLoadingSnapshot = (input: { |
| 128 | 130 | navStatus: { _tag: "Loading", url: input.url, title: input.title }, |
| 129 | 131 | canGoBack: false, |
| 130 | 132 | canGoForward: false, |
| 131 | viewport: FILL_PREVIEW_VIEWPORT, | |
| 133 | viewport: input.viewport, | |
| 132 | 134 | updatedAt: input.updatedAt, |
| 133 | 135 | }); |
| 134 | 136 | |
| 135 | 137 | const buildIdleSnapshot = (input: { |
| 136 | 138 | readonly threadId: string; |
| 137 | 139 | readonly tabId: string; |
| 140 | readonly viewport: PreviewViewportSetting; | |
| 138 | 141 | readonly updatedAt: string; |
| 139 | 142 | }): PreviewSessionSnapshot => ({ |
| 140 | 143 | threadId: input.threadId, |
| ... | ... | @@ -142,7 +145,7 @@ const buildIdleSnapshot = (input: { |
| 142 | 145 | navStatus: { _tag: "Idle" }, |
| 143 | 146 | canGoBack: false, |
| 144 | 147 | canGoForward: false, |
| 145 | viewport: FILL_PREVIEW_VIEWPORT, | |
| 148 | viewport: input.viewport, | |
| 146 | 149 | updatedAt: input.updatedAt, |
| 147 | 150 | }); |
| 148 | 151 | |
| ... | ... | @@ -215,15 +218,20 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 215 | 218 | function* (input) { |
| 216 | 219 | const tabId = newPreviewTabId(); |
| 217 | 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 | 225 | const snapshot = input.url |
| 219 | 226 | ? buildLoadingSnapshot({ |
| 220 | 227 | threadId: input.threadId, |
| 221 | 228 | tabId, |
| 222 | 229 | url: yield* normalizeUrl(input.url), |
| 223 | 230 | title: "", |
| 231 | viewport, | |
| 224 | 232 | updatedAt, |
| 225 | 233 | }) |
| 226 | : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt }); | |
| 234 | : buildIdleSnapshot({ threadId: input.threadId, tabId, viewport, updatedAt }); | |
| 227 | 235 | yield* SynchronizedRef.modifyEffect(stateRef, (state) => |
| 228 | 236 | Effect.gen(function* () { |
| 229 | 237 | const revision = state.revision + 1; |
apps/web/src/browser/BrowserDeviceToolbar.tsx+1-20| ... | ... | @@ -26,6 +26,7 @@ import { cn } from "~/lib/utils"; |
| 26 | 26 | |
| 27 | 27 | import { BROWSER_DEVICE_TOOLBAR_HEIGHT, resizeFreeformViewport } from "./browserViewportLayout"; |
| 28 | 28 | import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState"; |
| 29 | import { ScreenRotationIcon } from "./ScreenRotationIcon"; | |
| 29 | 30 | |
| 30 | 31 | const RESPONSIVE_VALUE = "responsive"; |
| 31 | 32 | const SELECT_ITEMS = [ |
| ... | ... | @@ -33,26 +34,6 @@ const SELECT_ITEMS = [ |
| 33 | 34 | ...PREVIEW_VIEWPORT_PRESETS.map((preset) => ({ value: preset.id, label: preset.label })), |
| 34 | 35 | ]; |
| 35 | 36 | |
| 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 | ||
| 56 | 37 | interface Props { |
| 57 | 38 | readonly setting: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>; |
| 58 | 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 | */ | |
| 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 | } |
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 | */ | |
| 16 | import type { | |
| 17 | DesktopPreviewTabDefaults, | |
| 18 | PreviewAppearancePreference, | |
| 19 | PreviewViewportSetting, | |
| 20 | } from "@t3tools/contracts"; | |
| 21 | ||
| 22 | import { | |
| 23 | ensureClientSettingsHydrated, | |
| 24 | getClientSettings, | |
| 25 | useClientSettings, | |
| 26 | } from "~/hooks/useSettings"; | |
| 27 | ||
| 28 | import { resolveResponsiveBrowserViewportSize } from "./browserViewportLayout"; | |
| 29 | ||
| 30 | export interface BrowserDefaults { | |
| 31 | readonly viewport: PreviewViewportSetting; | |
| 32 | readonly zoomFactor: number; | |
| 33 | readonly appearance: PreviewAppearancePreference; | |
| 34 | readonly autoShowFloatingPreview: boolean; | |
| 35 | } | |
| 36 | ||
| 37 | const 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). */ | |
| 50 | export 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 | */ | |
| 61 | export async function resolveBrowserDefaults(): Promise<BrowserDefaults> { | |
| 62 | await ensureClientSettingsHydrated(); | |
| 63 | return getBrowserDefaults(); | |
| 64 | } | |
| 65 | ||
| 66 | export 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 | */ | |
| 75 | export 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 | */ | |
| 86 | export 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 | */ | |
| 101 | export const FALLBACK_RESPONSIVE_VIEWPORT_SIZE = { width: 1024, height: 768 } as const; | |
| 102 | ||
| 103 | export 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 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | DEFAULT_PREVIEW_APPEARANCE, | |
| 3 | DEFAULT_PREVIEW_ZOOM_FACTOR, | |
| 4 | EnvironmentId, | |
| 5 | ThreadId, | |
| 6 | } from "@t3tools/contracts"; | |
| 2 | 7 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 8 | |
| 4 | 9 | const { closeTab, createTab, stopBrowserRecording } = vi.hoisted(() => ({ |
| ... | ... | @@ -16,6 +21,12 @@ vi.mock("./browserRecording", () => ({ |
| 16 | 21 | })); |
| 17 | 22 | |
| 18 | 23 | import { acquireDesktopTab } from "./desktopTabLifetime"; |
| 24 | ||
| 25 | /** Client settings are unset in tests, so creation carries the schema defaults. */ | |
| 26 | const DEFAULT_TAB_STATE = { | |
| 27 | zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, | |
| 28 | colorScheme: DEFAULT_PREVIEW_APPEARANCE, | |
| 29 | }; | |
| 19 | 30 | import { previewRuntimeTabId } from "./previewRuntimeTabId"; |
| 20 | 31 | |
| 21 | 32 | describe("desktopTabLifetime", () => { |
| ... | ... | @@ -42,14 +53,16 @@ describe("desktopTabLifetime", () => { |
| 42 | 53 | const first = acquireDesktopTab("tab_readiness"); |
| 43 | 54 | const second = acquireDesktopTab("tab_readiness"); |
| 44 | 55 | |
| 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 | 59 | expect(first.ready).toBe(second.ready); |
| 47 | 60 | |
| 48 | 61 | let ready = false; |
| 49 | 62 | void first.ready.then(() => { |
| 50 | 63 | ready = true; |
| 51 | 64 | }); |
| 52 | await Promise.resolve(); | |
| 65 | await vi.waitFor(() => expect(createTab).toHaveBeenCalledOnce()); | |
| 53 | 66 | expect(ready).toBe(false); |
| 54 | 67 | |
| 55 | 68 | resolveCreation?.(); |
| ... | ... | @@ -81,8 +94,8 @@ describe("desktopTabLifetime", () => { |
| 81 | 94 | const second = acquireDesktopTab(tabB); |
| 82 | 95 | await Promise.all([first.ready, second.ready]); |
| 83 | 96 | |
| 84 | expect(createTab).toHaveBeenCalledWith(tabA); | |
| 85 | expect(createTab).toHaveBeenCalledWith(tabB); | |
| 97 | expect(createTab).toHaveBeenCalledWith(tabA, DEFAULT_TAB_STATE); | |
| 98 | expect(createTab).toHaveBeenCalledWith(tabB, DEFAULT_TAB_STATE); | |
| 86 | 99 | expect(createTab).toHaveBeenCalledTimes(2); |
| 87 | 100 | |
| 88 | 101 | first.release(); |
apps/web/src/browser/desktopTabLifetime.ts+6-1| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { previewBridge } from "~/components/preview/previewBridge"; |
| 2 | 2 | |
| 3 | import { browserDefaultTabState, resolveBrowserDefaults } from "./browserDefaults"; | |
| 3 | 4 | import { stopBrowserRecording } from "./browserRecording"; |
| 4 | 5 | |
| 5 | 6 | interface DesktopTabLease { |
| ... | ... | @@ -41,7 +42,11 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab { |
| 41 | 42 | ({ |
| 42 | 43 | references: 0, |
| 43 | 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 | 50 | } satisfies DesktopTabLease); |
| 46 | 51 | if (current.closeTimer !== null) window.clearTimeout(current.closeTimer); |
| 47 | 52 | current.references += 1; |
apps/web/src/components/preview/PreviewAutomationHosts.tsx+8-1| ... | ... | @@ -38,6 +38,7 @@ import { |
| 38 | 38 | } from "~/browser/browserRecording"; |
| 39 | 39 | import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope"; |
| 40 | 40 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; |
| 41 | import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults"; | |
| 41 | 42 | import { runBrowserViewportMutation } from "~/browser/browserViewportActions"; |
| 42 | 43 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; |
| 43 | 44 | import { isElectron } from "~/env"; |
| ... | ... | @@ -380,6 +381,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 380 | 381 | input: { |
| 381 | 382 | threadId: request.threadId, |
| 382 | 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 | 389 | if (result._tag === "Failure") { |
| ... | ... | @@ -428,7 +432,10 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 428 | 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 | 439 | if (shouldPresentPreview) { |
| 433 | 440 | usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId); |
| 434 | 441 | } |
apps/web/src/components/preview/PreviewView.test.tsx+35-1| ... | ... | @@ -1,4 +1,10 @@ |
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | DEFAULT_PREVIEW_APPEARANCE, | |
| 3 | DEFAULT_PREVIEW_ZOOM_FACTOR, | |
| 4 | EnvironmentId, | |
| 5 | FILL_PREVIEW_VIEWPORT, | |
| 6 | ThreadId, | |
| 7 | } from "@t3tools/contracts"; | |
| 2 | 8 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | 9 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 10 | |
| ... | ... | @@ -41,6 +47,34 @@ vi.mock("~/state/session", () => ({ |
| 41 | 47 | readPreparedConnection: mocks.readPreparedConnection, |
| 42 | 48 | })); |
| 43 | 49 | |
| 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. | |
| 53 | vi.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 | ||
| 44 | 78 | vi.mock("~/composerDraftStore", () => ({ |
| 45 | 79 | useComposerDraftStore: ( |
| 46 | 80 | select: (store: { addPreviewAnnotation: () => void; addImage: () => void }) => unknown, |
apps/web/src/components/preview/PreviewView.tsx+10-7| ... | ... | @@ -43,7 +43,7 @@ import { |
| 43 | 43 | commitBrowserViewportChange, |
| 44 | 44 | subscribeBrowserViewportChange, |
| 45 | 45 | } from "~/browser/browserViewportActions"; |
| 46 | import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout"; | |
| 46 | import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults"; | |
| 47 | 47 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; |
| 48 | 48 | import { PreviewUnreachable } from "./PreviewUnreachable"; |
| 49 | 49 | import { revealInFileExplorerLabel } from "./fileExplorerLabel"; |
| ... | ... | @@ -144,6 +144,7 @@ export function PreviewView({ |
| 144 | 144 | const controller = desktopOverlay?.controller ?? "none"; |
| 145 | 145 | const loadProgress = useLoadingProgress(loading); |
| 146 | 146 | const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT; |
| 147 | const browserDefaults = useBrowserDefaults(); | |
| 147 | 148 | const panelRect = useBrowserSurfaceStore((state) => |
| 148 | 149 | runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null, |
| 149 | 150 | ); |
| ... | ... | @@ -249,12 +250,14 @@ export function PreviewView({ |
| 249 | 250 | return; |
| 250 | 251 | } |
| 251 | 252 | |
| 252 | const responsiveSize = panelRect | |
| 253 | ? resolveResponsiveBrowserViewportSize(panelRect, desktopOverlay?.zoomFactor) | |
| 254 | : { width: 1024, height: 768 }; | |
| 255 | void commitBrowserViewportChange(runtimeTabId, { _tag: "freeform", ...responsiveSize }).catch( | |
| 256 | () => undefined, | |
| 257 | ); | |
| 253 | void commitBrowserViewportChange( | |
| 254 | runtimeTabId, | |
| 255 | browserResponsiveViewportForToggle({ | |
| 256 | defaults: browserDefaults, | |
| 257 | panelRect, | |
| 258 | zoomFactor: desktopOverlay?.zoomFactor, | |
| 259 | }), | |
| 260 | ).catch(() => undefined); | |
| 258 | 261 | }; |
| 259 | 262 | |
| 260 | 263 | useEffect(() => { |
apps/web/src/components/preview/addBrowserSurface.test.ts+10-2| ... | ... | @@ -1,4 +1,9 @@ |
| 1 | import type { PreviewOpenInput, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | FILL_PREVIEW_VIEWPORT, | |
| 3 | type PreviewOpenInput, | |
| 4 | type PreviewSessionSnapshot, | |
| 5 | type ScopedThreadRef, | |
| 6 | } from "@t3tools/contracts"; | |
| 2 | 7 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 3 | 8 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 9 | |
| ... | ... | @@ -40,7 +45,10 @@ describe("addBrowserSurface", () => { |
| 40 | 45 | |
| 41 | 46 | await addBrowserSurface({ threadRef, openPreview: ({ input }) => openPreview(input) }); |
| 42 | 47 | |
| 43 | expect(openPreview).toHaveBeenCalledWith({ threadId: "thread-1" }); | |
| 48 | expect(openPreview).toHaveBeenCalledWith({ | |
| 49 | threadId: "thread-1", | |
| 50 | viewport: FILL_PREVIEW_VIEWPORT, | |
| 51 | }); | |
| 44 | 52 | expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]); |
| 45 | 53 | expect( |
| 46 | 54 | selectThreadRightPanelState( |
apps/web/src/components/preview/openPreviewSession.test.ts+15-3| ... | ... | @@ -1,4 +1,9 @@ |
| 1 | import type { PreviewOpenInput, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | FILL_PREVIEW_VIEWPORT, | |
| 3 | type PreviewOpenInput, | |
| 4 | type PreviewSessionSnapshot, | |
| 5 | type ScopedThreadRef, | |
| 6 | } from "@t3tools/contracts"; | |
| 2 | 7 | import * as Cause from "effect/Cause"; |
| 3 | 8 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 4 | 9 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| ... | ... | @@ -41,7 +46,10 @@ describe("openPreviewSession", () => { |
| 41 | 46 | threadRef, |
| 42 | 47 | }); |
| 43 | 48 | |
| 44 | expect(open).toHaveBeenCalledWith({ threadId: "thread-1" }); | |
| 49 | expect(open).toHaveBeenCalledWith({ | |
| 50 | threadId: "thread-1", | |
| 51 | viewport: FILL_PREVIEW_VIEWPORT, | |
| 52 | }); | |
| 45 | 53 | expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot); |
| 46 | 54 | expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); |
| 47 | 55 | }); |
| ... | ... | @@ -55,7 +63,11 @@ describe("openPreviewSession", () => { |
| 55 | 63 | url: "t3.chat", |
| 56 | 64 | }); |
| 57 | 65 | |
| 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 | 71 | expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); |
| 60 | 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 | 2 | EnvironmentId, |
| 3 | 3 | PreviewOpenInput, |
| 4 | 4 | PreviewSessionSnapshot, |
| 5 | PreviewViewportSetting, | |
| 5 | 6 | ScopedThreadRef, |
| 6 | 7 | } from "@t3tools/contracts"; |
| 7 | 8 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; |
| 8 | 9 | |
| 10 | import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults"; | |
| 9 | 11 | import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; |
| 10 | 12 | |
| 11 | 13 | interface OpenPreviewSessionInput<E> { |
| ... | ... | @@ -15,6 +17,8 @@ interface OpenPreviewSessionInput<E> { |
| 15 | 17 | }) => Promise<AtomCommandResult<PreviewSessionSnapshot, E>>; |
| 16 | 18 | threadRef: ScopedThreadRef; |
| 17 | 19 | url?: string; |
| 20 | /** Overrides the configured default; automation passes an explicit size. */ | |
| 21 | viewport?: PreviewViewportSetting; | |
| 18 | 22 | } |
| 19 | 23 | |
| 20 | 24 | export async function openPreviewSession<E>( |
| ... | ... | @@ -25,6 +29,7 @@ export async function openPreviewSession<E>( |
| 25 | 29 | input: { |
| 26 | 30 | threadId: input.threadRef.threadId, |
| 27 | 31 | ...(input.url === undefined ? {} : { url: input.url }), |
| 32 | viewport: input.viewport ?? browserDefaultOpenViewport(await resolveBrowserDefaults()), | |
| 28 | 33 | }, |
| 29 | 34 | }); |
| 30 | 35 | if (result._tag === "Failure") { |
apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts+15| ... | ... | @@ -77,3 +77,18 @@ describe("preview automation open readiness", () => { |
| 77 | 77 | ).toBeNull(); |
| 78 | 78 | }); |
| 79 | 79 | }); |
| 80 | ||
| 81 | describe("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 | 5 | type PreviewViewportSetting, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | |
| 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 | */ | |
| 8 | 14 | export const DEFAULT_PREVIEW_AUTOMATION_VIEWPORT = { |
| 9 | 15 | _tag: "freeform", |
| 10 | 16 | width: 1280, |
| 11 | 17 | height: 800, |
| 12 | 18 | } as const satisfies PreviewViewportSetting; |
| 13 | 19 | |
| 14 | export function shouldOpenPreviewMiniPlayer(input: PreviewAutomationOpenInput): boolean { | |
| 15 | return input.open ?? input.show ?? true; | |
| 20 | /** | |
| 21 | * An explicit `open`/`show` is the agent deliberately surfacing or suppressing | |
| 22 | * its work, so it outranks the preference; the setting only decides what | |
| 23 | * happens when the agent said nothing either way. | |
| 24 | */ | |
| 25 | export function shouldOpenPreviewMiniPlayer( | |
| 26 | input: PreviewAutomationOpenInput, | |
| 27 | autoShowFloatingPreview = true, | |
| 28 | ): boolean { | |
| 29 | return input.open ?? input.show ?? autoShowFloatingPreview; | |
| 16 | 30 | } |
| 17 | 31 | |
| 18 | 32 | export function previewAutomationOpenNeedsOverlay( |
| ... | ... | @@ -22,6 +36,13 @@ export function previewAutomationOpenNeedsOverlay( |
| 22 | 36 | return input.url !== undefined || snapshot.navStatus._tag !== "Idle"; |
| 23 | 37 | } |
| 24 | 38 | |
| 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 | */ | |
| 25 | 46 | export function previewAutomationDefaultViewport( |
| 26 | 47 | reusedExistingTab: boolean, |
| 27 | 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 | */ | |
| 9 | import { | |
| 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"; | |
| 22 | import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; | |
| 23 | import { InfoIcon } from "lucide-react"; | |
| 24 | import type { ReactNode } from "react"; | |
| 25 | ||
| 26 | import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; | |
| 27 | import { isElectron } from "../../env"; | |
| 28 | ||
| 29 | import { Button } from "../ui/button"; | |
| 30 | import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field"; | |
| 31 | import { | |
| 32 | Select, | |
| 33 | SelectGroup, | |
| 34 | SelectGroupLabel, | |
| 35 | SelectItem, | |
| 36 | SelectPopup, | |
| 37 | SelectTrigger, | |
| 38 | SelectValue, | |
| 39 | } from "../ui/select"; | |
| 40 | import { Switch } from "../ui/switch"; | |
| 41 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 42 | import { useClientSettings, useUpdatePrimarySettings } from "~/hooks/useSettings"; | |
| 43 | ||
| 44 | import { | |
| 45 | SettingResetButton, | |
| 46 | SettingsPageContainer, | |
| 47 | SettingsRow, | |
| 48 | SettingsSection, | |
| 49 | } from "./settingsLayout"; | |
| 50 | import { searchableSetting } from "./settingsSearch"; | |
| 51 | ||
| 52 | const FILL_VALUE = "fill"; | |
| 53 | const 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 | */ | |
| 60 | const RESPONSIVE_SEED_SIZE = { width: 1280, height: 800 } as const; | |
| 61 | ||
| 62 | const NO_GROUPING: Intl.NumberFormatOptions = { useGrouping: false }; | |
| 63 | ||
| 64 | const APPEARANCE_LABELS: Readonly<Record<PreviewAppearancePreference, string>> = { | |
| 65 | system: "System", | |
| 66 | light: "Light", | |
| 67 | dark: "Dark", | |
| 68 | }; | |
| 69 | ||
| 70 | const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`; | |
| 71 | ||
| 72 | const 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 | */ | |
| 88 | const 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 | ||
| 95 | const 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 | */ | |
| 106 | const rotateViewport = ( | |
| 107 | viewport: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>, | |
| 108 | ): PreviewViewportSetting => ({ | |
| 109 | ...viewport, | |
| 110 | width: viewport.height, | |
| 111 | height: viewport.width, | |
| 112 | }); | |
| 113 | ||
| 114 | function 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 | ||
| 268 | function 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 | ||
| 311 | function 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 | ||
| 353 | function 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 | */ | |
| 401 | function 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 | ||
| 413 | export 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 | 12 | backgroundActivitySharedPolicySettings, |
| 13 | 13 | buildProviderInstanceUpdatePatch, |
| 14 | 14 | formatDiagnosticsDescription, |
| 15 | getChangedBrowserSettingLabels, | |
| 15 | 16 | getChangedTypographySettingLabels, |
| 17 | isSamePreviewViewport, | |
| 16 | 18 | hasChangedBackgroundActivitySettings, |
| 17 | 19 | isProjectGroupingEnabled, |
| 18 | 20 | projectGroupingModeFromToggle, |
| ... | ... | @@ -242,3 +244,54 @@ describe("buildProviderInstanceUpdatePatch", () => { |
| 242 | 244 | expect(patch.providers).toBeUndefined(); |
| 243 | 245 | }); |
| 244 | 246 | }); |
| 247 | ||
| 248 | describe("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 | ||
| 277 | describe("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 | 3 | BackgroundActivitySettings, |
| 4 | 4 | ProviderDriverKind, |
| 5 | 5 | ProviderInstanceConfig, |
| 6 | PreviewViewportSetting, | |
| 6 | 7 | ProviderInstanceId, |
| 7 | 8 | ServerSettings, |
| 8 | 9 | SidebarProjectGroupingMode, |
| ... | ... | @@ -108,6 +109,55 @@ export function getChangedTypographySettingLabels(settings: TypographySettings): |
| 108 | 109 | ]; |
| 109 | 110 | } |
| 110 | 111 | |
| 112 | export 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 | */ | |
| 127 | export 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. */ | |
| 140 | export 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 | ||
| 111 | 161 | export function resolveBackgroundActivityProfileOption( |
| 112 | 162 | settings: ServerSettings, |
| 113 | 163 | ): BackgroundActivityProfile | "advanced" { |
apps/web/src/components/settings/SettingsPanels.tsx+10| ... | ... | @@ -122,6 +122,7 @@ import { |
| 122 | 122 | backgroundActivitySharedPolicySettings, |
| 123 | 123 | durationToSeconds, |
| 124 | 124 | formatDiagnosticsDescription, |
| 125 | getChangedBrowserSettingLabels, | |
| 125 | 126 | getChangedTypographySettingLabels, |
| 126 | 127 | normalizeIntervalSeconds, |
| 127 | 128 | PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, |
| ... | ... | @@ -529,10 +530,15 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 529 | 530 | ? ["Quit confirmation"] |
| 530 | 531 | : []), |
| 531 | 532 | ...(isTextGenerationModelDirty ? ["Text generation model"] : []), |
| 533 | ...getChangedBrowserSettingLabels(settings), | |
| 532 | 534 | ], |
| 533 | 535 | [ |
| 534 | 536 | isTextGenerationModelDirty, |
| 535 | 537 | isBackgroundActivityDirty, |
| 538 | settings.browserDefaultViewport, | |
| 539 | settings.browserDefaultZoomFactor, | |
| 540 | settings.browserDefaultAppearance, | |
| 541 | settings.browserAutoShowFloatingPreview, | |
| 536 | 542 | settings.confirmQuit, |
| 537 | 543 | settings.confirmThreadArchive, |
| 538 | 544 | settings.confirmThreadDelete, |
| ... | ... | @@ -657,6 +663,10 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 657 | 663 | fontSizePrompt: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt, |
| 658 | 664 | fontSizeCode: DEFAULT_UNIFIED_SETTINGS.fontSizeCode, |
| 659 | 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 | 671 | onRestored?.(); |
| 662 | 672 | }, [ |
apps/web/src/components/settings/SettingsSidebarNav.tsx+2| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | import { |
| 11 | 11 | ArchiveIcon, |
| 12 | 12 | ArrowLeftIcon, |
| 13 | BlocksIcon, | |
| 13 | 14 | BotIcon, |
| 14 | 15 | GitBranchIcon, |
| 15 | 16 | KeyboardIcon, |
| ... | ... | @@ -49,6 +50,7 @@ const SETTINGS_SECTION_ICONS: Readonly< |
| 49 | 50 | "/settings/appearance": PaletteIcon, |
| 50 | 51 | "/settings/keybindings": KeyboardIcon, |
| 51 | 52 | "/settings/providers": BotIcon, |
| 53 | "/settings/integrations": BlocksIcon, | |
| 52 | 54 | "/settings/source-control": GitBranchIcon, |
| 53 | 55 | "/settings/connections": Link2Icon, |
| 54 | 56 | "/settings/archived": ArchiveIcon, |
apps/web/src/components/settings/settingsSearch.ts+26| ... | ... | @@ -5,6 +5,7 @@ export type SettingsPath = |
| 5 | 5 | | "/settings/appearance" |
| 6 | 6 | | "/settings/keybindings" |
| 7 | 7 | | "/settings/providers" |
| 8 | | "/settings/integrations" | |
| 8 | 9 | | "/settings/source-control" |
| 9 | 10 | | "/settings/connections" |
| 10 | 11 | | "/settings/archived"; |
| ... | ... | @@ -28,6 +29,7 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = { |
| 28 | 29 | "/settings/appearance": "Appearance", |
| 29 | 30 | "/settings/keybindings": "Keybindings", |
| 30 | 31 | "/settings/providers": "Providers", |
| 32 | "/settings/integrations": "Integrations", | |
| 31 | 33 | "/settings/source-control": "Source Control", |
| 32 | 34 | "/settings/connections": "Connections", |
| 33 | 35 | "/settings/archived": "Archive", |
| ... | ... | @@ -195,6 +197,30 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 195 | 197 | title: "Providers", |
| 196 | 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 | 225 | id: "source-control", |
| 200 | 226 | title: "Source control", |
apps/web/src/hooks/useSettings.ts+11| ... | ... | @@ -185,6 +185,17 @@ export function getClientSettings(): ClientSettings { |
| 185 | 185 | return getClientSettingsSnapshot(); |
| 186 | 186 | } |
| 187 | 187 | |
| 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 | */ | |
| 195 | export function ensureClientSettingsHydrated(): Promise<void> { | |
| 196 | return hydrateClientSettings(); | |
| 197 | } | |
| 198 | ||
| 188 | 199 | export function useClientSettingsHydrated(): boolean { |
| 189 | 200 | return useSyncExternalStore( |
| 190 | 201 | subscribeClientSettingsHydration, |
apps/web/src/routeTree.gen.ts+21| ... | ... | @@ -18,6 +18,7 @@ import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| 18 | 18 | import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' |
| 19 | 19 | import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' |
| 20 | 20 | import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' |
| 21 | import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations' | |
| 21 | 22 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| 22 | 23 | import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' |
| 23 | 24 | import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' |
| ... | ... | @@ -73,6 +74,11 @@ const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ |
| 73 | 74 | path: '/keybindings', |
| 74 | 75 | getParentRoute: () => SettingsRoute, |
| 75 | 76 | } as any) |
| 77 | const SettingsIntegrationsRoute = SettingsIntegrationsRouteImport.update({ | |
| 78 | id: '/integrations', | |
| 79 | path: '/integrations', | |
| 80 | getParentRoute: () => SettingsRoute, | |
| 81 | } as any) | |
| 76 | 82 | const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ |
| 77 | 83 | id: '/general', |
| 78 | 84 | path: '/general', |
| ... | ... | @@ -139,6 +145,7 @@ export interface FileRoutesByFullPath { |
| 139 | 145 | '/settings/connections': typeof SettingsConnectionsRoute |
| 140 | 146 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 141 | 147 | '/settings/general': typeof SettingsGeneralRoute |
| 148 | '/settings/integrations': typeof SettingsIntegrationsRoute | |
| 142 | 149 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 143 | 150 | '/settings/providers': typeof SettingsProvidersRoute |
| 144 | 151 | '/settings/source-control': typeof SettingsSourceControlRoute |
| ... | ... | @@ -158,6 +165,7 @@ export interface FileRoutesByTo { |
| 158 | 165 | '/settings/connections': typeof SettingsConnectionsRoute |
| 159 | 166 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 160 | 167 | '/settings/general': typeof SettingsGeneralRoute |
| 168 | '/settings/integrations': typeof SettingsIntegrationsRoute | |
| 161 | 169 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 162 | 170 | '/settings/providers': typeof SettingsProvidersRoute |
| 163 | 171 | '/settings/source-control': typeof SettingsSourceControlRoute |
| ... | ... | @@ -180,6 +188,7 @@ export interface FileRoutesById { |
| 180 | 188 | '/settings/connections': typeof SettingsConnectionsRoute |
| 181 | 189 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 182 | 190 | '/settings/general': typeof SettingsGeneralRoute |
| 191 | '/settings/integrations': typeof SettingsIntegrationsRoute | |
| 183 | 192 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 184 | 193 | '/settings/providers': typeof SettingsProvidersRoute |
| 185 | 194 | '/settings/source-control': typeof SettingsSourceControlRoute |
| ... | ... | @@ -203,6 +212,7 @@ export interface FileRouteTypes { |
| 203 | 212 | | '/settings/connections' |
| 204 | 213 | | '/settings/diagnostics' |
| 205 | 214 | | '/settings/general' |
| 215 | | '/settings/integrations' | |
| 206 | 216 | | '/settings/keybindings' |
| 207 | 217 | | '/settings/providers' |
| 208 | 218 | | '/settings/source-control' |
| ... | ... | @@ -222,6 +232,7 @@ export interface FileRouteTypes { |
| 222 | 232 | | '/settings/connections' |
| 223 | 233 | | '/settings/diagnostics' |
| 224 | 234 | | '/settings/general' |
| 235 | | '/settings/integrations' | |
| 225 | 236 | | '/settings/keybindings' |
| 226 | 237 | | '/settings/providers' |
| 227 | 238 | | '/settings/source-control' |
| ... | ... | @@ -243,6 +254,7 @@ export interface FileRouteTypes { |
| 243 | 254 | | '/settings/connections' |
| 244 | 255 | | '/settings/diagnostics' |
| 245 | 256 | | '/settings/general' |
| 257 | | '/settings/integrations' | |
| 246 | 258 | | '/settings/keybindings' |
| 247 | 259 | | '/settings/providers' |
| 248 | 260 | | '/settings/source-control' |
| ... | ... | @@ -326,6 +338,13 @@ declare module '@tanstack/react-router' { |
| 326 | 338 | preLoaderRoute: typeof SettingsKeybindingsRouteImport |
| 327 | 339 | 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 | 348 | '/settings/general': { |
| 330 | 349 | id: '/settings/general' |
| 331 | 350 | path: '/general' |
| ... | ... | @@ -421,6 +440,7 @@ interface SettingsRouteChildren { |
| 421 | 440 | SettingsConnectionsRoute: typeof SettingsConnectionsRoute |
| 422 | 441 | SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute |
| 423 | 442 | SettingsGeneralRoute: typeof SettingsGeneralRoute |
| 443 | SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute | |
| 424 | 444 | SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute |
| 425 | 445 | SettingsProvidersRoute: typeof SettingsProvidersRoute |
| 426 | 446 | SettingsSourceControlRoute: typeof SettingsSourceControlRoute |
| ... | ... | @@ -432,6 +452,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { |
| 432 | 452 | SettingsConnectionsRoute: SettingsConnectionsRoute, |
| 433 | 453 | SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, |
| 434 | 454 | SettingsGeneralRoute: SettingsGeneralRoute, |
| 455 | SettingsIntegrationsRoute: SettingsIntegrationsRoute, | |
| 435 | 456 | SettingsKeybindingsRoute: SettingsKeybindingsRoute, |
| 436 | 457 | SettingsProvidersRoute: SettingsProvidersRoute, |
| 437 | 458 | SettingsSourceControlRoute: SettingsSourceControlRoute, |
apps/web/src/routes/settings.integrations.tsx created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | ||
| 3 | import { IntegrationsSettingsPanel } from "../components/settings/IntegrationsSettings"; | |
| 4 | ||
| 5 | function SettingsIntegrationsRoute() { | |
| 6 | return <IntegrationsSettingsPanel />; | |
| 7 | } | |
| 8 | ||
| 9 | export 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 | 956 | tabId: DesktopPreviewTabIdSchema, |
| 957 | 957 | }); |
| 958 | 958 | |
| 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 | */ | |
| 966 | export const DesktopPreviewCreateTabInputSchema = Schema.Struct({ | |
| 967 | tabId: DesktopPreviewTabIdSchema, | |
| 968 | zoomFactor: Schema.optional(Schema.Number.check(Schema.isGreaterThan(0))), | |
| 969 | colorScheme: Schema.optional(DesktopPreviewColorSchemeSchema), | |
| 970 | }); | |
| 971 | ||
| 972 | export interface DesktopPreviewTabDefaults { | |
| 973 | readonly zoomFactor?: number | undefined; | |
| 974 | readonly colorScheme?: DesktopPreviewColorScheme | undefined; | |
| 975 | } | |
| 976 | ||
| 959 | 977 | export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({ |
| 960 | 978 | tabId: DesktopPreviewTabIdSchema, |
| 961 | 979 | webContentsId: Schema.Int.check(Schema.isGreaterThan(0)), |
| ... | ... | @@ -1109,7 +1127,7 @@ export interface DesktopBridge { |
| 1109 | 1127 | } |
| 1110 | 1128 | |
| 1111 | 1129 | export interface DesktopPreviewBridge { |
| 1112 | createTab: (tabId: string) => Promise<void>; | |
| 1130 | createTab: (tabId: string, defaults?: DesktopPreviewTabDefaults) => Promise<void>; | |
| 1113 | 1131 | closeTab: (tabId: string) => Promise<void>; |
| 1114 | 1132 | registerWebview: (tabId: string, webContentsId: number) => Promise<void>; |
| 1115 | 1133 | navigate: (tabId: string, url: string) => Promise<void>; |
packages/contracts/src/preview.ts+31| ... | ... | @@ -118,6 +118,30 @@ export const FILL_PREVIEW_VIEWPORT = { |
| 118 | 118 | _tag: "fill", |
| 119 | 119 | } as const satisfies PreviewViewportSetting; |
| 120 | 120 | |
| 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 | */ | |
| 126 | export 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 | ||
| 130 | export const PreviewZoomFactor = Schema.Literals(PREVIEW_ZOOM_LEVELS); | |
| 131 | export type PreviewZoomFactor = typeof PreviewZoomFactor.Type; | |
| 132 | ||
| 133 | export 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 | */ | |
| 140 | export const PreviewAppearancePreference = Schema.Literals(["system", "light", "dark"]); | |
| 141 | export type PreviewAppearancePreference = typeof PreviewAppearancePreference.Type; | |
| 142 | ||
| 143 | export const DEFAULT_PREVIEW_APPEARANCE: PreviewAppearancePreference = "system"; | |
| 144 | ||
| 121 | 145 | export const PreviewNavStatus = Schema.Union([ |
| 122 | 146 | Schema.TaggedStruct("Idle", {}), |
| 123 | 147 | Schema.TaggedStruct("Loading", { |
| ... | ... | @@ -153,6 +177,13 @@ export const PreviewOpenInput = Schema.Struct({ |
| 153 | 177 | threadId: ThreadId, |
| 154 | 178 | /** Omit to create an empty (Idle) tab the user can type into. */ |
| 155 | 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 | }); |
| 157 | 188 | export type PreviewOpenInput = typeof PreviewOpenInput.Type; |
| 158 | 189 |
packages/contracts/src/settings.ts+39| ... | ... | @@ -10,6 +10,14 @@ import { |
| 10 | 10 | ProviderOptionSelections, |
| 11 | 11 | } from "./model.ts"; |
| 12 | 12 | import { ModelSelection } from "./orchestration.ts"; |
| 13 | import { | |
| 14 | DEFAULT_PREVIEW_APPEARANCE, | |
| 15 | DEFAULT_PREVIEW_ZOOM_FACTOR, | |
| 16 | FILL_PREVIEW_VIEWPORT, | |
| 17 | PreviewAppearancePreference, | |
| 18 | PreviewViewportSetting, | |
| 19 | PreviewZoomFactor, | |
| 20 | } from "./preview.ts"; | |
| 13 | 21 | import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts"; |
| 14 | 22 | |
| 15 | 23 | // ── Client Settings (local-only) ─────────────────────────────── |
| ... | ... | @@ -111,7 +119,34 @@ export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationM |
| 111 | 119 | export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)); |
| 112 | 120 | export type FontFamilyPreference = typeof FontFamilyPreference.Type; |
| 113 | 121 | |
| 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 | */ | |
| 128 | export const DEFAULT_BROWSER_VIEWPORT: PreviewViewportSetting = FILL_PREVIEW_VIEWPORT; | |
| 129 | export const DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW = true; | |
| 130 | ||
| 114 | 131 | export 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 | 150 | // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the |
| 116 | 151 | // app quits; a quick tap only shows a hint. Browser clients ignore it. |
| 117 | 152 | confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| ... | ... | @@ -759,6 +794,10 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 759 | 794 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; |
| 760 | 795 | |
| 761 | 796 | export 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 | 801 | confirmQuit: Schema.optionalKey(Schema.Boolean), |
| 763 | 802 | confirmThreadArchive: Schema.optionalKey(Schema.Boolean), |
| 764 | 803 | confirmThreadDelete: Schema.optionalKey(Schema.Boolean), |