diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index a21a1fe406fac..6e892d51f02e7 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -193,6 +193,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { canGoForward: false, viewport: input.viewport ?? FILL_PREVIEW_VIEWPORT, ...(input.profileId === undefined ? {} : { profileId: input.profileId }), + ...(input.colorScheme === undefined ? {} : { colorScheme: input.colorScheme }), ...(runtime === undefined ? {} : { runtime }), ...(runtime === "server" && input.automationOwner !== undefined ? { automationOwner: input.automationOwner } diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 961ea352c7c3b..73ddcc03258dd 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1726,6 +1726,58 @@ it.live("drives the desktop's own page for a tab the desktop renders", () => ).pipe(Effect.provide(layer)), ); +it.live("a system tab clears the light Playwright forces on attach, without redoing it", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + desktopRendersNext = true; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + yield* browser.attachViewer(viewerInput(opened.tabId, true)); + const page = desktopConnections[0]!.context.page; + expect(page.emulateMedia.mock.calls).toEqual([[{ colorScheme: null }]]); + const dark = Promise.withResolvers(); + page.emulateMedia.mockImplementation(async () => dark.resolve()); + const target = { threadId: scope.thread.threadId, tabId: opened.tabId }; + yield* manager.adjust({ ...target, zoomFactor: 1.25 }); + yield* manager.adjust({ ...target, colorScheme: "dark" }); + yield* Effect.promise(() => dark.promise); + expect(page.emulateMedia.mock.calls).toEqual([ + [{ colorScheme: null }], + [{ colorScheme: "dark" }], + ]); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a tab opens at its requested appearance and its popups keep it", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + const opened = yield* manager.open({ + threadId: scope.thread.threadId, + runtime: "server", + colorScheme: "dark", + }); + yield* browser.attachViewer(viewerInput(opened.tabId, true)); + expect(contexts[0]!.page.emulateMedia).toHaveBeenCalledWith({ colorScheme: "dark" }); + const popup = makeContext(); + const applied = Promise.withResolvers(); + popup.page.emulateMedia.mockImplementation(async () => applied.resolve()); + contexts[0]!.page.emit("popup", popup.page); + yield* Effect.promise(() => applied.promise); + expect(popup.page.emulateMedia.mock.calls).toEqual([[{ colorScheme: "dark" }]]); + const sessions = (yield* manager.list({ threadId: scope.thread.threadId })).sessions; + expect(sessions.find((session) => session.tabId !== opened.tabId)).toMatchObject({ + colorScheme: "dark", + }); + }), + ).pipe(Effect.provide(layer)), +); + it.live("desktop file pickers stay native outside agent actions and remote control", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index d9626784c32d2..aacd478299d7b 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -304,7 +304,8 @@ interface ServerTab { } | null; dialog: Dialog | null; setting: PreviewViewportSetting; - colorScheme: PreviewAppearancePreference; + /** Null until first applied, so a `system` tab still clears Playwright's default light. */ + colorScheme: PreviewAppearancePreference | null; zoomFactor: number; loading: boolean; closing: boolean; @@ -771,7 +772,7 @@ const make = Effect.gen(function* () { fileChooser: null, dialog: null, setting: snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, - colorScheme: "system", + colorScheme: null, zoomFactor: 1, loading: false, closing: false, @@ -1193,6 +1194,7 @@ const make = Effect.gen(function* () { ...(/^https?:/i.test(url) ? { url } : {}), runtime: "server", ...(opener.profileId === undefined ? {} : { profileId: opener.profileId }), + ...(opener.colorScheme === null ? {} : { colorScheme: opener.colorScheme }), ...(opener.control.agentId === null ? {} : { automationOwner: opener.control.agentId, reveal: false }), diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index 9c52e03536e91..266bf5fe6fad5 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -82,7 +82,7 @@ export async function openUrlInPreview(input: { url: input.url, viewport: browserDefaultOpenViewport(defaults), profileId: input.profileId ?? browserDefaultOpenProfileId(defaults), - ...(runtime === undefined ? {} : { runtime }), + ...(runtime === undefined ? {} : { runtime, colorScheme: defaults.appearance }), }, }); return mapAtomCommandResult(result, (snapshot) => { diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index 26428d488ffce..803b90315ca6d 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -50,7 +50,9 @@ export async function openPreviewSession( ...(input.url === undefined ? {} : { url: input.url }), viewport: input.viewport ?? browserDefaultOpenViewport(defaults), profileId: input.profileId ?? browserDefaultOpenProfileId(defaults), - ...(runtime === undefined || runtime === "desktop" ? {} : { runtime }), + ...(runtime === undefined || runtime === "desktop" + ? {} + : { runtime, colorScheme: defaults.appearance }), }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index c6a0e7c59d281..315cc6554ee2b 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -68,7 +68,7 @@ export async function openTerminalLinkInPreview( url: input.url, viewport: browserDefaultOpenViewport(defaults), profileId: browserDefaultOpenProfileId(defaults), - ...(runtime === undefined ? {} : { runtime }), + ...(runtime === undefined ? {} : { runtime, colorScheme: defaults.appearance }), }, }); if (result._tag === "Failure") { diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index 455663155cb40..426cabb4f5b6e 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -188,6 +188,8 @@ export const PreviewOpenInput = Schema.Struct({ viewport: Schema.optional(PreviewViewportSetting), profileId: Schema.optional(BrowserProfileId), runtime: Schema.optional(PreviewRuntime), + /** Server tabs only; the desktop seeds its own tabs at creation. */ + colorScheme: Schema.optional(PreviewAppearancePreference), reveal: Schema.optional(Schema.Boolean), }); export type PreviewOpenInput = typeof PreviewOpenInput.Type;