authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-25 19:06:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-25 19:06:44-07:00
logffae5410e8f8ff5ac72330e0b63535b89f9fd555
treed510fca9a10be7667e3961b1ef5c3dfdaab12db9
parent31dfe3596e429725d95d31ecff396caa02a47d12
signature Signed by PGP key B5690EEEBB952194

Route preview automation through live owner streams (#3548)

Co-authored-by: codex <codex@users.noreply.github.com>

68 files changed, 6109 insertions(+), 1067 deletions(-)

apps/desktop/scripts/electron-launcher.mjs+36-14
...@@ -100,25 +100,41 @@ function shellSingleQuote(value) {...@@ -100,25 +100,41 @@ function shellSingleQuote(value) {
100 return `'${value.replaceAll("'", "'\\''")}'`;100 return `'${value.replaceAll("'", "'\\''")}'`;
101}101}
102102
103function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) {103export function makeDevelopmentLauncherScript({
104 const mainEntryPath = NodePath.join(desktopDir, "dist-electron", "main.cjs");104 electronBinaryPath,
105 mainEntryPath,
106 desktopRoot,
107 environment,
108}) {
105 const envEntries = [109 const envEntries = [
106 ["VITE_DEV_SERVER_URL", process.env.VITE_DEV_SERVER_URL],110 ["VITE_DEV_SERVER_URL", environment.VITE_DEV_SERVER_URL],
107 ["T3CODE_PORT", process.env.T3CODE_PORT],111 ["T3CODE_PORT", environment.T3CODE_PORT],
108 ["T3CODE_HOME", process.env.T3CODE_HOME],112 ["T3CODE_HOME", environment.T3CODE_HOME],
109 ["T3CODE_COMMIT_HASH", process.env.T3CODE_COMMIT_HASH],113 ["T3CODE_COMMIT_HASH", environment.T3CODE_COMMIT_HASH],
110 ["T3CODE_OTLP_TRACES_URL", process.env.T3CODE_OTLP_TRACES_URL],114 ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL],
111 ["T3CODE_OTLP_EXPORT_INTERVAL_MS", process.env.T3CODE_OTLP_EXPORT_INTERVAL_MS],115 ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS],
112 ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID],116 ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID],
113 ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0);117 ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0);
118 return [
119 "#!/bin/sh",
120 ...envEntries.map(
121 ([name, value]) =>
122 `if [ -z "\${${name}:-}" ]; then export ${name}=${shellSingleQuote(value)}; fi`,
123 ),
124 `exec ${shellSingleQuote(electronBinaryPath)} --t3code-dev-root=${shellSingleQuote(desktopRoot)} ${shellSingleQuote(mainEntryPath)} "$@"`,
125 "",
126 ].join("\n");
127}
128
129function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) {
114 NodeFS.writeFileSync(130 NodeFS.writeFileSync(
115 targetBinaryPath,131 targetBinaryPath,
116 [132 makeDevelopmentLauncherScript({
117 "#!/bin/sh",133 electronBinaryPath,
118 ...envEntries.map(([name, value]) => `export ${name}=${shellSingleQuote(value)}`),134 mainEntryPath: NodePath.join(desktopDir, "dist-electron", "main.cjs"),
119 `exec ${shellSingleQuote(electronBinaryPath)} --t3code-dev-root=${shellSingleQuote(desktopDir)} ${shellSingleQuote(mainEntryPath)} "$@"`,135 desktopRoot: desktopDir,
120 "",136 environment: process.env,
121 ].join("\n"),137 }),
122 );138 );
123 NodeFS.chmodSync(targetBinaryPath, 0o755);139 NodeFS.chmodSync(targetBinaryPath, 0o755);
124}140}
...@@ -286,6 +302,12 @@ function buildMacLauncher(electronBinaryPath) {...@@ -286,6 +302,12 @@ function buildMacLauncher(electronBinaryPath) {
286 currentMetadata &&302 currentMetadata &&
287 JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata)303 JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata)
288 ) {304 ) {
305 if (isDevelopment) {
306 // The launcher also handles protocol activations outside the dev runner,
307 // so refresh its fallback environment on every launch. Never let a value
308 // captured by an older parent app override the live dev-runner environment.
309 writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath);
310 }
289 registerMacLauncherBundle(targetAppBundlePath);311 registerMacLauncherBundle(targetAppBundlePath);
290 return targetBinaryPath;312 return targetBinaryPath;
291 }313 }
apps/desktop/scripts/electron-launcher.test.mjs created+28
...@@ -0,0 +1,28 @@
1import { assert, describe, it } from "vite-plus/test";
2
3import { makeDevelopmentLauncherScript } from "./electron-launcher.mjs";
4
5describe("electron development launcher", () => {
6 it("uses captured values only as fallbacks for a live runner environment", () => {
7 const script = makeDevelopmentLauncherScript({
8 electronBinaryPath: "/repo/node_modules/electron/Electron",
9 mainEntryPath: "/repo/apps/desktop/dist-electron/main.cjs",
10 desktopRoot: "/repo/apps/desktop",
11 environment: {
12 VITE_DEV_SERVER_URL: "http://127.0.0.1:8526",
13 T3CODE_PORT: "16566",
14 T3CODE_HOME: "/tmp/t3",
15 },
16 });
17
18 assert.include(
19 script,
20 "if [ -z \"${VITE_DEV_SERVER_URL:-}\" ]; then export VITE_DEV_SERVER_URL='http://127.0.0.1:8526'; fi",
21 );
22 assert.notInclude(script, "\nexport VITE_DEV_SERVER_URL=");
23 assert.include(
24 script,
25 "exec '/repo/node_modules/electron/Electron' --t3code-dev-root='/repo/apps/desktop' '/repo/apps/desktop/dist-electron/main.cjs' \"$@\"",
26 );
27 });
28});
apps/desktop/src/preview/Manager.test.ts+385-10
...@@ -18,16 +18,25 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts";...@@ -18,16 +18,25 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts";
18import * as BrowserSession from "./BrowserSession.ts";18import * as BrowserSession from "./BrowserSession.ts";
19import * as PreviewManager from "./Manager.ts";19import * as PreviewManager from "./Manager.ts";
2020
21const { createFromPath, fromId, mkdir, showItemInFolder, webviewSend, writeFile, writeImage } =21const {
22 vi.hoisted(() => ({22 createFromPath,
23 createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })),23 fromId,
24 fromId: vi.fn(() => null),24 getFocusedWebContents,
25 mkdir: vi.fn((_path: string) => undefined),25 mkdir,
26 showItemInFolder: vi.fn(),26 showItemInFolder,
27 webviewSend: vi.fn(),27 webviewSend,
28 writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined),28 writeFile,
29 writeImage: vi.fn(),29 writeImage,
30 }));30} = vi.hoisted(() => ({
31 createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })),
32 fromId: vi.fn(() => null),
33 getFocusedWebContents: vi.fn(() => null),
34 mkdir: vi.fn((_path: string) => undefined),
35 showItemInFolder: vi.fn(),
36 webviewSend: vi.fn(),
37 writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined),
38 writeImage: vi.fn(),
39}));
3140
32vi.mock("electron", () => ({41vi.mock("electron", () => ({
33 clipboard: {42 clipboard: {
...@@ -44,6 +53,7 @@ vi.mock("electron", () => ({...@@ -44,6 +53,7 @@ vi.mock("electron", () => ({
44 },53 },
45 webContents: {54 webContents: {
46 fromId,55 fromId,
56 getFocusedWebContents,
47 },57 },
48}));58}));
4959
...@@ -97,6 +107,8 @@ const withManager = <A>(...@@ -97,6 +107,8 @@ const withManager = <A>(
97describe("PreviewManager", () => {107describe("PreviewManager", () => {
98 beforeEach(() => {108 beforeEach(() => {
99 fromId.mockClear();109 fromId.mockClear();
110 getFocusedWebContents.mockReset();
111 getFocusedWebContents.mockReturnValue(null);
100 mkdir.mockClear();112 mkdir.mockClear();
101 writeFile.mockClear();113 writeFile.mockClear();
102 showItemInFolder.mockClear();114 showItemInFolder.mockClear();
...@@ -250,6 +262,197 @@ describe("PreviewManager", () => {...@@ -250,6 +262,197 @@ describe("PreviewManager", () => {
250 ),262 ),
251 );263 );
252264
265 effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () =>
266 withManager((manager) =>
267 Effect.gen(function* () {
268 let effectiveZoom = 0.9;
269 let zoomReadable = true;
270 let url = "https://example.com";
271 const listeners = new Map<string, (...args: unknown[]) => void>();
272 const setZoomFactor = vi.fn();
273 fromId.mockReturnValue({
274 id: 42,
275 isDestroyed: () => false,
276 getType: () => "webview",
277 getURL: () => url,
278 getTitle: () => "Example",
279 isLoading: () => false,
280 getZoomFactor: () => {
281 if (!zoomReadable) throw new Error("zoom unavailable");
282 return effectiveZoom;
283 },
284 setZoomFactor,
285 on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
286 listeners.set(event, listener);
287 }),
288 off: vi.fn(),
289 ipc: { on: vi.fn(), off: vi.fn() },
290 send: webviewSend,
291 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
292 setWindowOpenHandler: vi.fn(),
293 debugger: {
294 isAttached: () => false,
295 attach: vi.fn(),
296 sendCommand: vi.fn(async () => undefined),
297 on: vi.fn(),
298 off: vi.fn(),
299 },
300 } as never);
301 const states: PreviewManager.PreviewTabState[] = [];
302
303 yield* manager.subscribeStateChanges((_tabId, state) =>
304 Effect.sync(() => {
305 states.push(state);
306 }),
307 );
308 yield* manager.createTab("tab_zoom");
309 yield* manager.registerWebview("tab_zoom", 42);
310
311 expect(states.at(-1)?.zoomFactor).toBe(0.9);
312 expect(setZoomFactor).not.toHaveBeenCalled();
313
314 effectiveZoom = 1.25;
315 listeners.get("did-navigate")?.();
316 yield* Effect.yieldNow;
317
318 expect(states.at(-1)?.zoomFactor).toBe(1.25);
319 expect(setZoomFactor).not.toHaveBeenCalled();
320
321 zoomReadable = false;
322 url = "https://example.com/after-zoom-read-failed";
323 listeners.get("did-navigate")?.();
324 yield* Effect.yieldNow;
325
326 expect(states.at(-1)?.navStatus).toEqual({
327 kind: "Success",
328 url,
329 title: "Example",
330 });
331 expect(states.at(-1)?.zoomFactor).toBe(1.25);
332
333 const replacementSetZoomFactor = vi.fn();
334 fromId.mockReturnValue({
335 id: 43,
336 isDestroyed: () => false,
337 getType: () => "webview",
338 getURL: () => url,
339 getTitle: () => "Example",
340 isLoading: () => false,
341 getZoomFactor: () => 1,
342 setZoomFactor: replacementSetZoomFactor,
343 on: vi.fn(),
344 off: vi.fn(),
345 ipc: { on: vi.fn(), off: vi.fn() },
346 send: webviewSend,
347 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
348 setWindowOpenHandler: vi.fn(),
349 debugger: {
350 isAttached: () => false,
351 attach: vi.fn(),
352 sendCommand: vi.fn(async () => undefined),
353 on: vi.fn(),
354 off: vi.fn(),
355 },
356 } as never);
357
358 yield* manager.registerWebview("tab_zoom", 43);
359
360 expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.25);
361 expect(states.at(-1)?.zoomFactor).toBe(1.25);
362 }),
363 ),
364 );
365
366 effectIt.effect("keeps a main-frame load failure visible until a retry starts", () =>
367 withManager((manager) =>
368 Effect.gen(function* () {
369 const url = "http://localhost:5733/";
370 let loading = false;
371 const listeners = new Map<string, (...args: unknown[]) => void>();
372 fromId.mockReturnValue({
373 id: 42,
374 isDestroyed: () => false,
375 getType: () => "webview",
376 getURL: () => url,
377 getTitle: () => "localhost:5733",
378 isLoading: () => loading,
379 getZoomFactor: () => 1,
380 setZoomFactor: vi.fn(),
381 on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
382 listeners.set(event, listener);
383 }),
384 off: vi.fn(),
385 ipc: { on: vi.fn(), off: vi.fn() },
386 send: webviewSend,
387 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
388 setWindowOpenHandler: vi.fn(),
389 debugger: {
390 isAttached: () => false,
391 attach: vi.fn(),
392 sendCommand: vi.fn(async () => undefined),
393 on: vi.fn(),
394 off: vi.fn(),
395 },
396 } as never);
397 const statuses: PreviewManager.PreviewNavStatus[] = [];
398
399 yield* manager.subscribeStateChanges((_tabId, state) =>
400 Effect.sync(() => {
401 statuses.push(state.navStatus);
402 }),
403 );
404 yield* manager.createTab("tab_failed");
405 yield* manager.registerWebview("tab_failed", 42);
406
407 listeners.get("did-fail-load")?.(
408 {},
409 -105,
410 "ERR_NAME_NOT_RESOLVED",
411 "https://missing-frame.example/",
412 false,
413 );
414 yield* Effect.yieldNow;
415 expect(statuses.at(-1)?.kind).toBe("Success");
416
417 loading = true;
418 listeners.get("did-start-loading")?.();
419 yield* Effect.yieldNow;
420 expect(statuses.at(-1)?.kind).toBe("Loading");
421
422 loading = false;
423 listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", url, true);
424 listeners.get("did-stop-loading")?.();
425 listeners.get("page-title-updated")?.();
426 yield* Effect.yieldNow;
427 expect(statuses.at(-1)).toEqual({
428 kind: "LoadFailed",
429 url,
430 title: "localhost:5733",
431 code: -102,
432 description: "ERR_CONNECTION_REFUSED",
433 });
434
435 loading = true;
436 listeners.get("did-start-loading")?.();
437 yield* Effect.yieldNow;
438 expect(statuses.at(-1)?.kind).toBe("Loading");
439
440 loading = false;
441 listeners.get("did-stop-loading")?.();
442 yield* Effect.yieldNow;
443 expect(statuses.at(-1)?.kind).toBe("Success");
444
445 listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", url, true);
446 yield* Effect.yieldNow;
447 expect(statuses.at(-1)?.kind).toBe("LoadFailed");
448
449 listeners.get("did-navigate")?.();
450 yield* Effect.yieldNow;
451 expect(statuses.at(-1)?.kind).toBe("Success");
452 }),
453 ),
454 );
455
253 effectIt.effect("captures a PNG screenshot into browser artifacts", () =>456 effectIt.effect("captures a PNG screenshot into browser artifacts", () =>
254 withManager((manager) =>457 withManager((manager) =>
255 Effect.gen(function* () {458 Effect.gen(function* () {
...@@ -511,6 +714,178 @@ describe("PreviewManager", () => {...@@ -511,6 +714,178 @@ describe("PreviewManager", () => {
511 ),714 ),
512 );715 );
513716
717 effectIt.effect("types in background webviews and enables native key input", () =>
718 withManager((manager) =>
719 Effect.gen(function* () {
720 let failKeyDown = false;
721 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
722 const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => {
723 if (
724 failKeyDown &&
725 method === "Input.dispatchKeyEvent" &&
726 (params?.["type"] === "keyDown" || params?.["type"] === "rawKeyDown")
727 ) {
728 throw new Error("key dispatch failed");
729 }
730 if (
731 method === "Input.dispatchKeyEvent" &&
732 (params?.["type"] === "keyDown" || params?.["type"] === "rawKeyDown")
733 ) {
734 humanInput?.(
735 {},
736 {
737 kind: "key",
738 key: params["key"],
739 code: params["code"] ?? "Digit1",
740 },
741 );
742 }
743 return method === "Runtime.evaluate" ? { result: { value: { ok: true } } } : undefined;
744 });
745 const restoreFocus = vi.fn();
746 const focus = vi.fn();
747 getFocusedWebContents.mockReturnValue({
748 id: 7,
749 isDestroyed: () => false,
750 focus: restoreFocus,
751 } as never);
752 fromId.mockReturnValue({
753 id: 42,
754 isDestroyed: () => false,
755 getType: () => "webview",
756 getURL: () => "https://example.com",
757 getTitle: () => "Example",
758 isLoading: () => false,
759 isDevToolsOpened: () => false,
760 focus,
761 getZoomFactor: () => 1,
762 setZoomFactor: vi.fn(),
763 on: vi.fn(),
764 off: vi.fn(),
765 ipc: {
766 on: vi.fn((channel: string, listener: typeof humanInput) => {
767 if (channel === "preview:human-input") humanInput = listener;
768 }),
769 off: vi.fn(),
770 },
771 send: webviewSend,
772 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
773 setWindowOpenHandler: vi.fn(),
774 debugger: {
775 isAttached: () => false,
776 attach: vi.fn(),
777 sendCommand,
778 on: vi.fn(),
779 off: vi.fn(),
780 },
781 } as never);
782
783 yield* manager.createTab("tab_input");
784 yield* manager.registerWebview("tab_input", 42);
785 yield* manager.automationType("tab_input", { text: "hello", clear: true });
786 yield* manager.automationType("tab_input", { text: "", clear: true });
787 yield* manager.automationPress("tab_input", { key: "x" });
788
789 const calls = sendCommand.mock.calls;
790 const methods = calls.map(([method]) => method);
791 const enableIndex = methods.indexOf("Input.setIgnoreInputEvents");
792 const focusOnIndex = calls.findIndex(
793 ([method, params]) =>
794 method === "Emulation.setFocusEmulationEnabled" && params?.["enabled"] === true,
795 );
796 const keyDownIndex = calls.findIndex(
797 ([method, params]) =>
798 method === "Input.dispatchKeyEvent" && params?.["type"] === "keyDown",
799 );
800 const keyUpIndex = calls.findIndex(
801 ([method, params]) => method === "Input.dispatchKeyEvent" && params?.["type"] === "keyUp",
802 );
803 const focusOffIndex = calls.findIndex(
804 ([method, params]) =>
805 method === "Emulation.setFocusEmulationEnabled" && params?.["enabled"] === false,
806 );
807 const typeEvaluation = sendCommand.mock.calls.find(
808 ([method, params]) =>
809 method === "Runtime.evaluate" &&
810 typeof params === "object" &&
811 params !== null &&
812 "expression" in params &&
813 typeof params.expression === "string" &&
814 params.expression.includes('document.execCommand("insertText"'),
815 );
816 expect(typeEvaluation).toBeDefined();
817 const clearOnlyEvaluation = sendCommand.mock.calls.find(
818 ([method, params]) =>
819 method === "Runtime.evaluate" &&
820 typeof params === "object" &&
821 params !== null &&
822 "expression" in params &&
823 typeof params.expression === "string" &&
824 params.expression.includes('const text = ""') &&
825 params.expression.includes("Object.getOwnPropertyDescriptor"),
826 );
827 expect(clearOnlyEvaluation).toBeDefined();
828 expect(methods).not.toContain("Input.insertText");
829 expect(enableIndex).toBeGreaterThanOrEqual(0);
830 expect(focus).toHaveBeenCalledOnce();
831 expect(restoreFocus).toHaveBeenCalledOnce();
832 expect(methods).toContain("Page.bringToFront");
833 expect(enableIndex).toBeLessThan(focusOnIndex);
834 expect(focusOnIndex).toBeLessThan(keyDownIndex);
835 expect(keyDownIndex).toBeLessThan(keyUpIndex);
836 expect(keyUpIndex).toBeLessThan(focusOffIndex);
837 expect(
838 calls.filter(
839 ([method, params]) =>
840 method === "Input.dispatchKeyEvent" && params?.["type"] === "keyUp",
841 ),
842 ).toHaveLength(1);
843 expect(sendCommand).toHaveBeenCalledWith("Input.setIgnoreInputEvents", { ignore: false });
844
845 sendCommand.mockClear();
846 failKeyDown = true;
847 const failedPress = yield* Effect.exit(manager.automationPress("tab_input", { key: "y" }));
848
849 expect(Exit.isFailure(failedPress)).toBe(true);
850 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchKeyEvent", {
851 type: "keyUp",
852 key: "y",
853 code: "KeyY",
854 modifiers: 0,
855 windowsVirtualKeyCode: 89,
856 location: 0,
857 isKeypad: false,
858 });
859 expect(sendCommand).toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", {
860 enabled: false,
861 });
862 expect(restoreFocus).toHaveBeenCalledTimes(2);
863 expect(
864 sendCommand.mock.calls.filter(
865 ([method, params]) =>
866 method === "Input.dispatchKeyEvent" && params?.["type"] === "keyUp",
867 ),
868 ).toHaveLength(1);
869
870 sendCommand.mockClear();
871 failKeyDown = false;
872 yield* manager.automationPress("tab_input", { key: "!" });
873 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchKeyEvent", {
874 type: "keyDown",
875 key: "!",
876 code: "Digit1",
877 modifiers: 0,
878 windowsVirtualKeyCode: 49,
879 location: 0,
880 isKeypad: false,
881 text: "!",
882 unmodifiedText: "!",
883 });
884 expect(restoreFocus).toHaveBeenCalledTimes(3);
885 }),
886 ),
887 );
888
514 effectIt.effect("still interrupts agent control for a different human pointer event", () =>889 effectIt.effect("still interrupts agent control for a different human pointer event", () =>
515 withManager((manager) =>890 withManager((manager) =>
516 Effect.gen(function* () {891 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+232-74
...@@ -62,6 +62,7 @@ import {...@@ -62,6 +62,7 @@ import {
62} from "./GuestProtocol.ts";62} from "./GuestProtocol.ts";
63import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";63import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";
64import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts";64import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts";
65import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts";
6566
66export type PreviewNavStatus =67export type PreviewNavStatus =
67 | { kind: "Idle" }68 | { kind: "Idle" }
...@@ -854,11 +855,24 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -854,11 +855,24 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
854 commandParams?: Record<string, unknown>,855 commandParams?: Record<string, unknown>,
855 ) => Effect.Effect<unknown, PreviewManagerError>;856 ) => Effect.Effect<unknown, PreviewManagerError>;
856857
858 const prepareAutomationInput = Effect.fn("PreviewManager.prepareAutomationInput")(function* (
859 send: SendCommand,
860 enableRuntime: boolean,
861 ) {
862 yield* Effect.all(
863 [
864 ...(enableRuntime ? [send("Runtime.enable")] : []),
865 send("Input.setIgnoreInputEvents", { ignore: false }),
866 ],
867 { concurrency: 2, discard: true },
868 );
869 });
870
857 const withControlSession = Effect.fn("PreviewManager.withControlSession")(function* <A>(871 const withControlSession = Effect.fn("PreviewManager.withControlSession")(function* <A>(
858 tabId: string,872 tabId: string,
859 wc: Electron.WebContents,873 wc: Electron.WebContents,
860 action: string,874 action: string,
861 use: (send: SendCommand) => Effect.Effect<A, PreviewManagerError>,875 use: (send: SendCommand, sendCleanup: SendCommand) => Effect.Effect<A, PreviewManagerError>,
862 ) {876 ) {
863 const sequence = yield* nextCounter(actionSequenceRef);877 const sequence = yield* nextCounter(actionSequenceRef);
864 const startedAt = yield* currentIso;878 const startedAt = yield* currentIso;
...@@ -899,7 +913,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -899,7 +913,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
899 return result;913 return result;
900 },914 },
901 );915 );
902 return yield* use(send);916 // Cleanup commands must still run after human input invalidates the action's
917 // control epoch. Otherwise a partially dispatched input can leave Chromium
918 // with a held key or focus emulation enabled for subsequent actions.
919 const sendCleanup: SendCommand = Effect.fn("PreviewManager.sendCleanupCommand")(
920 function* (method, commandParams) {
921 return yield* attemptPromise(
922 {
923 operation: `${action}.cleanup.${method}`,
924 tabId,
925 webContentsId: wc.id,
926 },
927 () => wc.debugger.sendCommand(method, commandParams),
928 );
929 },
930 );
931 return yield* use(send, sendCleanup);
903 });932 });
904 const finalize = Effect.fn("PreviewManager.finalizeControlAction")(function* (933 const finalize = Effect.fn("PreviewManager.finalizeControlAction")(function* (
905 exit: Exit.Exit<A, PreviewManagerError>,934 exit: Exit.Exit<A, PreviewManagerError>,
...@@ -1091,22 +1120,62 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1091,22 +1120,62 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1091 wc: Electron.WebContents,1120 wc: Electron.WebContents,
1092 ) {1121 ) {
1093 const scope = yield* Scope.fork(parentScope, "sequential");1122 const scope = yield* Scope.fork(parentScope, "sequential");
1094 const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* () {1123 const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* (
1124 preserveLoadFailure: boolean,
1125 ) {
1095 if (wc.isDestroyed()) return;1126 if (wc.isDestroyed()) return;
1096 yield* update(tabId, {1127 const zoomFactor = yield* attempt(
1097 navStatus: computeNavStatus(wc),1128 { operation: "syncWebContentsState.getZoomFactor", tabId, webContentsId: wc.id },
1098 canGoBack: wc.navigationHistory.canGoBack(),1129 () => wc.getZoomFactor(),
1099 canGoForward: wc.navigationHistory.canGoForward(),1130 ).pipe(Effect.option);
1131 const computedNavStatus = computeNavStatus(wc);
1132 const canGoBack = wc.navigationHistory.canGoBack();
1133 const canGoForward = wc.navigationHistory.canGoForward();
1134 const updatedAt = yield* currentIso;
1135 const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1136 const current = tabs.get(tabId);
1137 if (!current) return [Option.none<PreviewTabState>(), tabs] as const;
1138 // Electron emits did-stop-loading after did-fail-load. At that point the
1139 // failed guest is no longer "loading", but it has not successfully
1140 // navigated anywhere. Keep the failure until a new load actually starts.
1141 const navStatus =
1142 preserveLoadFailure &&
1143 current.navStatus.kind === "LoadFailed" &&
1144 computedNavStatus.kind === "Success"
1145 ? current.navStatus
1146 : computedNavStatus;
1147 const state: PreviewTabState = {
1148 ...current,
1149 navStatus,
1150 canGoBack,
1151 canGoForward,
1152 ...(Option.isSome(zoomFactor) ? { zoomFactor: zoomFactor.value } : {}),
1153 updatedAt,
1154 };
1155 return [
1156 Option.some(state),
1157 replaceMap(tabs, (copy) => {
1158 copy.set(tabId, state);
1159 }),
1160 ] as const;
1100 });1161 });
1162 if (Option.isSome(next)) yield* emit(tabId, next.value);
1101 });1163 });
1102 const sync = () => runFork(syncState());1164 const sync = () => runFork(syncState(true));
1103 const failed = (_event: Event, code: number, description: string): void => {1165 const syncNavigation = () => runFork(syncState(false));
1104 if (code === -3) return;1166 const failed = (
1167 _event: Event,
1168 code: number,
1169 description: string,
1170 validatedUrl: string,
1171 isMainFrame: boolean,
1172 ): void => {
1173 if (code === -3 || !isMainFrame) return;
1105 runFork(1174 runFork(
1106 update(tabId, {1175 update(tabId, {
1107 navStatus: {1176 navStatus: {
1108 kind: "LoadFailed",1177 kind: "LoadFailed",
1109 url: wc.getURL(),1178 url: validatedUrl || wc.getURL(),
1110 title: wc.getTitle(),1179 title: wc.getTitle(),
1111 code,1180 code,
1112 description,1181 description,
...@@ -1161,8 +1230,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1161,8 +1230,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1161 yield* Scope.addFinalizer(1230 yield* Scope.addFinalizer(
1162 scope,1231 scope,
1163 attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => {1232 attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => {
1164 wc.off("did-navigate", sync);1233 wc.off("did-navigate", syncNavigation);
1165 wc.off("did-navigate-in-page", sync);1234 wc.off("did-navigate-in-page", syncNavigation);
1166 wc.off("page-title-updated", sync);1235 wc.off("page-title-updated", sync);
1167 wc.off("did-start-loading", sync);1236 wc.off("did-start-loading", sync);
1168 wc.off("did-stop-loading", sync);1237 wc.off("did-stop-loading", sync);
...@@ -1173,8 +1242,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1173,8 +1242,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1173 );1242 );
1174 const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {1243 const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
1175 yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => {1244 yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => {
1176 wc.on("did-navigate", sync);1245 wc.on("did-navigate", syncNavigation);
1177 wc.on("did-navigate-in-page", sync);1246 wc.on("did-navigate-in-page", syncNavigation);
1178 wc.on("page-title-updated", sync);1247 wc.on("page-title-updated", sync);
1179 wc.on("did-start-loading", sync);1248 wc.on("did-start-loading", sync);
1180 wc.on("did-stop-loading", sync);1249 wc.on("did-stop-loading", sync);
...@@ -1280,21 +1349,40 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1280,21 +1349,40 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1280 const attached = yield* Ref.get(attachedRef);1349 const attached = yield* Ref.get(attachedRef);
1281 const annotationTheme = yield* Ref.get(annotationThemeRef);1350 const annotationTheme = yield* Ref.get(annotationThemeRef);
1282 if (tab.webContentsId === webContentsId && attached.has(webContentsId)) {1351 if (tab.webContentsId === webContentsId && attached.has(webContentsId)) {
1352 const zoomFactor = yield* attempt(
1353 { operation: "registerWebview.getZoomFactor", tabId, webContentsId },
1354 () => wc.getZoomFactor(),
1355 );
1356 yield* update(tabId, { zoomFactor });
1283 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>1357 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>
1284 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),1358 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
1285 );1359 );
1286 return;1360 return;
1287 }1361 }
1288 if (tab.webContentsId != null && tab.webContentsId !== webContentsId) {1362 const replacedWebContentsId =
1363 tab.webContentsId != null && tab.webContentsId !== webContentsId ? tab.webContentsId : null;
1364 if (replacedWebContentsId !== null) {
1289 yield* Effect.all(1365 yield* Effect.all(
1290 [1366 [
1291 detachControlSession(tab.webContentsId),1367 detachControlSession(replacedWebContentsId),
1292 detachListeners(tab.webContentsId),1368 detachListeners(replacedWebContentsId),
1293 cancelPickElement(tabId),1369 cancelPickElement(tabId),
1294 ],1370 ],
1295 { concurrency: 3, discard: true },1371 { concurrency: 3, discard: true },
1296 );1372 );
1297 }1373 }
1374 const zoomFactor =
1375 replacedWebContentsId !== null
1376 ? yield* attempt(
1377 { operation: "registerWebview.restoreZoomFactor", tabId, webContentsId },
1378 () => {
1379 wc.setZoomFactor(tab.zoomFactor);
1380 return tab.zoomFactor;
1381 },
1382 )
1383 : yield* attempt({ operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () =>
1384 wc.getZoomFactor(),
1385 );
1298 yield* attachListeners(tabId, wc);1386 yield* attachListeners(tabId, wc);
1299 runFork(ensureControlSession(wc).pipe(Effect.ignore));1387 runFork(ensureControlSession(wc).pipe(Effect.ignore));
1300 const registeredAt = yield* currentIso;1388 const registeredAt = yield* currentIso;
...@@ -1313,6 +1401,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1313,6 +1401,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1313 navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,1401 navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,
1314 canGoBack: wc.navigationHistory.canGoBack(),1402 canGoBack: wc.navigationHistory.canGoBack(),
1315 canGoForward: wc.navigationHistory.canGoForward(),1403 canGoForward: wc.navigationHistory.canGoForward(),
1404 zoomFactor,
1316 updatedAt: registeredAt,1405 updatedAt: registeredAt,
1317 };1406 };
1318 return [1407 return [
...@@ -1330,11 +1419,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1330,11 +1419,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1330 }1419 }
1331 const { state: registered, pendingUrl } = registration.value;1420 const { state: registered, pendingUrl } = registration.value;
1332 yield* emit(tabId, registered);1421 yield* emit(tabId, registered);
1333 if (Math.abs(registered.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) {
1334 yield* attempt({ operation: "registerWebview.restoreZoom", tabId, webContentsId }, () =>
1335 wc.setZoomFactor(registered.zoomFactor),
1336 ).pipe(Effect.ignore);
1337 }
1338 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>1422 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>
1339 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),1423 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
1340 );1424 );
...@@ -1946,10 +2030,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1946,10 +2030,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1946 input: PreviewAutomationClickInput,2030 input: PreviewAutomationClickInput,
1947 send: SendCommand,2031 send: SendCommand,
1948 ) {2032 ) {
1949 yield* Effect.all(2033 yield* prepareAutomationInput(send, true);
1950 [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })],
1951 { concurrency: 2, discard: true },
1952 );
1953 const point = yield* resolveClickPoint(tabId, send, input);2034 const point = yield* resolveClickPoint(tabId, send, input);
1954 const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>(2035 const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>(
1955 tabId,2036 tabId,
...@@ -2011,7 +2092,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2011,7 +2092,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2011 );2092 );
2012 });2093 });
20132094
2014 const focusAutomationTarget = Effect.fn("PreviewManager.focusAutomationTarget")(function* (2095 const typeIntoAutomationTarget = Effect.fn("PreviewManager.typeIntoAutomationTarget")(function* (
2015 tabId: string,2096 tabId: string,
2016 send: SendCommand,2097 send: SendCommand,
2017 input: PreviewAutomationTypeInput,2098 input: PreviewAutomationTypeInput,
...@@ -2021,8 +2102,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2021,8 +2102,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2021 const locatorJson = locator2102 const locatorJson = locator
2022 ? yield* encodeJson({ operation: "automationType.encodeLocator", tabId }, locator)2103 ? yield* encodeJson({ operation: "automationType.encodeLocator", tabId }, locator)
2023 : null;2104 : null;
2105 const textJson = yield* encodeJson(
2106 { operation: "automationType.encodeText", tabId },
2107 input.text,
2108 );
2024 const result = yield* evaluateWithDebugger<2109 const result = yield* evaluateWithDebugger<
2025 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }2110 | { ok: true }
2111 | { invalidSelector: true; message: string }
2112 | { notEditable: true }
2113 | { notFound: true }
2026 >(2114 >(
2027 tabId,2115 tabId,
2028 send,2116 send,
...@@ -2030,12 +2118,54 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2030,12 +2118,54 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2030 try {2118 try {
2031 const element = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "document.activeElement"};2119 const element = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "document.activeElement"};
2032 if (!element) return { notFound: true };2120 if (!element) return { notFound: true };
2121 const textControl =
2122 element instanceof HTMLTextAreaElement ||
2123 (element instanceof HTMLInputElement &&
2124 !new Set(["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"]).has(element.type));
2125 const editable = textControl || element.isContentEditable;
2126 if (!editable || element.disabled || element.readOnly) return { notEditable: true };
2033 element.focus();2127 element.focus();
2034 if (${input.clear ?? false}) {2128 if (document.activeElement !== element) return { notEditable: true };
2035 if ("value" in element) element.value = "";2129 const clear = ${input.clear ?? false};
2036 else if (element.isContentEditable) element.textContent = "";2130 if (clear) {
2037 element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" }));2131 if (textControl) {
2132 element.select();
2133 } else {
2134 const range = document.createRange();
2135 range.selectNodeContents(element);
2136 const selection = document.getSelection();
2137 selection?.removeAllRanges();
2138 selection?.addRange(range);
2139 }
2140 }
2141 const text = ${textJson};
2142 let inserted = true;
2143 if (text.length > 0) {
2144 inserted = document.execCommand("insertText", false, text);
2145 } else if (clear) {
2146 document.execCommand("delete", false);
2147 const cleared = textControl
2148 ? element.value.length === 0
2149 : (element.textContent ?? "").length === 0;
2150 if (!cleared) {
2151 if (textControl) {
2152 const prototype = element instanceof HTMLTextAreaElement
2153 ? HTMLTextAreaElement.prototype
2154 : HTMLInputElement.prototype;
2155 const valueSetter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
2156 if (valueSetter) valueSetter.call(element, "");
2157 else element.value = "";
2158 } else {
2159 element.replaceChildren();
2160 }
2161 element.dispatchEvent(new InputEvent("input", {
2162 bubbles: true,
2163 inputType: "deleteContentBackward",
2164 }));
2165 }
2038 }2166 }
2167 if (!inserted) return { notEditable: true };
2168 element.dispatchEvent(new Event("change", { bubbles: true }));
2039 return { ok: true };2169 return { ok: true };
2040 } catch (error) {2170 } catch (error) {
2041 return { invalidSelector: true, message: String(error) };2171 return { invalidSelector: true, message: String(error) };
...@@ -2059,6 +2189,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2059,6 +2189,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2059 ...automationSelectorDiagnostics(input),2189 ...automationSelectorDiagnostics(input),
2060 });2190 });
2061 }2191 }
2192 if ("notEditable" in result) {
2193 return yield* new PreviewAutomationTargetNotEditableError({
2194 tabId,
2195 ...automationSelectorDiagnostics(input),
2196 });
2197 }
2062 });2198 });
20632199
2064 const performAutomationType = Effect.fn("PreviewManager.performAutomationType")(function* (2200 const performAutomationType = Effect.fn("PreviewManager.performAutomationType")(function* (
...@@ -2066,23 +2202,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2066,23 +2202,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2066 input: PreviewAutomationTypeInput,2202 input: PreviewAutomationTypeInput,
2067 send: SendCommand,2203 send: SendCommand,
2068 ) {2204 ) {
2069 yield* send("Runtime.enable");2205 // CDP Input.insertText silently drops text until Electron has activated a hidden
2070 yield* focusAutomationTarget(tabId, send, input);2206 // guest WebContents with a pointer event. Editing in the page runtime keeps
2071 yield* send("Input.insertText", { text: input.text });2207 // background automation deterministic without stealing foreground app focus.
2072 const textJson = yield* encodeJson(2208 yield* typeIntoAutomationTarget(tabId, send, input);
2073 { operation: "automationType.encodeText", tabId },
2074 input.text,
2075 );
2076 yield* evaluateWithDebugger(
2077 tabId,
2078 send,
2079 `(() => {
2080 const element = document.activeElement;
2081 element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${textJson} }));
2082 element?.dispatchEvent(new Event("change", { bubbles: true }));
2083 })()`,
2084 false,
2085 );
2086 });2209 });
20872210
2088 const automationType = Effect.fn("PreviewManager.automationType")(function* (2211 const automationType = Effect.fn("PreviewManager.automationType")(function* (
...@@ -2097,32 +2220,52 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2097,32 +2220,52 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
20972220
2098 const performAutomationPress = Effect.fn("PreviewManager.performAutomationPress")(function* (2221 const performAutomationPress = Effect.fn("PreviewManager.performAutomationPress")(function* (
2099 tabId: string,2222 tabId: string,
2223 wc: Electron.WebContents,
2100 input: PreviewAutomationPressInput,2224 input: PreviewAutomationPressInput,
2101 send: SendCommand,2225 send: SendCommand,
2226 sendCleanup: SendCommand,
2102 ) {2227 ) {
2103 const modifiers = (input.modifiers ?? []).reduce((value, modifier) => {2228 yield* prepareAutomationInput(send, false);
2104 switch (modifier) {2229 const keySequence = makePreviewAutomationKeySequence(input);
2105 case "Alt":2230 const previouslyFocused = yield* attempt(
2106 return value | 1;2231 { operation: "automationPress.getFocusedWebContents", tabId, webContentsId: wc.id },
2107 case "Control":2232 () => webContents.getFocusedWebContents(),
2108 return value | 2;2233 );
2109 case "Meta":2234 let keyDownAttempted = false;
2110 return value | 4;2235 const releaseInput = Effect.gen(function* () {
2111 case "Shift":2236 if (keyDownAttempted) {
2112 return value | 8;2237 yield* sendCleanup("Input.dispatchKeyEvent", keySequence.keyUp).pipe(Effect.ignore);
2113 }2238 }
2114 }, 0);2239 yield* sendCleanup("Emulation.setFocusEmulationEnabled", { enabled: false }).pipe(
2115 const key = input.key;2240 Effect.ignore,
2116 const text = key.length === 1 ? key : undefined;2241 );
2117 const params = {2242 if (previouslyFocused && previouslyFocused.id !== wc.id && !previouslyFocused.isDestroyed()) {
2118 key,2243 yield* attempt(
2119 code: key.length === 1 ? `Key${key.toUpperCase()}` : key,2244 {
2120 modifiers,2245 operation: "automationPress.restoreFocusedWebContents",
2121 ...(text ? { text, unmodifiedText: text } : {}),2246 tabId,
2122 };2247 webContentsId: previouslyFocused.id,
2123 yield* expectAgentInput(tabId, { kind: "key", key, code: params.code });2248 },
2124 yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params });2249 () => previouslyFocused.focus(),
2125 yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params });2250 ).pipe(Effect.ignore);
2251 }
2252 });
2253
2254 // Focus the guest WebContents itself, not its containing BrowserWindow. This
2255 // activates native keyboard behavior for hidden/background previews without
2256 // changing which thread is mounted in the UI. Restore the previous renderer
2257 // after dispatch so automation never leaves the app's input focus behind.
2258 yield* Effect.gen(function* () {
2259 yield* attempt(
2260 { operation: "automationPress.focusWebContents", tabId, webContentsId: wc.id },
2261 () => wc.focus(),
2262 );
2263 yield* send("Page.bringToFront");
2264 yield* send("Emulation.setFocusEmulationEnabled", { enabled: true });
2265 yield* expectAgentInput(tabId, keySequence.signal);
2266 keyDownAttempted = true;
2267 yield* send("Input.dispatchKeyEvent", keySequence.keyDown);
2268 }).pipe(Effect.ensuring(releaseInput));
2126 });2269 });
21272270
2128 const automationPress = Effect.fn("PreviewManager.automationPress")(function* (2271 const automationPress = Effect.fn("PreviewManager.automationPress")(function* (
...@@ -2130,8 +2273,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2130,8 +2273,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2130 input: PreviewAutomationPressInput,2273 input: PreviewAutomationPressInput,
2131 ) {2274 ) {
2132 const wc = yield* requireWebContents(tabId);2275 const wc = yield* requireWebContents(tabId);
2133 yield* withControlSession(tabId, wc, "press", (send) =>2276 yield* withControlSession(tabId, wc, "press", (send, sendCleanup) =>
2134 performAutomationPress(tabId, input, send),2277 performAutomationPress(tabId, wc, input, send, sendCleanup),
2135 );2278 );
2136 });2279 });
21372280
...@@ -2529,6 +2672,20 @@ export class PreviewAutomationTargetNotFoundError extends Schema.TaggedErrorClas...@@ -2529,6 +2672,20 @@ export class PreviewAutomationTargetNotFoundError extends Schema.TaggedErrorClas
2529 }2672 }
2530}2673}
25312674
2675export class PreviewAutomationTargetNotEditableError extends Schema.TaggedErrorClass<PreviewAutomationTargetNotEditableError>()(
2676 "PreviewAutomationTargetNotEditableError",
2677 {
2678 tabId: Schema.String,
2679 selectorKind: PreviewAutomationSelectorKind,
2680 selectorLength: Schema.optionalKey(Schema.Number),
2681 },
2682) {
2683 override get message(): string {
2684 const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength);
2685 return `Preview automation type found ${target}, but it is not editable in tab ${this.tabId}`;
2686 }
2687}
2688
2532export class PreviewAutomationCoordinatesOutsideViewportError extends Schema.TaggedErrorClass<PreviewAutomationCoordinatesOutsideViewportError>()(2689export class PreviewAutomationCoordinatesOutsideViewportError extends Schema.TaggedErrorClass<PreviewAutomationCoordinatesOutsideViewportError>()(
2533 "PreviewAutomationCoordinatesOutsideViewportError",2690 "PreviewAutomationCoordinatesOutsideViewportError",
2534 {2691 {
...@@ -2631,6 +2788,7 @@ export const PreviewManagerError = Schema.Union([...@@ -2631,6 +2788,7 @@ export const PreviewManagerError = Schema.Union([
2631 PreviewAutomationDebuggerAttachedError,2788 PreviewAutomationDebuggerAttachedError,
2632 PreviewAutomationEvaluationError,2789 PreviewAutomationEvaluationError,
2633 PreviewAutomationTargetNotFoundError,2790 PreviewAutomationTargetNotFoundError,
2791 PreviewAutomationTargetNotEditableError,
2634 PreviewAutomationCoordinatesOutsideViewportError,2792 PreviewAutomationCoordinatesOutsideViewportError,
2635 PreviewAutomationInvalidSelectorError,2793 PreviewAutomationInvalidSelectorError,
2636 PreviewAutomationResultTooLargeError,2794 PreviewAutomationResultTooLargeError,
apps/desktop/src/preview/PreviewKeyboard.test.ts created+84
...@@ -0,0 +1,84 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts";
4
5describe("preview keyboard packets", () => {
6 it("includes the Chromium virtual key code and Enter text", () => {
7 expect(makePreviewAutomationKeySequence({ key: "Enter" })).toEqual({
8 keyDown: {
9 type: "keyDown",
10 key: "Enter",
11 code: "Enter",
12 modifiers: 0,
13 windowsVirtualKeyCode: 13,
14 location: 0,
15 isKeypad: false,
16 text: "\r",
17 unmodifiedText: "\r",
18 },
19 keyUp: {
20 type: "keyUp",
21 key: "Enter",
22 code: "Enter",
23 modifiers: 0,
24 windowsVirtualKeyCode: 13,
25 location: 0,
26 isKeypad: false,
27 },
28 signal: { kind: "key", key: "Enter", code: "Enter" },
29 });
30 });
31
32 it("dispatches printable keys as text key-down events", () => {
33 const sequence = makePreviewAutomationKeySequence({ key: "z" });
34 expect(sequence.keyDown).toMatchObject({
35 type: "keyDown",
36 key: "z",
37 code: "KeyZ",
38 windowsVirtualKeyCode: 90,
39 text: "z",
40 });
41 expect(sequence.keyUp).not.toHaveProperty("text");
42 });
43
44 it("suppresses text and uses raw key-down for shortcuts", () => {
45 expect(makePreviewAutomationKeySequence({ key: "a", modifiers: ["Meta"] }).keyDown).toEqual({
46 type: "rawKeyDown",
47 key: "a",
48 code: "KeyA",
49 modifiers: 4,
50 windowsVirtualKeyCode: 65,
51 location: 0,
52 isKeypad: false,
53 });
54 });
55
56 it("resolves shifted printable keys to their browser values", () => {
57 const sequence = makePreviewAutomationKeySequence({ key: "1", modifiers: ["Shift"] });
58 expect(sequence.keyDown).toMatchObject({
59 key: "!",
60 code: "Digit1",
61 modifiers: 8,
62 windowsVirtualKeyCode: 49,
63 text: "!",
64 });
65 expect(sequence.signal).toEqual({ kind: "key", key: "!", code: "Digit1" });
66 });
67
68 it("keeps shifted key values while suppressing text for modified chords", () => {
69 const sequence = makePreviewAutomationKeySequence({
70 key: "1",
71 modifiers: ["Control", "Shift"],
72 });
73 expect(sequence.keyDown).toEqual({
74 type: "rawKeyDown",
75 key: "!",
76 code: "Digit1",
77 modifiers: 10,
78 windowsVirtualKeyCode: 49,
79 location: 0,
80 isKeypad: false,
81 });
82 expect(sequence.signal).toEqual({ kind: "key", key: "!", code: "Digit1" });
83 });
84});
apps/desktop/src/preview/PreviewKeyboard.ts created+163
...@@ -0,0 +1,163 @@
1import type { PreviewAutomationPressInput } from "@t3tools/contracts";
2
3interface KeyDefinition {
4 readonly code: string;
5 readonly key: string;
6 readonly keyCode: number;
7 readonly text?: string;
8 readonly location?: number;
9 readonly shiftedKey?: string;
10}
11
12export interface PreviewAutomationKeyEvent {
13 readonly [key: string]: unknown;
14 readonly type: "keyDown" | "rawKeyDown" | "keyUp";
15 readonly key: string;
16 readonly code: string;
17 readonly modifiers: number;
18 readonly windowsVirtualKeyCode: number;
19 readonly location: number;
20 readonly isKeypad: boolean;
21 readonly text?: string;
22 readonly unmodifiedText?: string;
23}
24
25export interface PreviewAutomationKeySequence {
26 readonly keyDown: PreviewAutomationKeyEvent;
27 readonly keyUp: PreviewAutomationKeyEvent;
28 readonly signal: {
29 readonly kind: "key";
30 readonly key: string;
31 readonly code: string;
32 };
33}
34
35const NAMED_KEYS: Readonly<Record<string, KeyDefinition>> = {
36 Escape: { code: "Escape", key: "Escape", keyCode: 27 },
37 Backspace: { code: "Backspace", key: "Backspace", keyCode: 8 },
38 Tab: { code: "Tab", key: "Tab", keyCode: 9 },
39 Enter: { code: "Enter", key: "Enter", keyCode: 13, text: "\r" },
40 Shift: { code: "ShiftLeft", key: "Shift", keyCode: 16, location: 1 },
41 Control: { code: "ControlLeft", key: "Control", keyCode: 17, location: 1 },
42 Alt: { code: "AltLeft", key: "Alt", keyCode: 18, location: 1 },
43 Meta: { code: "MetaLeft", key: "Meta", keyCode: 91, location: 1 },
44 CapsLock: { code: "CapsLock", key: "CapsLock", keyCode: 20 },
45 Space: { code: "Space", key: " ", keyCode: 32, text: " " },
46 PageUp: { code: "PageUp", key: "PageUp", keyCode: 33 },
47 PageDown: { code: "PageDown", key: "PageDown", keyCode: 34 },
48 End: { code: "End", key: "End", keyCode: 35 },
49 Home: { code: "Home", key: "Home", keyCode: 36 },
50 ArrowLeft: { code: "ArrowLeft", key: "ArrowLeft", keyCode: 37 },
51 ArrowUp: { code: "ArrowUp", key: "ArrowUp", keyCode: 38 },
52 ArrowRight: { code: "ArrowRight", key: "ArrowRight", keyCode: 39 },
53 ArrowDown: { code: "ArrowDown", key: "ArrowDown", keyCode: 40 },
54 Insert: { code: "Insert", key: "Insert", keyCode: 45 },
55 Delete: { code: "Delete", key: "Delete", keyCode: 46 },
56};
57
58const PRINTABLE_KEYS: ReadonlyArray<KeyDefinition> = [
59 { code: "Backquote", key: "`", shiftedKey: "~", keyCode: 192 },
60 { code: "Digit1", key: "1", shiftedKey: "!", keyCode: 49 },
61 { code: "Digit2", key: "2", shiftedKey: "@", keyCode: 50 },
62 { code: "Digit3", key: "3", shiftedKey: "#", keyCode: 51 },
63 { code: "Digit4", key: "4", shiftedKey: "$", keyCode: 52 },
64 { code: "Digit5", key: "5", shiftedKey: "%", keyCode: 53 },
65 { code: "Digit6", key: "6", shiftedKey: "^", keyCode: 54 },
66 { code: "Digit7", key: "7", shiftedKey: "&", keyCode: 55 },
67 { code: "Digit8", key: "8", shiftedKey: "*", keyCode: 56 },
68 { code: "Digit9", key: "9", shiftedKey: "(", keyCode: 57 },
69 { code: "Digit0", key: "0", shiftedKey: ")", keyCode: 48 },
70 { code: "Minus", key: "-", shiftedKey: "_", keyCode: 189 },
71 { code: "Equal", key: "=", shiftedKey: "+", keyCode: 187 },
72 { code: "Backslash", key: "\\", shiftedKey: "|", keyCode: 220 },
73 { code: "BracketLeft", key: "[", shiftedKey: "{", keyCode: 219 },
74 { code: "BracketRight", key: "]", shiftedKey: "}", keyCode: 221 },
75 { code: "Semicolon", key: ";", shiftedKey: ":", keyCode: 186 },
76 { code: "Quote", key: "'", shiftedKey: '"', keyCode: 222 },
77 { code: "Comma", key: ",", shiftedKey: "<", keyCode: 188 },
78 { code: "Period", key: ".", shiftedKey: ">", keyCode: 190 },
79 { code: "Slash", key: "/", shiftedKey: "?", keyCode: 191 },
80];
81
82const modifierMask = (modifiers: PreviewAutomationPressInput["modifiers"]): number =>
83 (modifiers ?? []).reduce((value, modifier) => {
84 switch (modifier) {
85 case "Alt":
86 return value | 1;
87 case "Control":
88 return value | 2;
89 case "Meta":
90 return value | 4;
91 case "Shift":
92 return value | 8;
93 }
94 }, 0);
95
96function resolveKeyDefinition(input: PreviewAutomationPressInput): KeyDefinition {
97 const named = NAMED_KEYS[input.key];
98 if (named) return named;
99
100 const functionKey = /^F([1-9]|1[0-2])$/.exec(input.key);
101 if (functionKey) {
102 const number = Number(functionKey[1]);
103 return { code: input.key, key: input.key, keyCode: 111 + number };
104 }
105
106 if (/^[a-z]$/i.test(input.key)) {
107 const upper = input.key.toUpperCase();
108 const shifted = input.modifiers?.includes("Shift") ?? false;
109 const key = shifted || input.key === upper ? upper : input.key;
110 return { code: `Key${upper}`, key, keyCode: upper.charCodeAt(0), text: key };
111 }
112
113 const printable = PRINTABLE_KEYS.find(
114 (definition) => definition.key === input.key || definition.shiftedKey === input.key,
115 );
116 if (printable) {
117 const shifted = input.modifiers?.includes("Shift") ?? false;
118 const key =
119 printable.shiftedKey && (shifted || input.key === printable.shiftedKey)
120 ? printable.shiftedKey
121 : printable.key;
122 return { ...printable, key, text: key };
123 }
124
125 return {
126 code: input.key.length > 1 ? input.key : "",
127 key: input.key,
128 keyCode: 0,
129 ...(input.key.length === 1 ? { text: input.key } : {}),
130 };
131}
132
133/**
134 * Build Chromium CDP key packets using the same required fields and down-event
135 * choice as Playwright's pinned Chromium keyboard implementation.
136 */
137export function makePreviewAutomationKeySequence(
138 input: PreviewAutomationPressInput,
139): PreviewAutomationKeySequence {
140 const definition = resolveKeyDefinition(input);
141 const modifiers = modifierMask(input.modifiers);
142 const suppressText = input.modifiers?.some((modifier) => modifier !== "Shift") ?? false;
143 const text = suppressText ? "" : (definition.text ?? "");
144 const location = definition.location ?? 0;
145 const shared = {
146 key: definition.key,
147 code: definition.code,
148 modifiers,
149 windowsVirtualKeyCode: definition.keyCode,
150 location,
151 isKeypad: location === 3,
152 };
153
154 return {
155 keyDown: {
156 type: text ? "keyDown" : "rawKeyDown",
157 ...shared,
158 ...(text ? { text, unmodifiedText: text } : {}),
159 },
160 keyUp: { type: "keyUp", ...shared },
161 signal: { kind: "key", key: definition.key, code: definition.code },
162 };
163}
apps/desktop/src/window/DesktopWindow.test.ts+71
...@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";...@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6import * as Ref from "effect/Ref";6import * as Ref from "effect/Ref";
7import * as TestClock from "effect/testing/TestClock";
78
8import type * as Electron from "electron";9import type * as Electron from "electron";
9import { vi } from "vite-plus/test";10import { vi } from "vite-plus/test";
...@@ -47,12 +48,14 @@ function makeFakeBrowserWindow() {...@@ -47,12 +48,14 @@ function makeFakeBrowserWindow() {
47 const webContentsListeners = new Map<string, (...args: readonly unknown[]) => void>();48 const webContentsListeners = new Map<string, (...args: readonly unknown[]) => void>();
48 const webContents = {49 const webContents = {
49 copyImageAt: vi.fn(),50 copyImageAt: vi.fn(),
51 getURL: vi.fn(() => "t3code-dev://app/"),
50 isLoadingMainFrame: vi.fn(() => false),52 isLoadingMainFrame: vi.fn(() => false),
51 on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => {53 on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => {
52 webContentsListeners.set(eventName, listener);54 webContentsListeners.set(eventName, listener);
53 }),55 }),
54 once: vi.fn(),56 once: vi.fn(),
55 openDevTools: vi.fn(),57 openDevTools: vi.fn(),
58 reload: vi.fn(),
56 replaceMisspelling: vi.fn(),59 replaceMisspelling: vi.fn(),
57 send: vi.fn(),60 send: vi.fn(),
58 setWindowOpenHandler: vi.fn(),61 setWindowOpenHandler: vi.fn(),
...@@ -79,6 +82,7 @@ function makeFakeBrowserWindow() {...@@ -79,6 +82,7 @@ function makeFakeBrowserWindow() {
79 window: window as unknown as Electron.BrowserWindow,82 window: window as unknown as Electron.BrowserWindow,
80 loadURL: window.loadURL,83 loadURL: window.loadURL,
81 openDevTools: webContents.openDevTools,84 openDevTools: webContents.openDevTools,
85 reload: webContents.reload,
82 setAutoHideCursor: window.setAutoHideCursor,86 setAutoHideCursor: window.setAutoHideCursor,
83 webContentsListeners,87 webContentsListeners,
84 };88 };
...@@ -237,6 +241,73 @@ describe("DesktopWindow", () => {...@@ -237,6 +241,73 @@ describe("DesktopWindow", () => {
237 }),241 }),
238 );242 );
239243
244 it.effect("recovers when the development renderer is temporarily unreachable", () =>
245 Effect.gen(function* () {
246 const fakeWindow = makeFakeBrowserWindow();
247 const createCount = yield* Ref.make(0);
248 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
249 const layer = makeTestLayer({
250 window: fakeWindow.window,
251 createCount,
252 mainWindow,
253 });
254
255 yield* Effect.gen(function* () {
256 const desktopWindow = yield* DesktopWindow.DesktopWindow;
257 yield* desktopWindow.handleBackendReady;
258
259 const didFailLoad = fakeWindow.webContentsListeners.get("did-fail-load");
260 const didFinishLoad = fakeWindow.webContentsListeners.get("did-finish-load");
261 if (!didFailLoad || !didFinishLoad) {
262 return yield* Effect.die("renderer load listeners were not registered");
263 }
264
265 didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true);
266 assert.equal(fakeWindow.loadURL.mock.calls.length, 1);
267
268 yield* TestClock.adjust(100);
269 assert.deepEqual(fakeWindow.loadURL.mock.calls, [
270 ["t3code-dev://app/"],
271 ["t3code-dev://app/"],
272 ]);
273 assert.equal(fakeWindow.reload.mock.calls.length, 0);
274
275 didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true);
276 didFinishLoad();
277 yield* TestClock.adjust(250);
278 assert.equal(fakeWindow.loadURL.mock.calls.length, 2);
279 assert.equal(fakeWindow.reload.mock.calls.length, 0);
280 }).pipe(Effect.provide(layer));
281 }),
282 );
283
284 it("retries only transient failures for the development renderer", () => {
285 assert.isTrue(
286 DesktopWindow.isRetryableDevelopmentRendererLoadFailure({
287 applicationUrl: "t3code-dev://app/",
288 errorCode: -102,
289 isMainFrame: true,
290 validatedUrl: "t3code-dev://app/",
291 }),
292 );
293 assert.isFalse(
294 DesktopWindow.isRetryableDevelopmentRendererLoadFailure({
295 applicationUrl: "t3code-dev://app/",
296 errorCode: -3,
297 isMainFrame: true,
298 validatedUrl: "t3code-dev://app/",
299 }),
300 );
301 assert.isFalse(
302 DesktopWindow.isRetryableDevelopmentRendererLoadFailure({
303 applicationUrl: "t3code-dev://app/",
304 errorCode: -102,
305 isMainFrame: true,
306 validatedUrl: "https://example.com/",
307 }),
308 );
309 });
310
240 it.effect("opens safe off-origin renderer navigations in the system browser", () =>311 it.effect("opens safe off-origin renderer navigations in the system browser", () =>
241 Effect.gen(function* () {312 Effect.gen(function* () {
242 const fakeWindow = makeFakeBrowserWindow();313 const fakeWindow = makeFakeBrowserWindow();
apps/desktop/src/window/DesktopWindow.ts+95-3
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import * as Context from "effect/Context";1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Fiber from "effect/Fiber";
3import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";5import * as Option from "effect/Option";
5import * as Ref from "effect/Ref";6import * as Ref from "effect/Ref";
...@@ -22,6 +23,16 @@ const TITLEBAR_HEIGHT = 40;...@@ -22,6 +23,16 @@ const TITLEBAR_HEIGHT = 40;
22const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux23const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
23const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";24const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";
24const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc";25const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc";
26const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const;
27const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([
28 -2, // ERR_FAILED
29 -7, // ERR_TIMED_OUT
30 -9, // ERR_UNEXPECTED (custom protocol handler rejected)
31 -102, // ERR_CONNECTION_REFUSED
32 -105, // ERR_NAME_NOT_RESOLVED
33 -106, // ERR_INTERNET_DISCONNECTED
34 -118, // ERR_CONNECTION_TIMED_OUT
35]);
2536
26type WindowTitleBarOptions = Pick<37type WindowTitleBarOptions = Pick<
27 Electron.BrowserWindowConstructorOptions,38 Electron.BrowserWindowConstructorOptions,
...@@ -86,6 +97,22 @@ export function isSameOriginRendererNavigation(input: {...@@ -86,6 +97,22 @@ export function isSameOriginRendererNavigation(input: {
86 }97 }
87}98}
8899
100export function isRetryableDevelopmentRendererLoadFailure(input: {
101 readonly applicationUrl: string;
102 readonly errorCode: number;
103 readonly isMainFrame: boolean;
104 readonly validatedUrl: string;
105}): boolean {
106 return (
107 input.isMainFrame &&
108 DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES.has(input.errorCode) &&
109 isSameOriginRendererNavigation({
110 applicationUrl: input.applicationUrl,
111 navigationUrl: input.validatedUrl,
112 })
113 );
114}
115
89function getWindowTitleBarOptions(116function getWindowTitleBarOptions(
90 shouldUseDarkColors: boolean,117 shouldUseDarkColors: boolean,
91 platform: NodeJS.Platform,118 platform: NodeJS.Platform,
...@@ -152,6 +179,7 @@ export const make = Effect.gen(function* () {...@@ -152,6 +179,7 @@ export const make = Effect.gen(function* () {
152 const previewManager = yield* PreviewManager.PreviewManager;179 const previewManager = yield* PreviewManager.PreviewManager;
153 const state = yield* DesktopState.DesktopState;180 const state = yield* DesktopState.DesktopState;
154 const context = yield* Effect.context<DesktopWindowRuntimeServices>();181 const context = yield* Effect.context<DesktopWindowRuntimeServices>();
182 const runFork = Effect.runForkWith(context);
155 const runPromise = Effect.runPromiseWith(context);183 const runPromise = Effect.runPromiseWith(context);
156184
157 const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return<185 const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return<
...@@ -277,7 +305,61 @@ export const make = Effect.gen(function* () {...@@ -277,7 +305,61 @@ export const make = Effect.gen(function* () {
277 event.preventDefault();305 event.preventDefault();
278 window.setTitle(environment.displayName);306 window.setTitle(environment.displayName);
279 });307 });
308
309 let developmentLoadRetryIndex = 0;
310 let developmentLoadRetryFiber: Fiber.Fiber<void, never> | undefined;
311 const clearDevelopmentLoadRetry = () => {
312 if (developmentLoadRetryFiber === undefined) {
313 return;
314 }
315 const retryFiber = developmentLoadRetryFiber;
316 developmentLoadRetryFiber = undefined;
317 runFork(Fiber.interrupt(retryFiber));
318 };
319 const loadApplication = () => {
320 if (window.isDestroyed()) {
321 return;
322 }
323 void window.loadURL(applicationUrl).catch(() => undefined);
324 };
325 const scheduleDevelopmentLoadRetry = () => {
326 if (developmentLoadRetryFiber !== undefined || window.isDestroyed()) {
327 return undefined;
328 }
329
330 const retryIndex = Math.min(
331 developmentLoadRetryIndex,
332 DEVELOPMENT_LOAD_RETRY_DELAYS_MS.length - 1,
333 );
334 const retryInMs = DEVELOPMENT_LOAD_RETRY_DELAYS_MS[retryIndex] ?? 2_000;
335 developmentLoadRetryIndex += 1;
336 developmentLoadRetryFiber = runFork(
337 Effect.sleep(retryInMs).pipe(
338 Effect.andThen(
339 Effect.sync(() => {
340 developmentLoadRetryFiber = undefined;
341 if (!window.isDestroyed()) {
342 loadApplication();
343 }
344 }),
345 ),
346 ),
347 );
348 return retryInMs;
349 };
350
280 window.webContents.on("did-finish-load", () => {351 window.webContents.on("did-finish-load", () => {
352 if (
353 environment.isDevelopment &&
354 !isSameOriginRendererNavigation({
355 applicationUrl,
356 navigationUrl: window.webContents.getURL(),
357 })
358 ) {
359 return;
360 }
361 clearDevelopmentLoadRetry();
362 developmentLoadRetryIndex = 0;
281 window.setTitle(environment.displayName);363 window.setTitle(environment.displayName);
282 });364 });
283 window.webContents.on(365 window.webContents.on(
...@@ -286,11 +368,22 @@ export const make = Effect.gen(function* () {...@@ -286,11 +368,22 @@ export const make = Effect.gen(function* () {
286 if (!isMainFrame) {368 if (!isMainFrame) {
287 return;369 return;
288 }370 }
371 const retryInMs =
372 environment.isDevelopment &&
373 isRetryableDevelopmentRendererLoadFailure({
374 applicationUrl,
375 errorCode,
376 isMainFrame,
377 validatedUrl: validatedURL,
378 })
379 ? scheduleDevelopmentLoadRetry()
380 : undefined;
289 void runPromise(381 void runPromise(
290 logWindowWarning("main window failed to load", {382 logWindowWarning("main window failed to load", {
291 errorCode,383 errorCode,
292 errorDescription,384 errorDescription,
293 url: validatedURL,385 url: validatedURL,
386 ...(retryInMs === undefined ? {} : { retryInMs }),
294 }),387 }),
295 );388 );
296 },389 },
...@@ -312,14 +405,13 @@ export const make = Effect.gen(function* () {...@@ -312,14 +405,13 @@ export const make = Effect.gen(function* () {
312 void runPromise(electronWindow.reveal(window));405 void runPromise(electronWindow.reveal(window));
313 });406 });
314407
408 loadApplication();
315 if (environment.isDevelopment) {409 if (environment.isDevelopment) {
316 void window.loadURL(applicationUrl);
317 window.webContents.openDevTools({ mode: "detach" });410 window.webContents.openDevTools({ mode: "detach" });
318 } else {
319 void window.loadURL(applicationUrl);
320 }411 }
321412
322 window.on("closed", () => {413 window.on("closed", () => {
414 clearDevelopmentLoadRetry();
323 void runPromise(electronWindow.clearMain(Option.some(window)));415 void runPromise(electronWindow.clearMain(Option.some(window)));
324 });416 });
325417
apps/server/src/mcp/McpHttpServer.test.ts+56-75
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { expect, it } from "@effect/vitest";1import { expect, it } from "@effect/vitest";
2import { NodeHttpServer } from "@effect/platform-node";2import { NodeHttpServer } from "@effect/platform-node";
3import * as NodeServices from "@effect/platform-node/NodeServices";
3import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";4import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
...@@ -34,7 +35,7 @@ const client = McpSchema.McpServerClient.of({...@@ -34,7 +35,7 @@ const client = McpSchema.McpServerClient.of({
34});35});
35const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe(36const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe(
36 Layer.provideMerge(McpServer.McpServer.layer),37 Layer.provideMerge(McpServer.McpServer.layer),
37 Layer.provideMerge(PreviewAutomationBroker.layer),38 Layer.provideMerge(PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer))),
38);39);
3940
40it("normalizes empty successful notification responses to accepted", () => {41it("normalizes empty successful notification responses to accepted", () => {
...@@ -54,36 +55,26 @@ it.effect("returns bounded structural preview snapshot failures", () =>...@@ -54,36 +55,26 @@ it.effect("returns bounded structural preview snapshot failures", () =>
54 Effect.gen(function* () {55 Effect.gen(function* () {
55 const server = yield* McpServer.McpServer;56 const server = yield* McpServer.McpServer;
56 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;57 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
57 const requests = yield* broker.connect({58 const events = yield* broker.connect({
58 clientId: "mcp-failure-client",59 clientId: "mcp-failure-client",
59 environmentId,60 environmentId,
60 threadId,
61 tabId,
62 visible: true,
63 supportsAutomation: true,
64 focusedAt: "2026-06-11T00:00:00.000Z",
65 });61 });
66 yield* Stream.runForEach(requests, (request) =>62 yield* Stream.runForEach(events, (event) =>
67 broker.respond({63 event.type === "connected"
68 requestId: request.requestId,64 ? Effect.void
69 ok: false,65 : broker.respond({
70 error: {66 clientId: "mcp-failure-client",
71 _tag: "PreviewAutomationExecutionError",67 connectionId: event.connectionId,
72 message: "sensitive renderer failure",68 requestId: event.request.requestId,
73 detail: { consoleOutput: "sensitive browser output" },69 ok: false,
74 },70 error: {
75 }),71 _tag: "PreviewAutomationExecutionError",
72 message: "sensitive renderer failure",
73 detail: { consoleOutput: "sensitive browser output" },
74 },
75 }),
76 ).pipe(Effect.forkScoped);76 ).pipe(Effect.forkScoped);
77 yield* Effect.yieldNow;77 yield* Effect.yieldNow;
78 yield* broker.reportOwner({
79 clientId: "mcp-failure-client",
80 environmentId,
81 threadId,
82 tabId,
83 visible: true,
84 supportsAutomation: true,
85 focusedAt: "2026-06-11T00:00:00.000Z",
86 });
8778
88 const snapshot = yield* server79 const snapshot = yield* server
89 .callTool({ name: "preview_snapshot", arguments: {} })80 .callTool({ name: "preview_snapshot", arguments: {} })
...@@ -163,60 +154,50 @@ it.effect("registers annotated tools and preserves authenticated request context...@@ -163,60 +154,50 @@ it.effect("registers annotated tools and preserves authenticated request context
163 Effect.gen(function* () {154 Effect.gen(function* () {
164 const server = yield* McpServer.McpServer;155 const server = yield* McpServer.McpServer;
165 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;156 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
166 const requests = yield* broker.connect({157 const events = yield* broker.connect({
167 clientId: "mcp-test-client",158 clientId: "mcp-test-client",
168 environmentId,159 environmentId,
169 threadId,
170 tabId,
171 visible: true,
172 supportsAutomation: true,
173 focusedAt: "2026-06-11T00:00:00.000Z",
174 });160 });
175 yield* Stream.runForEach(requests, (request) =>161 yield* Stream.runForEach(events, (event) =>
176 broker.respond({162 event.type === "connected"
177 requestId: request.requestId,163 ? Effect.void
178 ok: true,164 : broker.respond({
179 result:165 clientId: "mcp-test-client",
180 request.operation === "snapshot"166 connectionId: event.connectionId,
181 ? {167 requestId: event.request.requestId,
182 url: "http://example.test/",168 ok: true,
183 title: "Example",169 result:
184 loading: false,170 event.request.operation === "snapshot"
185 visibleText: "Example",171 ? {
186 interactiveElements: [],172 url: "http://example.test/",
187 accessibilityTree: {},173 title: "Example",
188 consoleEntries: [],174 loading: false,
189 networkEntries: [],175 visibleText: "Example",
190 actionTimeline: [],176 interactiveElements: [],
191 screenshot: {177 accessibilityTree: {},
192 mimeType: "image/png",178 consoleEntries: [],
193 data: Buffer.from("png").toString("base64"),179 networkEntries: [],
194 width: 10,180 actionTimeline: [],
195 height: 5,181 screenshot: {
196 },182 mimeType: "image/png",
197 }183 data: Buffer.from("png").toString("base64"),
198 : request.operation === "press"184 width: 10,
199 ? undefined185 height: 5,
200 : {186 },
201 available: true,187 }
202 visible: true,188 : event.request.operation === "press"
203 tabId,189 ? undefined
204 url: "http://example.test/",190 : {
205 title: "Example",191 available: true,
206 loading: false,192 visible: true,
207 },193 tabId,
208 }),194 url: "http://example.test/",
195 title: "Example",
196 loading: false,
197 },
198 }),
209 ).pipe(Effect.forkScoped);199 ).pipe(Effect.forkScoped);
210 yield* Effect.yieldNow;200 yield* Effect.yieldNow;
211 yield* broker.reportOwner({
212 clientId: "mcp-test-client",
213 environmentId,
214 threadId,
215 tabId,
216 visible: true,
217 supportsAutomation: true,
218 focusedAt: "2026-06-11T00:00:00.000Z",
219 });
220201
221 const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status");202 const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status");
222 expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true);203 expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true);
apps/server/src/mcp/McpHttpServer.ts+1-4
...@@ -214,7 +214,4 @@ const McpTransportLive = McpServer.layerHttp({...@@ -214,7 +214,4 @@ const McpTransportLive = McpServer.layerHttp({
214 path: "/mcp",214 path: "/mcp",
215}).pipe(Layer.provide(McpAuthMiddlewareLive));215}).pipe(Layer.provide(McpAuthMiddlewareLive));
216216
217export const layer = PreviewToolkitRegistrationLive.pipe(217export const layer = PreviewToolkitRegistrationLive.pipe(Layer.provideMerge(McpTransportLive));
218 Layer.provideMerge(McpTransportLive),
219 Layer.provide(PreviewAutomationBroker.layer),
220);
apps/server/src/mcp/PreviewAutomationBroker.test.ts+584-74
...@@ -1,20 +1,28 @@...@@ -1,20 +1,28 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
1import { expect, it } from "@effect/vitest";2import { expect, it } from "@effect/vitest";
2import {3import {
3 EnvironmentId,4 EnvironmentId,
4 PreviewAutomationClientDisconnectedError,5 PreviewAutomationClientDisconnectedError,
5 PreviewAutomationInvalidSelectorError,6 PreviewAutomationInvalidSelectorError,
6 PreviewAutomationMalformedResponseError,7 PreviewAutomationMalformedResponseError,
7 PreviewAutomationNoFocusedOwnerError,8 PreviewAutomationNoAvailableHostError,
9 PreviewAutomationTargetNotEditableError,
10 PreviewTabId,
8 ProviderInstanceId,11 ProviderInstanceId,
9 ThreadId,12 ThreadId,
10 type PreviewAutomationOwner,13 type PreviewAutomationHost,
14 type PreviewAutomationRequest,
15 type PreviewAutomationStreamEvent,
11} from "@t3tools/contracts";16} from "@t3tools/contracts";
12import * as Effect from "effect/Effect";17import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";18import * as Fiber from "effect/Fiber";
19import * as Result from "effect/Result";
14import * as Stream from "effect/Stream";20import * as Stream from "effect/Stream";
1521
16import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";22import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
1723
24const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer));
25
18const scope = {26const scope = {
19 environmentId: EnvironmentId.make("environment-1"),27 environmentId: EnvironmentId.make("environment-1"),
20 threadId: ThreadId.make("thread-1"),28 threadId: ThreadId.make("thread-1"),
...@@ -22,27 +30,42 @@ const scope = {...@@ -22,27 +30,42 @@ const scope = {
22 providerInstanceId: ProviderInstanceId.make("codex"),30 providerInstanceId: ProviderInstanceId.make("codex"),
23 capabilities: new Set(["preview"] as const),31 capabilities: new Set(["preview"] as const),
24 issuedAt: 1,32 issuedAt: 1,
25 expiresAt: 2,33 expiresAt: Number.MAX_SAFE_INTEGER,
26};34};
2735
28const makeOwner = (overrides: Partial<PreviewAutomationOwner> = {}): PreviewAutomationOwner => ({36const makeHost = (overrides: Partial<PreviewAutomationHost> = {}): PreviewAutomationHost => ({
29 clientId: "client-1",37 clientId: "client-1",
30 environmentId: scope.environmentId,38 environmentId: scope.environmentId,
31 threadId: scope.threadId,
32 tabId: null,
33 visible: false,
34 supportsAutomation: true,
35 focusedAt: "2026-06-11T00:00:00.000Z",
36 ...overrides,39 ...overrides,
37});40});
3841
39it.effect("atomically registers a connected owner and correlates its response", () =>42type RoutedRequest = PreviewAutomationRequest & {
43 readonly connectionId: PreviewAutomationStreamEvent["connectionId"];
44};
45
46const requestsFrom = (
47 events: Stream.Stream<PreviewAutomationStreamEvent>,
48 onConnected: (connectionId: PreviewAutomationStreamEvent["connectionId"]) => void = () => {},
49): Stream.Stream<RoutedRequest> =>
50 events.pipe(
51 Stream.filterMap((event) => {
52 if (event.type === "connected") {
53 onConnected(event.connectionId);
54 return Result.failVoid;
55 }
56 return Result.succeed({ ...event.request, connectionId: event.connectionId });
57 }),
58 );
59
60it.effect("atomically registers a connected host and correlates its response", () =>
40 Effect.scoped(61 Effect.scoped(
41 Effect.gen(function* () {62 Effect.gen(function* () {
42 const broker = yield* PreviewAutomationBroker.make;63 const broker = yield* makeBroker;
43 const requests = yield* broker.connect(makeOwner());64 const requests = requestsFrom(yield* broker.connect(makeHost()));
44 yield* Stream.runForEach(requests, (request) =>65 yield* Stream.runForEach(requests, (request) =>
45 broker.respond({66 broker.respond({
67 clientId: "client-1",
68 connectionId: request.connectionId,
46 requestId: request.requestId,69 requestId: request.requestId,
47 ok: true,70 ok: true,
48 result: { available: true },71 result: { available: true },
...@@ -61,6 +84,39 @@ it.effect("atomically registers a connected owner and correlates its response",...@@ -61,6 +84,39 @@ it.effect("atomically registers a connected owner and correlates its response",
61 ),84 ),
62);85);
6386
87it.effect("announces a live replacement stream before delivering requests", () =>
88 Effect.scoped(
89 Effect.gen(function* () {
90 const broker = yield* makeBroker;
91 const events = yield* broker.connect(makeHost());
92 const receivedTypes: PreviewAutomationStreamEvent["type"][] = [];
93 const consumer = yield* events.pipe(
94 Stream.take(2),
95 Stream.runForEach((event) => {
96 receivedTypes.push(event.type);
97 return event.type === "connected"
98 ? Effect.void
99 : broker.respond({
100 clientId: "client-1",
101 connectionId: event.connectionId,
102 requestId: event.request.requestId,
103 ok: true,
104 result: "ready",
105 });
106 }),
107 Effect.forkScoped,
108 );
109 yield* Effect.yieldNow;
110
111 const result = yield* broker.invoke<string>({ scope, operation: "status", input: {} });
112 yield* Fiber.join(consumer);
113
114 expect(receivedTypes).toEqual(["connected", "request"]);
115 expect(result).toBe("ready");
116 }),
117 ),
118);
119
64it.effect("preserves bounded request and remote selector diagnostics", () => {120it.effect("preserves bounded request and remote selector diagnostics", () => {
65 const locator = "role=button[name='request-secret']";121 const locator = "role=button[name='request-secret']";
66 const remoteMessage = "Unexpected token near remote-secret.";122 const remoteMessage = "Unexpected token near remote-secret.";
...@@ -72,10 +128,12 @@ it.effect("preserves bounded request and remote selector diagnostics", () => {...@@ -72,10 +128,12 @@ it.effect("preserves bounded request and remote selector diagnostics", () => {
72128
73 return Effect.scoped(129 return Effect.scoped(
74 Effect.gen(function* () {130 Effect.gen(function* () {
75 const broker = yield* PreviewAutomationBroker.make;131 const broker = yield* makeBroker;
76 const requests = yield* broker.connect(makeOwner({ tabId: "tab-1" }));132 const requests = requestsFrom(yield* broker.connect(makeHost()));
77 yield* Stream.runForEach(requests, (request) =>133 yield* Stream.runForEach(requests, (request) =>
78 broker.respond({134 broker.respond({
135 clientId: "client-1",
136 connectionId: request.connectionId,
79 requestId: request.requestId,137 requestId: request.requestId,
80 ok: false,138 ok: false,
81 error: remoteError,139 error: remoteError,
...@@ -88,6 +146,7 @@ it.effect("preserves bounded request and remote selector diagnostics", () => {...@@ -88,6 +146,7 @@ it.effect("preserves bounded request and remote selector diagnostics", () => {
88 scope,146 scope,
89 operation: "click",147 operation: "click",
90 input: { locator },148 input: { locator },
149 tabId: PreviewTabId.make("tab-1"),
91 timeoutMs: 1_234,150 timeoutMs: 1_234,
92 })151 })
93 .pipe(Effect.flip);152 .pipe(Effect.flip);
...@@ -121,13 +180,61 @@ it.effect("preserves bounded request and remote selector diagnostics", () => {...@@ -121,13 +180,61 @@ it.effect("preserves bounded request and remote selector diagnostics", () => {
121 );180 );
122});181});
123182
183it.effect("classifies a remote non-editable target without collapsing it to execution", () => {
184 const remoteError = {
185 _tag: "PreviewAutomationTargetNotEditableError",
186 message: "remote target details",
187 detail: { selectorKind: "focused-element" },
188 } as const;
189
190 return Effect.scoped(
191 Effect.gen(function* () {
192 const broker = yield* makeBroker;
193 const requests = requestsFrom(yield* broker.connect(makeHost()));
194 yield* Stream.runForEach(requests, (request) =>
195 broker.respond({
196 clientId: "client-1",
197 connectionId: request.connectionId,
198 requestId: request.requestId,
199 ok: false,
200 error: remoteError,
201 }),
202 ).pipe(Effect.forkScoped);
203 yield* Effect.yieldNow;
204
205 const error = yield* broker
206 .invoke<void>({
207 scope,
208 operation: "type",
209 input: { text: "hello" },
210 tabId: PreviewTabId.make("tab-1"),
211 })
212 .pipe(Effect.flip);
213
214 expect(error).toBeInstanceOf(PreviewAutomationTargetNotEditableError);
215 expect(error).toMatchObject({
216 operation: "type",
217 tabId: "tab-1",
218 selectorKind: "focused-element",
219 remoteTag: "PreviewAutomationTargetNotEditableError",
220 });
221 expect(error.message).toBe("Preview automation type requires an editable focused element.");
222 }),
223 );
224});
225
124it.effect("distinguishes malformed remote failures", () =>226it.effect("distinguishes malformed remote failures", () =>
125 Effect.scoped(227 Effect.scoped(
126 Effect.gen(function* () {228 Effect.gen(function* () {
127 const broker = yield* PreviewAutomationBroker.make;229 const broker = yield* makeBroker;
128 const requests = yield* broker.connect(makeOwner());230 const requests = requestsFrom(yield* broker.connect(makeHost()));
129 yield* Stream.runForEach(requests, (request) =>231 yield* Stream.runForEach(requests, (request) =>
130 broker.respond({ requestId: request.requestId, ok: false }),232 broker.respond({
233 clientId: "client-1",
234 connectionId: request.connectionId,
235 requestId: request.requestId,
236 ok: false,
237 }),
131 ).pipe(Effect.forkScoped);238 ).pipe(Effect.forkScoped);
132 yield* Effect.yieldNow;239 yield* Effect.yieldNow;
133240
...@@ -150,13 +257,14 @@ it.effect("distinguishes malformed remote failures", () =>...@@ -150,13 +257,14 @@ it.effect("distinguishes malformed remote failures", () =>
150 ),257 ),
151);258);
152259
153it.effect("rejects calls when no focused owner exists", () =>260it.effect("rejects calls when no connected host exists", () =>
154 Effect.gen(function* () {261 Effect.gen(function* () {
155 const broker = yield* PreviewAutomationBroker.make;262 const broker = yield* makeBroker;
156 const error = yield* broker263 const error = yield* broker
157 .invoke<void>({ scope, operation: "status", input: {} })264 .invoke<void>({ scope, operation: "status", input: {} })
158 .pipe(Effect.flip);265 .pipe(Effect.flip);
159 expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError);266
267 expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError);
160 expect(error).toMatchObject({268 expect(error).toMatchObject({
161 operation: "status",269 operation: "status",
162 environmentId: scope.environmentId,270 environmentId: scope.environmentId,
...@@ -167,97 +275,484 @@ it.effect("rejects calls when no focused owner exists", () =>...@@ -167,97 +275,484 @@ it.effect("rejects calls when no focused owner exists", () =>
167 }),275 }),
168);276);
169277
170it.effect("routes interactive commands to a hidden durable browser host", () =>278it.effect("does not create host state from focus updates without a live stream", () =>
279 Effect.gen(function* () {
280 const broker = yield* makeBroker;
281 yield* broker.focusHost({
282 clientId: "client-1",
283 environmentId: scope.environmentId,
284 connectionId: "connection-missing",
285 focused: true,
286 });
287
288 const error = yield* broker
289 .invoke<void>({ scope, operation: "status", input: {} })
290 .pipe(Effect.flip);
291 expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError);
292 }),
293);
294
295it.effect("removes host availability when the authoritative request stream disconnects", () =>
171 Effect.scoped(296 Effect.scoped(
172 Effect.gen(function* () {297 Effect.gen(function* () {
173 const broker = yield* PreviewAutomationBroker.make;298 const broker = yield* makeBroker;
174 const requests = yield* broker.connect(299 const requests = requestsFrom(yield* broker.connect(makeHost()));
175 makeOwner({ clientId: "client-hidden", tabId: "tab-hidden" }),300 const beforeAcquisition = yield* broker
176 );301 .invoke<void>({ scope, operation: "status", input: {} })
177 yield* Stream.runForEach(requests, (request) =>302 .pipe(Effect.flip);
178 broker.respond({ requestId: request.requestId, ok: true }),303 expect(beforeAcquisition).toBeInstanceOf(PreviewAutomationNoAvailableHostError);
179 ).pipe(Effect.forkScoped);304
305 const consumer = yield* Stream.runDrain(requests).pipe(Effect.forkScoped);
180 yield* Effect.yieldNow;306 yield* Effect.yieldNow;
307 yield* Fiber.interrupt(consumer);
181308
182 yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } });309 const error = yield* broker
310 .invoke<void>({ scope, operation: "status", input: {} })
311 .pipe(Effect.flip);
312 expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError);
183 }),313 }),
184 ),314 ),
185);315);
186316
187it.effect("lets the browser host resolve an active tab that has not been reported yet", () =>317it.effect("routes requests for background threads through an environment-level host", () =>
188 Effect.scoped(318 Effect.scoped(
189 Effect.gen(function* () {319 Effect.gen(function* () {
190 const broker = yield* PreviewAutomationBroker.make;320 const broker = yield* makeBroker;
191 const requests = yield* broker.connect(makeOwner({ tabId: null }));321 const backgroundThreadId = ThreadId.make("thread-background");
192 let routedTabId: string | undefined;322 const requests = requestsFrom(yield* broker.connect(makeHost()));
323 let routedThreadId: string | undefined;
193 yield* Stream.runForEach(requests, (request) => {324 yield* Stream.runForEach(requests, (request) => {
194 routedTabId = request.tabId;325 routedThreadId = request.threadId;
195 return broker.respond({ requestId: request.requestId, ok: true });326 return broker.respond({
327 clientId: "client-1",
328 connectionId: request.connectionId,
329 requestId: request.requestId,
330 ok: true,
331 result: "background",
332 });
196 }).pipe(Effect.forkScoped);333 }).pipe(Effect.forkScoped);
197 yield* Effect.yieldNow;334 yield* Effect.yieldNow;
198335
199 yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } });336 const result = yield* broker.invoke<string>({
337 scope: {
338 ...scope,
339 threadId: backgroundThreadId,
340 providerSessionId: "provider-session-background",
341 },
342 operation: "status",
343 input: {},
344 });
200345
201 expect(routedTabId).toBeUndefined();346 expect(result).toBe("background");
347 expect(routedThreadId).toBe(backgroundThreadId);
202 }),348 }),
203 ),349 ),
204);350);
205351
206it.effect("preserves current owner metadata when its request stream reconnects", () =>352it.effect("never routes a provider session to a host from another environment", () =>
207 Effect.scoped(353 Effect.scoped(
208 Effect.gen(function* () {354 Effect.gen(function* () {
209 const broker = yield* PreviewAutomationBroker.make;355 const broker = yield* makeBroker;
210 const firstRequests = yield* broker.connect(makeOwner());356 const matchingRequests = requestsFrom(
211 yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped);357 yield* broker.connect(makeHost({ clientId: "client-matching" })),
212 yield* broker.reportOwner(makeOwner({ tabId: "tab-current", visible: true }));358 );
359 const foreignRequests = requestsFrom(
360 yield* broker.connect(
361 makeHost({
362 clientId: "client-foreign",
363 environmentId: EnvironmentId.make("environment-foreign"),
364 }),
365 ),
366 );
367 yield* Stream.runForEach(matchingRequests, (request) =>
368 broker.respond({
369 clientId: "client-matching",
370 connectionId: request.connectionId,
371 requestId: request.requestId,
372 ok: true,
373 result: "matching",
374 }),
375 ).pipe(Effect.forkScoped);
376 yield* Stream.runForEach(foreignRequests, (request) =>
377 broker.respond({
378 clientId: "client-foreign",
379 connectionId: request.connectionId,
380 requestId: request.requestId,
381 ok: true,
382 result: "foreign",
383 }),
384 ).pipe(Effect.forkScoped);
385 yield* Effect.yieldNow;
386
387 expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(
388 "matching",
389 );
390 }),
391 ),
392);
213393
214 const reconnectedRequests = yield* broker.connect(makeOwner());394it.effect("pins a provider session to its initial host despite later focus changes", () =>
395 Effect.scoped(
396 Effect.gen(function* () {
397 const broker = yield* makeBroker;
398 let firstConnectionId = "";
399 let secondConnectionId = "";
400 const firstRequests = requestsFrom(
401 yield* broker.connect(makeHost({ clientId: "client-first" })),
402 (connectionId) => {
403 firstConnectionId = connectionId;
404 },
405 );
406 const secondRequests = requestsFrom(
407 yield* broker.connect(makeHost({ clientId: "client-second" })),
408 (connectionId) => {
409 secondConnectionId = connectionId;
410 },
411 );
412 yield* Stream.runForEach(firstRequests, (request) =>
413 broker.respond({
414 clientId: "client-first",
415 connectionId: request.connectionId,
416 requestId: request.requestId,
417 ok: true,
418 result: "first",
419 }),
420 ).pipe(Effect.forkScoped);
421 yield* Stream.runForEach(secondRequests, (request) =>
422 broker.respond({
423 clientId: "client-second",
424 connectionId: request.connectionId,
425 requestId: request.requestId,
426 ok: true,
427 result: "second",
428 }),
429 ).pipe(Effect.forkScoped);
430 yield* Effect.yieldNow;
431
432 yield* broker.focusHost({
433 clientId: "client-first",
434 environmentId: scope.environmentId,
435 connectionId: "connection-stale",
436 focused: true,
437 });
438 expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(
439 "second",
440 );
441 yield* broker.focusHost({
442 clientId: "client-first",
443 environmentId: scope.environmentId,
444 connectionId: firstConnectionId,
445 focused: true,
446 });
447
448 const firstPinnedScope = {
449 ...scope,
450 providerSessionId: "provider-session-first-pinned",
451 };
452 expect(
453 yield* broker.invoke<string>({ scope: firstPinnedScope, operation: "status", input: {} }),
454 ).toBe("first");
455
456 yield* broker.focusHost({
457 clientId: "client-second",
458 environmentId: scope.environmentId,
459 connectionId: secondConnectionId,
460 focused: true,
461 });
462
463 expect(
464 yield* broker.invoke<string>({ scope: firstPinnedScope, operation: "status", input: {} }),
465 ).toBe("first");
466 expect(
467 yield* broker.invoke<string>({
468 scope: { ...scope, providerSessionId: "provider-session-second-pinned" },
469 operation: "status",
470 input: {},
471 }),
472 ).toBe("second");
473 }),
474 ),
475);
476
477it.effect("does not route new operations to legacy hosts that did not advertise support", () =>
478 Effect.scoped(
479 Effect.gen(function* () {
480 const broker = yield* makeBroker;
481 const legacyEvents = yield* broker.connect(makeHost());
482 yield* Stream.runDrain(legacyEvents).pipe(Effect.forkScoped);
483 yield* Effect.yieldNow;
484
485 const error = yield* broker
486 .invoke<void>({ scope, operation: "resize", input: { mode: "fill" } })
487 .pipe(Effect.flip);
488
489 expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError);
490 expect(error).toMatchObject({ operation: "resize", environmentId: scope.environmentId });
491 }),
492 ),
493);
494
495it.effect("routes resize to a capable host instead of a newer legacy connection", () =>
496 Effect.scoped(
497 Effect.gen(function* () {
498 const broker = yield* makeBroker;
499 const capableRequests = requestsFrom(
500 yield* broker.connect(
501 makeHost({ clientId: "client-capable", supportedOperations: ["resize"] }),
502 ),
503 );
504 const legacyRequests = requestsFrom(
505 yield* broker.connect(makeHost({ clientId: "client-legacy" })),
506 );
507 yield* Stream.runForEach(capableRequests, (request) =>
508 broker.respond({
509 clientId: "client-capable",
510 connectionId: request.connectionId,
511 requestId: request.requestId,
512 ok: true,
513 result: "capable",
514 }),
515 ).pipe(Effect.forkScoped);
516 yield* Stream.runForEach(legacyRequests, (request) =>
517 broker.respond({
518 clientId: "client-legacy",
519 connectionId: request.connectionId,
520 requestId: request.requestId,
521 ok: true,
522 result: "legacy",
523 }),
524 ).pipe(Effect.forkScoped);
525 yield* Effect.yieldNow;
526
527 expect(
528 yield* broker.invoke<string>({ scope, operation: "resize", input: { mode: "fill" } }),
529 ).toBe("capable");
530 }),
531 ),
532);
533
534it.effect("does not move a live legacy assignment to another runtime for resize", () =>
535 Effect.scoped(
536 Effect.gen(function* () {
537 const broker = yield* makeBroker;
538 const legacyRequests = requestsFrom(
539 yield* broker.connect(makeHost({ clientId: "client-legacy" })),
540 );
541 yield* Stream.runForEach(legacyRequests, (request) =>
542 broker.respond({
543 clientId: "client-legacy",
544 connectionId: request.connectionId,
545 requestId: request.requestId,
546 ok: true,
547 result: "legacy",
548 }),
549 ).pipe(Effect.forkScoped);
550 yield* Effect.yieldNow;
551
552 expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(
553 "legacy",
554 );
555
556 const capableRequests = requestsFrom(
557 yield* broker.connect(
558 makeHost({ clientId: "client-capable", supportedOperations: ["resize"] }),
559 ),
560 );
561 yield* Stream.runForEach(capableRequests, (request) =>
562 broker.respond({
563 clientId: "client-capable",
564 connectionId: request.connectionId,
565 requestId: request.requestId,
566 ok: true,
567 result: "capable",
568 }),
569 ).pipe(Effect.forkScoped);
570 yield* Effect.yieldNow;
571
572 const error = yield* broker
573 .invoke<void>({ scope, operation: "resize", input: { mode: "fill" } })
574 .pipe(Effect.flip);
575 expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError);
576 expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(
577 "legacy",
578 );
579 }),
580 ),
581);
582
583it.effect("ignores stale focus updates for a different environment", () =>
584 Effect.scoped(
585 Effect.gen(function* () {
586 const broker = yield* makeBroker;
587 let firstConnectionId = "";
588 const firstRequests = requestsFrom(
589 yield* broker.connect(makeHost({ clientId: "client-first" })),
590 (connectionId) => {
591 firstConnectionId = connectionId;
592 },
593 );
594 const secondRequests = requestsFrom(
595 yield* broker.connect(makeHost({ clientId: "client-second" })),
596 );
597 yield* Stream.runForEach(firstRequests, (request) =>
598 broker.respond({
599 clientId: "client-first",
600 connectionId: request.connectionId,
601 requestId: request.requestId,
602 ok: true,
603 result: "first",
604 }),
605 ).pipe(Effect.forkScoped);
606 yield* Stream.runForEach(secondRequests, (request) =>
607 broker.respond({
608 clientId: "client-second",
609 connectionId: request.connectionId,
610 requestId: request.requestId,
611 ok: true,
612 result: "second",
613 }),
614 ).pipe(Effect.forkScoped);
615 yield* Effect.yieldNow;
616
617 yield* broker.focusHost({
618 clientId: "client-first",
619 environmentId: EnvironmentId.make("environment-stale"),
620 connectionId: firstConnectionId,
621 focused: true,
622 });
623
624 expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(
625 "second",
626 );
627 }),
628 ),
629);
630
631it.effect("fails over a pinned provider session only after its host disconnects", () =>
632 Effect.scoped(
633 Effect.gen(function* () {
634 const broker = yield* makeBroker;
635 let firstConnectionId = "";
636 const firstRequests = requestsFrom(
637 yield* broker.connect(makeHost({ clientId: "client-first" })),
638 (connectionId) => {
639 firstConnectionId = connectionId;
640 },
641 );
642 const secondRequests = requestsFrom(
643 yield* broker.connect(makeHost({ clientId: "client-second" })),
644 );
645 const firstConsumer = yield* Stream.runForEach(firstRequests, (request) =>
646 broker.respond({
647 clientId: "client-first",
648 connectionId: request.connectionId,
649 requestId: request.requestId,
650 ok: true,
651 result: "first",
652 }),
653 ).pipe(Effect.forkScoped);
654 yield* Stream.runForEach(secondRequests, (request) =>
655 broker.respond({
656 clientId: "client-second",
657 connectionId: request.connectionId,
658 requestId: request.requestId,
659 ok: true,
660 result: "second",
661 }),
662 ).pipe(Effect.forkScoped);
663 yield* Effect.yieldNow;
664
665 yield* broker.focusHost({
666 clientId: "client-first",
667 environmentId: scope.environmentId,
668 connectionId: firstConnectionId,
669 focused: true,
670 });
671 expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe("first");
672
673 yield* Fiber.interrupt(firstConsumer);
674 yield* Effect.yieldNow;
675
676 expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(
677 "second",
678 );
679 }),
680 ),
681);
682
683it.effect("lets the browser host resolve an active tab locally", () =>
684 Effect.scoped(
685 Effect.gen(function* () {
686 const broker = yield* makeBroker;
687 const requests = requestsFrom(yield* broker.connect(makeHost()));
215 let routedTabId: string | undefined;688 let routedTabId: string | undefined;
216 yield* Stream.runForEach(reconnectedRequests, (request) => {689 yield* Stream.runForEach(requests, (request) => {
217 routedTabId = request.tabId;690 routedTabId = request.tabId;
218 return broker.respond({ requestId: request.requestId, ok: true });691 return broker.respond({
692 clientId: "client-1",
693 connectionId: request.connectionId,
694 requestId: request.requestId,
695 ok: true,
696 });
219 }).pipe(Effect.forkScoped);697 }).pipe(Effect.forkScoped);
220 yield* Effect.yieldNow;698 yield* Effect.yieldNow;
221699
222 yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } });700 yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } });
223701
224 expect(routedTabId).toBe("tab-current");702 expect(routedTabId).toBeUndefined();
225 }),703 }),
226 ),704 ),
227);705);
228706
229it.effect("ignores stale owner cleanup after the client moves to another thread", () =>707it.effect("keeps a replacement stream authoritative when the old stream finalizes", () =>
230 Effect.scoped(708 Effect.scoped(
231 Effect.gen(function* () {709 Effect.gen(function* () {
232 const broker = yield* PreviewAutomationBroker.make;710 const broker = yield* makeBroker;
233 const requests = yield* broker.connect(makeOwner());711 let firstConnectionId = "";
234 yield* Stream.runForEach(requests, (request) =>712 let replacementConnectionId = "";
235 broker.respond({ requestId: request.requestId, ok: true }),713 const firstRequests = requestsFrom(yield* broker.connect(makeHost()), (connectionId) => {
236 ).pipe(Effect.forkScoped);714 firstConnectionId = connectionId;
715 });
716 yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped);
237 yield* Effect.yieldNow;717 yield* Effect.yieldNow;
238718
239 yield* broker.clearOwner({719 const replacementRequests = requestsFrom(
240 clientId: "client-1",720 yield* broker.connect(makeHost()),
241 environmentId: scope.environmentId,721 (connectionId) => {
242 threadId: ThreadId.make("thread-stale"),722 replacementConnectionId = connectionId;
243 });723 },
724 );
725 yield* Stream.runForEach(replacementRequests, (request) =>
726 broker.respond({
727 clientId: "client-1",
728 connectionId: request.connectionId,
729 requestId: request.requestId,
730 ok: true,
731 result: "replacement",
732 }),
733 ).pipe(Effect.forkScoped);
734 yield* Effect.yieldNow;
244735
245 yield* broker.invoke<void>({ scope, operation: "status", input: {} });736 expect(replacementConnectionId).not.toBe(firstConnectionId);
737 const result = yield* broker.invoke<string>({ scope, operation: "status", input: {} });
738 expect(result).toBe("replacement");
246 }),739 }),
247 ),740 ),
248);741);
249742
250it.effect("fails requests assigned to a browser stream when that stream reconnects", () =>743it.effect("fails requests assigned to the stream that is replaced", () =>
251 Effect.scoped(744 Effect.scoped(
252 Effect.gen(function* () {745 Effect.gen(function* () {
253 const broker = yield* PreviewAutomationBroker.make;746 const broker = yield* makeBroker;
254 const _requests = yield* broker.connect(makeOwner());747 const requests = requestsFrom(yield* broker.connect(makeHost()));
748 yield* Stream.runDrain(requests).pipe(Effect.forkScoped);
255 const pending = yield* broker749 const pending = yield* broker
256 .invoke<void>({ scope, operation: "status", input: {} })750 .invoke<void>({ scope, operation: "status", input: {} })
257 .pipe(Effect.flip, Effect.forkScoped);751 .pipe(Effect.flip, Effect.forkScoped);
258 yield* Effect.yieldNow;752 yield* Effect.yieldNow;
259753
260 const _replacementRequests = yield* broker.connect(makeOwner());754 const replacementRequests = requestsFrom(yield* broker.connect(makeHost()));
755 yield* Stream.runDrain(replacementRequests).pipe(Effect.forkScoped);
261756
262 const error = yield* Fiber.join(pending);757 const error = yield* Fiber.join(pending);
263 expect(error).toBeInstanceOf(PreviewAutomationClientDisconnectedError);758 expect(error).toBeInstanceOf(PreviewAutomationClientDisconnectedError);
...@@ -275,25 +770,40 @@ it.effect("fails requests assigned to a browser stream when that stream reconnec...@@ -275,25 +770,40 @@ it.effect("fails requests assigned to a browser stream when that stream reconnec
275 ),770 ),
276);771);
277772
278it.effect("falls back to an older connected owner when a newer report is not connected", () =>773it.effect("accepts responses only from the host that received the request", () =>
279 Effect.scoped(774 Effect.scoped(
280 Effect.gen(function* () {775 Effect.gen(function* () {
281 const broker = yield* PreviewAutomationBroker.make;776 const broker = yield* makeBroker;
282 const requests = yield* broker.connect(makeOwner({ clientId: "client-connected" }));777 const requests = requestsFrom(yield* broker.connect(makeHost()));
283 yield* Stream.runForEach(requests, (request) =>778 yield* Stream.runForEach(requests, (request) =>
284 broker.respond({ requestId: request.requestId, ok: true, result: "connected" }),779 Effect.gen(function* () {
780 yield* broker.respond({
781 clientId: "client-foreign",
782 connectionId: request.connectionId,
783 requestId: request.requestId,
784 ok: true,
785 result: "foreign",
786 });
787 yield* broker.respond({
788 clientId: "client-1",
789 connectionId: "connection-stale",
790 requestId: request.requestId,
791 ok: true,
792 result: "stale",
793 });
794 yield* broker.respond({
795 clientId: "client-1",
796 connectionId: request.connectionId,
797 requestId: request.requestId,
798 ok: true,
799 result: "owner",
800 });
801 }),
285 ).pipe(Effect.forkScoped);802 ).pipe(Effect.forkScoped);
286 yield* Effect.yieldNow;803 yield* Effect.yieldNow;
287 yield* broker.reportOwner(
288 makeOwner({
289 clientId: "client-report-only",
290 focusedAt: "2026-06-11T00:00:01.000Z",
291 }),
292 );
293804
294 const result = yield* broker.invoke<string>({ scope, operation: "status", input: {} });805 const result = yield* broker.invoke<string>({ scope, operation: "status", input: {} });
295806 expect(result).toBe("owner");
296 expect(result).toBe("connected");
297 }),807 }),
298 ),808 ),
299);809);
apps/server/src/mcp/PreviewAutomationBroker.ts+254-129
...@@ -1,26 +1,29 @@...@@ -1,26 +1,29 @@
1import {1import {
2 PREVIEW_AUTOMATION_V1_OPERATIONS,
2 PreviewAutomationClientDisconnectedError,3 PreviewAutomationClientDisconnectedError,
3 PreviewAutomationControlInterruptedError,4 PreviewAutomationControlInterruptedError,
4 PreviewAutomationExecutionError,5 PreviewAutomationExecutionError,
5 PreviewAutomationHostNotConnectedError,
6 PreviewAutomationInvalidSelectorError,6 PreviewAutomationInvalidSelectorError,
7 PreviewAutomationMalformedResponseError,7 PreviewAutomationMalformedResponseError,
8 PreviewAutomationNoFocusedOwnerError,8 PreviewAutomationNoAvailableHostError,
9 PreviewAutomationRemoteUnavailableError,9 PreviewAutomationRemoteUnavailableError,
10 PreviewAutomationRequestQueueClosedError,10 PreviewAutomationRequestQueueClosedError,
11 PreviewAutomationResultTooLargeError,11 PreviewAutomationResultTooLargeError,
12 PreviewAutomationTabNotFoundError,12 PreviewAutomationTabNotFoundError,
13 PreviewAutomationTargetNotEditableError,
13 PreviewAutomationTimeoutError,14 PreviewAutomationTimeoutError,
14 PreviewAutomationUnsupportedClientError,15 PreviewAutomationUnsupportedClientError,
15 type PreviewAutomationError,16 type PreviewAutomationError,
16 type PreviewAutomationOperation,17 type PreviewAutomationOperation,
17 type PreviewAutomationOwner,18 type PreviewAutomationHost,
18 type PreviewAutomationOwnerIdentity,19 type PreviewAutomationHostFocus,
19 type PreviewAutomationRequest,
20 type PreviewAutomationResponse,20 type PreviewAutomationResponse,
21 type PreviewAutomationStreamEvent,
21 type PreviewTabId,22 type PreviewTabId,
22} from "@t3tools/contracts";23} from "@t3tools/contracts";
23import * as Context from "effect/Context";24import * as Context from "effect/Context";
25import * as Clock from "effect/Clock";
26import * as Crypto from "effect/Crypto";
24import * as Deferred from "effect/Deferred";27import * as Deferred from "effect/Deferred";
25import * as Effect from "effect/Effect";28import * as Effect from "effect/Effect";
26import * as Layer from "effect/Layer";29import * as Layer from "effect/Layer";
...@@ -43,12 +46,9 @@ export class PreviewAutomationBroker extends Context.Service<...@@ -43,12 +46,9 @@ export class PreviewAutomationBroker extends Context.Service<
43 PreviewAutomationBroker,46 PreviewAutomationBroker,
44 {47 {
45 readonly connect: (48 readonly connect: (
46 owner: PreviewAutomationOwner,49 host: PreviewAutomationHost,
47 ) => Effect.Effect<Stream.Stream<PreviewAutomationRequest>>;50 ) => Effect.Effect<Stream.Stream<PreviewAutomationStreamEvent>>;
48 readonly reportOwner: (51 readonly focusHost: (host: PreviewAutomationHostFocus) => Effect.Effect<void>;
49 owner: PreviewAutomationOwner,
50 ) => Effect.Effect<void, PreviewAutomationError>;
51 readonly clearOwner: (owner: PreviewAutomationOwnerIdentity) => Effect.Effect<void>;
52 readonly respond: (52 readonly respond: (
53 response: PreviewAutomationResponse,53 response: PreviewAutomationResponse,
54 ) => Effect.Effect<void, PreviewAutomationError>;54 ) => Effect.Effect<void, PreviewAutomationError>;
...@@ -60,7 +60,12 @@ export class PreviewAutomationBroker extends Context.Service<...@@ -60,7 +60,12 @@ export class PreviewAutomationBroker extends Context.Service<
6060
61interface ClientConnection {61interface ClientConnection {
62 readonly clientId: string;62 readonly clientId: string;
63 readonly queue: Queue.Queue<PreviewAutomationRequest>;63 readonly connectionId: string;
64 readonly environmentId: PreviewAutomationHost["environmentId"];
65 readonly supportedOperations: ReadonlySet<PreviewAutomationOperation>;
66 readonly focused: boolean;
67 readonly focusOrder: number;
68 readonly queue: Queue.Queue<PreviewAutomationStreamEvent>;
64}69}
6570
66interface PendingRequest {71interface PendingRequest {
...@@ -69,6 +74,13 @@ interface PendingRequest {...@@ -69,6 +74,13 @@ interface PendingRequest {
69 readonly context: PreviewAutomationRequestErrorContext;74 readonly context: PreviewAutomationRequestErrorContext;
70}75}
7176
77interface HostAssignment {
78 readonly clientId: ClientConnection["clientId"];
79 readonly connectionId: ClientConnection["connectionId"];
80 readonly queue: ClientConnection["queue"];
81 readonly expiresAt: number;
82}
83
72interface PreviewAutomationRequestErrorContext {84interface PreviewAutomationRequestErrorContext {
73 readonly operation: PreviewAutomationOperation;85 readonly operation: PreviewAutomationOperation;
74 readonly environmentId: McpInvocationContext.McpInvocationScope["environmentId"];86 readonly environmentId: McpInvocationContext.McpInvocationScope["environmentId"];
...@@ -76,6 +88,7 @@ interface PreviewAutomationRequestErrorContext {...@@ -76,6 +88,7 @@ interface PreviewAutomationRequestErrorContext {
76 readonly providerSessionId: string;88 readonly providerSessionId: string;
77 readonly providerInstanceId: McpInvocationContext.McpInvocationScope["providerInstanceId"];89 readonly providerInstanceId: McpInvocationContext.McpInvocationScope["providerInstanceId"];
78 readonly clientId: string;90 readonly clientId: string;
91 readonly connectionId: ClientConnection["connectionId"];
79 readonly requestId: string;92 readonly requestId: string;
80 readonly tabId?: PreviewTabId;93 readonly tabId?: PreviewTabId;
81 readonly timeoutMs: number;94 readonly timeoutMs: number;
...@@ -85,11 +98,36 @@ interface PreviewAutomationRequestErrorContext {...@@ -85,11 +98,36 @@ interface PreviewAutomationRequestErrorContext {
8598
86interface BrokerState {99interface BrokerState {
87 readonly clients: ReadonlyMap<string, ClientConnection>;100 readonly clients: ReadonlyMap<string, ClientConnection>;
88 readonly owners: ReadonlyMap<string, PreviewAutomationOwner>;101 readonly assignments: ReadonlyMap<string, HostAssignment>;
89 readonly pending: ReadonlyMap<string, PendingRequest>;102 readonly pending: ReadonlyMap<string, PendingRequest>;
90 readonly requestSequence: number;103 readonly requestSequence: number;
104 readonly focusSequence: number;
91}105}
92106
107const removeConnectionFromState = (
108 current: BrokerState,
109 clientId: string,
110 queue: ClientConnection["queue"],
111): { readonly state: BrokerState; readonly disconnected: ReadonlyArray<PendingRequest> } => {
112 const clients = new Map(current.clients);
113 const assignments = new Map(current.assignments);
114 const pending = new Map(current.pending);
115 const disconnected: PendingRequest[] = [];
116 if (current.clients.get(clientId)?.queue === queue) clients.delete(clientId);
117 for (const [assignmentKey, assignment] of assignments) {
118 if (assignment.queue === queue) assignments.delete(assignmentKey);
119 }
120 for (const [requestId, entry] of pending) {
121 if (entry.queue !== queue) continue;
122 pending.delete(requestId);
123 disconnected.push(entry);
124 }
125 return {
126 state: { ...current, clients, assignments, pending },
127 disconnected,
128 };
129};
130
93const selectorDiagnosticsFromInput = (131const selectorDiagnosticsFromInput = (
94 input: unknown,132 input: unknown,
95): Pick<PreviewAutomationRequestErrorContext, "selectorKind" | "selectorLength"> => {133): Pick<PreviewAutomationRequestErrorContext, "selectorKind" | "selectorLength"> => {
...@@ -103,6 +141,14 @@ const selectorDiagnosticsFromInput = (...@@ -103,6 +141,14 @@ const selectorDiagnosticsFromInput = (
103 return {};141 return {};
104};142};
105143
144const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string =>
145 `${scope.environmentId}\u0000${scope.providerSessionId}`;
146
147const supportsOperation = (
148 connection: ClientConnection,
149 operation: PreviewAutomationOperation,
150): boolean => connection.supportedOperations.has(operation);
151
106type RemoteDetailKind = "null" | "array" | "object" | "string" | "number" | "boolean";152type RemoteDetailKind = "null" | "array" | "object" | "string" | "number" | "boolean";
107153
108function remoteDetailKind(detail: unknown): RemoteDetailKind {154function remoteDetailKind(detail: unknown): RemoteDetailKind {
...@@ -131,8 +177,8 @@ const classifyResponseError = (...@@ -131,8 +177,8 @@ const classifyResponseError = (
131 cause: error,177 cause: error,
132 };178 };
133 switch (error._tag) {179 switch (error._tag) {
134 case "PreviewAutomationNoFocusedOwnerError":180 case "PreviewAutomationNoAvailableHostError":
135 return new PreviewAutomationNoFocusedOwnerError({181 return new PreviewAutomationNoAvailableHostError({
136 ...context,182 ...context,
137 ...remoteDiagnostics,183 ...remoteDiagnostics,
138 });184 });
...@@ -162,6 +208,36 @@ const classifyResponseError = (...@@ -162,6 +208,36 @@ const classifyResponseError = (
162 ...remoteDiagnostics,208 ...remoteDiagnostics,
163 });209 });
164 }210 }
211 case "PreviewAutomationTargetNotEditableError": {
212 const detail =
213 typeof error.detail === "object" && error.detail !== null ? error.detail : undefined;
214 const remoteSelectorKind =
215 detail &&
216 "selectorKind" in detail &&
217 (detail.selectorKind === "focused-element" ||
218 detail.selectorKind === "locator" ||
219 detail.selectorKind === "selector")
220 ? detail.selectorKind
221 : undefined;
222 const remoteSelectorLength =
223 detail &&
224 "selectorLength" in detail &&
225 typeof detail.selectorLength === "number" &&
226 Number.isInteger(detail.selectorLength) &&
227 detail.selectorLength >= 0
228 ? detail.selectorLength
229 : undefined;
230 return new PreviewAutomationTargetNotEditableError({
231 ...context,
232 ...remoteDiagnostics,
233 ...(remoteSelectorKind === undefined && context.selectorKind === undefined
234 ? {}
235 : { selectorKind: remoteSelectorKind ?? context.selectorKind }),
236 ...(remoteSelectorLength === undefined && context.selectorLength === undefined
237 ? {}
238 : { selectorLength: remoteSelectorLength ?? context.selectorLength }),
239 });
240 }
165 case "PreviewAutomationResultTooLargeError": {241 case "PreviewAutomationResultTooLargeError": {
166 const detail =242 const detail =
167 typeof error.detail === "object" && error.detail !== null ? error.detail : undefined;243 typeof error.detail === "object" && error.detail !== null ? error.detail : undefined;
...@@ -193,36 +269,21 @@ const classifyResponseError = (...@@ -193,36 +269,21 @@ const classifyResponseError = (
193};269};
194270
195export const make = Effect.gen(function* PreviewAutomationBrokerMake() {271export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
272 const crypto = yield* Crypto.Crypto;
196 const state = yield* SynchronizedRef.make<BrokerState>({273 const state = yield* SynchronizedRef.make<BrokerState>({
197 clients: new Map(),274 clients: new Map(),
198 owners: new Map(),275 assignments: new Map(),
199 pending: new Map(),276 pending: new Map(),
200 requestSequence: 0,277 requestSequence: 0,
278 focusSequence: 0,
201 });279 });
202280
203 const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* (281 const closeConnection = Effect.fn("PreviewAutomationBroker.closeConnection")(function* (
204 clientId: string,
205 queue: ClientConnection["queue"],282 queue: ClientConnection["queue"],
283 disconnected: ReadonlyArray<PendingRequest>,
206 ) {284 ) {
207 const toFail = yield* SynchronizedRef.modify(state, (current) => {
208 const clients = new Map(current.clients);
209 const owners = new Map(current.owners);
210 const pending = new Map(current.pending);
211 const disconnected: PendingRequest[] = [];
212 if (current.clients.get(clientId)?.queue === queue) {
213 clients.delete(clientId);
214 owners.delete(clientId);
215 }
216 for (const [requestId, entry] of pending) {
217 if (entry.queue === queue) {
218 pending.delete(requestId);
219 disconnected.push(entry);
220 }
221 }
222 return [disconnected, { ...current, clients, owners, pending }] as const;
223 });
224 yield* Effect.forEach(285 yield* Effect.forEach(
225 toFail,286 disconnected,
226 ({ deferred, context }) =>287 ({ deferred, context }) =>
227 Deferred.fail(deferred, new PreviewAutomationClientDisconnectedError(context)),288 Deferred.fail(deferred, new PreviewAutomationClientDisconnectedError(context)),
228 { discard: true },289 { discard: true },
...@@ -230,54 +291,89 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -230,54 +291,89 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
230 yield* Queue.shutdown(queue);291 yield* Queue.shutdown(queue);
231 });292 });
232293
233 const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn(294 const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* (
234 "PreviewAutomationBroker.connect",295 clientId: string,
235 )(function* (owner) {296 queue: ClientConnection["queue"],
236 const clientId = owner.clientId;297 ) {
237 const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>();298 const disconnected = yield* SynchronizedRef.modify(state, (current) => {
238 const previous = yield* SynchronizedRef.modify(state, (current) => {299 const removed = removeConnectionFromState(current, clientId, queue);
239 const clients = new Map(current.clients);300 return [removed.disconnected, removed.state] as const;
240 const owners = new Map(current.owners);
241 const existingOwner = current.owners.get(clientId);
242 clients.set(clientId, { clientId, queue });
243 owners.set(
244 clientId,
245 existingOwner?.environmentId === owner.environmentId &&
246 existingOwner.threadId === owner.threadId
247 ? { ...existingOwner, supportsAutomation: owner.supportsAutomation }
248 : owner,
249 );
250 return [current.clients.get(clientId), { ...current, clients, owners }] as const;
251 });301 });
252 if (previous) yield* disconnect(clientId, previous.queue);302 yield* closeConnection(queue, disconnected);
253 return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue)));
254 });303 });
255304
256 const reportOwner: PreviewAutomationBroker["Service"]["reportOwner"] = Effect.fn(305 const acquireConnection = Effect.fn("PreviewAutomationBroker.acquireConnection")(function* (
257 "PreviewAutomationBroker.reportOwner",306 host: PreviewAutomationHost,
258 )(function* (owner) {307 ) {
259 yield* SynchronizedRef.update(state, (current) => {308 const clientId = host.clientId;
260 const owners = new Map(current.owners);309 const queue = yield* Queue.unbounded<PreviewAutomationStreamEvent>();
261 owners.set(owner.clientId, owner);310 const connectionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
262 return { ...current, owners };311 yield* Queue.offer(queue, { type: "connected", connectionId });
312 const connection: ClientConnection = {
313 clientId,
314 connectionId,
315 environmentId: host.environmentId,
316 supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS),
317 focused: false,
318 focusOrder: 0,
319 queue,
320 };
321 const registration = yield* SynchronizedRef.modify(state, (current) => {
322 const previousConnection = current.clients.get(clientId);
323 const removed = previousConnection
324 ? removeConnectionFromState(current, clientId, previousConnection.queue)
325 : { state: current, disconnected: [] };
326 const clients = new Map(removed.state.clients);
327 const focusSequence = removed.state.focusSequence + 1;
328 const registeredConnection = { ...connection, focusOrder: focusSequence };
329 clients.set(clientId, registeredConnection);
330 return [
331 {
332 previousConnection,
333 disconnected: removed.disconnected,
334 registeredConnection,
335 },
336 { ...removed.state, clients, focusSequence },
337 ] as const;
263 });338 });
339 if (registration.previousConnection) {
340 yield* closeConnection(registration.previousConnection.queue, registration.disconnected);
341 }
342 return registration.registeredConnection;
264 });343 });
265344
266 const clearOwner: PreviewAutomationBroker["Service"]["clearOwner"] = Effect.fn(345 const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn(
267 "PreviewAutomationBroker.clearOwner",346 "PreviewAutomationBroker.connect",
268 )(function* (owner) {347 )((host) =>
348 Effect.succeed(
349 Stream.unwrap(
350 Effect.acquireRelease(acquireConnection(host), (connection) =>
351 disconnect(connection.clientId, connection.queue),
352 ).pipe(Effect.map((connection) => Stream.fromQueue(connection.queue))),
353 ),
354 ),
355 );
356
357 const focusHost: PreviewAutomationBroker["Service"]["focusHost"] = Effect.fn(
358 "PreviewAutomationBroker.focusHost",
359 )(function* (host) {
269 yield* SynchronizedRef.update(state, (current) => {360 yield* SynchronizedRef.update(state, (current) => {
270 const currentOwner = current.owners.get(owner.clientId);361 const currentHost = current.clients.get(host.clientId);
271 if (362 if (
272 !currentOwner ||363 !currentHost ||
273 currentOwner.environmentId !== owner.environmentId ||364 currentHost.environmentId !== host.environmentId ||
274 currentOwner.threadId !== owner.threadId365 currentHost.connectionId !== host.connectionId
275 ) {366 ) {
276 return current;367 return current;
277 }368 }
278 const owners = new Map(current.owners);369 const clients = new Map(current.clients);
279 owners.delete(owner.clientId);370 const focusSequence = host.focused ? current.focusSequence + 1 : current.focusSequence;
280 return { ...current, owners };371 clients.set(host.clientId, {
372 ...currentHost,
373 focused: host.focused,
374 focusOrder: host.focused ? focusSequence : currentHost.focusOrder,
375 });
376 return { ...current, clients, focusSequence };
281 });377 });
282 });378 });
283379
...@@ -286,7 +382,13 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -286,7 +382,13 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
286 )(function* (response) {382 )(function* (response) {
287 const pending = yield* SynchronizedRef.modify(state, (current) => {383 const pending = yield* SynchronizedRef.modify(state, (current) => {
288 const entry = current.pending.get(response.requestId);384 const entry = current.pending.get(response.requestId);
289 if (!entry) return [undefined, current] as const;385 if (
386 !entry ||
387 entry.context.clientId !== response.clientId ||
388 entry.context.connectionId !== response.connectionId
389 ) {
390 return [undefined, current] as const;
391 }
290 const next = new Map(current.pending);392 const next = new Map(current.pending);
291 next.delete(response.requestId);393 next.delete(response.requestId);
292 return [entry, { ...current, pending: next }] as const;394 return [entry, { ...current, pending: next }] as const;
...@@ -307,52 +409,60 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -307,52 +409,60 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
307 const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>(409 const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>(
308 input: Parameters<PreviewAutomationBroker["Service"]["invoke"]>[0],410 input: Parameters<PreviewAutomationBroker["Service"]["invoke"]>[0],
309 ): Effect.fn.Return<A, PreviewAutomationError> {411 ): Effect.fn.Return<A, PreviewAutomationError> {
310 const current = yield* SynchronizedRef.get(state);
311 const candidates = Array.from(current.owners.values())
312 .filter(
313 (owner) =>
314 owner.environmentId === input.scope.environmentId &&
315 owner.threadId === input.scope.threadId &&
316 owner.supportsAutomation,
317 )
318 .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt));
319 const owner = candidates.find((candidate) => current.clients.has(candidate.clientId));
320 if (!owner) {
321 const disconnectedOwner = candidates[0];
322 if (disconnectedOwner) {
323 return yield* new PreviewAutomationHostNotConnectedError({
324 operation: input.operation,
325 environmentId: input.scope.environmentId,
326 threadId: input.scope.threadId,
327 providerSessionId: input.scope.providerSessionId,
328 providerInstanceId: input.scope.providerInstanceId,
329 clientId: disconnectedOwner.clientId,
330 });
331 }
332 return yield* new PreviewAutomationNoFocusedOwnerError({
333 operation: input.operation,
334 environmentId: input.scope.environmentId,
335 threadId: input.scope.threadId,
336 providerSessionId: input.scope.providerSessionId,
337 providerInstanceId: input.scope.providerInstanceId,
338 });
339 }
340 const connection = current.clients.get(owner.clientId);
341 if (!connection) {
342 return yield* new PreviewAutomationHostNotConnectedError({
343 operation: input.operation,
344 environmentId: input.scope.environmentId,
345 threadId: input.scope.threadId,
346 providerSessionId: input.scope.providerSessionId,
347 providerInstanceId: input.scope.providerInstanceId,
348 clientId: owner.clientId,
349 });
350 }
351 const timeoutMs = input.timeoutMs ?? 15_000;412 const timeoutMs = input.timeoutMs ?? 15_000;
352 const deferred = yield* Deferred.make<unknown, PreviewAutomationError>();413 const deferred = yield* Deferred.make<unknown, PreviewAutomationError>();
353 const [requestId, requestContext] = yield* SynchronizedRef.modify(state, (next) => {414 const now = yield* Clock.currentTimeMillis;
354 const requestId = `preview-${next.requestSequence}`;415 const route = yield* SynchronizedRef.modify(state, (current) => {
355 const tabId = input.tabId ?? owner.tabId ?? undefined;416 const assignments = new Map(
417 Array.from(current.assignments).filter(([, assignment]) => {
418 const connection = current.clients.get(assignment.clientId);
419 return (
420 assignment.expiresAt > now &&
421 connection?.connectionId === assignment.connectionId &&
422 connection.queue === assignment.queue
423 );
424 }),
425 );
426 const assignmentKey = hostAssignmentKey(input.scope);
427 const assigned = assignments.get(assignmentKey);
428 const assignedConnection = assigned ? current.clients.get(assigned.clientId) : undefined;
429 const hasLiveAssignment = assignedConnection?.environmentId === input.scope.environmentId;
430 // Keep one provider session on one physical desktop runtime so a
431 // multi-step browser interaction cannot jump between independent
432 // Electron cookie/DOM state. A live assignment that predates an
433 // operation is not silently moved to a newer client: the caller gets a
434 // capability failure and can deliberately start a fresh provider
435 // session. A dead lease is pruned above and may fail over.
436 const connection =
437 hasLiveAssignment && supportsOperation(assignedConnection, input.operation)
438 ? assignedConnection
439 : hasLiveAssignment
440 ? undefined
441 : Array.from(current.clients.values())
442 .filter(
443 (host) =>
444 host.environmentId === input.scope.environmentId &&
445 supportsOperation(host, input.operation),
446 )
447 .sort(
448 (left, right) =>
449 right.supportedOperations.size - left.supportedOperations.size ||
450 Number(right.focused) - Number(left.focused) ||
451 right.focusOrder - left.focusOrder,
452 )[0];
453 if (!connection) {
454 if (!hasLiveAssignment) assignments.delete(assignmentKey);
455 return [undefined, { ...current, assignments }] as const;
456 }
457 assignments.set(assignmentKey, {
458 clientId: connection.clientId,
459 connectionId: connection.connectionId,
460 queue: connection.queue,
461 expiresAt: input.scope.expiresAt,
462 });
463
464 const requestId = `preview-${current.requestSequence}`;
465 const tabId = input.tabId;
356 const selectorDiagnostics = selectorDiagnosticsFromInput(input.input);466 const selectorDiagnostics = selectorDiagnosticsFromInput(input.input);
357 const context: PreviewAutomationRequestErrorContext = {467 const context: PreviewAutomationRequestErrorContext = {
358 operation: input.operation,468 operation: input.operation,
...@@ -360,19 +470,30 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -360,19 +470,30 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
360 threadId: input.scope.threadId,470 threadId: input.scope.threadId,
361 providerSessionId: input.scope.providerSessionId,471 providerSessionId: input.scope.providerSessionId,
362 providerInstanceId: input.scope.providerInstanceId,472 providerInstanceId: input.scope.providerInstanceId,
363 clientId: owner.clientId,473 clientId: connection.clientId,
474 connectionId: connection.connectionId,
364 requestId,475 requestId,
365 ...(tabId === undefined ? {} : { tabId }),476 ...(tabId === undefined ? {} : { tabId }),
366 timeoutMs,477 timeoutMs,
367 ...selectorDiagnostics,478 ...selectorDiagnostics,
368 };479 };
369 const pending = new Map(next.pending);480 const pending = new Map(current.pending);
370 pending.set(requestId, { queue: connection.queue, deferred, context });481 pending.set(requestId, { queue: connection.queue, deferred, context });
371 return [482 return [
372 [requestId, context] as const,483 { connection, requestId, requestContext: context },
373 { ...next, pending, requestSequence: next.requestSequence + 1 },484 { ...current, assignments, pending, requestSequence: current.requestSequence + 1 },
374 ] as const;485 ] as const;
375 });486 });
487 if (!route) {
488 return yield* new PreviewAutomationNoAvailableHostError({
489 operation: input.operation,
490 environmentId: input.scope.environmentId,
491 threadId: input.scope.threadId,
492 providerSessionId: input.scope.providerSessionId,
493 providerInstanceId: input.scope.providerInstanceId,
494 });
495 }
496 const { connection, requestId, requestContext } = route;
376 const removePending = SynchronizedRef.update(state, (next) => {497 const removePending = SynchronizedRef.update(state, (next) => {
377 if (!next.pending.has(requestId)) return next;498 if (!next.pending.has(requestId)) return next;
378 const pending = new Map(next.pending);499 const pending = new Map(next.pending);
...@@ -381,12 +502,16 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -381,12 +502,16 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
381 });502 });
382 const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () {503 const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () {
383 const offered = yield* Queue.offer(connection.queue, {504 const offered = yield* Queue.offer(connection.queue, {
384 requestId,505 type: "request",
385 threadId: input.scope.threadId,506 connectionId: connection.connectionId,
386 tabId: requestContext.tabId,507 request: {
387 operation: input.operation,508 requestId,
388 input: input.input,509 threadId: input.scope.threadId,
389 timeoutMs,510 tabId: requestContext.tabId,
511 operation: input.operation,
512 input: input.input,
513 timeoutMs,
514 },
390 });515 });
391 if (!offered) {516 if (!offered) {
392 const completion = yield* Deferred.poll(deferred);517 const completion = yield* Deferred.poll(deferred);
...@@ -404,7 +529,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -404,7 +529,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
404 return yield* awaitResponse().pipe(Effect.ensuring(removePending));529 return yield* awaitResponse().pipe(Effect.ensuring(removePending));
405 });530 });
406531
407 return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke });532 return PreviewAutomationBroker.of({ connect, focusHost, respond, invoke });
408}).pipe(Effect.withSpan("PreviewAutomationBroker.make"));533}).pipe(Effect.withSpan("PreviewAutomationBroker.make"));
409534
410export const layer = Layer.effect(PreviewAutomationBroker, make);535export const layer = Layer.effect(PreviewAutomationBroker, make);
apps/server/src/mcp/toolkits/preview/handlers.ts+3
...@@ -3,6 +3,7 @@ import type {...@@ -3,6 +3,7 @@ import type {
3 PreviewAutomationOperation,3 PreviewAutomationOperation,
4 PreviewAutomationRecordingArtifact,4 PreviewAutomationRecordingArtifact,
5 PreviewAutomationRecordingStatus,5 PreviewAutomationRecordingStatus,
6 PreviewAutomationResizeResult,
6 PreviewAutomationSnapshot,7 PreviewAutomationSnapshot,
7 PreviewAutomationStatus,8 PreviewAutomationStatus,
8} from "@t3tools/contracts";9} from "@t3tools/contracts";
...@@ -39,6 +40,8 @@ const handlers = {...@@ -39,6 +40,8 @@ const handlers = {
39 reuseExistingTab: input.reuseExistingTab ?? true,40 reuseExistingTab: input.reuseExistingTab ?? true,
40 }),41 }),
41 preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs),42 preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs),
43 preview_resize: (input) =>
44 invoke<PreviewAutomationResizeResult>("resize", input, input.timeoutMs),
42 preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}),45 preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}),
43 preview_click: (input) => invoke<void>("click", input, input.timeoutMs).pipe(Effect.as(null)),46 preview_click: (input) => invoke<void>("click", input, input.timeoutMs).pipe(Effect.as(null)),
44 preview_type: (input) => invoke<void>("type", input, input.timeoutMs).pipe(Effect.as(null)),47 preview_type: (input) => invoke<void>("type", input, input.timeoutMs).pipe(Effect.as(null)),
apps/server/src/mcp/toolkits/preview/tools.ts+18-1
...@@ -7,6 +7,8 @@ import {...@@ -7,6 +7,8 @@ import {
7 PreviewAutomationPressInput,7 PreviewAutomationPressInput,
8 PreviewAutomationRecordingArtifact,8 PreviewAutomationRecordingArtifact,
9 PreviewAutomationRecordingStatus,9 PreviewAutomationRecordingStatus,
10 PreviewAutomationResizeInput,
11 PreviewAutomationResizeResult,
10 PreviewAutomationScrollInput,12 PreviewAutomationScrollInput,
11 PreviewAutomationSnapshot,13 PreviewAutomationSnapshot,
12 PreviewAutomationStatus,14 PreviewAutomationStatus,
...@@ -35,7 +37,7 @@ const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T =>...@@ -35,7 +37,7 @@ const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T =>
3537
36export const PreviewStatusTool = Tool.make("preview_status", {38export const PreviewStatusTool = Tool.make("preview_status", {
37 description:39 description:
38 "Report whether the scoped thread has an automation-capable desktop preview, including its active tab, URL, title, visibility, and loading state.",40 "Report whether the scoped thread has an automation-capable desktop preview, including its active tab, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size.",
39 success: PreviewAutomationStatus,41 success: PreviewAutomationStatus,
40 failure: PreviewAutomationError,42 failure: PreviewAutomationError,
41 dependencies,43 dependencies,
...@@ -69,6 +71,19 @@ export const PreviewNavigateTool = safeBrowserTool(...@@ -69,6 +71,19 @@ export const PreviewNavigateTool = safeBrowserTool(
69 }).annotate(Tool.Title, "Navigate browser preview"),71 }).annotate(Tool.Title, "Navigate browser preview"),
70);72);
7173
74export const PreviewResizeTool = safeBrowserTool(
75 Tool.make("preview_resize", {
76 description:
77 "Set the active collaborative browser tab to fill-panel sizing, an independently resizable freeform size, or a Chrome-standard device preset. Use {mode:'fill'}, {mode:'freeform',width:1024,height:768}, or {mode:'preset',preset:'iphone-12-pro',orientation:'portrait'}. This changes CSS layout breakpoints without changing the desktop browser user agent.",
78 parameters: PreviewAutomationResizeInput,
79 success: PreviewAutomationResizeResult,
80 failure: PreviewAutomationError,
81 dependencies,
82 })
83 .annotate(Tool.Title, "Resize browser viewport")
84 .annotate(Tool.Idempotent, true),
85);
86
72export const PreviewSnapshotTool = readonlyBrowserTool(87export const PreviewSnapshotTool = readonlyBrowserTool(
73 Tool.make("preview_snapshot", {88 Tool.make("preview_snapshot", {
74 description:89 description:
...@@ -168,6 +183,7 @@ export const PreviewToolkit = Toolkit.make(...@@ -168,6 +183,7 @@ export const PreviewToolkit = Toolkit.make(
168 PreviewStatusTool,183 PreviewStatusTool,
169 PreviewOpenTool,184 PreviewOpenTool,
170 PreviewNavigateTool,185 PreviewNavigateTool,
186 PreviewResizeTool,
171 PreviewSnapshotTool,187 PreviewSnapshotTool,
172 PreviewClickTool,188 PreviewClickTool,
173 PreviewTypeTool,189 PreviewTypeTool,
...@@ -183,6 +199,7 @@ export const PreviewStandardToolkit = Toolkit.make(...@@ -183,6 +199,7 @@ export const PreviewStandardToolkit = Toolkit.make(
183 PreviewStatusTool,199 PreviewStatusTool,
184 PreviewOpenTool,200 PreviewOpenTool,
185 PreviewNavigateTool,201 PreviewNavigateTool,
202 PreviewResizeTool,
186 PreviewClickTool,203 PreviewClickTool,
187 PreviewTypeTool,204 PreviewTypeTool,
188 PreviewPressTool,205 PreviewPressTool,
apps/server/src/preview/Manager.test.ts+55
...@@ -151,6 +151,61 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => {...@@ -151,6 +151,61 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => {
151 }),151 }),
152 );152 );
153153
154 it.effect("resizes a tab and preserves its viewport across navigation reports", () =>
155 Effect.gen(function* () {
156 const threadId = freshThreadId();
157 const manager = yield* PreviewManager.PreviewManager;
158 const collector = yield* collectEvents;
159 const opened = yield* manager.open({ threadId, url: "http://localhost:5173" });
160
161 const resized = yield* manager.resize({
162 threadId,
163 tabId: opened.tabId,
164 viewport: { _tag: "freeform", width: 1024, height: 768 },
165 });
166 expect(resized.viewport).toEqual({ _tag: "freeform", width: 1024, height: 768 });
167
168 const navigated = yield* manager.navigate({
169 threadId,
170 tabId: opened.tabId,
171 url: "http://localhost:5173/resized",
172 });
173 expect(navigated.viewport).toEqual(resized.viewport);
174
175 yield* manager.reportStatus({
176 threadId,
177 tabId: opened.tabId,
178 navStatus: { _tag: "Success", url: "http://localhost:5173/resized", title: "Resized" },
179 canGoBack: true,
180 canGoForward: false,
181 });
182 const listed = yield* manager.list({ threadId });
183 expect(listed.sessions[0]?.viewport).toEqual(resized.viewport);
184
185 const events = yield* collector.drain;
186 expect(events.map((event) => event.type)).toEqual([
187 "opened",
188 "resized",
189 "navigated",
190 "navigated",
191 ]);
192 }),
193 );
194
195 it.effect("rejects resize for an unknown tab", () =>
196 Effect.gen(function* () {
197 const manager = yield* PreviewManager.PreviewManager;
198 const error = yield* Effect.flip(
199 manager.resize({
200 threadId: freshThreadId(),
201 tabId: "tab_missing",
202 viewport: { _tag: "fill" },
203 }),
204 );
205 expect(error._tag).toBe("PreviewSessionLookupError");
206 }),
207 );
208
154 it.effect("reportStatus emits failed for LoadFailed nav", () =>209 it.effect("reportStatus emits failed for LoadFailed nav", () =>
155 Effect.gen(function* () {210 Effect.gen(function* () {
156 const threadId = freshThreadId();211 const threadId = freshThreadId();
apps/server/src/preview/Manager.ts+38
...@@ -20,6 +20,8 @@ import {...@@ -20,6 +20,8 @@ import {
20 type PreviewOpenInput,20 type PreviewOpenInput,
21 type PreviewRefreshInput,21 type PreviewRefreshInput,
22 type PreviewReportStatusInput,22 type PreviewReportStatusInput,
23 type PreviewResizeInput,
24 FILL_PREVIEW_VIEWPORT,
23 PreviewSessionLookupError,25 PreviewSessionLookupError,
24 type PreviewSessionSnapshot,26 type PreviewSessionSnapshot,
25} from "@t3tools/contracts";27} from "@t3tools/contracts";
...@@ -45,6 +47,9 @@ export class PreviewManager extends Context.Service<...@@ -45,6 +47,9 @@ export class PreviewManager extends Context.Service<
45 input: PreviewNavigateInput,47 input: PreviewNavigateInput,
46 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;48 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
47 readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect<void, PreviewError>;49 readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect<void, PreviewError>;
50 readonly resize: (
51 input: PreviewResizeInput,
52 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
48 readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>;53 readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>;
49 readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>;54 readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>;
50 readonly list: (input: PreviewListInput) => Effect.Effect<PreviewListResult>;55 readonly list: (input: PreviewListInput) => Effect.Effect<PreviewListResult>;
...@@ -114,6 +119,7 @@ const buildLoadingSnapshot = (input: {...@@ -114,6 +119,7 @@ const buildLoadingSnapshot = (input: {
114 navStatus: { _tag: "Loading", url: input.url, title: input.title },119 navStatus: { _tag: "Loading", url: input.url, title: input.title },
115 canGoBack: false,120 canGoBack: false,
116 canGoForward: false,121 canGoForward: false,
122 viewport: FILL_PREVIEW_VIEWPORT,
117 updatedAt: input.updatedAt,123 updatedAt: input.updatedAt,
118});124});
119125
...@@ -127,6 +133,7 @@ const buildIdleSnapshot = (input: {...@@ -127,6 +133,7 @@ const buildIdleSnapshot = (input: {
127 navStatus: { _tag: "Idle" },133 navStatus: { _tag: "Idle" },
128 canGoBack: false,134 canGoBack: false,
129 canGoForward: false,135 canGoForward: false,
136 viewport: FILL_PREVIEW_VIEWPORT,
130 updatedAt: input.updatedAt,137 updatedAt: input.updatedAt,
131});138});
132139
...@@ -237,6 +244,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -237,6 +244,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
237 navStatus: { _tag: "Success", url, title: resolvedTitle },244 navStatus: { _tag: "Success", url, title: resolvedTitle },
238 canGoBack: session.snapshot.canGoBack,245 canGoBack: session.snapshot.canGoBack,
239 canGoForward: session.snapshot.canGoForward,246 canGoForward: session.snapshot.canGoForward,
247 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
240 updatedAt,248 updatedAt,
241 };249 };
242 return {250 return {
...@@ -269,6 +277,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -269,6 +277,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
269 navStatus: input.navStatus,277 navStatus: input.navStatus,
270 canGoBack: input.canGoBack,278 canGoBack: input.canGoBack,
271 canGoForward: input.canGoForward,279 canGoForward: input.canGoForward,
280 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
272 updatedAt,281 updatedAt,
273 };282 };
274 const emit: PreviewEvent =283 const emit: PreviewEvent =
...@@ -299,6 +308,34 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -299,6 +308,34 @@ export const make = Effect.gen(function* PreviewManagerMake() {
299 );308 );
300 });309 });
301310
311 const resize: PreviewManager["Service"]["resize"] = Effect.fn("PreviewManager.resize")(
312 function* (input) {
313 return yield* mutateExistingSession(
314 input.threadId,
315 input.tabId,
316 Effect.fn("PreviewManager.resizeSession")(function* (session) {
317 const updatedAt = yield* currentIsoTimestamp;
318 const snapshot: PreviewSessionSnapshot = {
319 ...session.snapshot,
320 viewport: input.viewport,
321 updatedAt,
322 };
323 return {
324 next: { ...session, snapshot },
325 emit: {
326 type: "resized",
327 threadId: session.threadId,
328 tabId: session.tabId,
329 createdAt: snapshot.updatedAt,
330 snapshot,
331 },
332 result: snapshot,
333 };
334 }),
335 );
336 },
337 );
338
302 const refresh: PreviewManager["Service"]["refresh"] = Effect.fn("PreviewManager.refresh")(339 const refresh: PreviewManager["Service"]["refresh"] = Effect.fn("PreviewManager.refresh")(
303 function* (input) {340 function* (input) {
304 // Verify the session exists; the desktop bridge handles the actual reload341 // Verify the session exists; the desktop bridge handles the actual reload
...@@ -360,6 +397,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -360,6 +397,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
360 open,397 open,
361 navigate,398 navigate,
362 reportStatus,399 reportStatus,
400 resize,
363 refresh,401 refresh,
364 close,402 close,
365 list,403 list,
apps/server/src/server.test.ts+41
...@@ -653,6 +653,7 @@ const buildAppUnderTest = (options?: {...@@ -653,6 +653,7 @@ const buildAppUnderTest = (options?: {
653 Layer.mock(PreviewManager.PreviewManager)({653 Layer.mock(PreviewManager.PreviewManager)({
654 open: () => Effect.die("PreviewManager not stubbed in this test"),654 open: () => Effect.die("PreviewManager not stubbed in this test"),
655 navigate: () => Effect.die("PreviewManager not stubbed in this test"),655 navigate: () => Effect.die("PreviewManager not stubbed in this test"),
656 resize: () => Effect.die("PreviewManager not stubbed in this test"),
656 reportStatus: () => Effect.void,657 reportStatus: () => Effect.void,
657 refresh: () => Effect.void,658 refresh: () => Effect.void,
658 close: () => Effect.void,659 close: () => Effect.void,
...@@ -4125,6 +4126,46 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4125,6 +4126,46 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
4125 }).pipe(Effect.provide(NodeHttpServer.layerTest)),4126 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4126 );4127 );
41274128
4129 it.effect("shares one preview automation broker across websocket sessions", () =>
4130 Effect.scoped(
4131 Effect.gen(function* () {
4132 yield* buildAppUnderTest();
4133
4134 const wsUrl = yield* getWsServerUrl("/ws");
4135 const firstConnected = yield* Deferred.make<string>();
4136 const firstClosed = yield* Deferred.make<void>();
4137 const host = {
4138 clientId: "shared-preview-host",
4139 environmentId: testEnvironmentDescriptor.environmentId,
4140 } as const;
4141
4142 yield* withWsRpcClient(wsUrl, (client) =>
4143 client[WS_METHODS.previewAutomationConnect](host).pipe(
4144 Stream.tap((event) =>
4145 event.type === "connected"
4146 ? Deferred.succeed(firstConnected, event.connectionId)
4147 : Effect.void,
4148 ),
4149 Stream.runDrain,
4150 Effect.ensuring(Deferred.succeed(firstClosed, undefined)),
4151 ),
4152 ).pipe(Effect.forkScoped);
4153
4154 const firstConnectionId = yield* Deferred.await(firstConnected);
4155 const replacementEvent = yield* withWsRpcClient(wsUrl, (client) =>
4156 client[WS_METHODS.previewAutomationConnect](host).pipe(Stream.runHead),
4157 ).pipe(Effect.map(Option.getOrThrow));
4158 const firstStreamClosed = yield* Deferred.await(firstClosed).pipe(
4159 Effect.timeoutOption("2 seconds"),
4160 );
4161
4162 assert.equal(replacementEvent.type, "connected");
4163 assert.notEqual(replacementEvent.connectionId, firstConnectionId);
4164 assert.isTrue(Option.isSome(firstStreamClosed));
4165 }),
4166 ).pipe(Effect.provide(NodeHttpServer.layerTest)),
4167 );
4168
4128 it.effect("rejects websocket rpc handshake when session authentication is missing", () =>4169 it.effect("rejects websocket rpc handshake when session authentication is missing", () =>
4129 Effect.gen(function* () {4170 Effect.gen(function* () {
4130 const fs = yield* FileSystem.FileSystem;4171 const fs = yield* FileSystem.FileSystem;
apps/server/src/server.ts+2-1
...@@ -36,6 +36,7 @@ import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/Provide...@@ -36,6 +36,7 @@ import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/Provide
36import * as TerminalManager from "./terminal/Manager.ts";36import * as TerminalManager from "./terminal/Manager.ts";
37import * as McpHttpServer from "./mcp/McpHttpServer.ts";37import * as McpHttpServer from "./mcp/McpHttpServer.ts";
38import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts";38import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts";
39import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
39import * as PreviewManager from "./preview/Manager.ts";40import * as PreviewManager from "./preview/Manager.ts";
40import * as PortScanner from "./preview/PortScanner.ts";41import * as PortScanner from "./preview/PortScanner.ts";
41import * as ProcessRunner from "./processRunner.ts";42import * as ProcessRunner from "./processRunner.ts";
...@@ -357,7 +358,7 @@ export const makeRoutesLayer = Layer.mergeAll(...@@ -357,7 +358,7 @@ export const makeRoutesLayer = Layer.mergeAll(
357 websocketRpcRouteLayer,358 websocketRpcRouteLayer,
358 ),359 ),
359 McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)),360 McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)),
360).pipe(Layer.provide(browserApiCorsLayer));361).pipe(Layer.provide(PreviewAutomationBroker.layer), Layer.provide(browserApiCorsLayer));
361362
362export const makeServerLayer = Layer.unwrap(363export const makeServerLayer = Layer.unwrap(
363 Effect.gen(function* () {364 Effect.gen(function* () {
apps/server/src/ws.ts+19-19
...@@ -330,14 +330,14 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([...@@ -330,14 +330,14 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
330 [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope],330 [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope],
331 [WS_METHODS.previewOpen, AuthOrchestrationOperateScope],331 [WS_METHODS.previewOpen, AuthOrchestrationOperateScope],
332 [WS_METHODS.previewNavigate, AuthOrchestrationOperateScope],332 [WS_METHODS.previewNavigate, AuthOrchestrationOperateScope],
333 [WS_METHODS.previewResize, AuthOrchestrationOperateScope],
333 [WS_METHODS.previewRefresh, AuthOrchestrationOperateScope],334 [WS_METHODS.previewRefresh, AuthOrchestrationOperateScope],
334 [WS_METHODS.previewClose, AuthOrchestrationOperateScope],335 [WS_METHODS.previewClose, AuthOrchestrationOperateScope],
335 [WS_METHODS.previewList, AuthOrchestrationReadScope],336 [WS_METHODS.previewList, AuthOrchestrationReadScope],
336 [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope],337 [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope],
337 [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope],338 [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope],
338 [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope],339 [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope],
339 [WS_METHODS.previewAutomationReportOwner, AuthOrchestrationOperateScope],340 [WS_METHODS.previewAutomationFocusHost, AuthOrchestrationOperateScope],
340 [WS_METHODS.previewAutomationClearOwner, AuthOrchestrationOperateScope],
341 [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope],341 [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope],
342 [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope],342 [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope],
343 [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope],343 [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope],
...@@ -385,7 +385,10 @@ function toAuthAccessStreamEvent(...@@ -385,7 +385,10 @@ function toAuthAccessStreamEvent(
385 }385 }
386}386}
387387
388const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>388const makeWsRpcLayer = (
389 currentSession: EnvironmentAuth.AuthenticatedSession,
390 previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"],
391) =>
389 WsRpcGroup.toLayer(392 WsRpcGroup.toLayer(
390 Effect.gen(function* () {393 Effect.gen(function* () {
391 const currentSessionId = currentSession.sessionId;394 const currentSessionId = currentSession.sessionId;
...@@ -400,7 +403,6 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>...@@ -400,7 +403,6 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
400 const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService;403 const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService;
401 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;404 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
402 const terminalManager = yield* TerminalManager.TerminalManager;405 const terminalManager = yield* TerminalManager.TerminalManager;
403 const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
404 const previewManager = yield* PreviewManager.PreviewManager;406 const previewManager = yield* PreviewManager.PreviewManager;
405 const portDiscovery = yield* PortScanner.PortDiscovery;407 const portDiscovery = yield* PortScanner.PortDiscovery;
406 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;408 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
...@@ -1622,6 +1624,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>...@@ -1622,6 +1624,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
1622 observeRpcEffect(WS_METHODS.previewNavigate, previewManager.navigate(input), {1624 observeRpcEffect(WS_METHODS.previewNavigate, previewManager.navigate(input), {
1623 "rpc.aggregate": "preview",1625 "rpc.aggregate": "preview",
1624 }),1626 }),
1627 [WS_METHODS.previewResize]: (input) =>
1628 observeRpcEffect(WS_METHODS.previewResize, previewManager.resize(input), {
1629 "rpc.aggregate": "preview",
1630 }),
1625 [WS_METHODS.previewRefresh]: (input) =>1631 [WS_METHODS.previewRefresh]: (input) =>
1626 observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), {1632 observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), {
1627 "rpc.aggregate": "preview",1633 "rpc.aggregate": "preview",
...@@ -1650,16 +1656,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>...@@ -1650,16 +1656,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
1650 previewAutomationBroker.respond(input),1656 previewAutomationBroker.respond(input),
1651 { "rpc.aggregate": "preview-automation" },1657 { "rpc.aggregate": "preview-automation" },
1652 ),1658 ),
1653 [WS_METHODS.previewAutomationReportOwner]: (input) =>1659 [WS_METHODS.previewAutomationFocusHost]: (input) =>
1654 observeRpcEffect(
1655 WS_METHODS.previewAutomationReportOwner,
1656 previewAutomationBroker.reportOwner(input),
1657 { "rpc.aggregate": "preview-automation" },
1658 ),
1659 [WS_METHODS.previewAutomationClearOwner]: (input) =>
1660 observeRpcEffect(1660 observeRpcEffect(
1661 WS_METHODS.previewAutomationClearOwner,1661 WS_METHODS.previewAutomationFocusHost,
1662 previewAutomationBroker.clearOwner(input),1662 previewAutomationBroker.focusHost(input),
1663 { "rpc.aggregate": "preview-automation" },1663 { "rpc.aggregate": "preview-automation" },
1664 ),1664 ),
1665 [WS_METHODS.subscribePreviewEvents]: (_input) =>1665 [WS_METHODS.subscribePreviewEvents]: (_input) =>
...@@ -1791,8 +1791,9 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>...@@ -1791,8 +1791,9 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
1791 );1791 );
17921792
1793export const websocketRpcRouteLayer = Layer.unwrap(1793export const websocketRpcRouteLayer = Layer.unwrap(
1794 Effect.succeed(1794 Effect.gen(function* () {
1795 HttpRouter.add(1795 const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
1796 return HttpRouter.add(
1796 "GET",1797 "GET",
1797 "/ws",1798 "/ws",
1798 Effect.gen(function* () {1799 Effect.gen(function* () {
...@@ -1811,9 +1812,8 @@ export const websocketRpcRouteLayer = Layer.unwrap(...@@ -1811,9 +1812,8 @@ export const websocketRpcRouteLayer = Layer.unwrap(
1811 disableTracing: true,1812 disableTracing: true,
1812 }).pipe(1813 }).pipe(
1813 Effect.provide(1814 Effect.provide(
1814 makeWsRpcLayer(session).pipe(1815 makeWsRpcLayer(session, previewAutomationBroker).pipe(
1815 Layer.provideMerge(RpcSerialization.layerJson),1816 Layer.provideMerge(RpcSerialization.layerJson),
1816 Layer.provide(PreviewAutomationBroker.layer),
1817 Layer.provide(ProviderMaintenanceRunner.layer),1817 Layer.provide(ProviderMaintenanceRunner.layer),
1818 Layer.provide(1818 Layer.provide(
1819 SourceControlDiscovery.layer.pipe(1819 SourceControlDiscovery.layer.pipe(
...@@ -1850,6 +1850,6 @@ export const websocketRpcRouteLayer = Layer.unwrap(...@@ -1850,6 +1850,6 @@ export const websocketRpcRouteLayer = Layer.unwrap(
1850 EnvironmentInternalError: HttpServerRespondable.toResponse,1850 EnvironmentInternalError: HttpServerRespondable.toResponse,
1851 }),1851 }),
1852 ),1852 ),
1853 ),1853 );
1854 ),1854 }),
1855);1855);
apps/web/src/AppRoot.test.tsx+5-3
...@@ -3,20 +3,22 @@ import { RouterProvider } from "@tanstack/react-router";...@@ -3,20 +3,22 @@ import { RouterProvider } from "@tanstack/react-router";
3import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
44
5import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";5import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
6import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
6import { AppAtomRegistryProvider } from "./rpc/atomRegistry";7import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
7import type { AppRouter } from "./router";8import type { AppRouter } from "./router";
8import { AppRoot } from "./AppRoot";9import { AppRoot } from "./AppRoot";
910
10describe("AppRoot", () => {11describe("AppRoot", () => {
11 it("shares the application atom registry with routed UI and the Electron browser host", () => {12 it("shares the application atom registry with routed UI and renderer-wide desktop hosts", () => {
12 const root = AppRoot({ router: {} as AppRouter });13 const root = AppRoot({ router: {} as AppRouter });
1314
14 expect(root.type).toBe(AppAtomRegistryProvider);15 expect(root.type).toBe(AppAtomRegistryProvider);
15 const children = Children.toArray(16 const children = Children.toArray(
16 (root as ReactElement<{ readonly children: ReactNode }>).props.children,17 (root as ReactElement<{ readonly children: ReactNode }>).props.children,
17 );18 );
18 expect(children).toHaveLength(2);19 expect(children).toHaveLength(3);
19 expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider);20 expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider);
20 expect(isValidElement(children[1]) && children[1].type).toBe(ElectronBrowserHost);21 expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts);
22 expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost);
21 });23 });
22});24});
apps/web/src/AppRoot.tsx+2
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { RouterProvider } from "@tanstack/react-router";1import { RouterProvider } from "@tanstack/react-router";
22
3import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";3import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
4import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
4import { AppAtomRegistryProvider } from "./rpc/atomRegistry";5import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
5import type { AppRouter } from "./router";6import type { AppRouter } from "./router";
67
...@@ -13,6 +14,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) {...@@ -13,6 +14,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) {
13 return (14 return (
14 <AppAtomRegistryProvider>15 <AppAtomRegistryProvider>
15 <RouterProvider router={router} />16 <RouterProvider router={router} />
17 <PreviewAutomationHosts />
16 <ElectronBrowserHost />18 <ElectronBrowserHost />
17 </AppAtomRegistryProvider>19 </AppAtomRegistryProvider>
18 );20 );
apps/web/src/browser/BrowserDeviceToolbar.test.ts created+49
...@@ -0,0 +1,49 @@
1import type { PreviewViewportSetting } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4import {
5 commitViewportAndAspectRatio,
6 reconcileLockedAspectRatio,
7} from "./browserDeviceToolbarState";
8
9describe("commitViewportAndAspectRatio", () => {
10 it("commits the aspect ratio only after the viewport succeeds", async () => {
11 let resolveChange: (() => void) | undefined;
12 const onChange = vi.fn(
13 () =>
14 new Promise<void>((resolve) => {
15 resolveChange = resolve;
16 }),
17 );
18 const onAspectRatioChange = vi.fn();
19 const setting: PreviewViewportSetting = { _tag: "freeform", width: 900, height: 600 };
20
21 const commit = commitViewportAndAspectRatio(setting, 1.5, onChange, onAspectRatioChange);
22 expect(onAspectRatioChange).not.toHaveBeenCalled();
23
24 resolveChange?.();
25 await commit;
26 expect(onAspectRatioChange).toHaveBeenCalledWith(1.5);
27 });
28
29 it("keeps the previous aspect ratio when the viewport commit fails", async () => {
30 const onAspectRatioChange = vi.fn();
31 await expect(
32 commitViewportAndAspectRatio(
33 { _tag: "fill" },
34 null,
35 async () => Promise.reject(new Error("resize failed")),
36 onAspectRatioChange,
37 ),
38 ).rejects.toThrow("resize failed");
39 expect(onAspectRatioChange).not.toHaveBeenCalled();
40 });
41});
42
43describe("reconcileLockedAspectRatio", () => {
44 it("tracks external viewport ratios only while the lock remains active", () => {
45 expect(reconcileLockedAspectRatio(1.5, 16 / 9)).toBe(16 / 9);
46 expect(reconcileLockedAspectRatio(null, 16 / 9)).toBeNull();
47 expect(reconcileLockedAspectRatio(1.5, null)).toBeNull();
48 });
49});
apps/web/src/browser/BrowserDeviceToolbar.tsx created+340
...@@ -0,0 +1,340 @@
1"use client";
2
3import {
4 PREVIEW_VIEWPORT_MAX_AREA,
5 PREVIEW_VIEWPORT_MAX_DIMENSION,
6 PREVIEW_VIEWPORT_MIN_DIMENSION,
7 type PreviewViewportSetting,
8} from "@t3tools/contracts";
9import { PREVIEW_VIEWPORT_PRESETS, resolvePreviewViewport } from "@t3tools/shared/previewViewport";
10import { Link2, X } from "lucide-react";
11import { useState } from "react";
12
13import { Button } from "~/components/ui/button";
14import { Input } from "~/components/ui/input";
15import {
16 Select,
17 SelectGroup,
18 SelectGroupLabel,
19 SelectItem,
20 SelectPopup,
21 SelectTrigger,
22 SelectValue,
23} from "~/components/ui/select";
24import { cn } from "~/lib/utils";
25
26import { BROWSER_DEVICE_TOOLBAR_HEIGHT, resizeFreeformViewport } from "./browserViewportLayout";
27import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState";
28
29const RESPONSIVE_VALUE = "responsive";
30const SELECT_ITEMS = [
31 { value: RESPONSIVE_VALUE, label: "Responsive" },
32 ...PREVIEW_VIEWPORT_PRESETS.map((preset) => ({ value: preset.id, label: preset.label })),
33];
34
35function ScreenRotationIcon() {
36 return (
37 <svg
38 viewBox="0 0 24 24"
39 fill="none"
40 stroke="currentColor"
41 strokeWidth="2"
42 strokeLinecap="round"
43 strokeLinejoin="round"
44 aria-hidden="true"
45 >
46 <rect x="7.25" y="7.25" width="9.5" height="9.5" rx="1.4" transform="rotate(-45 12 12)" />
47 <path d="M12.5 2a10 10 0 0 1 8.4 5.4" />
48 <path d="M20.8 3.5v4h-4" />
49 <path d="M11.5 22a10 10 0 0 1-8.4-5.4" />
50 <path d="M3.2 20.5v-4h4" />
51 </svg>
52 );
53}
54
55interface Props {
56 readonly setting: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>;
57 readonly width: number;
58 readonly aspectRatio: number | null;
59 readonly onAspectRatioChange: (aspectRatio: number | null) => void;
60 readonly onChange: (setting: PreviewViewportSetting) => Promise<void>;
61}
62
63export function BrowserDeviceToolbar({
64 setting,
65 width,
66 aspectRatio,
67 onAspectRatioChange,
68 onChange,
69}: Props) {
70 const [pending, setPending] = useState(false);
71 const [customSize, setCustomSize] = useState<{
72 readonly width: string;
73 readonly height: string;
74 } | null>(null);
75 const presentedSize = customSize ?? {
76 width: String(setting.width),
77 height: String(setting.height),
78 };
79 const selectedValue =
80 setting._tag === "preset" &&
81 PREVIEW_VIEWPORT_PRESETS.some((preset) => preset.id === setting.presetId)
82 ? setting.presetId
83 : RESPONSIVE_VALUE;
84 const customWidth = Number(presentedSize.width);
85 const customHeight = Number(presentedSize.height);
86 const customValid =
87 Number.isInteger(customWidth) &&
88 Number.isInteger(customHeight) &&
89 customWidth >= PREVIEW_VIEWPORT_MIN_DIMENSION &&
90 customWidth <= PREVIEW_VIEWPORT_MAX_DIMENSION &&
91 customHeight >= PREVIEW_VIEWPORT_MIN_DIMENSION &&
92 customHeight <= PREVIEW_VIEWPORT_MAX_DIMENSION &&
93 customWidth * customHeight <= PREVIEW_VIEWPORT_MAX_AREA;
94
95 const apply = (next: PreviewViewportSetting, nextAspectRatio = aspectRatio) => {
96 setPending(true);
97 void commitViewportAndAspectRatio(next, nextAspectRatio, onChange, onAspectRatioChange).then(
98 () => {
99 setPending(false);
100 setCustomSize(null);
101 },
102 () => setPending(false),
103 );
104 };
105
106 const applyCustomSize = () => {
107 if (!customValid || (customWidth === setting.width && customHeight === setting.height)) {
108 setCustomSize(null);
109 return;
110 }
111 apply({ _tag: "freeform", width: customWidth, height: customHeight });
112 };
113
114 const updateCustomDimension = (axis: "width" | "height", value: string) => {
115 setCustomSize((current) => {
116 const next = {
117 width: axis === "width" ? value : (current?.width ?? String(setting.width)),
118 height: axis === "height" ? value : (current?.height ?? String(setting.height)),
119 };
120 const numeric = Number(value);
121 if (
122 aspectRatio === null ||
123 !Number.isInteger(numeric) ||
124 numeric < PREVIEW_VIEWPORT_MIN_DIMENSION ||
125 numeric > PREVIEW_VIEWPORT_MAX_DIMENSION
126 ) {
127 return next;
128 }
129 const resized = resizeFreeformViewport(
130 setting,
131 axis === "width"
132 ? { x: numeric - setting.width, y: 0 }
133 : { x: 0, y: numeric - setting.height },
134 1,
135 axis === "width" ? "east" : "south",
136 aspectRatio,
137 );
138 return { width: String(resized.width), height: String(resized.height) };
139 });
140 };
141
142 const selectViewport = (value: string | null) => {
143 if (!value) return;
144 if (value === RESPONSIVE_VALUE) {
145 if (setting._tag === "freeform") return;
146 apply({ _tag: "freeform", width: setting.width, height: setting.height });
147 return;
148 }
149 const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === value);
150 if (!preset) return;
151 apply(
152 resolvePreviewViewport({ mode: "preset", preset: preset.id }),
153 aspectRatio === null ? null : preset.width / preset.height,
154 );
155 };
156
157 const rotate = () => {
158 const hasCustomSize =
159 customValid && (customWidth !== setting.width || customHeight !== setting.height);
160 const source = hasCustomSize
161 ? ({ _tag: "freeform", width: customWidth, height: customHeight } as const)
162 : setting;
163 apply(
164 { ...source, width: source.height, height: source.width },
165 aspectRatio === null ? null : 1 / aspectRatio,
166 );
167 };
168
169 const toggleAspectRatio = () => {
170 onAspectRatioChange(aspectRatio === null ? customWidth / customHeight : null);
171 };
172
173 return (
174 <div
175 className="sticky left-0 top-0 z-50 flex items-center gap-0.5 overflow-x-auto border-b border-border/70 bg-background/95 px-1.5 shadow-xs backdrop-blur-md [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
176 style={{ width, height: BROWSER_DEVICE_TOOLBAR_HEIGHT }}
177 role="toolbar"
178 aria-label="Browser device toolbar"
179 data-browser-device-toolbar
180 onBlur={(event) => {
181 const nextTarget = event.relatedTarget;
182 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
183 const eventTarget = event.target;
184 if (
185 (nextTarget instanceof HTMLElement &&
186 nextTarget.closest('[data-slot="select-positioner"]')) ||
187 (eventTarget instanceof HTMLElement &&
188 eventTarget.closest('[data-slot="select-positioner"]'))
189 ) {
190 return;
191 }
192 applyCustomSize();
193 }}
194 >
195 {width >= 560 ? (
196 <span className="mr-0.5 shrink-0 text-[11px] font-medium text-muted-foreground">
197 Dimensions
198 </span>
199 ) : null}
200 <Select
201 modal={false}
202 value={selectedValue}
203 onValueChange={selectViewport}
204 items={SELECT_ITEMS}
205 disabled={pending}
206 >
207 <SelectTrigger
208 variant="ghost"
209 size="xs"
210 className={cn(
211 "shrink-0 justify-between px-1.5 font-medium",
212 width >= 440 ? "w-36" : "w-24",
213 )}
214 aria-label="Browser device preset"
215 >
216 <SelectValue />
217 </SelectTrigger>
218 <SelectPopup align="start" alignItemWithTrigger={false} className="min-w-64">
219 <SelectItem value={RESPONSIVE_VALUE}>Responsive</SelectItem>
220 <SelectGroup>
221 <SelectGroupLabel>Standard</SelectGroupLabel>
222 {PREVIEW_VIEWPORT_PRESETS.map((preset) => (
223 <SelectItem key={preset.id} value={preset.id}>
224 <span className="flex w-full items-center justify-between gap-5">
225 <span>{preset.label}</span>
226 <span className="text-xs tabular-nums text-muted-foreground">
227 {preset.detail}
228 </span>
229 </span>
230 </SelectItem>
231 ))}
232 </SelectGroup>
233 </SelectPopup>
234 </Select>
235
236 <form
237 className="m-0 flex min-w-0 shrink-0 items-center gap-0.5 border-0 p-0"
238 aria-label="Viewport dimensions"
239 onSubmit={(event) => {
240 event.preventDefault();
241 applyCustomSize();
242 }}
243 >
244 <Input
245 nativeInput
246 type="number"
247 inputMode="numeric"
248 size="sm"
249 min={PREVIEW_VIEWPORT_MIN_DIMENSION}
250 max={PREVIEW_VIEWPORT_MAX_DIMENSION}
251 value={presentedSize.width}
252 disabled={pending}
253 onFocus={() =>
254 setCustomSize(
255 (current) =>
256 current ?? {
257 width: String(setting.width),
258 height: String(setting.height),
259 },
260 )
261 }
262 onChange={(event) => updateCustomDimension("width", event.target.value)}
263 aria-label="Viewport width"
264 aria-invalid={!customValid}
265 className={cn(
266 "h-6 rounded-md text-center tabular-nums [&_[data-slot=input]]:h-full [&_[data-slot=input]]:px-1 [&_[data-slot=input]]:text-xs [&_[data-slot=input]]:leading-none [&_[data-slot=input]::-webkit-inner-spin-button]:appearance-none [&_[data-slot=input]]:[appearance:textfield]",
267 width >= 360 ? "w-14" : "w-11",
268 )}
269 />
270 <span className="text-xs text-muted-foreground">×</span>
271 <Input
272 nativeInput
273 type="number"
274 inputMode="numeric"
275 size="sm"
276 min={PREVIEW_VIEWPORT_MIN_DIMENSION}
277 max={PREVIEW_VIEWPORT_MAX_DIMENSION}
278 value={presentedSize.height}
279 disabled={pending}
280 onFocus={() =>
281 setCustomSize(
282 (current) =>
283 current ?? {
284 width: String(setting.width),
285 height: String(setting.height),
286 },
287 )
288 }
289 onChange={(event) => updateCustomDimension("height", event.target.value)}
290 aria-label="Viewport height"
291 aria-invalid={!customValid}
292 className={cn(
293 "h-6 rounded-md text-center tabular-nums [&_[data-slot=input]]:h-full [&_[data-slot=input]]:px-1 [&_[data-slot=input]]:text-xs [&_[data-slot=input]]:leading-none [&_[data-slot=input]::-webkit-inner-spin-button]:appearance-none [&_[data-slot=input]]:[appearance:textfield]",
294 width >= 360 ? "w-14" : "w-11",
295 )}
296 />
297 </form>
298
299 <Button
300 variant="ghost"
301 size="icon-xs"
302 type="button"
303 aria-label={
304 aspectRatio === null ? "Lock viewport aspect ratio" : "Unlock viewport aspect ratio"
305 }
306 aria-pressed={aspectRatio !== null}
307 title={aspectRatio === null ? "Lock aspect ratio" : "Unlock aspect ratio"}
308 className={cn(aspectRatio !== null && "bg-accent text-foreground")}
309 disabled={pending || !customValid}
310 onPointerDown={(event) => event.preventDefault()}
311 onClick={toggleAspectRatio}
312 >
313 <Link2 className={cn(aspectRatio !== null && "text-foreground")} />
314 </Button>
315 <Button
316 variant="ghost"
317 size="icon-xs"
318 type="button"
319 aria-label="Rotate viewport"
320 disabled={pending}
321 onClick={rotate}
322 >
323 <ScreenRotationIcon />
324 </Button>
325 <Button
326 variant="ghost"
327 size="icon-xs"
328 type="button"
329 aria-label="Close device toolbar"
330 className="sticky right-0 ml-auto bg-background/95"
331 disabled={pending}
332 onClick={() => {
333 apply({ _tag: "fill" }, null);
334 }}
335 >
336 <X />
337 </Button>
338 </div>
339 );
340}
apps/web/src/browser/BrowserViewportResizeHandles.tsx created+169
...@@ -0,0 +1,169 @@
1"use client";
2
3import type {
4 CSSProperties,
5 KeyboardEvent as ReactKeyboardEvent,
6 PointerEvent as ReactPointerEvent,
7} from "react";
8
9import { cn } from "~/lib/utils";
10
11import {
12 BROWSER_VIEWPORT_RESIZE_RAIL_SIZE,
13 type BrowserViewportLayout,
14 type BrowserViewportResizeDirection,
15} from "./browserViewportLayout";
16
17interface Props {
18 readonly layout: BrowserViewportLayout;
19 readonly activeDirection: BrowserViewportResizeDirection | null;
20 readonly onPointerDown: (
21 direction: BrowserViewportResizeDirection,
22 event: ReactPointerEvent<HTMLButtonElement>,
23 ) => void;
24 readonly onKeyDown: (
25 direction: BrowserViewportResizeDirection,
26 event: ReactKeyboardEvent<HTMLButtonElement>,
27 ) => void;
28}
29
30type HandleKind = "horizontal" | "vertical" | "corner";
31
32const EDGE_BUTTON_CLASS =
33 "group absolute z-20 touch-none border-0 bg-transparent p-0 outline-none before:absolute before:-inset-1 before:content-[''] focus-visible:bg-foreground/[0.04]";
34const EDGE_GRIP_CLASS =
35 "pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center text-muted-foreground/55 transition-colors duration-150 group-hover:text-foreground/85 group-focus-visible:text-foreground group-active:text-foreground";
36
37function ResizeHandle(props: {
38 readonly direction: BrowserViewportResizeDirection;
39 readonly label: string;
40 readonly kind: HandleKind;
41 readonly cursorClassName: string;
42 readonly style: CSSProperties;
43 readonly active: boolean;
44 readonly mirrorCorner?: boolean;
45 readonly onPointerDown: Props["onPointerDown"];
46 readonly onKeyDown: Props["onKeyDown"];
47}) {
48 const {
49 direction,
50 label,
51 kind,
52 cursorClassName,
53 style,
54 active,
55 mirrorCorner = false,
56 onPointerDown,
57 onKeyDown,
58 } = props;
59 return (
60 <button
61 type="button"
62 aria-label={`${label}. Use arrow keys to resize.`}
63 className={cn(EDGE_BUTTON_CLASS, kind === "corner" && "z-30", cursorClassName)}
64 style={style}
65 onPointerDown={(event) => onPointerDown(direction, event)}
66 onKeyDown={(event) => onKeyDown(direction, event)}
67 >
68 <span
69 className={cn(
70 EDGE_GRIP_CLASS,
71 kind === "vertical" && "h-8 w-1.5",
72 kind === "horizontal" && "h-1.5 w-8",
73 kind === "corner" && "size-3",
74 active && "text-foreground",
75 )}
76 >
77 {kind === "vertical" ? (
78 <span className="flex gap-px" aria-hidden="true">
79 <span className="h-6 w-px rounded-full bg-current" />
80 <span className="h-6 w-px rounded-full bg-current" />
81 </span>
82 ) : kind === "horizontal" ? (
83 <span className="flex flex-col gap-px" aria-hidden="true">
84 <span className="h-px w-6 rounded-full bg-current" />
85 <span className="h-px w-6 rounded-full bg-current" />
86 </span>
87 ) : (
88 <span
89 className={cn("relative block size-3", mirrorCorner && "-scale-x-100")}
90 aria-hidden="true"
91 >
92 <span className="absolute bottom-[3px] left-0 h-px w-3 -rotate-45 rounded-full bg-current" />
93 <span className="absolute bottom-0 left-[5px] h-px w-2 -rotate-45 rounded-full bg-current" />
94 </span>
95 )}
96 </span>
97 </button>
98 );
99}
100
101export function BrowserViewportResizeHandles({
102 layout,
103 activeDirection,
104 onPointerDown,
105 onKeyDown,
106}: Props) {
107 const left = layout.viewportX;
108 const top = layout.viewportY;
109 const right = left + layout.viewportWidth;
110 const bottom = top + layout.viewportHeight;
111 const railSize = BROWSER_VIEWPORT_RESIZE_RAIL_SIZE;
112
113 const shared = { activeDirection, onPointerDown, onKeyDown };
114 return (
115 <>
116 <ResizeHandle
117 direction="west"
118 label="Resize browser viewport from left edge"
119 kind="vertical"
120 cursorClassName="cursor-ew-resize"
121 style={{ left: left - railSize, top, width: railSize, height: layout.viewportHeight }}
122 active={shared.activeDirection === "west"}
123 onPointerDown={shared.onPointerDown}
124 onKeyDown={shared.onKeyDown}
125 />
126 <ResizeHandle
127 direction="east"
128 label="Resize browser viewport from right edge"
129 kind="vertical"
130 cursorClassName="cursor-ew-resize"
131 style={{ left: right, top, width: railSize, height: layout.viewportHeight }}
132 active={shared.activeDirection === "east"}
133 onPointerDown={shared.onPointerDown}
134 onKeyDown={shared.onKeyDown}
135 />
136 <ResizeHandle
137 direction="south"
138 label="Resize browser viewport from bottom edge"
139 kind="horizontal"
140 cursorClassName="cursor-ns-resize"
141 style={{ left, top: bottom, width: layout.viewportWidth, height: railSize }}
142 active={shared.activeDirection === "south"}
143 onPointerDown={shared.onPointerDown}
144 onKeyDown={shared.onKeyDown}
145 />
146 <ResizeHandle
147 direction="southwest"
148 label="Resize browser viewport from bottom-left corner"
149 kind="corner"
150 cursorClassName="cursor-nesw-resize"
151 style={{ left: left - railSize, top: bottom, width: railSize, height: railSize }}
152 active={shared.activeDirection === "southwest"}
153 mirrorCorner
154 onPointerDown={shared.onPointerDown}
155 onKeyDown={shared.onKeyDown}
156 />
157 <ResizeHandle
158 direction="southeast"
159 label="Resize browser viewport from bottom-right corner"
160 kind="corner"
161 cursorClassName="cursor-nwse-resize"
162 style={{ left: right, top: bottom, width: railSize, height: railSize }}
163 active={shared.activeDirection === "southeast"}
164 onPointerDown={shared.onPointerDown}
165 onKeyDown={shared.onKeyDown}
166 />
167 </>
168 );
169}
apps/web/src/browser/ElectronBrowserHost.tsx+5-2
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1"use client";1"use client";
22
3import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";3import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
4import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";
4import { useEffect, useMemo } from "react";5import { useEffect, useMemo } from "react";
56
6import { isElectron } from "~/env";7import { isElectron } from "~/env";
...@@ -22,7 +23,7 @@ export function ElectronBrowserHost() {...@@ -22,7 +23,7 @@ export function ElectronBrowserHost() {
22 ? Object.values(previewState.sessions).map((snapshot) => ({23 ? Object.values(previewState.sessions).map((snapshot) => ({
23 threadRef,24 threadRef,
24 snapshot,25 snapshot,
25 active: previewState.activeTabId === snapshot.tabId,26 zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1,
26 }))27 }))
27 : [];28 : [];
28 }),29 }),
...@@ -73,7 +74,7 @@ export function ElectronBrowserHost() {...@@ -73,7 +74,7 @@ export function ElectronBrowserHost() {
73 if (!isElectron) return null;74 if (!isElectron) return null;
74 return (75 return (
75 <div className="contents" data-electron-browser-host>76 <div className="contents" data-electron-browser-host>
76 {sessions.map(({ threadRef, snapshot }) => {77 {sessions.map(({ threadRef, snapshot, zoomFactor }) => {
77 const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;78 const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;
78 return (79 return (
79 <HostedBrowserWebview80 <HostedBrowserWebview
...@@ -81,6 +82,8 @@ export function ElectronBrowserHost() {...@@ -81,6 +82,8 @@ export function ElectronBrowserHost() {
81 threadRef={threadRef}82 threadRef={threadRef}
82 tabId={snapshot.tabId}83 tabId={snapshot.tabId}
83 initialUrl={url}84 initialUrl={url}
85 viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT}
86 zoomFactor={zoomFactor}
84 />87 />
85 );88 );
86 })}89 })}
apps/web/src/browser/HostedBrowserWebview.tsx+163-22
...@@ -1,16 +1,22 @@...@@ -1,16 +1,22 @@
1"use client";1"use client";
22
3import type { ScopedThreadRef } from "@t3tools/contracts";3import type { PreviewViewportSetting, ScopedThreadRef } from "@t3tools/contracts";
4import { useShallow } from "zustand/react/shallow";4import { useShallow } from "zustand/react/shallow";
5import { useCallback, useEffect, useRef } from "react";5import { useCallback, useEffect, useRef, useState } from "react";
66
7import { previewBridge } from "~/components/preview/previewBridge";7import { previewBridge } from "~/components/preview/previewBridge";
8import { usePreviewBridge } from "~/components/preview/usePreviewBridge";8import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
9import { cn } from "~/lib/utils";
910
10import { useActiveBrowserRecordingTabId } from "./browserRecording";11import { useActiveBrowserRecordingTabId } from "./browserRecording";
11import { useBrowserSurfaceStore } from "./browserSurfaceStore";12import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
13import { browserViewportSettingKey } from "./browserViewportLayout";
14import { reconcileLockedAspectRatio } from "./browserDeviceToolbarState";
15import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar";
16import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles";
12import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime";17import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime";
13import { usePreviewWebviewConfig } from "./previewWebviewConfigState";18import { usePreviewWebviewConfig } from "./previewWebviewConfigState";
19import { useBrowserViewportResize } from "./useBrowserViewportResize";
1420
15interface ElectronWebview extends HTMLElement {21interface ElectronWebview extends HTMLElement {
16 src: string;22 src: string;
...@@ -18,6 +24,7 @@ interface ElectronWebview extends HTMLElement {...@@ -18,6 +24,7 @@ interface ElectronWebview extends HTMLElement {
18 preload?: string;24 preload?: string;
19 webpreferences?: string;25 webpreferences?: string;
20 getWebContentsId: () => number;26 getWebContentsId: () => number;
27 executeJavaScript: (code: string, userGesture?: boolean) => Promise<unknown>;
21}28}
2229
23declare global {30declare global {
...@@ -30,13 +37,25 @@ export function HostedBrowserWebview(props: {...@@ -30,13 +37,25 @@ export function HostedBrowserWebview(props: {
30 readonly threadRef: ScopedThreadRef;37 readonly threadRef: ScopedThreadRef;
31 readonly tabId: string;38 readonly tabId: string;
32 readonly initialUrl: string | null;39 readonly initialUrl: string | null;
40 readonly viewport: PreviewViewportSetting;
41 readonly zoomFactor: number;
33}) {42}) {
34 const { threadRef, tabId, initialUrl } = props;43 const { threadRef, tabId, initialUrl, viewport, zoomFactor } = props;
35 const config = usePreviewWebviewConfig(threadRef.environmentId);44 const config = usePreviewWebviewConfig(threadRef.environmentId);
36 const initialSrcRef = useRef(initialUrl ?? "about:blank");45 const [initialSrc] = useState(() => initialUrl ?? "about:blank");
37 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);46 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
47 const wrapperRef = useRef<HTMLDivElement | null>(null);
38 const webviewRef = useRef<ElectronWebview | null>(null);48 const webviewRef = useRef<ElectronWebview | null>(null);
39 const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null));49 const [lockedAspectRatio, setLockedAspectRatio] = useState<number | null>(null);
50 const presentation = useBrowserSurfaceStore(
51 useShallow((state) => {
52 const current = state.byTabId[tabId];
53 return {
54 rect: resolveBrowserSurfacePanelRect(state.byTabId, tabId),
55 visible: current?.visible ?? false,
56 };
57 }),
58 );
40 const recording = useActiveBrowserRecordingTabId() === tabId;59 const recording = useActiveBrowserRecordingTabId() === tabId;
4160
42 usePreviewBridge({ threadRef, tabId });61 usePreviewBridge({ threadRef, tabId });
...@@ -89,10 +108,69 @@ export function HostedBrowserWebview(props: {...@@ -89,10 +108,69 @@ export function HostedBrowserWebview(props: {
89 };108 };
90 }, [config, tabId]);109 }, [config, tabId]);
91110
111 const active = presentation.visible && presentation.rect !== null;
112 const lastRect = presentation.rect;
113 const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
114 const viewportWidth = viewport._tag === "fill" ? null : viewport.width;
115 const viewportHeight = viewport._tag === "fill" ? null : viewport.height;
116 const viewportAspectRatio =
117 viewportWidth === null || viewportHeight === null ? null : viewportWidth / viewportHeight;
118 useEffect(() => {
119 setLockedAspectRatio((current) => reconcileLockedAspectRatio(current, viewportAspectRatio));
120 }, [viewportAspectRatio]);
121 const hiddenSize =
122 viewport._tag !== "fill"
123 ? {
124 width: viewport.width * normalizedZoomFactor,
125 height: viewport.height * normalizedZoomFactor,
126 }
127 : { width: lastRect?.width ?? 1280, height: lastRect?.height ?? 800 };
128 const containerSize = active && lastRect ? lastRect : hiddenSize;
129 const deviceToolbarVisible = active && viewport._tag !== "fill";
130 const {
131 activeDrag,
132 commitViewportChange,
133 effectiveViewport,
134 handleResizeKeyDown,
135 handleResizePointerDown,
136 layout,
137 } = useBrowserViewportResize({
138 tabId,
139 viewport,
140 zoomFactor,
141 containerSize,
142 deviceToolbarVisible,
143 aspectRatio: lockedAspectRatio,
144 });
145
146 const syncContentPresentation = useCallback(() => {
147 const wrapper = wrapperRef.current;
148 if (!wrapper) return;
149 useBrowserSurfaceStore.getState().presentContent(tabId, {
150 x: layout.viewportX,
151 y: layout.viewportY,
152 width: layout.viewportWidth,
153 height: layout.viewportHeight,
154 scale: layout.viewportScale,
155 scrollLeft: wrapper.scrollLeft,
156 scrollTop: wrapper.scrollTop,
157 });
158 }, [layout, tabId]);
159
160 useEffect(() => {
161 const frameId = window.requestAnimationFrame(syncContentPresentation);
162 return () => window.cancelAnimationFrame(frameId);
163 }, [syncContentPresentation]);
164
165 useEffect(() => {
166 const wrapper = wrapperRef.current;
167 if (!wrapper) return;
168 wrapper.scrollTo({ left: 0, top: 0 });
169 }, [tabId, viewport._tag, viewportHeight, viewportWidth]);
170
92 if (!config) return null;171 if (!config) return null;
93 const active = presentation?.visible === true && presentation.rect !== null;172
94 const lastRect = presentation?.rect;173 const wrapperStyle =
95 const style =
96 active && lastRect174 active && lastRect
97 ? {175 ? {
98 left: lastRect.x,176 left: lastRect.x,
...@@ -105,23 +183,86 @@ export function HostedBrowserWebview(props: {...@@ -105,23 +183,86 @@ export function HostedBrowserWebview(props: {
105 : {183 : {
106 left: 0,184 left: 0,
107 top: 0,185 top: 0,
108 width: lastRect?.width ?? 1280,186 width: hiddenSize.width,
109 height: lastRect?.height ?? 800,187 height: hiddenSize.height,
110 zIndex: recording ? 0 : -1,188 zIndex: recording ? 0 : -1,
111 pointerEvents: "none" as const,189 pointerEvents: "none" as const,
112 };190 };
113191
114 return (192 return (
115 <webview193 <div
116 ref={setWebviewRef}194 ref={wrapperRef}
117 src={initialSrcRef.current}195 className="fixed overflow-hidden bg-muted/35"
118 partition={config.partition}196 style={{ ...wrapperStyle, overscrollBehavior: "contain" }}
119 webpreferences={config.webPreferences}197 onScroll={syncContentPresentation}
120 {...(config.preloadUrl ? { preload: config.preloadUrl } : {})}198 data-preview-viewport={tabId}
121 data-preview-tab={tabId}199 >
122 aria-hidden={active ? undefined : true}200 <div className="relative" style={{ width: layout.canvasWidth, height: layout.canvasHeight }}>
123 className="fixed flex overflow-hidden bg-background"201 {deviceToolbarVisible && effectiveViewport._tag !== "fill" ? (
124 style={style}202 <BrowserDeviceToolbar
125 />203 setting={effectiveViewport}
204 width={Math.max(1, Math.round(containerSize.width))}
205 aspectRatio={lockedAspectRatio}
206 onAspectRatioChange={setLockedAspectRatio}
207 onChange={commitViewportChange}
208 />
209 ) : null}
210 <webview
211 ref={setWebviewRef}
212 src={initialSrc}
213 partition={config.partition}
214 webpreferences={config.webPreferences}
215 {...(config.preloadUrl ? { preload: config.preloadUrl } : {})}
216 data-preview-tab={tabId}
217 data-preview-viewport-mode={effectiveViewport._tag}
218 data-preview-viewport-key={browserViewportSettingKey(effectiveViewport)}
219 data-preview-css-width={
220 effectiveViewport._tag === "fill"
221 ? Math.max(1, Math.round(layout.viewportWidth / normalizedZoomFactor))
222 : effectiveViewport.width
223 }
224 data-preview-css-height={
225 effectiveViewport._tag === "fill"
226 ? Math.max(1, Math.round(layout.viewportHeight / normalizedZoomFactor))
227 : effectiveViewport.height
228 }
229 aria-hidden={active ? undefined : true}
230 className={cn(
231 "absolute flex overflow-hidden bg-background",
232 active && !layout.fillsPanel && "ring-1 ring-border/70 shadow-sm",
233 )}
234 style={{
235 left: layout.viewportX,
236 top: layout.viewportY,
237 width: layout.viewportWidth / layout.viewportScale,
238 height: layout.viewportHeight / layout.viewportScale,
239 transform: layout.viewportScale < 1 ? `scale(${layout.viewportScale})` : undefined,
240 transformOrigin: "top left",
241 }}
242 />
243 {active && effectiveViewport._tag !== "fill" ? (
244 <>
245 <BrowserViewportResizeHandles
246 layout={layout}
247 activeDirection={activeDrag?.direction ?? null}
248 onPointerDown={handleResizePointerDown}
249 onKeyDown={handleResizeKeyDown}
250 />
251 {activeDrag ? (
252 <div
253 className="pointer-events-none absolute z-40 -translate-x-1/2 rounded-md border border-border/80 bg-background/95 px-2 py-1 text-[11px] font-medium tabular-nums text-foreground shadow-md backdrop-blur-sm"
254 style={{
255 left: layout.viewportX + layout.viewportWidth / 2,
256 top: layout.viewportY + 10,
257 }}
258 aria-hidden="true"
259 >
260 {activeDrag.width} × {activeDrag.height}
261 </div>
262 ) : null}
263 </>
264 ) : null}
265 </div>
266 </div>
126 );267 );
127}268}
apps/web/src/browser/browserDeviceToolbarState.ts created+18
...@@ -0,0 +1,18 @@
1import type { PreviewViewportSetting } from "@t3tools/contracts";
2
3export function reconcileLockedAspectRatio(
4 current: number | null,
5 viewportAspectRatio: number | null,
6): number | null {
7 return current === null || viewportAspectRatio === null ? null : viewportAspectRatio;
8}
9
10export async function commitViewportAndAspectRatio(
11 setting: PreviewViewportSetting,
12 aspectRatio: number | null,
13 onChange: (setting: PreviewViewportSetting) => Promise<void>,
14 onAspectRatioChange: (aspectRatio: number | null) => void,
15): Promise<void> {
16 await onChange(setting);
17 onAspectRatioChange(aspectRatio);
18}
apps/web/src/browser/browserRecording.ts+8-3
...@@ -90,6 +90,10 @@ export function useActiveBrowserRecordingTabId(): string | null {...@@ -90,6 +90,10 @@ export function useActiveBrowserRecordingTabId(): string | null {
90let active: ActiveRecording | null = null;90let active: ActiveRecording | null = null;
91let unsubscribeFrames: (() => void) | null = null;91let unsubscribeFrames: (() => void) | null = null;
9292
93export function readActiveBrowserRecordingTabId(): string | null {
94 return active?.tabId ?? null;
95}
96
93const preferredMimeType = (): string => {97const preferredMimeType = (): string => {
94 const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"];98 const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"];
95 return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm";99 return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm";
...@@ -137,10 +141,11 @@ export async function startBrowserRecording(tabId: string): Promise<string> {...@@ -137,10 +141,11 @@ export async function startBrowserRecording(tabId: string): Promise<string> {
137 activeTabId: active.tabId,141 activeTabId: active.tabId,
138 });142 });
139 }143 }
140 const rect = useBrowserSurfaceStore.getState().byTabId[tabId]?.rect;144 const surface = useBrowserSurfaceStore.getState().byTabId[tabId];
145 const recordingSize = surface?.content ?? surface?.rect;
141 const canvas = document.createElement("canvas");146 const canvas = document.createElement("canvas");
142 canvas.width = Math.max(1, rect?.width ?? 1280);147 canvas.width = Math.max(1, recordingSize?.width ?? 1280);
143 canvas.height = Math.max(1, rect?.height ?? 800);148 canvas.height = Math.max(1, recordingSize?.height ?? 800);
144 const context = canvas.getContext("2d", { alpha: false });149 const context = canvas.getContext("2d", { alpha: false });
145 if (!context) {150 if (!context) {
146 throw new BrowserRecordingCanvasUnavailableError({151 throw new BrowserRecordingCanvasUnavailableError({
apps/web/src/browser/browserRecordingScope.test.ts created+11
...@@ -0,0 +1,11 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope";
4
5describe("resolveBrowserRecordingStopTarget", () => {
6 it("stops the active recording even after the requested tab changes", () => {
7 expect(resolveBrowserRecordingStopTarget("tab-a")).toBe("tab-a");
8 expect(resolveBrowserRecordingStopTarget("tab-b")).toBe("tab-b");
9 expect(resolveBrowserRecordingStopTarget(null)).toBeNull();
10 });
11});
apps/web/src/browser/browserRecordingScope.ts created+3
...@@ -0,0 +1,3 @@
1export function resolveBrowserRecordingStopTarget(activeTabId: string | null): string | null {
2 return activeTabId;
3}
apps/web/src/browser/browserSurfaceStore.test.ts created+38
...@@ -0,0 +1,38 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
4
5describe("browserSurfaceStore", () => {
6 it("tracks content dimensions for a browser that has never been visible", () => {
7 const tabId = "hidden-browser-surface-content-test";
8 useBrowserSurfaceStore.getState().presentContent(tabId, {
9 x: 0,
10 y: 0,
11 width: 393,
12 height: 852,
13 scale: 1,
14 scrollLeft: 0,
15 scrollTop: 0,
16 });
17
18 expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({
19 rect: null,
20 visible: false,
21 content: { width: 393, height: 852 },
22 });
23 });
24
25 it("uses the live panel rect for a hidden background tab", () => {
26 const staleRect = { x: 0, y: 0, width: 500, height: 700 };
27 const liveRect = { x: 10, y: 20, width: 900, height: 640 };
28 expect(
29 resolveBrowserSurfacePanelRect(
30 {
31 hidden: { rect: staleRect, visible: false, content: null, updatedAt: 1 },
32 active: { rect: liveRect, visible: true, content: null, updatedAt: 2 },
33 },
34 "hidden",
35 ),
36 ).toEqual(liveRect);
37 });
38});
apps/web/src/browser/browserSurfaceStore.ts+69-1
...@@ -10,15 +10,47 @@ export interface BrowserSurfaceRect {...@@ -10,15 +10,47 @@ export interface BrowserSurfaceRect {
10export interface BrowserSurfacePresentation {10export interface BrowserSurfacePresentation {
11 readonly rect: BrowserSurfaceRect | null;11 readonly rect: BrowserSurfaceRect | null;
12 readonly visible: boolean;12 readonly visible: boolean;
13 readonly content: BrowserSurfaceContentPresentation | null;
13 readonly updatedAt: number;14 readonly updatedAt: number;
14}15}
1516
17export interface BrowserSurfaceContentPresentation {
18 readonly x: number;
19 readonly y: number;
20 readonly width: number;
21 readonly height: number;
22 readonly scale: number;
23 readonly scrollLeft: number;
24 readonly scrollTop: number;
25}
26
16interface BrowserSurfaceStoreState {27interface BrowserSurfaceStoreState {
17 readonly byTabId: Record<string, BrowserSurfacePresentation>;28 readonly byTabId: Record<string, BrowserSurfacePresentation>;
18 readonly present: (tabId: string, rect: BrowserSurfaceRect, visible: boolean) => void;29 readonly present: (tabId: string, rect: BrowserSurfaceRect, visible: boolean) => void;
30 readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void;
19 readonly hide: (tabId: string) => void;31 readonly hide: (tabId: string) => void;
20}32}
2133
34export function resolveBrowserSurfacePanelRect(
35 byTabId: Readonly<Record<string, BrowserSurfacePresentation>>,
36 tabId: string,
37): BrowserSurfaceRect | null {
38 const current = byTabId[tabId];
39 if (current?.visible && current.rect) return current.rect;
40
41 let latestVisible: BrowserSurfacePresentation | undefined;
42 for (const presentation of Object.values(byTabId)) {
43 if (
44 presentation.visible &&
45 presentation.rect &&
46 (!latestVisible || presentation.updatedAt > latestVisible.updatedAt)
47 ) {
48 latestVisible = presentation;
49 }
50 }
51 return latestVisible?.rect ?? current?.rect ?? null;
52}
53
22const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean =>54const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean =>
23 left !== null &&55 left !== null &&
24 left.x === right.x &&56 left.x === right.x &&
...@@ -35,7 +67,43 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =...@@ -35,7 +67,43 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =
35 return {67 return {
36 byTabId: {68 byTabId: {
37 ...state.byTabId,69 ...state.byTabId,
38 [tabId]: { rect, visible, updatedAt: Date.now() },70 [tabId]: { rect, visible, content: current?.content ?? null, updatedAt: Date.now() },
71 },
72 };
73 }),
74 presentContent: (tabId, content) =>
75 set((state) => {
76 const current = state.byTabId[tabId];
77 if (!current) {
78 return {
79 byTabId: {
80 ...state.byTabId,
81 [tabId]: {
82 rect: null,
83 visible: false,
84 content,
85 updatedAt: Date.now(),
86 },
87 },
88 };
89 }
90 const previous = current.content;
91 if (
92 previous &&
93 previous.x === content.x &&
94 previous.y === content.y &&
95 previous.width === content.width &&
96 previous.height === content.height &&
97 previous.scale === content.scale &&
98 previous.scrollLeft === content.scrollLeft &&
99 previous.scrollTop === content.scrollTop
100 ) {
101 return state;
102 }
103 return {
104 byTabId: {
105 ...state.byTabId,
106 [tabId]: { ...current, content, updatedAt: Date.now() },
39 },107 },
40 };108 };
41 }),109 }),
apps/web/src/browser/browserViewportActions.test.ts created+112
...@@ -0,0 +1,112 @@
1import type { PreviewViewportSetting } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4import {
5 BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS,
6 commitBrowserViewportChange,
7 subscribeBrowserViewportChange,
8} from "./browserViewportActions";
9
10describe("browserViewportActions", () => {
11 it("routes drag commits to the visible tab handler and cleans up exactly that handler", async () => {
12 const first = vi.fn(async () => undefined);
13 const second = vi.fn(async () => undefined);
14 const unsubscribeFirst = subscribeBrowserViewportChange("tab-1", first);
15 const unsubscribeSecond = subscribeBrowserViewportChange("tab-1", second);
16
17 unsubscribeFirst();
18 await commitBrowserViewportChange("tab-1", {
19 _tag: "freeform",
20 width: 900,
21 height: 700,
22 });
23 expect(first).not.toHaveBeenCalled();
24 expect(second).toHaveBeenCalledWith({ _tag: "freeform", width: 900, height: 700 });
25
26 unsubscribeSecond();
27 await expect(
28 commitBrowserViewportChange("tab-1", {
29 _tag: "freeform",
30 width: 800,
31 height: 600,
32 }),
33 ).rejects.toThrow("No visible browser viewport handler");
34 });
35
36 it("commits viewport changes in order for each tab", async () => {
37 let releaseFirst: (() => void) | undefined;
38 let markFirstStarted: (() => void) | undefined;
39 const firstPending = new Promise<void>((resolve) => {
40 releaseFirst = resolve;
41 });
42 const firstStarted = new Promise<void>((resolve) => {
43 markFirstStarted = resolve;
44 });
45 const calls: Array<number> = [];
46 const unsubscribe = subscribeBrowserViewportChange("tab-serial", async (setting) => {
47 if (setting._tag === "fill") return;
48 calls.push(setting.width);
49 if (setting.width === 800) {
50 markFirstStarted?.();
51 await firstPending;
52 }
53 });
54
55 const first = commitBrowserViewportChange("tab-serial", {
56 _tag: "freeform",
57 width: 800,
58 height: 600,
59 });
60 const second = commitBrowserViewportChange("tab-serial", {
61 _tag: "freeform",
62 width: 900,
63 height: 700,
64 });
65 await firstStarted;
66 expect(calls).toEqual([800]);
67
68 releaseFirst?.();
69 await Promise.all([first, second]);
70 expect(calls).toEqual([800, 900]);
71 unsubscribe();
72 });
73
74 it("does not let a timed-out handler overtake a newer viewport commit", async () => {
75 vi.useFakeTimers();
76 try {
77 let releaseFirst: (() => void) | undefined;
78 const delayed = new Promise<void>((resolve) => {
79 releaseFirst = resolve;
80 });
81 const handler = vi.fn(async (_setting: PreviewViewportSetting): Promise<void> => undefined);
82 handler.mockImplementationOnce(() => delayed).mockResolvedValueOnce(undefined);
83 const unsubscribe = subscribeBrowserViewportChange("tab-timeout", handler);
84 const first = commitBrowserViewportChange("tab-timeout", {
85 _tag: "freeform",
86 width: 800,
87 height: 600,
88 });
89 const firstResult = expect(first).rejects.toThrow(
90 "Timed out committing the browser viewport for tab tab-timeout",
91 );
92 const second = commitBrowserViewportChange("tab-timeout", {
93 _tag: "freeform",
94 width: 900,
95 height: 700,
96 });
97
98 await vi.advanceTimersByTimeAsync(BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS);
99 await firstResult;
100 expect(handler).toHaveBeenCalledTimes(1);
101
102 releaseFirst?.();
103 await second;
104
105 expect(handler).toHaveBeenCalledTimes(2);
106 expect(handler.mock.calls[1]?.[0]).toMatchObject({ width: 900, height: 700 });
107 unsubscribe();
108 } finally {
109 vi.useRealTimers();
110 }
111 });
112});
apps/web/src/browser/browserViewportActions.ts created+66
...@@ -0,0 +1,66 @@
1import type { PreviewViewportSetting } from "@t3tools/contracts";
2
3type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise<void>;
4
5export const BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS = 15_000;
6
7export class BrowserViewportCommitTimeoutError extends Error {
8 override readonly name = "BrowserViewportCommitTimeoutError";
9
10 constructor(readonly tabId: string) {
11 super(`Timed out committing the browser viewport for tab ${tabId}`);
12 }
13}
14
15const handlers = new Map<string, BrowserViewportHandler>();
16const commitTails = new Map<string, Promise<void>>();
17
18const runHandlerWithTimeout = (tabId: string, operation: Promise<void>): Promise<void> => {
19 let timeoutId: ReturnType<typeof setTimeout> | undefined;
20 const timeout = new Promise<never>((_resolve, reject) => {
21 timeoutId = setTimeout(
22 () => reject(new BrowserViewportCommitTimeoutError(tabId)),
23 BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS,
24 );
25 });
26 return Promise.race([operation, timeout]).finally(() => {
27 if (timeoutId !== undefined) clearTimeout(timeoutId);
28 });
29};
30
31export function subscribeBrowserViewportChange(
32 tabId: string,
33 handler: BrowserViewportHandler,
34): () => void {
35 handlers.set(tabId, handler);
36 return () => {
37 if (handlers.get(tabId) === handler) handlers.delete(tabId);
38 };
39}
40
41export function commitBrowserViewportChange(
42 tabId: string,
43 setting: PreviewViewportSetting,
44): Promise<void> {
45 const previous = commitTails.get(tabId) ?? Promise.resolve();
46 const started = previous
47 .catch(() => undefined)
48 .then(() => {
49 const handler = handlers.get(tabId);
50 const operation = handler
51 ? Promise.resolve().then(() => handler(setting))
52 : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`));
53 return { operation };
54 });
55 // The queue follows the real handler lifetime, not the caller-facing timeout.
56 // A slow commit therefore cannot time out, release the queue, and overwrite a
57 // newer viewport after that newer request has already completed.
58 const execution = started.then(({ operation }) => operation);
59 const result = started.then(({ operation }) => runHandlerWithTimeout(tabId, operation));
60 commitTails.set(tabId, execution);
61 const clear = () => {
62 if (commitTails.get(tabId) === execution) commitTails.delete(tabId);
63 };
64 void execution.then(clear, clear);
65 return result;
66}
apps/web/src/browser/browserViewportLayout.test.ts created+165
...@@ -0,0 +1,165 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 resizeBrowserViewportFromRail,
5 resizeFreeformViewport,
6 resolveBrowserDeviceViewportLayout,
7 resolveBrowserViewportLayout,
8 resolveResponsiveBrowserViewportSize,
9} from "./browserViewportLayout";
10
11describe("resolveBrowserViewportLayout", () => {
12 it("fills the available surface in fill mode", () => {
13 expect(resolveBrowserViewportLayout({ width: 700, height: 500 }, { _tag: "fill" })).toEqual({
14 canvasWidth: 700,
15 canvasHeight: 500,
16 viewportX: 0,
17 viewportY: 0,
18 viewportWidth: 700,
19 viewportHeight: 500,
20 viewportScale: 1,
21 fillsPanel: true,
22 });
23 });
24
25 it("centers a smaller fixed viewport", () => {
26 expect(
27 resolveBrowserViewportLayout(
28 { width: 700, height: 1000 },
29 { _tag: "freeform", width: 393, height: 852 },
30 ),
31 ).toMatchObject({
32 canvasWidth: 700,
33 canvasHeight: 1000,
34 viewportX: 154,
35 viewportY: 74,
36 viewportWidth: 393,
37 viewportHeight: 852,
38 });
39 });
40
41 it("scales a larger fixed viewport down to fit without creating overflow", () => {
42 const layout = resolveBrowserViewportLayout(
43 { width: 600, height: 700 },
44 { _tag: "freeform", width: 1440, height: 900 },
45 );
46 expect(layout).toMatchObject({
47 canvasWidth: 600,
48 canvasHeight: 700,
49 viewportX: 0,
50 viewportY: 163,
51 viewportWidth: 600,
52 viewportHeight: 375,
53 });
54 expect(layout.viewportScale).toBeCloseTo(5 / 12);
55 });
56
57 it("keeps fixed dimensions in page CSS pixels when browser zoom changes", () => {
58 expect(
59 resolveBrowserViewportLayout(
60 { width: 800, height: 700 },
61 { _tag: "freeform", width: 400, height: 300 },
62 1.5,
63 ),
64 ).toMatchObject({
65 viewportX: 100,
66 viewportY: 125,
67 viewportWidth: 600,
68 viewportHeight: 450,
69 });
70 expect(resizeFreeformViewport({ width: 400, height: 300 }, { x: 150, y: 75 }, 1.5)).toEqual({
71 width: 500,
72 height: 350,
73 });
74 });
75
76 it("bounds freeform drag sizes and total render area", () => {
77 expect(resizeFreeformViewport({ width: 1024, height: 768 }, { x: -2000, y: -2000 })).toEqual({
78 width: 240,
79 height: 240,
80 });
81 const large = resizeFreeformViewport({ width: 1920, height: 1080 }, { x: 2000, y: 2000 });
82 expect(large.width * large.height).toBeLessThanOrEqual(3840 * 2160);
83 });
84
85 it("resizes only the axes controlled by each edge", () => {
86 expect(
87 resizeFreeformViewport({ width: 800, height: 600 }, { x: -100, y: 500 }, 1, "west"),
88 ).toEqual({ width: 900, height: 600 });
89 expect(
90 resizeFreeformViewport({ width: 800, height: 600 }, { x: 500, y: 100 }, 1, "north"),
91 ).toEqual({ width: 800, height: 500 });
92 expect(
93 resizeFreeformViewport({ width: 800, height: 600 }, { x: -100, y: -50 }, 1, "northwest"),
94 ).toEqual({ width: 900, height: 650 });
95 });
96
97 it("preserves a locked aspect ratio from either axis", () => {
98 expect(
99 resizeFreeformViewport({ width: 800, height: 600 }, { x: 200, y: 0 }, 1, "east", 4 / 3),
100 ).toEqual({ width: 1000, height: 750 });
101 expect(
102 resizeFreeformViewport({ width: 800, height: 600 }, { x: 0, y: 150 }, 1, "south", 4 / 3),
103 ).toEqual({ width: 1000, height: 750 });
104 });
105
106 it("reserves persistent device-toolbar rails around the guest viewport", () => {
107 expect(
108 resolveBrowserDeviceViewportLayout(
109 { width: 1200, height: 900 },
110 { _tag: "freeform", width: 1180, height: 858 },
111 ),
112 ).toEqual({
113 canvasWidth: 1200,
114 canvasHeight: 900,
115 viewportX: 10,
116 viewportY: 32,
117 viewportWidth: 1180,
118 viewportHeight: 858,
119 viewportScale: 1,
120 fillsPanel: false,
121 });
122 });
123
124 it("captures the available framed area when responsive mode is enabled", () => {
125 expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({
126 width: 1180,
127 height: 858,
128 });
129 expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({
130 width: 590,
131 height: 429,
132 });
133 });
134
135 it("keeps the grabbed rail under the pointer across centered layout boundaries", () => {
136 const available = { width: 1120, height: 818 };
137 expect(
138 resizeBrowserViewportFromRail(
139 { width: 1120, height: 818 },
140 { x: -100, y: -50 },
141 available,
142 1,
143 "southeast",
144 ),
145 ).toEqual({ width: 920, height: 718 });
146 expect(
147 resizeBrowserViewportFromRail(
148 { width: 800, height: 600 },
149 { x: 300, y: 0 },
150 { width: 1200, height: 800 },
151 1,
152 "east",
153 ),
154 ).toEqual({ width: 1300, height: 600 });
155 expect(
156 resizeBrowserViewportFromRail(
157 { width: 560, height: 409 },
158 { x: -100, y: 0 },
159 available,
160 2,
161 "east",
162 ),
163 ).toEqual({ width: 460, height: 409 });
164 });
165});
apps/web/src/browser/browserViewportLayout.ts created+286
...@@ -0,0 +1,286 @@
1import {
2 PREVIEW_VIEWPORT_MAX_AREA,
3 PREVIEW_VIEWPORT_MAX_DIMENSION,
4 PREVIEW_VIEWPORT_MIN_DIMENSION,
5 type PreviewViewportSetting,
6 type PreviewViewportSize,
7} from "@t3tools/contracts";
8
9export interface BrowserViewportLayout {
10 readonly canvasWidth: number;
11 readonly canvasHeight: number;
12 readonly viewportX: number;
13 readonly viewportY: number;
14 /** Visible footprint inside the preview panel after fit-to-panel scaling. */
15 readonly viewportWidth: number;
16 readonly viewportHeight: number;
17 /** Presentation-only scale; the guest keeps its requested CSS viewport. */
18 readonly viewportScale: number;
19 readonly fillsPanel: boolean;
20}
21
22export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 32;
23export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 10;
24
25export type BrowserViewportResizeDirection =
26 | "north"
27 | "northeast"
28 | "east"
29 | "southeast"
30 | "south"
31 | "southwest"
32 | "west"
33 | "northwest";
34
35export const browserViewportSettingKey = (setting: PreviewViewportSetting): string =>
36 setting._tag === "fill"
37 ? "fill"
38 : `${setting._tag}:${setting.width}:${setting.height}:${setting._tag === "preset" ? setting.presetId : ""}`;
39
40const normalizeZoomFactor = (zoomFactor: number): number =>
41 Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
42
43export function resolveBrowserDeviceViewportArea(container: {
44 readonly width: number;
45 readonly height: number;
46}): PreviewViewportSize {
47 return {
48 width: Math.max(1, container.width - BROWSER_VIEWPORT_RESIZE_RAIL_SIZE * 2),
49 height: Math.max(
50 1,
51 container.height - BROWSER_DEVICE_TOOLBAR_HEIGHT - BROWSER_VIEWPORT_RESIZE_RAIL_SIZE,
52 ),
53 };
54}
55
56export function resolveBrowserViewportLayout(
57 container: { readonly width: number; readonly height: number },
58 setting: PreviewViewportSetting,
59 zoomFactor = 1,
60): BrowserViewportLayout {
61 const containerWidth = Math.max(1, Math.round(container.width));
62 const containerHeight = Math.max(1, Math.round(container.height));
63 if (setting._tag === "fill") {
64 return {
65 canvasWidth: containerWidth,
66 canvasHeight: containerHeight,
67 viewportX: 0,
68 viewportY: 0,
69 viewportWidth: containerWidth,
70 viewportHeight: containerHeight,
71 viewportScale: 1,
72 fillsPanel: true,
73 };
74 }
75 const normalizedZoomFactor = normalizeZoomFactor(zoomFactor);
76 const renderedWidth = setting.width * normalizedZoomFactor;
77 const renderedHeight = setting.height * normalizedZoomFactor;
78 const viewportScale = Math.min(
79 1,
80 containerWidth / renderedWidth,
81 containerHeight / renderedHeight,
82 );
83 const viewportWidth = renderedWidth * viewportScale;
84 const viewportHeight = renderedHeight * viewportScale;
85 return {
86 canvasWidth: containerWidth,
87 canvasHeight: containerHeight,
88 viewportX: Math.max(0, Math.round((containerWidth - viewportWidth) / 2)),
89 viewportY: Math.max(0, Math.round((containerHeight - viewportHeight) / 2)),
90 viewportWidth,
91 viewportHeight,
92 viewportScale,
93 fillsPanel: false,
94 };
95}
96
97export function resolveBrowserDeviceViewportLayout(
98 container: { readonly width: number; readonly height: number },
99 setting: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>,
100 zoomFactor = 1,
101): BrowserViewportLayout {
102 const layout = resolveBrowserViewportLayout(
103 resolveBrowserDeviceViewportArea(container),
104 setting,
105 zoomFactor,
106 );
107 return {
108 ...layout,
109 canvasWidth: Math.max(1, Math.round(container.width)),
110 canvasHeight: Math.max(1, Math.round(container.height)),
111 viewportX: layout.viewportX + BROWSER_VIEWPORT_RESIZE_RAIL_SIZE,
112 viewportY: layout.viewportY + BROWSER_DEVICE_TOOLBAR_HEIGHT,
113 };
114}
115
116const clampViewportDimension = (value: number): number =>
117 Math.min(PREVIEW_VIEWPORT_MAX_DIMENSION, Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, value));
118
119const validAspectRatio = (aspectRatio: number | undefined): aspectRatio is number =>
120 aspectRatio !== undefined && Number.isFinite(aspectRatio) && aspectRatio > 0;
121
122function resizeAtAspectRatio(
123 desired: number,
124 aspectRatio: number,
125 primaryAxis: "width" | "height",
126): PreviewViewportSize {
127 if (primaryAxis === "width") {
128 const minimum = Math.ceil(
129 Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION * aspectRatio),
130 );
131 const maximum = Math.floor(
132 Math.min(
133 PREVIEW_VIEWPORT_MAX_DIMENSION,
134 PREVIEW_VIEWPORT_MAX_DIMENSION * aspectRatio,
135 Math.sqrt(PREVIEW_VIEWPORT_MAX_AREA * aspectRatio),
136 ),
137 );
138 let width = Math.min(maximum, Math.max(minimum, Math.round(desired)));
139 let height = Math.round(width / aspectRatio);
140 while (width * height > PREVIEW_VIEWPORT_MAX_AREA && width > minimum) {
141 width -= 1;
142 height = Math.round(width / aspectRatio);
143 }
144 return { width, height };
145 }
146
147 const minimum = Math.ceil(
148 Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION / aspectRatio),
149 );
150 const maximum = Math.floor(
151 Math.min(
152 PREVIEW_VIEWPORT_MAX_DIMENSION,
153 PREVIEW_VIEWPORT_MAX_DIMENSION / aspectRatio,
154 Math.sqrt(PREVIEW_VIEWPORT_MAX_AREA / aspectRatio),
155 ),
156 );
157 let height = Math.min(maximum, Math.max(minimum, Math.round(desired)));
158 let width = Math.round(height * aspectRatio);
159 while (width * height > PREVIEW_VIEWPORT_MAX_AREA && height > minimum) {
160 height -= 1;
161 width = Math.round(height * aspectRatio);
162 }
163 return { width, height };
164}
165
166export function resizeFreeformViewport(
167 start: PreviewViewportSize,
168 delta: { readonly x: number; readonly y: number },
169 zoomFactor = 1,
170 direction: BrowserViewportResizeDirection = "southeast",
171 aspectRatio?: number,
172): PreviewViewportSize {
173 const normalizedZoomFactor = normalizeZoomFactor(zoomFactor);
174 const horizontalDelta = direction.includes("east")
175 ? delta.x
176 : direction.includes("west")
177 ? -delta.x
178 : 0;
179 const verticalDelta = direction.includes("south")
180 ? delta.y
181 : direction.includes("north")
182 ? -delta.y
183 : 0;
184 const desiredWidth = start.width + horizontalDelta / normalizedZoomFactor;
185 const desiredHeight = start.height + verticalDelta / normalizedZoomFactor;
186 if (validAspectRatio(aspectRatio)) {
187 const controlsWidth = horizontalDelta !== 0 || direction === "east" || direction === "west";
188 const controlsHeight = verticalDelta !== 0 || direction === "north" || direction === "south";
189 const primaryAxis =
190 controlsWidth && !controlsHeight
191 ? "width"
192 : controlsHeight && !controlsWidth
193 ? "height"
194 : Math.abs(desiredWidth - start.width) / start.width >=
195 Math.abs(desiredHeight - start.height) / start.height
196 ? "width"
197 : "height";
198 return resizeAtAspectRatio(
199 primaryAxis === "width" ? desiredWidth : desiredHeight,
200 aspectRatio,
201 primaryAxis,
202 );
203 }
204 let width = clampViewportDimension(Math.round(desiredWidth));
205 let height = clampViewportDimension(Math.round(desiredHeight));
206 if (width * height <= PREVIEW_VIEWPORT_MAX_AREA) return { width, height };
207 if (Math.abs(horizontalDelta) >= Math.abs(verticalDelta)) {
208 width = Math.max(
209 PREVIEW_VIEWPORT_MIN_DIMENSION,
210 Math.floor(PREVIEW_VIEWPORT_MAX_AREA / height),
211 );
212 } else {
213 height = Math.max(
214 PREVIEW_VIEWPORT_MIN_DIMENSION,
215 Math.floor(PREVIEW_VIEWPORT_MAX_AREA / width),
216 );
217 }
218 return { width, height };
219}
220
221const resizeFromEndRail = (start: number, pointerDelta: number, available: number): number => {
222 const startEdge = start < available ? (available + start) / 2 : start;
223 const targetEdge = startEdge + pointerDelta;
224 return targetEdge <= available ? targetEdge * 2 - available : targetEdge;
225};
226
227const resizeFromStartRail = (start: number, pointerDelta: number, available: number): number => {
228 if (start > available) {
229 const distanceToFit = start - available;
230 return pointerDelta <= distanceToFit
231 ? start - pointerDelta
232 : available - (pointerDelta - distanceToFit) * 2;
233 }
234 const targetEdge = (available - start) / 2 + pointerDelta;
235 return targetEdge >= 0 ? available - targetEdge * 2 : available - targetEdge;
236};
237
238export function resizeBrowserViewportFromRail(
239 start: PreviewViewportSize,
240 pointerDelta: { readonly x: number; readonly y: number },
241 available: PreviewViewportSize,
242 zoomFactor = 1,
243 direction: BrowserViewportResizeDirection = "southeast",
244 aspectRatio?: number,
245): PreviewViewportSize {
246 const normalizedZoomFactor = normalizeZoomFactor(zoomFactor);
247 const startWidth = start.width * normalizedZoomFactor;
248 const startHeight = start.height * normalizedZoomFactor;
249 const desiredWidth = direction.includes("east")
250 ? resizeFromEndRail(startWidth, pointerDelta.x, available.width)
251 : direction.includes("west")
252 ? resizeFromStartRail(startWidth, pointerDelta.x, available.width)
253 : startWidth;
254 const desiredHeight = direction.includes("south")
255 ? resizeFromEndRail(startHeight, pointerDelta.y, available.height)
256 : direction.includes("north")
257 ? resizeFromStartRail(startHeight, pointerDelta.y, available.height)
258 : startHeight;
259 const widthDelta = desiredWidth - startWidth;
260 const heightDelta = desiredHeight - startHeight;
261 return resizeFreeformViewport(
262 start,
263 {
264 x: direction.includes("west") ? -widthDelta : widthDelta,
265 y: direction.includes("north") ? -heightDelta : heightDelta,
266 },
267 normalizedZoomFactor,
268 direction,
269 aspectRatio,
270 );
271}
272
273export function resolveResponsiveBrowserViewportSize(
274 container: { readonly width: number; readonly height: number },
275 zoomFactor = 1,
276): PreviewViewportSize {
277 const area = resolveBrowserDeviceViewportArea(container);
278 const normalizedZoomFactor = normalizeZoomFactor(zoomFactor);
279 return resizeFreeformViewport(
280 {
281 width: area.width / normalizedZoomFactor,
282 height: area.height / normalizedZoomFactor,
283 },
284 { x: 0, y: 0 },
285 );
286}
apps/web/src/browser/useBrowserViewportResize.ts created+260
...@@ -0,0 +1,260 @@
1"use client";
2
3import type { PreviewViewportSetting, PreviewViewportSize } from "@t3tools/contracts";
4import {
5 useCallback,
6 useEffect,
7 useRef,
8 useState,
9 type KeyboardEvent as ReactKeyboardEvent,
10 type PointerEvent as ReactPointerEvent,
11} from "react";
12
13import { commitBrowserViewportChange } from "./browserViewportActions";
14import {
15 browserViewportSettingKey,
16 resizeBrowserViewportFromRail,
17 resizeFreeformViewport,
18 resolveBrowserDeviceViewportArea,
19 resolveBrowserDeviceViewportLayout,
20 resolveBrowserViewportLayout,
21 type BrowserViewportResizeDirection,
22} from "./browserViewportLayout";
23
24interface ViewportDrag extends PreviewViewportSize {
25 readonly sourceKey: string;
26 readonly direction: BrowserViewportResizeDirection;
27}
28
29const KEYBOARD_RESIZE_COMMIT_DELAY_MS = 150;
30
31export function useBrowserViewportResize(options: {
32 readonly tabId: string;
33 readonly viewport: PreviewViewportSetting;
34 readonly zoomFactor: number;
35 readonly containerSize: PreviewViewportSize;
36 readonly deviceToolbarVisible: boolean;
37 readonly aspectRatio: number | null;
38}) {
39 const { tabId, viewport, zoomFactor, containerSize, deviceToolbarVisible, aspectRatio } = options;
40 const dragCleanupRef = useRef<(() => void) | null>(null);
41 const dragVersionRef = useRef(0);
42 const keyboardCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
43 const keyboardViewportRef = useRef<ViewportDrag | null>(null);
44 const [dragViewport, setDragViewport] = useState<ViewportDrag | null>(null);
45 const sourceViewportKey = browserViewportSettingKey(viewport);
46 const sourceViewportKeyRef = useRef(sourceViewportKey);
47 sourceViewportKeyRef.current = sourceViewportKey;
48 const activeDrag = dragViewport?.sourceKey === sourceViewportKey ? dragViewport : null;
49 const effectiveViewport = activeDrag
50 ? ({
51 _tag: "freeform",
52 width: activeDrag.width,
53 height: activeDrag.height,
54 } as const satisfies PreviewViewportSetting)
55 : viewport;
56 const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
57 const viewportContainerSize = deviceToolbarVisible
58 ? resolveBrowserDeviceViewportArea(containerSize)
59 : containerSize;
60 const layout =
61 deviceToolbarVisible && effectiveViewport._tag !== "fill"
62 ? resolveBrowserDeviceViewportLayout(containerSize, effectiveViewport, zoomFactor)
63 : resolveBrowserViewportLayout(containerSize, effectiveViewport, zoomFactor);
64
65 useEffect(
66 () => () => {
67 dragVersionRef.current += 1;
68 dragCleanupRef.current?.();
69 if (keyboardCommitTimerRef.current !== null) {
70 clearTimeout(keyboardCommitTimerRef.current);
71 }
72 keyboardCommitTimerRef.current = null;
73 keyboardViewportRef.current = null;
74 },
75 [],
76 );
77
78 useEffect(() => {
79 const pending = keyboardViewportRef.current;
80 if (!pending || pending.sourceKey === sourceViewportKey) return;
81 if (keyboardCommitTimerRef.current !== null) {
82 clearTimeout(keyboardCommitTimerRef.current);
83 keyboardCommitTimerRef.current = null;
84 }
85 keyboardViewportRef.current = null;
86 }, [sourceViewportKey]);
87
88 const commitViewportChange = useCallback(
89 (next: PreviewViewportSetting) => {
90 dragVersionRef.current += 1;
91 dragCleanupRef.current?.();
92 if (keyboardCommitTimerRef.current !== null) {
93 clearTimeout(keyboardCommitTimerRef.current);
94 keyboardCommitTimerRef.current = null;
95 }
96 keyboardViewportRef.current = null;
97 setDragViewport(null);
98 return commitBrowserViewportChange(tabId, next);
99 },
100 [tabId],
101 );
102
103 const clearDrag = () => setDragViewport(null);
104 const commitDrag = (next: PreviewViewportSetting) => {
105 const version = ++dragVersionRef.current;
106 const clearIfCurrent = () => {
107 if (dragVersionRef.current === version) clearDrag();
108 };
109 void commitBrowserViewportChange(tabId, next).then(clearIfCurrent, clearIfCurrent);
110 };
111
112 const handleResizeKeyDown = (
113 direction: BrowserViewportResizeDirection,
114 event: ReactKeyboardEvent<HTMLButtonElement>,
115 ) => {
116 if (effectiveViewport._tag === "fill") return;
117 const controlsWidth = direction.includes("east") || direction.includes("west");
118 const controlsHeight = direction.includes("north") || direction.includes("south");
119 const step = (event.shiftKey ? 50 : 10) * normalizedZoomFactor;
120 const delta =
121 event.key === "ArrowLeft" && controlsWidth
122 ? { x: -step, y: 0 }
123 : event.key === "ArrowRight" && controlsWidth
124 ? { x: step, y: 0 }
125 : event.key === "ArrowUp" && controlsHeight
126 ? { x: 0, y: -step }
127 : event.key === "ArrowDown" && controlsHeight
128 ? { x: 0, y: step }
129 : null;
130 if (!delta) return;
131 event.preventDefault();
132 event.stopPropagation();
133 const pending = keyboardViewportRef.current;
134 const base = pending?.sourceKey === sourceViewportKey ? pending : effectiveViewport;
135 const next = resizeFreeformViewport(
136 base,
137 delta,
138 zoomFactor,
139 direction,
140 aspectRatio ?? undefined,
141 );
142 if (next.width === base.width && next.height === base.height) return;
143 const keyboardViewport = { sourceKey: sourceViewportKey, ...next, direction };
144 keyboardViewportRef.current = keyboardViewport;
145 setDragViewport(keyboardViewport);
146 if (keyboardCommitTimerRef.current !== null) {
147 clearTimeout(keyboardCommitTimerRef.current);
148 }
149 keyboardCommitTimerRef.current = setTimeout(() => {
150 keyboardCommitTimerRef.current = null;
151 const latest = keyboardViewportRef.current;
152 if (!latest || latest.sourceKey !== sourceViewportKeyRef.current) return;
153 keyboardViewportRef.current = null;
154 commitDrag({ _tag: "freeform", width: latest.width, height: latest.height });
155 }, KEYBOARD_RESIZE_COMMIT_DELAY_MS);
156 };
157
158 const handleResizePointerDown = (
159 direction: BrowserViewportResizeDirection,
160 event: ReactPointerEvent<HTMLButtonElement>,
161 ) => {
162 if (effectiveViewport._tag === "fill") return;
163 event.preventDefault();
164 event.stopPropagation();
165 if (keyboardCommitTimerRef.current !== null) {
166 clearTimeout(keyboardCommitTimerRef.current);
167 keyboardCommitTimerRef.current = null;
168 }
169 keyboardViewportRef.current = null;
170 dragCleanupRef.current?.();
171 dragVersionRef.current += 1;
172 const pointerId = event.pointerId;
173 const target = event.currentTarget;
174 const startX = event.clientX;
175 const startY = event.clientY;
176 const startWidth = effectiveViewport.width;
177 const startHeight = effectiveViewport.height;
178 const dragZoomFactor = normalizedZoomFactor * layout.viewportScale;
179 let latest = { width: startWidth, height: startHeight };
180 setDragViewport({
181 sourceKey: sourceViewportKey,
182 width: startWidth,
183 height: startHeight,
184 direction,
185 });
186 try {
187 target.setPointerCapture(pointerId);
188 } catch {
189 // Window listeners below keep the drag functional when capture is unavailable.
190 }
191
192 const sourceChanged = () => sourceViewportKeyRef.current !== sourceViewportKey;
193 const move = (moveEvent: PointerEvent) => {
194 if (moveEvent.pointerId !== pointerId) return;
195 if (sourceChanged()) {
196 cleanup();
197 dragVersionRef.current += 1;
198 clearDrag();
199 return;
200 }
201 moveEvent.preventDefault();
202 const { width, height } = resizeBrowserViewportFromRail(
203 { width: startWidth, height: startHeight },
204 {
205 x: moveEvent.clientX - startX,
206 y: moveEvent.clientY - startY,
207 },
208 viewportContainerSize,
209 dragZoomFactor,
210 direction,
211 aspectRatio ?? undefined,
212 );
213 latest = { width, height };
214 setDragViewport({ sourceKey: sourceViewportKey, width, height, direction });
215 };
216 function cleanup() {
217 window.removeEventListener("pointermove", move);
218 window.removeEventListener("pointerup", finish);
219 window.removeEventListener("pointercancel", cancel);
220 dragCleanupRef.current = null;
221 try {
222 target.releasePointerCapture(pointerId);
223 } catch {
224 // The browser may already have released capture on pointerup.
225 }
226 }
227 function finish(upEvent: PointerEvent) {
228 if (upEvent.pointerId !== pointerId) return;
229 cleanup();
230 if (sourceChanged() || (latest.width === startWidth && latest.height === startHeight)) {
231 clearDrag();
232 return;
233 }
234 commitDrag({
235 _tag: "freeform",
236 width: latest.width,
237 height: latest.height,
238 });
239 }
240 function cancel(cancelEvent: PointerEvent) {
241 if (cancelEvent.pointerId !== pointerId) return;
242 cleanup();
243 dragVersionRef.current += 1;
244 clearDrag();
245 }
246 dragCleanupRef.current = cleanup;
247 window.addEventListener("pointermove", move, { passive: false });
248 window.addEventListener("pointerup", finish);
249 window.addEventListener("pointercancel", cancel);
250 };
251
252 return {
253 activeDrag,
254 commitViewportChange,
255 effectiveViewport,
256 handleResizeKeyDown,
257 handleResizePointerDown,
258 layout,
259 };
260}
apps/web/src/components/ChatView.tsx-4
...@@ -129,7 +129,6 @@ import { addBrowserSurface } from "./preview/addBrowserSurface";...@@ -129,7 +129,6 @@ import { addBrowserSurface } from "./preview/addBrowserSurface";
129import { closePreviewSession } from "./preview/closePreviewSession";129import { closePreviewSession } from "./preview/closePreviewSession";
130import { subscribePreviewAction } from "./preview/previewActionBus";130import { subscribePreviewAction } from "./preview/previewActionBus";
131import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";131import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
132import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner";
133import { RightPanelTabs } from "./RightPanelTabs";132import { RightPanelTabs } from "./RightPanelTabs";
134import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";133import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
135import { BranchToolbar } from "./BranchToolbar";134import { BranchToolbar } from "./BranchToolbar";
...@@ -4704,9 +4703,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4704,9 +4703,6 @@ function ChatViewContent(props: ChatViewProps) {
47044703
4705 return (4704 return (
4706 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">4705 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
4707 {isElectron && activeThreadRef ? (
4708 <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} />
4709 ) : null}
4710 {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}4706 {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}
4711 <div4707 <div
4712 className={cn(4708 className={cn(
apps/web/src/components/preview/AgentBrowserCursor.tsx+12-2
...@@ -5,6 +5,7 @@ import { MousePointer2 } from "lucide-react";...@@ -5,6 +5,7 @@ import { MousePointer2 } from "lucide-react";
5import { useEffect, useState } from "react";5import { useEffect, useState } from "react";
66
7import { useBrowserPointerStore } from "~/browser/browserPointerStore";7import { useBrowserPointerStore } from "~/browser/browserPointerStore";
8import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
89
9import { agentBrowserCursorOpacity, type BrowserController } from "./agentBrowserCursorLogic";10import { agentBrowserCursorOpacity, type BrowserController } from "./agentBrowserCursorLogic";
1011
...@@ -17,6 +18,7 @@ export function AgentBrowserCursor(props: {...@@ -17,6 +18,7 @@ export function AgentBrowserCursor(props: {
17}) {18}) {
18 const { tabId, zoomFactor, controller } = props;19 const { tabId, zoomFactor, controller } = props;
19 const event = useBrowserPointerStore((state) => state.byTabId[tabId] ?? null);20 const event = useBrowserPointerStore((state) => state.byTabId[tabId] ?? null);
21 const content = useBrowserSurfaceStore((state) => state.byTabId[tabId]?.content ?? null);
2022
21 if (!event) return null;23 if (!event) return null;
2224
...@@ -24,6 +26,7 @@ export function AgentBrowserCursor(props: {...@@ -24,6 +26,7 @@ export function AgentBrowserCursor(props: {
24 <AgentBrowserCursorEvent26 <AgentBrowserCursorEvent
25 key={event.sequence}27 key={event.sequence}
26 event={event}28 event={event}
29 content={content}
27 zoomFactor={zoomFactor}30 zoomFactor={zoomFactor}
28 controller={controller}31 controller={controller}
29 />32 />
...@@ -32,10 +35,17 @@ export function AgentBrowserCursor(props: {...@@ -32,10 +35,17 @@ export function AgentBrowserCursor(props: {
3235
33function AgentBrowserCursorEvent(props: {36function AgentBrowserCursorEvent(props: {
34 readonly event: DesktopPreviewPointerEvent;37 readonly event: DesktopPreviewPointerEvent;
38 readonly content: {
39 readonly x: number;
40 readonly y: number;
41 readonly scale: number;
42 readonly scrollLeft: number;
43 readonly scrollTop: number;
44 } | null;
35 readonly zoomFactor: number;45 readonly zoomFactor: number;
36 readonly controller: BrowserController;46 readonly controller: BrowserController;
37}) {47}) {
38 const { event, zoomFactor, controller } = props;48 const { event, content, zoomFactor, controller } = props;
39 const [active, setActive] = useState(true);49 const [active, setActive] = useState(true);
4050
41 useEffect(() => {51 useEffect(() => {
...@@ -48,7 +58,7 @@ function AgentBrowserCursorEvent(props: {...@@ -48,7 +58,7 @@ function AgentBrowserCursorEvent(props: {
48 className="pointer-events-none absolute left-0 top-0 z-40 transition-[transform,opacity] duration-150 ease-out motion-reduce:transition-none"58 className="pointer-events-none absolute left-0 top-0 z-40 transition-[transform,opacity] duration-150 ease-out motion-reduce:transition-none"
49 style={{59 style={{
50 opacity: agentBrowserCursorOpacity(active, controller),60 opacity: agentBrowserCursorOpacity(active, controller),
51 transform: `translate3d(${event.x * zoomFactor}px, ${event.y * zoomFactor}px, 0)`,61 transform: `translate3d(${event.x * zoomFactor * (content?.scale ?? 1) + (content?.x ?? 0) - (content?.scrollLeft ?? 0)}px, ${event.y * zoomFactor * (content?.scale ?? 1) + (content?.y ?? 0) - (content?.scrollTop ?? 0)}px, 0)`,
52 }}62 }}
53 aria-hidden="true"63 aria-hidden="true"
54 data-agent-browser-cursor64 data-agent-browser-cursor
apps/web/src/components/preview/PreviewAutomationHosts.tsx created+569
...@@ -0,0 +1,569 @@
1"use client";
2
3import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react";
4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
5import {
6 FILL_PREVIEW_VIEWPORT,
7 PREVIEW_AUTOMATION_OPERATIONS,
8 type EnvironmentId,
9 type PreviewAutomationNavigateInput,
10 type PreviewAutomationOpenInput,
11 type PreviewAutomationResizeInput,
12 type PreviewAutomationResizeResult,
13 type PreviewAutomationHost as PreviewAutomationHostState,
14 type PreviewAutomationRequest,
15 type PreviewAutomationStatus,
16 type PreviewRenderedViewportSize,
17 type PreviewViewportSetting,
18 type ScopedThreadRef,
19} from "@t3tools/contracts";
20import { resolvePreviewViewport } from "@t3tools/shared/previewViewport";
21import { useCallback, useContext, useEffect, useMemo, useState } from "react";
22import { Atom } from "effect/unstable/reactivity";
23
24import {
25 applyPreviewServerSnapshot,
26 readThreadPreviewState,
27 reconcilePreviewServerSessions,
28 updatePreviewServerSnapshot,
29} from "~/previewStateStore";
30import { useRightPanelStore } from "~/rightPanelStore";
31import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver";
32import {
33 readActiveBrowserRecordingTabId,
34 startBrowserRecording,
35 stopBrowserRecording,
36} from "~/browser/browserRecording";
37import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope";
38import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
39import { isElectron } from "~/env";
40import { useEnvironments } from "~/state/environments";
41import { previewEnvironment } from "~/state/preview";
42import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
43import { useAtomCommand } from "~/state/use-atom-command";
44
45import { previewBridge } from "./previewBridge";
46import {
47 PreviewAutomationNavigationTimeoutError,
48 PreviewAutomationOperationError,
49 PreviewAutomationOverlayTimeoutError,
50 PreviewAutomationRecordingNotActiveError,
51 PreviewAutomationTargetUnavailableError,
52 PreviewAutomationViewportTimeoutError,
53} from "./previewAutomationErrors";
54import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer";
55import { createPreviewAutomationClientId } from "./previewAutomationClientId";
56import {
57 needsPreviewAutomationSessionSync,
58 resolvePreviewAutomationTarget,
59} from "./previewAutomationTarget";
60import { isPreviewViewportReady } from "./previewViewportReadiness";
61
62const waitForDesktopOverlay = async (
63 threadRef: ScopedThreadRef,
64 requestId: string,
65 tabId: string,
66 timeoutMs: number,
67): Promise<void> => {
68 const deadline = Date.now() + timeoutMs;
69 while (Date.now() <= deadline) {
70 const state = readThreadPreviewState(threadRef);
71 if (state.desktopByTabId[tabId] && previewBridge) {
72 const status = await previewBridge.automation.status(tabId);
73 if (status.available) return;
74 }
75 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
76 }
77 throw new PreviewAutomationOverlayTimeoutError({
78 requestId,
79 environmentId: threadRef.environmentId,
80 threadId: threadRef.threadId,
81 timeoutMs,
82 });
83};
84
85const waitForNavigationReadiness = async (
86 threadRef: ScopedThreadRef,
87 requestId: string,
88 tabId: string,
89 readiness: PreviewAutomationNavigateInput["readiness"],
90 timeoutMs: number,
91): Promise<void> => {
92 const targetReadiness = readiness ?? "load";
93 if (!previewBridge || targetReadiness === "none") return;
94 const deadline = Date.now() + timeoutMs;
95 while (Date.now() <= deadline) {
96 if (targetReadiness === "domContentLoaded") {
97 const readyState = await previewBridge.automation.evaluate(tabId, {
98 expression: "document.readyState",
99 });
100 if (readyState === "interactive" || readyState === "complete") return;
101 } else {
102 const status = await previewBridge.automation.status(tabId);
103 if (!status.loading) return;
104 }
105 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
106 }
107 throw new PreviewAutomationNavigationTimeoutError({
108 requestId,
109 environmentId: threadRef.environmentId,
110 threadId: threadRef.threadId,
111 tabId,
112 readiness: targetReadiness,
113 timeoutMs,
114 });
115};
116
117interface ExecutablePreviewWebview extends Element {
118 readonly executeJavaScript: (code: string, userGesture?: boolean) => Promise<unknown>;
119}
120
121const findPreviewWebview = (tabId: string): ExecutablePreviewWebview | null =>
122 Array.from(document.querySelectorAll<ExecutablePreviewWebview>("webview[data-preview-tab]")).find(
123 (candidate) => candidate.getAttribute("data-preview-tab") === tabId,
124 ) ?? null;
125
126const readWebviewViewport = async (
127 webview: ExecutablePreviewWebview,
128): Promise<PreviewRenderedViewportSize | null> => {
129 const value = await webview.executeJavaScript(
130 "({ width: window.innerWidth, height: window.innerHeight })",
131 );
132 if (typeof value !== "object" || value === null) return null;
133 const { width, height } = value as { readonly width?: unknown; readonly height?: unknown };
134 return typeof width === "number" &&
135 Number.isInteger(width) &&
136 width > 0 &&
137 typeof height === "number" &&
138 Number.isInteger(height) &&
139 height > 0
140 ? { width, height }
141 : null;
142};
143
144const readRenderedViewport = async (tabId: string): Promise<PreviewRenderedViewportSize | null> => {
145 const webview = findPreviewWebview(tabId);
146 if (!webview) return null;
147 return await readWebviewViewport(webview);
148};
149
150const readDeclaredViewport = (
151 webview: ExecutablePreviewWebview | null,
152): PreviewRenderedViewportSize | null => {
153 const width = Number(webview?.getAttribute("data-preview-css-width"));
154 const height = Number(webview?.getAttribute("data-preview-css-height"));
155 return Number.isInteger(width) && width > 0 && Number.isInteger(height) && height > 0
156 ? { width, height }
157 : null;
158};
159
160const waitForRenderedViewport = async (
161 tabId: string,
162 setting: PreviewViewportSetting,
163 timeoutMs: number,
164 context: {
165 readonly requestId: PreviewAutomationRequest["requestId"];
166 readonly environmentId: EnvironmentId;
167 readonly threadId: PreviewAutomationRequest["threadId"];
168 },
169): Promise<PreviewRenderedViewportSize> => {
170 const deadline = Date.now() + timeoutMs;
171 while (Date.now() <= deadline) {
172 try {
173 const webview = findPreviewWebview(tabId);
174 const appliedSettingKey = webview?.getAttribute("data-preview-viewport-key") ?? null;
175 const declaredViewport = readDeclaredViewport(webview);
176 const renderedViewport = webview ? await readWebviewViewport(webview) : null;
177 if (
178 renderedViewport &&
179 isPreviewViewportReady({
180 setting,
181 appliedSettingKey,
182 declaredViewport,
183 renderedViewport,
184 })
185 ) {
186 return renderedViewport;
187 }
188 } catch {
189 // Registration and navigation can transiently replace the guest while
190 // React applies the server snapshot. Retry until the operation deadline.
191 }
192 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
193 }
194 throw new PreviewAutomationViewportTimeoutError({
195 ...context,
196 tabId,
197 timeoutMs,
198 });
199};
200
201const currentStatus = async (
202 threadRef: ScopedThreadRef,
203 requestedTabId: string | null,
204): Promise<PreviewAutomationStatus> => {
205 const state = readThreadPreviewState(threadRef);
206 const { snapshot, tabId } = resolvePreviewAutomationTarget(state, requestedTabId);
207 const visible = tabId
208 ? (useBrowserSurfaceStore.getState().byTabId[tabId]?.visible ?? false)
209 : false;
210 const viewportSetting = snapshot ? (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT) : undefined;
211 const viewport = tabId ? await readRenderedViewport(tabId).catch(() => null) : null;
212 const viewportStatus = {
213 ...(viewportSetting === undefined ? {} : { viewportSetting }),
214 ...(viewport === null ? {} : { viewport }),
215 };
216 if (tabId && previewBridge && state.desktopByTabId[tabId]) {
217 const status = await previewBridge.automation.status(tabId);
218 return { ...status, visible, ...viewportStatus };
219 }
220 const navStatus = snapshot?.navStatus;
221 return {
222 available: Boolean(previewBridge?.automation),
223 visible,
224 tabId,
225 url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null,
226 title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null,
227 loading: navStatus?._tag === "Loading",
228 ...viewportStatus,
229 };
230};
231
232export function PreviewAutomationHosts() {
233 const { environments } = useEnvironments();
234 if (!isElectron || !previewBridge?.automation) return null;
235 return (
236 <>
237 {/*
238 * Host lifetime follows the desktop runtime's environment connections,
239 * not the routed thread. This keeps background threads automatable and
240 * lets the subscription runtime own reconnects for every saved target.
241 */}
242 {environments.map((environment) => (
243 <PreviewAutomationHost
244 key={environment.environmentId}
245 environmentId={environment.environmentId}
246 />
247 ))}
248 </>
249 );
250}
251
252function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) {
253 const { environmentId } = props;
254 const registry = useContext(RegistryContext);
255 const [automationClientId] = useState(createPreviewAutomationClientId);
256 const initialAutomationHost = useMemo<PreviewAutomationHostState>(
257 () => ({
258 clientId: automationClientId,
259 environmentId,
260 supportedOperations: [...PREVIEW_AUTOMATION_OPERATIONS],
261 }),
262 [automationClientId, environmentId],
263 );
264 const automationRequestsAtom = previewEnvironment.automationRequests({
265 environmentId,
266 input: initialAutomationHost,
267 });
268 const listPreviews = useAtomQueryRunner(previewEnvironment.list, {
269 reportFailure: false,
270 });
271 const open = useAtomCommand(previewEnvironment.open, {
272 reportFailure: false,
273 });
274 const resize = useAtomCommand(previewEnvironment.resize, {
275 reportFailure: false,
276 });
277 const respondToAutomation = useAtomCommand(
278 previewEnvironment.respondToAutomation,
279 "preview automation response",
280 );
281 const focusAutomationHost = useAtomCommand(
282 previewEnvironment.focusAutomationHost,
283 "preview automation host focus",
284 );
285 const [automationConnectionAtom] = useState(() => Atom.make<string | null>(null));
286 const automationConnectionId = useAtomValue(automationConnectionAtom);
287
288 const handleRequest = useCallback(
289 async (request: PreviewAutomationRequest): Promise<unknown> => {
290 const threadRef: ScopedThreadRef = {
291 environmentId,
292 threadId: request.threadId,
293 };
294 let tabId = request.tabId ?? null;
295 try {
296 let state = readThreadPreviewState(threadRef);
297 const needsSessionSync = needsPreviewAutomationSessionSync(state, request.tabId);
298 if (needsSessionSync) {
299 const listTarget = {
300 environmentId,
301 input: { threadId: request.threadId },
302 } as const;
303 registry.refresh(previewEnvironment.list(listTarget));
304 const result = await listPreviews(listTarget);
305 if (result._tag === "Failure") {
306 throw squashAtomCommandFailure(result);
307 }
308 reconcilePreviewServerSessions(threadRef, result.value.sessions);
309 state = readThreadPreviewState(threadRef);
310 }
311 tabId = request.tabId ?? state.snapshot?.tabId ?? null;
312 const unavailableTarget = {
313 requestId: request.requestId,
314 operation: request.operation,
315 environmentId,
316 threadId: request.threadId,
317 tabId,
318 bridgeAvailable: Boolean(previewBridge),
319 };
320 const requireReadyTab = async () => {
321 const bridge = previewBridge;
322 const readyTabId = tabId;
323 if (!bridge || !readyTabId) {
324 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
325 }
326 await waitForDesktopOverlay(threadRef, request.requestId, readyTabId, request.timeoutMs);
327 return { bridge, tabId: readyTabId };
328 };
329 switch (request.operation) {
330 case "status":
331 return await currentStatus(threadRef, tabId);
332 case "open": {
333 const input = request.input as PreviewAutomationOpenInput;
334 let activeTabId =
335 (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null;
336 const reusedExistingTab = activeTabId !== null;
337 tabId = activeTabId;
338 if (!activeTabId) {
339 const result = await open({
340 environmentId,
341 input: {
342 threadId: request.threadId,
343 ...(input.url ? { url: input.url } : {}),
344 },
345 });
346 if (result._tag === "Failure") {
347 throw squashAtomCommandFailure(result);
348 }
349 const snapshot = result.value;
350 applyPreviewServerSnapshot(threadRef, snapshot);
351 activeTabId = snapshot.tabId;
352 tabId = activeTabId;
353 }
354 if (input.show ?? true) {
355 useRightPanelStore.getState().openBrowser(threadRef, activeTabId);
356 }
357 await waitForDesktopOverlay(
358 threadRef,
359 request.requestId,
360 activeTabId,
361 request.timeoutMs,
362 );
363 if (reusedExistingTab && input.url && previewBridge) {
364 const resolution = resolveBrowserNavigationTarget(environmentId, {
365 kind: "url",
366 url: input.url,
367 });
368 await previewBridge.navigate(activeTabId, resolution.resolvedUrl);
369 await waitForNavigationReadiness(
370 threadRef,
371 request.requestId,
372 activeTabId,
373 "load",
374 request.timeoutMs,
375 );
376 }
377 return await currentStatus(threadRef, activeTabId);
378 }
379 case "navigate": {
380 const ready = await requireReadyTab();
381 const input = request.input as PreviewAutomationNavigateInput;
382 const resolution = resolveBrowserNavigationTarget(
383 environmentId,
384 input.target ?? {
385 kind: "url",
386 url: input.url!,
387 },
388 );
389 await ready.bridge.navigate(ready.tabId, resolution.resolvedUrl);
390 await waitForNavigationReadiness(
391 threadRef,
392 request.requestId,
393 ready.tabId,
394 input.readiness ?? "load",
395 input.timeoutMs ?? request.timeoutMs,
396 );
397 return await currentStatus(threadRef, ready.tabId);
398 }
399 case "resize": {
400 const ready = await requireReadyTab();
401 const input = request.input as PreviewAutomationResizeInput;
402 const setting = resolvePreviewViewport(input);
403 const result = await resize({
404 environmentId,
405 input: {
406 threadId: request.threadId,
407 tabId: ready.tabId,
408 viewport: setting,
409 },
410 });
411 if (result._tag === "Failure") {
412 throw squashAtomCommandFailure(result);
413 }
414 updatePreviewServerSnapshot(threadRef, result.value);
415 const viewport = await waitForRenderedViewport(
416 ready.tabId,
417 setting,
418 input.timeoutMs ?? request.timeoutMs,
419 {
420 requestId: request.requestId,
421 environmentId,
422 threadId: request.threadId,
423 },
424 );
425 return {
426 tabId: ready.tabId,
427 setting,
428 viewport,
429 } satisfies PreviewAutomationResizeResult;
430 }
431 case "snapshot": {
432 const ready = await requireReadyTab();
433 return await ready.bridge.automation.snapshot(ready.tabId);
434 }
435 case "click": {
436 const ready = await requireReadyTab();
437 return await ready.bridge.automation.click(
438 ready.tabId,
439 request.input as Parameters<typeof ready.bridge.automation.click>[1],
440 );
441 }
442 case "type": {
443 const ready = await requireReadyTab();
444 return await ready.bridge.automation.type(
445 ready.tabId,
446 request.input as Parameters<typeof ready.bridge.automation.type>[1],
447 );
448 }
449 case "press": {
450 const ready = await requireReadyTab();
451 return await ready.bridge.automation.press(
452 ready.tabId,
453 request.input as Parameters<typeof ready.bridge.automation.press>[1],
454 );
455 }
456 case "scroll": {
457 const ready = await requireReadyTab();
458 return await ready.bridge.automation.scroll(
459 ready.tabId,
460 request.input as Parameters<typeof ready.bridge.automation.scroll>[1],
461 );
462 }
463 case "evaluate": {
464 const ready = await requireReadyTab();
465 return await ready.bridge.automation.evaluate(
466 ready.tabId,
467 request.input as Parameters<typeof ready.bridge.automation.evaluate>[1],
468 );
469 }
470 case "waitFor": {
471 const ready = await requireReadyTab();
472 return await ready.bridge.automation.waitFor(
473 ready.tabId,
474 request.input as Parameters<typeof ready.bridge.automation.waitFor>[1],
475 );
476 }
477 case "recordingStart": {
478 const ready = await requireReadyTab();
479 const startedAt = await startBrowserRecording(ready.tabId);
480 return {
481 tabId: ready.tabId,
482 recording: true,
483 startedAt,
484 };
485 }
486 case "recordingStop": {
487 const recordingTabId = readActiveBrowserRecordingTabId();
488 const stopTabId = resolveBrowserRecordingStopTarget(recordingTabId);
489 const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null;
490 if (!artifact) {
491 throw new PreviewAutomationRecordingNotActiveError({
492 requestId: request.requestId,
493 environmentId,
494 threadId: request.threadId,
495 tabId,
496 });
497 }
498 return artifact;
499 }
500 }
501 } catch (cause) {
502 throw PreviewAutomationOperationError.fromCause({
503 requestId: request.requestId,
504 operation: request.operation,
505 environmentId,
506 threadId: request.threadId,
507 tabId,
508 cause,
509 });
510 }
511 },
512 [environmentId, listPreviews, open, registry, resize],
513 );
514 const [requestHandlerAtom] = useState(() => Atom.make({ handle: handleRequest }));
515 const setRequestHandler = useAtomSet(requestHandlerAtom);
516 useEffect(() => {
517 setRequestHandler({ handle: handleRequest });
518 }, [handleRequest, setRequestHandler]);
519
520 const automationRequestConsumerAtom = useMemo(
521 () =>
522 createPreviewAutomationRequestConsumerAtom({
523 requestsAtom: automationRequestsAtom,
524 clientId: automationClientId,
525 connectionAtom: automationConnectionAtom,
526 environmentId,
527 requestHandlerAtom,
528 respond: (response) =>
529 respondToAutomation({
530 environmentId,
531 input: response,
532 }),
533 label: `preview:automation-host:${environmentId}:${automationClientId}`,
534 }),
535 [
536 automationClientId,
537 automationConnectionAtom,
538 automationRequestsAtom,
539 requestHandlerAtom,
540 respondToAutomation,
541 environmentId,
542 ],
543 );
544 useAtomValue(automationRequestConsumerAtom);
545
546 useEffect(() => {
547 const report = () => {
548 if (!automationConnectionId) return;
549 void focusAutomationHost({
550 environmentId,
551 input: {
552 clientId: automationClientId,
553 environmentId,
554 connectionId: automationConnectionId,
555 focused: document.hasFocus(),
556 },
557 });
558 };
559 report();
560 window.addEventListener("focus", report);
561 window.addEventListener("blur", report);
562 return () => {
563 window.removeEventListener("focus", report);
564 window.removeEventListener("blur", report);
565 };
566 }, [automationClientId, automationConnectionId, environmentId, focusAutomationHost]);
567
568 return null;
569}
apps/web/src/components/preview/PreviewAutomationOwner.test.ts deleted-31
...@@ -1,31 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { observeAutomationOwnerConnectedGeneration } from "./PreviewAutomationOwner";
4
5describe("observeAutomationOwnerConnectedGeneration", () => {
6 it("reports ownership when the initial transport generation connects", () => {
7 const initial = observeAutomationOwnerConnectedGeneration(null, 1);
8 expect(initial).toEqual({
9 nextGeneration: 1,
10 shouldReport: true,
11 });
12
13 const disconnected = observeAutomationOwnerConnectedGeneration(initial.nextGeneration, null);
14 expect(disconnected).toEqual({
15 nextGeneration: 1,
16 shouldReport: false,
17 });
18
19 expect(observeAutomationOwnerConnectedGeneration(disconnected.nextGeneration, 2)).toEqual({
20 nextGeneration: 2,
21 shouldReport: true,
22 });
23 });
24
25 it("does not re-report for repeated connected state from the same generation", () => {
26 expect(observeAutomationOwnerConnectedGeneration(3, 3)).toEqual({
27 nextGeneration: 3,
28 shouldReport: false,
29 });
30 });
31});
apps/web/src/components/preview/PreviewAutomationOwner.tsx deleted-426
...@@ -1,426 +0,0 @@
1"use client";
2
3import { useAtomValue } from "@effect/atom-react";
4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
5import {
6 type PreviewAutomationNavigateInput,
7 type PreviewAutomationOpenInput,
8 type PreviewAutomationOwner as PreviewAutomationOwnerState,
9 type PreviewAutomationRequest,
10 type PreviewAutomationStatus,
11 type ScopedThreadRef,
12} from "@t3tools/contracts";
13import { useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react";
14
15import {
16 applyPreviewServerSnapshot,
17 readThreadPreviewState,
18 subscribeThreadPreviewState,
19} from "~/previewStateStore";
20import { useRightPanelStore } from "~/rightPanelStore";
21import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver";
22import { startBrowserRecording, stopBrowserRecording } from "~/browser/browserRecording";
23import { previewEnvironment } from "~/state/preview";
24import { useEnvironmentConnectionState } from "~/state/environments";
25import { useAtomCommand } from "~/state/use-atom-command";
26
27import { previewBridge } from "./previewBridge";
28import {
29 PreviewAutomationNavigationTimeoutError,
30 PreviewAutomationOperationError,
31 PreviewAutomationOverlayTimeoutError,
32 PreviewAutomationRecordingNotActiveError,
33 PreviewAutomationStaleOwnerError,
34 PreviewAutomationTargetUnavailableError,
35} from "./previewAutomationErrors";
36import {
37 createLatestPreviewAutomationRequestHandler,
38 createPreviewAutomationRequestConsumerAtom,
39} from "./previewAutomationRequestConsumer";
40
41export function observeAutomationOwnerConnectedGeneration(
42 previousGeneration: number | null,
43 connectedGeneration: number | null,
44): {
45 readonly nextGeneration: number | null;
46 readonly shouldReport: boolean;
47} {
48 if (connectedGeneration === null) {
49 return {
50 nextGeneration: previousGeneration,
51 shouldReport: false,
52 };
53 }
54 return {
55 nextGeneration: connectedGeneration,
56 shouldReport: previousGeneration !== connectedGeneration,
57 };
58}
59
60const waitForDesktopOverlay = async (
61 threadRef: ScopedThreadRef,
62 requestId: string,
63 timeoutMs: number,
64): Promise<void> => {
65 const deadline = Date.now() + timeoutMs;
66 while (Date.now() <= deadline) {
67 const state = readThreadPreviewState(threadRef);
68 const tabId = state.snapshot?.tabId;
69 if (tabId && state.desktopOverlay && previewBridge) {
70 const status = await previewBridge.automation.status(tabId);
71 if (status.available) return;
72 }
73 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
74 }
75 throw new PreviewAutomationOverlayTimeoutError({
76 requestId,
77 environmentId: threadRef.environmentId,
78 threadId: threadRef.threadId,
79 timeoutMs,
80 });
81};
82
83const waitForNavigationReadiness = async (
84 threadRef: ScopedThreadRef,
85 requestId: string,
86 tabId: string,
87 readiness: PreviewAutomationNavigateInput["readiness"],
88 timeoutMs: number,
89): Promise<void> => {
90 const targetReadiness = readiness ?? "load";
91 if (!previewBridge || targetReadiness === "none") return;
92 const deadline = Date.now() + timeoutMs;
93 while (Date.now() <= deadline) {
94 if (targetReadiness === "domContentLoaded") {
95 const readyState = await previewBridge.automation.evaluate(tabId, {
96 expression: "document.readyState",
97 });
98 if (readyState === "interactive" || readyState === "complete") return;
99 } else {
100 const status = await previewBridge.automation.status(tabId);
101 if (!status.loading) return;
102 }
103 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
104 }
105 throw new PreviewAutomationNavigationTimeoutError({
106 requestId,
107 environmentId: threadRef.environmentId,
108 threadId: threadRef.threadId,
109 tabId,
110 readiness: targetReadiness,
111 timeoutMs,
112 });
113};
114
115const currentStatus = async (
116 threadRef: ScopedThreadRef,
117 visible: boolean,
118): Promise<PreviewAutomationStatus> => {
119 const state = readThreadPreviewState(threadRef);
120 const tabId = state.snapshot?.tabId ?? null;
121 if (tabId && previewBridge && state.desktopOverlay) {
122 const status = await previewBridge.automation.status(tabId);
123 return { ...status, visible };
124 }
125 const navStatus = state.snapshot?.navStatus;
126 return {
127 available: Boolean(previewBridge?.automation),
128 visible,
129 tabId,
130 url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null,
131 title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null,
132 loading: navStatus?._tag === "Loading",
133 };
134};
135
136export function PreviewAutomationOwner(props: {
137 readonly threadRef: ScopedThreadRef;
138 readonly visible: boolean;
139}) {
140 const { threadRef, visible } = props;
141 const automationClientId = useId();
142 const initialAutomationOwner = useMemo<PreviewAutomationOwnerState>(
143 () => ({
144 clientId: automationClientId,
145 environmentId: threadRef.environmentId,
146 threadId: threadRef.threadId,
147 tabId: null,
148 visible: false,
149 supportsAutomation: Boolean(previewBridge?.automation),
150 focusedAt: new Date().toISOString(),
151 }),
152 [automationClientId, threadRef.environmentId, threadRef.threadId],
153 );
154 const automationRequestsAtom = previewEnvironment.automationRequests({
155 environmentId: threadRef.environmentId,
156 input: initialAutomationOwner,
157 });
158 const connectionState = useEnvironmentConnectionState(threadRef.environmentId).data;
159 const connectedGeneration =
160 connectionState?.phase === "connected" ? connectionState.generation : null;
161 const open = useAtomCommand(previewEnvironment.open, {
162 reportFailure: false,
163 });
164 const respondToAutomation = useAtomCommand(
165 previewEnvironment.respondToAutomation,
166 "preview automation response",
167 );
168 const reportAutomationOwner = useAtomCommand(
169 previewEnvironment.reportAutomationOwner,
170 "preview automation owner report",
171 );
172 const clearAutomationOwner = useAtomCommand(
173 previewEnvironment.clearAutomationOwner,
174 "preview automation owner clear",
175 );
176 const connectedGenerationRef = useRef<number | null>(null);
177 const reportCurrentAutomationOwner = useEffectEvent(() => {
178 const state = readThreadPreviewState(threadRef);
179 return reportAutomationOwner({
180 environmentId: threadRef.environmentId,
181 input: {
182 clientId: automationClientId,
183 environmentId: threadRef.environmentId,
184 threadId: threadRef.threadId,
185 tabId: state.snapshot?.tabId ?? null,
186 visible,
187 supportsAutomation: Boolean(previewBridge?.automation),
188 focusedAt: new Date().toISOString(),
189 },
190 });
191 });
192 useEffect(() => {
193 void reportCurrentAutomationOwner();
194 }, [threadRef, visible]);
195
196 const handleRequest = useCallback(
197 async (request: PreviewAutomationRequest): Promise<unknown> => {
198 let tabId = request.tabId ?? null;
199 try {
200 if (request.threadId !== threadRef.threadId) {
201 throw new PreviewAutomationStaleOwnerError({
202 requestId: request.requestId,
203 environmentId: threadRef.environmentId,
204 expectedThreadId: threadRef.threadId,
205 requestedThreadId: request.threadId,
206 });
207 }
208 const state = readThreadPreviewState(threadRef);
209 tabId = request.tabId ?? state.snapshot?.tabId ?? null;
210 const unavailableTarget = {
211 requestId: request.requestId,
212 operation: request.operation,
213 environmentId: threadRef.environmentId,
214 threadId: threadRef.threadId,
215 tabId,
216 bridgeAvailable: Boolean(previewBridge),
217 };
218 switch (request.operation) {
219 case "status":
220 return await currentStatus(threadRef, visible);
221 case "open": {
222 const input = request.input as PreviewAutomationOpenInput;
223 let activeTabId =
224 (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null;
225 tabId = activeTabId;
226 if (!activeTabId) {
227 const result = await open({
228 environmentId: threadRef.environmentId,
229 input: {
230 threadId: threadRef.threadId,
231 ...(input.url ? { url: input.url } : {}),
232 },
233 });
234 if (result._tag === "Failure") {
235 throw squashAtomCommandFailure(result);
236 }
237 const snapshot = result.value;
238 applyPreviewServerSnapshot(threadRef, snapshot);
239 activeTabId = snapshot.tabId;
240 tabId = activeTabId;
241 } else if (input.url && previewBridge) {
242 await previewBridge.navigate(activeTabId, input.url);
243 }
244 if (input.show ?? true) {
245 useRightPanelStore.getState().openBrowser(threadRef, activeTabId);
246 }
247 await waitForDesktopOverlay(threadRef, request.requestId, request.timeoutMs);
248 return await currentStatus(threadRef, input.show ?? true);
249 }
250 case "navigate": {
251 if (!previewBridge || !tabId) {
252 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
253 }
254 const input = request.input as PreviewAutomationNavigateInput;
255 const resolution = resolveBrowserNavigationTarget(
256 threadRef.environmentId,
257 input.target ?? { kind: "url", url: input.url! },
258 );
259 await previewBridge.navigate(tabId, resolution.resolvedUrl);
260 await waitForNavigationReadiness(
261 threadRef,
262 request.requestId,
263 tabId,
264 input.readiness ?? "load",
265 input.timeoutMs ?? request.timeoutMs,
266 );
267 return await currentStatus(threadRef, visible);
268 }
269 case "snapshot":
270 if (!previewBridge || !tabId) {
271 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
272 }
273 return await previewBridge.automation.snapshot(tabId);
274 case "click":
275 if (!previewBridge || !tabId) {
276 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
277 }
278 return await previewBridge.automation.click(
279 tabId,
280 request.input as Parameters<typeof previewBridge.automation.click>[1],
281 );
282 case "type":
283 if (!previewBridge || !tabId) {
284 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
285 }
286 return await previewBridge.automation.type(
287 tabId,
288 request.input as Parameters<typeof previewBridge.automation.type>[1],
289 );
290 case "press":
291 if (!previewBridge || !tabId) {
292 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
293 }
294 return await previewBridge.automation.press(
295 tabId,
296 request.input as Parameters<typeof previewBridge.automation.press>[1],
297 );
298 case "scroll":
299 if (!previewBridge || !tabId) {
300 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
301 }
302 return await previewBridge.automation.scroll(
303 tabId,
304 request.input as Parameters<typeof previewBridge.automation.scroll>[1],
305 );
306 case "evaluate":
307 if (!previewBridge || !tabId) {
308 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
309 }
310 return await previewBridge.automation.evaluate(
311 tabId,
312 request.input as Parameters<typeof previewBridge.automation.evaluate>[1],
313 );
314 case "waitFor":
315 if (!previewBridge || !tabId) {
316 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
317 }
318 return await previewBridge.automation.waitFor(
319 tabId,
320 request.input as Parameters<typeof previewBridge.automation.waitFor>[1],
321 );
322 case "recordingStart": {
323 if (!tabId) {
324 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
325 }
326 const startedAt = await startBrowserRecording(tabId);
327 return {
328 tabId,
329 recording: true,
330 startedAt,
331 };
332 }
333 case "recordingStop": {
334 if (!tabId) {
335 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
336 }
337 const artifact = await stopBrowserRecording(tabId);
338 if (!artifact) {
339 throw new PreviewAutomationRecordingNotActiveError({
340 requestId: request.requestId,
341 environmentId: threadRef.environmentId,
342 threadId: threadRef.threadId,
343 tabId,
344 });
345 }
346 return artifact;
347 }
348 }
349 } catch (cause) {
350 throw PreviewAutomationOperationError.fromCause({
351 requestId: request.requestId,
352 operation: request.operation,
353 environmentId: threadRef.environmentId,
354 threadId: threadRef.threadId,
355 tabId,
356 cause,
357 });
358 }
359 },
360 [open, threadRef, visible],
361 );
362 const [requestHandler] = useState(() =>
363 createLatestPreviewAutomationRequestHandler(handleRequest),
364 );
365 useEffect(() => {
366 requestHandler.set(handleRequest);
367 }, [handleRequest, requestHandler]);
368
369 const automationRequestConsumerAtom = useMemo(
370 () =>
371 createPreviewAutomationRequestConsumerAtom({
372 requestsAtom: automationRequestsAtom,
373 environmentId: threadRef.environmentId,
374 handleRequest: requestHandler.handle,
375 respond: (response) =>
376 respondToAutomation({
377 environmentId: threadRef.environmentId,
378 input: response,
379 }),
380 label: `preview:automation-request-consumer:${automationClientId}`,
381 }),
382 [
383 automationClientId,
384 automationRequestsAtom,
385 requestHandler,
386 respondToAutomation,
387 threadRef.environmentId,
388 ],
389 );
390 useAtomValue(automationRequestConsumerAtom);
391
392 useEffect(() => {
393 const observation = observeAutomationOwnerConnectedGeneration(
394 connectedGenerationRef.current,
395 connectedGeneration,
396 );
397 connectedGenerationRef.current = observation.nextGeneration;
398 if (!observation.shouldReport) return;
399
400 void reportCurrentAutomationOwner();
401 }, [connectedGeneration]);
402
403 useEffect(() => {
404 const report = () => void reportCurrentAutomationOwner();
405 window.addEventListener("focus", report);
406 const unsubscribe = subscribeThreadPreviewState(threadRef, (state, previous) => {
407 if (state.snapshot?.tabId !== previous.snapshot?.tabId) {
408 report();
409 }
410 });
411 return () => {
412 window.removeEventListener("focus", report);
413 unsubscribe();
414 void clearAutomationOwner({
415 environmentId: threadRef.environmentId,
416 input: {
417 clientId: automationClientId,
418 environmentId: threadRef.environmentId,
419 threadId: threadRef.threadId,
420 },
421 });
422 };
423 }, [automationClientId, clearAutomationOwner, threadRef]);
424
425 return null;
426}
apps/web/src/components/preview/PreviewMoreMenu.tsx+15-1
...@@ -19,6 +19,10 @@ interface Props {...@@ -19,6 +19,10 @@ interface Props {
19 hasWebContents: boolean;19 hasWebContents: boolean;
20 /** Current zoom factor as a number (1.0 = 100%). */20 /** Current zoom factor as a number (1.0 = 100%). */
21 zoomFactor: number;21 zoomFactor: number;
22 /** Fixed viewport modes expose the device toolbar and resize rails. */
23 deviceToolbarVisible: boolean;
24 /** Switches between fill-panel mode and a fixed responsive viewport. */
25 onToggleDeviceToolbar: () => void;
22}26}
2327
24/**28/**
...@@ -26,7 +30,13 @@ interface Props {...@@ -26,7 +30,13 @@ interface Props {
26 * controls, and storage-clearing actions. Only mounted by `PreviewView`30 * controls, and storage-clearing actions. Only mounted by `PreviewView`
27 * when the desktop bridge is present, so we can call it unconditionally.31 * when the desktop bridge is present, so we can call it unconditionally.
28 */32 */
29export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) {33export function PreviewMoreMenu({
34 tabId,
35 hasWebContents,
36 zoomFactor,
37 deviceToolbarVisible,
38 onToggleDeviceToolbar,
39}: Props) {
30 if (!previewBridge) return null;40 if (!previewBridge) return null;
31 const bridge = previewBridge;41 const bridge = previewBridge;
32 const tabDisabled = !tabId || !hasWebContents;42 const tabDisabled = !tabId || !hasWebContents;
...@@ -59,6 +69,10 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) {...@@ -59,6 +69,10 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) {
59 <MenuItem onClick={callTab(bridge.openDevTools)} disabled={tabDisabled}>69 <MenuItem onClick={callTab(bridge.openDevTools)} disabled={tabDisabled}>
60 Open DevTools70 Open DevTools
61 </MenuItem>71 </MenuItem>
72 <MenuItem onClick={onToggleDeviceToolbar} disabled={tabDisabled}>
73 {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"}
74 </MenuItem>
75 <MenuSeparator />
62 {/*76 {/*
63 Zoom row: label + inline control cluster. `closeOnClick=false`77 Zoom row: label + inline control cluster. `closeOnClick=false`
64 keeps the menu open while the user clicks the +/− buttons.78 keeps the menu open while the user clicks the +/− buttons.
apps/web/src/components/preview/PreviewView.tsx+69-2
...@@ -1,13 +1,22 @@...@@ -1,13 +1,22 @@
1"use client";1"use client";
22
3import { scopedThreadKey } from "@t3tools/client-runtime/environment";3import { scopedThreadKey } from "@t3tools/client-runtime/environment";
4import { type ScopedThreadRef } from "@t3tools/contracts";4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
5import {
6 FILL_PREVIEW_VIEWPORT,
7 type PreviewViewportSetting,
8 type ScopedThreadRef,
9} from "@t3tools/contracts";
5import { useCallback, useEffect, useRef, useState } from "react";10import { useCallback, useEffect, useRef, useState } from "react";
611
7import { useComposerDraftStore } from "~/composerDraftStore";12import { useComposerDraftStore } from "~/composerDraftStore";
8import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation";13import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation";
9import { ensureLocalApi } from "~/localApi";14import { ensureLocalApi } from "~/localApi";
10import { rememberPreviewUrl, useThreadPreviewState } from "~/previewStateStore";15import {
16 rememberPreviewUrl,
17 updatePreviewServerSnapshot,
18 useThreadPreviewState,
19} from "~/previewStateStore";
11import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";20import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
12import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments";21import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments";
13import { previewEnvironment } from "~/state/preview";22import { previewEnvironment } from "~/state/preview";
...@@ -20,10 +29,16 @@ import { PreviewChromeRow } from "./PreviewChromeRow";...@@ -20,10 +29,16 @@ import { PreviewChromeRow } from "./PreviewChromeRow";
20import { formatPreviewUrl } from "./previewUrlPresentation";29import { formatPreviewUrl } from "./previewUrlPresentation";
21import { PreviewEmptyState } from "./PreviewEmptyState";30import { PreviewEmptyState } from "./PreviewEmptyState";
22import { PreviewMoreMenu } from "./PreviewMoreMenu";31import { PreviewMoreMenu } from "./PreviewMoreMenu";
32import {
33 commitBrowserViewportChange,
34 subscribeBrowserViewportChange,
35} from "~/browser/browserViewportActions";
36import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout";
23import { PreviewUnreachable } from "./PreviewUnreachable";37import { PreviewUnreachable } from "./PreviewUnreachable";
24import { revealInFileExplorerLabel } from "./fileExplorerLabel";38import { revealInFileExplorerLabel } from "./fileExplorerLabel";
25import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";39import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
26import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";40import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
41import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
27import { useLoadingProgress } from "./useLoadingProgress";42import { useLoadingProgress } from "./useLoadingProgress";
28import { usePreviewSession } from "./usePreviewSession";43import { usePreviewSession } from "./usePreviewSession";
29import { ZoomIndicator } from "./ZoomIndicator";44import { ZoomIndicator } from "./ZoomIndicator";
...@@ -60,6 +75,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -60,6 +75,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
60 const environment = useEnvironment(threadRef.environmentId);75 const environment = useEnvironment(threadRef.environmentId);
61 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId);76 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId);
62 const open = useAtomCommand(previewEnvironment.open);77 const open = useAtomCommand(previewEnvironment.open);
78 const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize");
6379
64 usePreviewSession(threadRef);80 usePreviewSession(threadRef);
6581
...@@ -91,6 +107,10 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -91,6 +107,10 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
91 environmentHttpBaseUrl,107 environmentHttpBaseUrl,
92 }) ?? undefined)108 }) ?? undefined)
93 : undefined;109 : undefined;
110 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;
111 const panelRect = useBrowserSurfaceStore((state) =>
112 tabId ? (state.byTabId[tabId]?.rect ?? null) : null,
113 );
94114
95 const handleSubmitUrl = useCallback(115 const handleSubmitUrl = useCallback(
96 async (next: string) => {116 async (next: string) => {
...@@ -131,6 +151,51 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -131,6 +151,51 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
131 if (previewBridge && tabId) void previewBridge.resetZoom(tabId);151 if (previewBridge && tabId) void previewBridge.resetZoom(tabId);
132 }, [tabId]);152 }, [tabId]);
133153
154 const handleViewportChange = useCallback(
155 async (nextViewport: PreviewViewportSetting) => {
156 if (!tabId) return;
157 const result = await resize({
158 environmentId: threadRef.environmentId,
159 input: {
160 threadId: threadRef.threadId,
161 tabId,
162 viewport: nextViewport,
163 },
164 });
165 if (result._tag === "Failure") {
166 const error = squashAtomCommandFailure(result);
167 toastManager.add({
168 type: "error",
169 title: "Unable to resize browser viewport",
170 description: error instanceof Error ? error.message : "An error occurred.",
171 });
172 throw error;
173 }
174 updatePreviewServerSnapshot(threadRef, result.value);
175 },
176 [resize, tabId, threadRef],
177 );
178
179 const handleToggleDeviceToolbar = () => {
180 if (!tabId) return;
181 if (viewport._tag !== "fill") {
182 void commitBrowserViewportChange(tabId, FILL_PREVIEW_VIEWPORT).catch(() => undefined);
183 return;
184 }
185
186 const responsiveSize = panelRect
187 ? resolveResponsiveBrowserViewportSize(panelRect, desktopOverlay?.zoomFactor)
188 : { width: 1024, height: 768 };
189 void commitBrowserViewportChange(tabId, { _tag: "freeform", ...responsiveSize }).catch(
190 () => undefined,
191 );
192 };
193
194 useEffect(() => {
195 if (!tabId) return;
196 return subscribeBrowserViewportChange(tabId, handleViewportChange);
197 }, [handleViewportChange, tabId]);
198
134 const handleBack = useCallback(() => {199 const handleBack = useCallback(() => {
135 if (previewBridge && tabId) void previewBridge.goBack(tabId);200 if (previewBridge && tabId) void previewBridge.goBack(tabId);
136 }, [tabId]);201 }, [tabId]);
...@@ -527,6 +592,8 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -527,6 +592,8 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
527 tabId={tabId}592 tabId={tabId}
528 hasWebContents={desktopOverlay !== null}593 hasWebContents={desktopOverlay !== null}
529 zoomFactor={desktopOverlay?.zoomFactor ?? 1}594 zoomFactor={desktopOverlay?.zoomFactor ?? 1}
595 deviceToolbarVisible={viewport._tag !== "fill"}
596 onToggleDeviceToolbar={handleToggleDeviceToolbar}
530 />597 />
531 ) : null598 ) : null
532 }599 }
apps/web/src/components/preview/previewAutomationClientId.test.ts created+13
...@@ -0,0 +1,13 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { createPreviewAutomationClientId } from "./previewAutomationClientId";
4
5describe("createPreviewAutomationClientId", () => {
6 it("creates bounded cryptographically random identities for independent host lifetimes", () => {
7 const clientIds = Array.from({ length: 32 }, createPreviewAutomationClientId);
8
9 expect(new Set(clientIds).size).toBe(clientIds.length);
10 expect(clientIds.every((clientId) => clientId.startsWith("preview-"))).toBe(true);
11 expect(clientIds.every((clientId) => clientId.length <= 128)).toBe(true);
12 });
13});
apps/web/src/components/preview/previewAutomationClientId.ts created+4
...@@ -0,0 +1,4 @@
1export function createPreviewAutomationClientId(): string {
2 const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
3 return `preview-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
4}
apps/web/src/components/preview/previewAutomationErrors.ts+85-19
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import {1import {
2 EnvironmentId,2 EnvironmentId,
3 type PreviewAutomationOwner,3 type PreviewAutomationHost,
4 PreviewAutomationOperation,4 PreviewAutomationOperation,
5 type PreviewAutomationRequest,5 type PreviewAutomationRequest,
6 type PreviewAutomationResponse,6 type PreviewAutomationResponse,
...@@ -13,7 +13,7 @@ import * as Schema from "effect/Schema";...@@ -13,7 +13,7 @@ import * as Schema from "effect/Schema";
13export interface PreviewAutomationOperationContext {13export interface PreviewAutomationOperationContext {
14 readonly requestId: PreviewAutomationRequest["requestId"];14 readonly requestId: PreviewAutomationRequest["requestId"];
15 readonly operation: PreviewAutomationRequest["operation"];15 readonly operation: PreviewAutomationRequest["operation"];
16 readonly environmentId: PreviewAutomationOwner["environmentId"];16 readonly environmentId: PreviewAutomationHost["environmentId"];
17 readonly threadId: PreviewAutomationRequest["threadId"];17 readonly threadId: PreviewAutomationRequest["threadId"];
18 readonly tabId: Exclude<PreviewAutomationRequest["tabId"], undefined> | null;18 readonly tabId: Exclude<PreviewAutomationRequest["tabId"], undefined> | null;
19}19}
...@@ -56,21 +56,22 @@ export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorC...@@ -56,21 +56,22 @@ export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorC
56 }56 }
57}57}
5858
59export class PreviewAutomationStaleOwnerError extends Schema.TaggedErrorClass<PreviewAutomationStaleOwnerError>()(59export class PreviewAutomationViewportTimeoutError extends Schema.TaggedErrorClass<PreviewAutomationViewportTimeoutError>()(
60 "PreviewAutomationStaleOwnerError",60 "PreviewAutomationViewportTimeoutError",
61 {61 {
62 requestId: TrimmedNonEmptyString,62 requestId: TrimmedNonEmptyString,
63 environmentId: EnvironmentId,63 environmentId: EnvironmentId,
64 expectedThreadId: ThreadId,64 threadId: ThreadId,
65 requestedThreadId: ThreadId,65 tabId: PreviewTabId,
66 timeoutMs: Schema.Int,
66 },67 },
67) {68) {
68 get responseTag() {69 get responseTag() {
69 return "PreviewAutomationUnavailableError" as const;70 return "PreviewAutomationTimeoutError" as const;
70 }71 }
7172
72 override get message(): string {73 override get message(): string {
73 return `Preview automation request ${this.requestId} targeted thread ${this.requestedThreadId}, but the owner for environment ${this.environmentId} is attached to thread ${this.expectedThreadId}.`;74 return `Preview viewport for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} was not rendered within ${this.timeoutMs}ms.`;
74 }75 }
75}76}
7677
...@@ -100,7 +101,7 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError...@@ -100,7 +101,7 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError
100 requestId: TrimmedNonEmptyString,101 requestId: TrimmedNonEmptyString,
101 environmentId: EnvironmentId,102 environmentId: EnvironmentId,
102 threadId: ThreadId,103 threadId: ThreadId,
103 tabId: PreviewTabId,104 tabId: Schema.NullOr(PreviewTabId),
104 },105 },
105) {106) {
106 get responseTag() {107 get responseTag() {
...@@ -108,10 +109,65 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError...@@ -108,10 +109,65 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError
108 }109 }
109110
110 override get message(): string {111 override get message(): string {
111 return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId} on environment ${this.environmentId} thread ${this.threadId}.`;112 return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId ?? "unassigned"} on environment ${this.environmentId} thread ${this.threadId}.`;
112 }113 }
113}114}
114115
116export class PreviewAutomationTargetNotEditableHostError extends Schema.TaggedErrorClass<PreviewAutomationTargetNotEditableHostError>()(
117 "PreviewAutomationTargetNotEditableHostError",
118 {
119 requestId: TrimmedNonEmptyString,
120 operation: PreviewAutomationOperation,
121 environmentId: EnvironmentId,
122 threadId: ThreadId,
123 tabId: Schema.NullOr(PreviewTabId),
124 selectorKind: Schema.optional(Schema.Literals(["focused-element", "locator", "selector"])),
125 selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
126 },
127) {
128 get responseTag() {
129 return "PreviewAutomationTargetNotEditableError" as const;
130 }
131
132 override get message(): string {
133 return `Preview automation ${this.operation} request ${this.requestId} requires an editable target in tab ${this.tabId ?? "unassigned"}.`;
134 }
135}
136
137const targetNotEditableDiagnostics = (
138 cause: unknown,
139): {
140 readonly selectorKind?: "focused-element" | "locator" | "selector";
141 readonly selectorLength?: number;
142} | null => {
143 if (
144 typeof cause !== "object" ||
145 cause === null ||
146 !("_tag" in cause) ||
147 cause._tag !== "PreviewAutomationTargetNotEditableError"
148 ) {
149 return null;
150 }
151 const selectorKind =
152 "selectorKind" in cause &&
153 (cause.selectorKind === "focused-element" ||
154 cause.selectorKind === "locator" ||
155 cause.selectorKind === "selector")
156 ? cause.selectorKind
157 : undefined;
158 const selectorLength =
159 "selectorLength" in cause &&
160 typeof cause.selectorLength === "number" &&
161 Number.isInteger(cause.selectorLength) &&
162 cause.selectorLength >= 0
163 ? cause.selectorLength
164 : undefined;
165 return {
166 ...(selectorKind === undefined ? {} : { selectorKind }),
167 ...(selectorLength === undefined ? {} : { selectorLength }),
168 };
169};
170
115export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<PreviewAutomationOperationError>()(171export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<PreviewAutomationOperationError>()(
116 "PreviewAutomationOperationError",172 "PreviewAutomationOperationError",
117 {173 {
...@@ -125,9 +181,18 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<Pre...@@ -125,9 +181,18 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<Pre
125) {181) {
126 static fromCause(182 static fromCause(
127 input: PreviewAutomationOperationContext & { readonly cause: unknown },183 input: PreviewAutomationOperationContext & { readonly cause: unknown },
128 ): PreviewAutomationOwnerError {184 ): PreviewAutomationHostError {
129 return isPreviewAutomationOwnerError(input.cause)185 if (isPreviewAutomationHostError(input.cause)) return input.cause;
130 ? input.cause186 const diagnostics = targetNotEditableDiagnostics(input.cause);
187 return diagnostics
188 ? new PreviewAutomationTargetNotEditableHostError({
189 requestId: input.requestId,
190 operation: input.operation,
191 environmentId: input.environmentId,
192 threadId: input.threadId,
193 tabId: input.tabId,
194 ...diagnostics,
195 })
131 : new PreviewAutomationOperationError(input);196 : new PreviewAutomationOperationError(input);
132 }197 }
133198
...@@ -140,20 +205,21 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<Pre...@@ -140,20 +205,21 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<Pre
140 }205 }
141}206}
142207
143export const PreviewAutomationOwnerError = Schema.Union([208export const PreviewAutomationHostError = Schema.Union([
144 PreviewAutomationOverlayTimeoutError,209 PreviewAutomationOverlayTimeoutError,
145 PreviewAutomationNavigationTimeoutError,210 PreviewAutomationNavigationTimeoutError,
146 PreviewAutomationStaleOwnerError,211 PreviewAutomationViewportTimeoutError,
147 PreviewAutomationTargetUnavailableError,212 PreviewAutomationTargetUnavailableError,
148 PreviewAutomationRecordingNotActiveError,213 PreviewAutomationRecordingNotActiveError,
214 PreviewAutomationTargetNotEditableHostError,
149 PreviewAutomationOperationError,215 PreviewAutomationOperationError,
150]);216]);
151export type PreviewAutomationOwnerError = typeof PreviewAutomationOwnerError.Type;217export type PreviewAutomationHostError = typeof PreviewAutomationHostError.Type;
152218
153export const isPreviewAutomationOwnerError = Schema.is(PreviewAutomationOwnerError);219export const isPreviewAutomationHostError = Schema.is(PreviewAutomationHostError);
154220
155export function serializePreviewAutomationOwnerError(221export function serializePreviewAutomationHostError(
156 error: PreviewAutomationOwnerError,222 error: PreviewAutomationHostError,
157): NonNullable<PreviewAutomationResponse["error"]> {223): NonNullable<PreviewAutomationResponse["error"]> {
158 const detail = Object.fromEntries(224 const detail = Object.fromEntries(
159 Object.entries(error).filter(225 Object.entries(error).filter(
apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts+226-15
...@@ -2,13 +2,18 @@ import {...@@ -2,13 +2,18 @@ import {
2 EnvironmentId,2 EnvironmentId,
3 type PreviewAutomationRequest,3 type PreviewAutomationRequest,
4 type PreviewAutomationResponse,4 type PreviewAutomationResponse,
5 type PreviewAutomationStreamEvent,
5 PreviewTabId,6 PreviewTabId,
6 ThreadId,7 ThreadId,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";9import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
9import { describe, expect, it, vi } from "vite-plus/test";10import { describe, expect, it, vi } from "vite-plus/test";
1011
11import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors";12import {
13 PreviewAutomationRecordingNotActiveError,
14 PreviewAutomationTargetUnavailableError,
15 PreviewAutomationViewportTimeoutError,
16} from "./previewAutomationErrors";
12import {17import {
13 createPreviewAutomationRequestConsumerAtom,18 createPreviewAutomationRequestConsumerAtom,
14 serializePreviewAutomationError,19 serializePreviewAutomationError,
...@@ -17,6 +22,8 @@ import {...@@ -17,6 +22,8 @@ import {
17const environmentId = EnvironmentId.make("environment-1");22const environmentId = EnvironmentId.make("environment-1");
18const threadId = ThreadId.make("thread-1");23const threadId = ThreadId.make("thread-1");
19const tabId = PreviewTabId.make("tab-1");24const tabId = PreviewTabId.make("tab-1");
25const clientId = "client-1";
26const connectionId = "connection-1";
2027
21const request = (28const request = (
22 requestId: string,29 requestId: string,
...@@ -30,10 +37,88 @@ const request = (...@@ -30,10 +37,88 @@ const request = (
30 ...overrides,37 ...overrides,
31});38});
3239
40const requestEvent = (
41 requestId: string,
42 overrides: Partial<PreviewAutomationRequest> = {},
43 eventConnectionId = connectionId,
44): PreviewAutomationStreamEvent => ({
45 type: "request",
46 connectionId: eventConnectionId,
47 request: request(requestId, overrides),
48});
49
50const consumerState = (handleRequest: (request: PreviewAutomationRequest) => Promise<unknown>) => ({
51 connectionAtom: Atom.make<string | null>(null),
52 requestHandlerAtom: Atom.make({ handle: handleRequest }),
53});
54
33describe("previewAutomationRequestConsumer", () => {55describe("previewAutomationRequestConsumer", () => {
56 it("acknowledges a replacement stream before consuming requests from it", async () => {
57 const requestsAtom = Atom.make(
58 AsyncResult.success<PreviewAutomationStreamEvent, Error>({
59 type: "connected",
60 connectionId,
61 }),
62 );
63 const handleRequest = vi.fn(async () => undefined);
64 const respond = vi.fn(async () => undefined);
65 const state = consumerState(handleRequest);
66 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
67 requestsAtom,
68 clientId,
69 connectionAtom: state.connectionAtom,
70 environmentId,
71 requestHandlerAtom: state.requestHandlerAtom,
72 respond,
73 label: "test:preview-automation-connected",
74 });
75 const registry = AtomRegistry.make();
76
77 registry.mount(consumerAtom);
78 registry.set(requestsAtom, AsyncResult.success(requestEvent("request-after-connect")));
79
80 await vi.waitFor(() => expect(registry.get(state.connectionAtom)).toBe(connectionId));
81 await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1));
82 expect(handleRequest).toHaveBeenCalledTimes(1);
83 registry.dispose();
84 });
85
86 it("drops late requests from an older stream generation", async () => {
87 const requestsAtom = Atom.make(
88 AsyncResult.success<PreviewAutomationStreamEvent, Error>({
89 type: "connected",
90 connectionId: "connection-2",
91 }),
92 );
93 const handleRequest = vi.fn(async () => undefined);
94 const respond = vi.fn(async () => undefined);
95 const state = consumerState(handleRequest);
96 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
97 requestsAtom,
98 clientId,
99 connectionAtom: state.connectionAtom,
100 environmentId,
101 requestHandlerAtom: state.requestHandlerAtom,
102 respond,
103 label: "test:preview-automation-stale-generation",
104 });
105 const registry = AtomRegistry.make();
106
107 registry.mount(consumerAtom);
108 registry.set(
109 requestsAtom,
110 AsyncResult.success(requestEvent("request-stale", {}, "connection-1")),
111 );
112
113 await vi.waitFor(() => expect(registry.get(state.connectionAtom)).toBe("connection-2"));
114 expect(handleRequest).not.toHaveBeenCalled();
115 expect(respond).not.toHaveBeenCalled();
116 registry.dispose();
117 });
118
34 it("consumes every request emitted before React can render", async () => {119 it("consumes every request emitted before React can render", async () => {
35 const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationRequest, Error>>(120 const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationStreamEvent, Error>>(
36 AsyncResult.initial<PreviewAutomationRequest, Error>(false),121 AsyncResult.initial<PreviewAutomationStreamEvent, Error>(false),
37 );122 );
38 const handleRequest = vi.fn(async (value: PreviewAutomationRequest) => ({123 const handleRequest = vi.fn(async (value: PreviewAutomationRequest) => ({
39 requestId: value.requestId,124 requestId: value.requestId,
...@@ -42,18 +127,21 @@ describe("previewAutomationRequestConsumer", () => {...@@ -42,18 +127,21 @@ describe("previewAutomationRequestConsumer", () => {
42 const respond = vi.fn(async (response: PreviewAutomationResponse) => {127 const respond = vi.fn(async (response: PreviewAutomationResponse) => {
43 responses.push(response);128 responses.push(response);
44 });129 });
130 const state = consumerState(handleRequest);
45 const consumerAtom = createPreviewAutomationRequestConsumerAtom({131 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
46 requestsAtom,132 requestsAtom,
133 clientId,
134 connectionAtom: state.connectionAtom,
47 environmentId,135 environmentId,
48 handleRequest,136 requestHandlerAtom: state.requestHandlerAtom,
49 respond,137 respond,
50 label: "test:preview-automation-consumer",138 label: "test:preview-automation-consumer",
51 });139 });
52 const registry = AtomRegistry.make();140 const registry = AtomRegistry.make();
53 registry.mount(consumerAtom);141 registry.mount(consumerAtom);
54142
55 registry.set(requestsAtom, AsyncResult.success(request("request-1")));143 registry.set(requestsAtom, AsyncResult.success(requestEvent("request-1")));
56 registry.set(requestsAtom, AsyncResult.success(request("request-2")));144 registry.set(requestsAtom, AsyncResult.success(requestEvent("request-2")));
57145
58 await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2));146 await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2));
59 expect(handleRequest.mock.calls.map(([value]) => value.requestId)).toEqual([147 expect(handleRequest.mock.calls.map(([value]) => value.requestId)).toEqual([
...@@ -64,15 +152,50 @@ describe("previewAutomationRequestConsumer", () => {...@@ -64,15 +152,50 @@ describe("previewAutomationRequestConsumer", () => {
64 registry.dispose();152 registry.dispose();
65 });153 });
66154
155 it("uses the latest request handler without rebuilding the stream consumer", async () => {
156 const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationStreamEvent, Error>>(
157 AsyncResult.initial<PreviewAutomationStreamEvent, Error>(false),
158 );
159 const firstHandler = vi.fn(async () => "first");
160 const secondHandler = vi.fn(async () => "second");
161 const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined);
162 const state = consumerState(firstHandler);
163 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
164 requestsAtom,
165 clientId,
166 connectionAtom: state.connectionAtom,
167 environmentId,
168 requestHandlerAtom: state.requestHandlerAtom,
169 respond,
170 label: "test:preview-automation-latest-handler",
171 });
172 const registry = AtomRegistry.make();
173 registry.mount(consumerAtom);
174
175 registry.set(requestsAtom, AsyncResult.success(requestEvent("request-first")));
176 await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1));
177 registry.set(state.requestHandlerAtom, { handle: secondHandler });
178 registry.set(requestsAtom, AsyncResult.success(requestEvent("request-second")));
179
180 await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2));
181 expect(firstHandler).toHaveBeenCalledTimes(1);
182 expect(secondHandler).toHaveBeenCalledTimes(1);
183 expect(respond.mock.calls.map(([response]) => response.result)).toEqual(["first", "second"]);
184 registry.dispose();
185 });
186
67 it("consumes a request that arrived immediately before the consumer mounted", async () => {187 it("consumes a request that arrived immediately before the consumer mounted", async () => {
68 const requestsAtom = Atom.make(188 const requestsAtom = Atom.make(
69 AsyncResult.success<PreviewAutomationRequest, Error>(request("request-ready")),189 AsyncResult.success<PreviewAutomationStreamEvent, Error>(requestEvent("request-ready")),
70 );190 );
71 const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined);191 const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined);
192 const state = consumerState(async () => undefined);
72 const consumerAtom = createPreviewAutomationRequestConsumerAtom({193 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
73 requestsAtom,194 requestsAtom,
195 clientId,
196 connectionAtom: state.connectionAtom,
74 environmentId,197 environmentId,
75 handleRequest: async () => undefined,198 requestHandlerAtom: state.requestHandlerAtom,
76 respond,199 respond,
77 label: "test:preview-automation-initial-request",200 label: "test:preview-automation-initial-request",
78 });201 });
...@@ -81,7 +204,12 @@ describe("previewAutomationRequestConsumer", () => {...@@ -81,7 +204,12 @@ describe("previewAutomationRequestConsumer", () => {
81 registry.mount(consumerAtom);204 registry.mount(consumerAtom);
82205
83 await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1));206 await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1));
84 expect(respond).toHaveBeenCalledWith({ requestId: "request-ready", ok: true });207 expect(respond).toHaveBeenCalledWith({
208 clientId,
209 connectionId,
210 requestId: "request-ready",
211 ok: true,
212 });
85 registry.dispose();213 registry.dispose();
86 });214 });
87215
...@@ -118,6 +246,84 @@ describe("previewAutomationRequestConsumer", () => {...@@ -118,6 +246,84 @@ describe("previewAutomationRequestConsumer", () => {
118 });246 });
119 });247 });
120248
249 it("reports a missing recording even when no preview tab remains", () => {
250 const error = new PreviewAutomationRecordingNotActiveError({
251 requestId: "request-recording-stop",
252 environmentId,
253 threadId,
254 tabId: null,
255 });
256
257 expect(
258 serializePreviewAutomationError(error, {
259 requestId: "request-recording-stop",
260 operation: "recordingStop",
261 environmentId,
262 threadId,
263 tabId: null,
264 }),
265 ).toMatchObject({
266 _tag: "PreviewAutomationExecutionError",
267 detail: { tabId: null },
268 });
269 });
270
271 it("preserves viewport render timeouts as timeout responses", () => {
272 const error = new PreviewAutomationViewportTimeoutError({
273 requestId: "request-resize",
274 environmentId,
275 threadId,
276 tabId,
277 timeoutMs: 2_500,
278 });
279
280 expect(
281 serializePreviewAutomationError(error, {
282 requestId: "request-resize",
283 operation: "resize",
284 environmentId,
285 threadId,
286 tabId,
287 }),
288 ).toMatchObject({
289 _tag: "PreviewAutomationTimeoutError",
290 detail: { tabId: "tab-1", timeoutMs: 2_500 },
291 });
292 });
293
294 it("maps desktop non-editable targets to the public typed response", () => {
295 expect(
296 serializePreviewAutomationError(
297 {
298 _tag: "PreviewAutomationTargetNotEditableError",
299 tabId: "tab-1",
300 selectorKind: "selector",
301 selectorLength: 6,
302 },
303 {
304 requestId: "request-type",
305 operation: "type",
306 environmentId,
307 threadId,
308 tabId,
309 },
310 ),
311 ).toEqual({
312 _tag: "PreviewAutomationTargetNotEditableError",
313 message:
314 "Preview automation type request request-type requires an editable target in tab tab-1.",
315 detail: {
316 requestId: "request-type",
317 operation: "type",
318 environmentId: "environment-1",
319 threadId: "thread-1",
320 tabId: "tab-1",
321 selectorKind: "selector",
322 selectorLength: 6,
323 },
324 });
325 });
326
121 it("correlates unexpected failures without exposing cause details", () => {327 it("correlates unexpected failures without exposing cause details", () => {
122 const cause = new Error("private bridge token: preview-secret");328 const cause = new Error("private bridge token: preview-secret");
123 const context = {329 const context = {
...@@ -145,16 +351,19 @@ describe("previewAutomationRequestConsumer", () => {...@@ -145,16 +351,19 @@ describe("previewAutomationRequestConsumer", () => {
145 });351 });
146352
147 it("sanitizes unexpected handler failures at the response boundary", async () => {353 it("sanitizes unexpected handler failures at the response boundary", async () => {
148 const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationRequest, Error>>(354 const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationStreamEvent, Error>>(
149 AsyncResult.initial<PreviewAutomationRequest, Error>(false),355 AsyncResult.initial<PreviewAutomationStreamEvent, Error>(false),
150 );356 );
151 const responses: PreviewAutomationResponse[] = [];357 const responses: PreviewAutomationResponse[] = [];
358 const state = consumerState(async () => {
359 throw new Error("desktop IPC secret: do-not-return");
360 });
152 const consumerAtom = createPreviewAutomationRequestConsumerAtom({361 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
153 requestsAtom,362 requestsAtom,
363 clientId,
364 connectionAtom: state.connectionAtom,
154 environmentId,365 environmentId,
155 handleRequest: async () => {366 requestHandlerAtom: state.requestHandlerAtom,
156 throw new Error("desktop IPC secret: do-not-return");
157 },
158 respond: async (response) => {367 respond: async (response) => {
159 responses.push(response);368 responses.push(response);
160 },369 },
...@@ -166,7 +375,7 @@ describe("previewAutomationRequestConsumer", () => {...@@ -166,7 +375,7 @@ describe("previewAutomationRequestConsumer", () => {
166 registry.set(375 registry.set(
167 requestsAtom,376 requestsAtom,
168 AsyncResult.success(377 AsyncResult.success(
169 request("request-failed", {378 requestEvent("request-failed", {
170 operation: "click",379 operation: "click",
171 tabId,380 tabId,
172 }),381 }),
...@@ -175,6 +384,8 @@ describe("previewAutomationRequestConsumer", () => {...@@ -175,6 +384,8 @@ describe("previewAutomationRequestConsumer", () => {
175384
176 await vi.waitFor(() => expect(responses).toHaveLength(1));385 await vi.waitFor(() => expect(responses).toHaveLength(1));
177 expect(responses[0]).toEqual({386 expect(responses[0]).toEqual({
387 clientId,
388 connectionId,
178 requestId: "request-failed",389 requestId: "request-failed",
179 ok: false,390 ok: false,
180 error: {391 error: {
apps/web/src/components/preview/previewAutomationRequestConsumer.ts+76-42
...@@ -1,87 +1,121 @@...@@ -1,87 +1,121 @@
1import type {1import type {
2 PreviewAutomationOwner,2 PreviewAutomationHost,
3 PreviewAutomationRequest,3 PreviewAutomationRequest,
4 PreviewAutomationResponse,4 PreviewAutomationResponse,
5 PreviewAutomationStreamEvent,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
6import { AsyncResult, Atom } from "effect/unstable/reactivity";7import { AsyncResult, Atom } from "effect/unstable/reactivity";
78
8import {9import {
9 PreviewAutomationOperationError,10 PreviewAutomationOperationError,
10 type PreviewAutomationOperationContext,11 type PreviewAutomationOperationContext,
11 serializePreviewAutomationOwnerError,12 serializePreviewAutomationHostError,
12} from "./previewAutomationErrors";13} from "./previewAutomationErrors";
1314
14type AutomationRequestResult<E> = AsyncResult.AsyncResult<PreviewAutomationRequest, E>;15type AutomationStreamResult<E> = AsyncResult.AsyncResult<PreviewAutomationStreamEvent, E>;
15type AutomationRequestHandler = (request: PreviewAutomationRequest) => Promise<unknown>;
16
17export function createLatestPreviewAutomationRequestHandler(initial: AutomationRequestHandler): {
18 readonly set: (handler: AutomationRequestHandler) => void;
19 readonly handle: AutomationRequestHandler;
20} {
21 let current = initial;
22 return {
23 set: (handler) => {
24 current = handler;
25 },
26 handle: (request) => current(request),
27 };
28}
2916
30export function serializePreviewAutomationError(17export function serializePreviewAutomationError(
31 error: unknown,18 error: unknown,
32 context: PreviewAutomationOperationContext,19 context: PreviewAutomationOperationContext,
33): NonNullable<PreviewAutomationResponse["error"]> {20): NonNullable<PreviewAutomationResponse["error"]> {
34 return serializePreviewAutomationOwnerError(21 return serializePreviewAutomationHostError(
35 PreviewAutomationOperationError.fromCause({ ...context, cause: error }),22 PreviewAutomationOperationError.fromCause({ ...context, cause: error }),
36 );23 );
37}24}
3825
39export function createPreviewAutomationRequestConsumerAtom<E>(options: {26export function createPreviewAutomationRequestConsumerAtom<E>(options: {
40 readonly requestsAtom: Atom.Atom<AutomationRequestResult<E>>;27 readonly requestsAtom: Atom.Atom<AutomationStreamResult<E>>;
41 readonly environmentId: PreviewAutomationOwner["environmentId"];28 readonly clientId: PreviewAutomationHost["clientId"];
42 readonly handleRequest: (request: PreviewAutomationRequest) => Promise<unknown>;29 readonly connectionAtom: Atom.Writable<PreviewAutomationStreamEvent["connectionId"] | null>;
30 readonly environmentId: PreviewAutomationHost["environmentId"];
31 readonly requestHandlerAtom: Atom.Atom<{
32 readonly handle: (request: PreviewAutomationRequest) => Promise<unknown>;
33 }>;
43 readonly respond: (response: PreviewAutomationResponse) => Promise<unknown>;34 readonly respond: (response: PreviewAutomationResponse) => Promise<unknown>;
44 readonly label: string;35 readonly label: string;
45}): Atom.Atom<void> {36}): Atom.Atom<void> {
46 return Atom.make((get) => {37 return Atom.make((get) => {
38 get.mount(options.connectionAtom);
39 get.mount(options.requestHandlerAtom);
47 let disposed = false;40 let disposed = false;
41 let activeConnectionId: PreviewAutomationStreamEvent["connectionId"] | null = null;
42 let connectionExplicitlyAnnounced = false;
43 let reportedConnectionId: PreviewAutomationStreamEvent["connectionId"] | null = null;
48 let requestsVersion = 0;44 let requestsVersion = 0;
4945
50 const consume = (result: AutomationRequestResult<E>) => {46 const consume = (result: AutomationStreamResult<E>) => {
51 if (!AsyncResult.isSuccess(result)) return;47 if (!AsyncResult.isSuccess(result)) return;
52 const request = result.value;48 const event = result.value;
53 void options.handleRequest(request).then(49 if (event.type === "connected") {
54 (value) =>50 activeConnectionId = event.connectionId;
55 options.respond({51 connectionExplicitlyAnnounced = true;
56 requestId: request.requestId,52 } else if (activeConnectionId === null) {
57 ok: true,53 activeConnectionId = event.connectionId;
58 ...(value === undefined ? {} : { result: value }),54 } else if (activeConnectionId !== event.connectionId) {
59 }),55 if (connectionExplicitlyAnnounced) return;
60 (error) =>56 activeConnectionId = event.connectionId;
61 options.respond({57 }
62 requestId: request.requestId,58 if (reportedConnectionId !== event.connectionId) {
63 ok: false,59 reportedConnectionId = event.connectionId;
64 error: serializePreviewAutomationError(error, {60 get.set(options.connectionAtom, event.connectionId);
61 }
62 if (event.type === "connected") {
63 return;
64 }
65 const request = event.request;
66 void get
67 .once(options.requestHandlerAtom)
68 .handle(request)
69 .then(
70 (value) =>
71 options.respond({
72 clientId: options.clientId,
73 connectionId: event.connectionId,
74 requestId: request.requestId,
75 ok: true,
76 ...(value === undefined ? {} : { result: value }),
77 }),
78 (error) =>
79 options.respond({
80 clientId: options.clientId,
81 connectionId: event.connectionId,
65 requestId: request.requestId,82 requestId: request.requestId,
66 operation: request.operation,83 ok: false,
67 environmentId: options.environmentId,84 error: serializePreviewAutomationError(error, {
68 threadId: request.threadId,85 requestId: request.requestId,
69 tabId: request.tabId ?? null,86 operation: request.operation,
87 environmentId: options.environmentId,
88 threadId: request.threadId,
89 tabId: request.tabId ?? null,
90 }),
70 }),91 }),
71 }),92 );
72 );
73 };93 };
7494
75 get.addFinalizer(() => {95 get.addFinalizer(() => {
76 disposed = true;96 disposed = true;
77 });97 });
78 const initialRequest = get.once(options.requestsAtom);98 const initialRequest = get.once(options.requestsAtom);
99 if (AsyncResult.isSuccess(initialRequest)) {
100 activeConnectionId = initialRequest.value.connectionId;
101 connectionExplicitlyAnnounced = initialRequest.value.type === "connected";
102 if (initialRequest.value.type === "connected") {
103 reportedConnectionId = initialRequest.value.connectionId;
104 get.set(options.connectionAtom, initialRequest.value.connectionId);
105 }
106 }
79 get.subscribe(options.requestsAtom, (result) => {107 get.subscribe(options.requestsAtom, (result) => {
80 requestsVersion += 1;108 requestsVersion += 1;
81 consume(result);109 consume(result);
82 });110 });
83 queueMicrotask(() => {111 queueMicrotask(() => {
84 if (!disposed && requestsVersion === 0) consume(initialRequest);112 const initialConnectionWasSkipped =
113 AsyncResult.isSuccess(initialRequest) &&
114 initialRequest.value.connectionId === activeConnectionId &&
115 initialRequest.value.connectionId !== reportedConnectionId;
116 if (!disposed && (requestsVersion === 0 || initialConnectionWasSkipped)) {
117 consume(initialRequest);
118 }
85 });119 });
86 }).pipe(Atom.setIdleTTL(0), Atom.withLabel(options.label));120 }).pipe(Atom.setIdleTTL(0), Atom.withLabel(options.label));
87}121}
apps/web/src/components/preview/previewAutomationTarget.test.ts created+45
...@@ -0,0 +1,45 @@
1import type { PreviewSessionSnapshot } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 needsPreviewAutomationSessionSync,
6 resolvePreviewAutomationTarget,
7} from "./previewAutomationTarget";
8
9const snapshot = (tabId: string): PreviewSessionSnapshot => ({
10 threadId: "thread-1",
11 tabId,
12 navStatus: { _tag: "Idle" },
13 canGoBack: false,
14 canGoForward: false,
15 updatedAt: "2026-01-01T00:00:00.000Z",
16});
17
18describe("preview automation target selection", () => {
19 it("refreshes authoritative sessions whenever the caller relies on the active tab", () => {
20 const active = snapshot("tab-active");
21 expect(
22 needsPreviewAutomationSessionSync(
23 { snapshot: active, sessions: { [active.tabId]: active } },
24 undefined,
25 ),
26 ).toBe(true);
27 });
28
29 it("refreshes an explicit tab only when it is absent locally", () => {
30 const active = snapshot("tab-active");
31 const state = { snapshot: active, sessions: { [active.tabId]: active } };
32 expect(needsPreviewAutomationSessionSync(state, active.tabId)).toBe(false);
33 expect(needsPreviewAutomationSessionSync(state, "tab-missing")).toBe(true);
34 });
35
36 it("does not report the active tab under an unknown requested tab id", () => {
37 const active = snapshot("tab-active");
38 expect(
39 resolvePreviewAutomationTarget(
40 { snapshot: active, sessions: { [active.tabId]: active } },
41 "tab-missing",
42 ),
43 ).toEqual({ tabId: null, snapshot: null });
44 });
45});
apps/web/src/components/preview/previewAutomationTarget.ts created+25
...@@ -0,0 +1,25 @@
1import type { PreviewSessionSnapshot } from "@t3tools/contracts";
2
3interface PreviewAutomationSessionIndex {
4 readonly snapshot: PreviewSessionSnapshot | null;
5 readonly sessions: Readonly<Record<string, PreviewSessionSnapshot>>;
6}
7
8export function needsPreviewAutomationSessionSync(
9 state: PreviewAutomationSessionIndex,
10 requestedTabId: string | undefined,
11): boolean {
12 return (
13 Object.keys(state.sessions).length === 0 ||
14 requestedTabId === undefined ||
15 state.sessions[requestedTabId] === undefined
16 );
17}
18
19export function resolvePreviewAutomationTarget(
20 state: PreviewAutomationSessionIndex,
21 requestedTabId: string | null,
22): { readonly tabId: string | null; readonly snapshot: PreviewSessionSnapshot | null } {
23 const snapshot = requestedTabId ? (state.sessions[requestedTabId] ?? null) : state.snapshot;
24 return { tabId: snapshot?.tabId ?? null, snapshot };
25}
apps/web/src/components/preview/previewViewportReadiness.test.ts created+72
...@@ -0,0 +1,72 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { browserViewportSettingKey } from "~/browser/browserViewportLayout";
4
5import { isPreviewViewportReady } from "./previewViewportReadiness";
6
7describe("isPreviewViewportReady", () => {
8 const landscape = {
9 _tag: "preset",
10 width: 844,
11 height: 390,
12 presetId: "iphone-12-pro",
13 } as const;
14
15 it("rejects a stale same-mode preset while React applies the requested orientation", () => {
16 expect(
17 isPreviewViewportReady({
18 setting: landscape,
19 appliedSettingKey: "preset:390:844:iphone-12-pro",
20 declaredViewport: { width: 390, height: 844 },
21 renderedViewport: { width: 390, height: 844 },
22 }),
23 ).toBe(false);
24 });
25
26 it("requires both the declaration and guest viewport to match a fixed request", () => {
27 const appliedSettingKey = browserViewportSettingKey(landscape);
28 expect(
29 isPreviewViewportReady({
30 setting: landscape,
31 appliedSettingKey,
32 declaredViewport: { width: 390, height: 844 },
33 renderedViewport: { width: 844, height: 390 },
34 }),
35 ).toBe(false);
36 expect(
37 isPreviewViewportReady({
38 setting: landscape,
39 appliedSettingKey,
40 declaredViewport: { width: 844, height: 390 },
41 renderedViewport: { width: 844, height: 390 },
42 }),
43 ).toBe(true);
44 });
45
46 it("allows one pixel of Electron rounding tolerance in every mode", () => {
47 expect(
48 isPreviewViewportReady({
49 setting: { _tag: "fill" },
50 appliedSettingKey: "fill",
51 declaredViewport: { width: 500, height: 700 },
52 renderedViewport: { width: 501, height: 699 },
53 }),
54 ).toBe(true);
55 expect(
56 isPreviewViewportReady({
57 setting: landscape,
58 appliedSettingKey: browserViewportSettingKey(landscape),
59 declaredViewport: { width: 844, height: 390 },
60 renderedViewport: { width: 845, height: 389 },
61 }),
62 ).toBe(true);
63 expect(
64 isPreviewViewportReady({
65 setting: landscape,
66 appliedSettingKey: browserViewportSettingKey(landscape),
67 declaredViewport: { width: 844, height: 390 },
68 renderedViewport: { width: 846, height: 390 },
69 }),
70 ).toBe(false);
71 });
72});
apps/web/src/components/preview/previewViewportReadiness.ts created+37
...@@ -0,0 +1,37 @@
1import type { PreviewRenderedViewportSize, PreviewViewportSetting } from "@t3tools/contracts";
2
3import { browserViewportSettingKey } from "~/browser/browserViewportLayout";
4
5export function isPreviewViewportReady(input: {
6 readonly setting: PreviewViewportSetting;
7 readonly appliedSettingKey: string | null;
8 readonly declaredViewport: PreviewRenderedViewportSize | null;
9 readonly renderedViewport: PreviewRenderedViewportSize | null;
10}): boolean {
11 const { setting, appliedSettingKey, declaredViewport, renderedViewport } = input;
12 if (
13 appliedSettingKey !== browserViewportSettingKey(setting) ||
14 declaredViewport === null ||
15 renderedViewport === null
16 ) {
17 return false;
18 }
19
20 const expectedViewport =
21 setting._tag === "fill" ? declaredViewport : { width: setting.width, height: setting.height };
22 if (
23 setting._tag !== "fill" &&
24 (declaredViewport.width !== expectedViewport.width ||
25 declaredViewport.height !== expectedViewport.height)
26 ) {
27 return false;
28 }
29
30 // Electron rounds CSS pixels through the guest's fractional zoom/device scale,
31 // so a successfully applied fixed viewport can measure one pixel either way.
32 const tolerance = 1;
33 return (
34 Math.abs(renderedViewport.width - expectedViewport.width) <= tolerance &&
35 Math.abs(renderedViewport.height - expectedViewport.height) <= tolerance
36 );
37}
apps/web/src/components/preview/usePreviewSession.ts+2-3
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 applyPreviewServerEvent,11 applyPreviewServerEvent,
12 applyPreviewServerSnapshot,12 applyPreviewServerSnapshot,
13 readThreadPreviewState,13 readThreadPreviewState,
14 reconcilePreviewServerSessions,
14} from "~/previewStateStore";15} from "~/previewStateStore";
15import { previewEnvironment } from "~/state/preview";16import { previewEnvironment } from "~/state/preview";
1617
...@@ -50,9 +51,7 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {...@@ -50,9 +51,7 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {
50 if (result.value.sessions.length > 0) {51 if (result.value.sessions.length > 0) {
51 recoveringUrl = null;52 recoveringUrl = null;
52 recoveryId += 1;53 recoveryId += 1;
53 for (const snapshot of result.value.sessions) {54 reconcilePreviewServerSessions(threadRef, result.value.sessions);
54 applyPreviewServerSnapshot(threadRef, snapshot);
55 }
56 return;55 return;
57 }56 }
5857
apps/web/src/previewStateStore.test.ts+88
...@@ -11,10 +11,12 @@ import {...@@ -11,10 +11,12 @@ import {
11 cancelPreviewSessionClose,11 cancelPreviewSessionClose,
12 previewStateAtom,12 previewStateAtom,
13 readThreadPreviewState,13 readThreadPreviewState,
14 reconcilePreviewServerSessions,
14 rememberPreviewUrl,15 rememberPreviewUrl,
15 removePreviewThread,16 removePreviewThread,
16 resetPreviewStateForTests,17 resetPreviewStateForTests,
17 setActivePreviewTab,18 setActivePreviewTab,
19 updatePreviewServerSnapshot,
18} from "./previewStateStore";20} from "./previewStateStore";
1921
20const environmentId = "env-1" as EnvironmentId;22const environmentId = "env-1" as EnvironmentId;
...@@ -108,6 +110,34 @@ describe("previewStateStore (single-tab)", () => {...@@ -108,6 +110,34 @@ describe("previewStateStore (single-tab)", () => {
108 }110 }
109 });111 });
110112
113 it("resized event updates tab viewport without changing the active tab", () => {
114 const active = makeSnapshot({ tabId: "tab_a" });
115 const background = makeSnapshot({ tabId: "tab_b" });
116 applyPreviewServerSnapshot(ref, background);
117 applyPreviewServerSnapshot(ref, active);
118
119 applyPreviewServerEvent(ref, {
120 type: "resized",
121 threadId: "thread-1",
122 tabId: background.tabId,
123 createdAt: "2026-01-01T00:00:01.000Z",
124 snapshot: {
125 ...background,
126 viewport: { _tag: "preset", presetId: "pixel-8", width: 412, height: 915 },
127 updatedAt: "2026-01-01T00:00:01.000Z",
128 },
129 });
130
131 const state = readThreadPreviewState(ref);
132 expect(state.activeTabId).toBe(active.tabId);
133 expect(state.sessions[background.tabId]?.viewport).toEqual({
134 _tag: "preset",
135 presetId: "pixel-8",
136 width: 412,
137 height: 915,
138 });
139 });
140
111 it("failed event flips the snapshot to LoadFailed when tabId matches", () => {141 it("failed event flips the snapshot to LoadFailed when tabId matches", () => {
112 const snapshot = makeSnapshot();142 const snapshot = makeSnapshot();
113 applyPreviewServerEvent(ref, {143 applyPreviewServerEvent(ref, {
...@@ -292,6 +322,64 @@ describe("previewStateStore (single-tab)", () => {...@@ -292,6 +322,64 @@ describe("previewStateStore (single-tab)", () => {
292 expect(state.desktopOverlay?.canGoBack).toBe(true);322 expect(state.desktopOverlay?.canGoBack).toBe(true);
293 });323 });
294324
325 it("updates a background snapshot without changing the active tab", () => {
326 const background = makeSnapshot({ tabId: "tab_a" });
327 const active = makeSnapshot({
328 tabId: "tab_b",
329 updatedAt: "2026-01-01T00:00:01.000Z",
330 });
331 applyPreviewServerSnapshot(ref, background);
332 applyPreviewServerSnapshot(ref, active);
333
334 const resized = {
335 ...background,
336 viewport: { _tag: "freeform" as const, width: 900, height: 700 },
337 updatedAt: "2026-01-01T00:00:02.000Z",
338 };
339 updatePreviewServerSnapshot(ref, resized);
340
341 const state = readThreadPreviewState(ref);
342 expect(state.activeTabId).toBe(active.tabId);
343 expect(state.snapshot?.tabId).toBe(active.tabId);
344 expect(state.sessions[background.tabId]).toEqual(resized);
345 });
346
347 it("reconciles an authoritative session list without focusing a background tab", () => {
348 const active = makeSnapshot({ tabId: "tab_a" });
349 const stale = makeSnapshot({
350 tabId: "tab_stale",
351 updatedAt: "2026-01-01T00:00:01.000Z",
352 });
353 applyPreviewServerSnapshot(ref, stale);
354 applyPreviewServerSnapshot(ref, active);
355 applyPreviewDesktopState(ref, stale.tabId, {
356 canGoBack: false,
357 canGoForward: false,
358 loading: false,
359 zoomFactor: 1,
360 controller: "none",
361 });
362
363 reconcilePreviewServerSessions(ref, [active]);
364
365 const state = readThreadPreviewState(ref);
366 expect(Object.keys(state.sessions)).toEqual([active.tabId]);
367 expect(state.activeTabId).toBe(active.tabId);
368 expect(state.snapshot).toEqual(active);
369 expect(state.desktopByTabId[stale.tabId]).toBeUndefined();
370 });
371
372 it("clears stale sessions when an authoritative list is empty", () => {
373 applyPreviewServerSnapshot(ref, makeSnapshot());
374
375 reconcilePreviewServerSessions(ref, []);
376
377 const state = readThreadPreviewState(ref);
378 expect(state.sessions).toEqual({});
379 expect(state.activeTabId).toBeNull();
380 expect(state.snapshot).toBeNull();
381 });
382
295 it("applyServerSnapshot null clears snapshot for a thread that had one", () => {383 it("applyServerSnapshot null clears snapshot for a thread that had one", () => {
296 const snapshot = makeSnapshot();384 const snapshot = makeSnapshot();
297 applyPreviewServerSnapshot(ref, snapshot);385 applyPreviewServerSnapshot(ref, snapshot);
apps/web/src/previewStateStore.ts+89-9
...@@ -112,14 +112,26 @@ const dedupeRecentUrls = (existing: string[], url: string): string[] => {...@@ -112,14 +112,26 @@ const dedupeRecentUrls = (existing: string[], url: string): string[] => {
112 return next.slice(0, PREVIEW_RECENT_URL_LIMIT);112 return next.slice(0, PREVIEW_RECENT_URL_LIMIT);
113};113};
114114
115const rememberSnapshotUrl = (
116 recentlySeenUrls: string[],
117 snapshot: PreviewSessionSnapshot,
118): string[] =>
119 snapshot.navStatus._tag === "Idle"
120 ? recentlySeenUrls
121 : dedupeRecentUrls(recentlySeenUrls, snapshot.navStatus.url);
122
123const latestSnapshot = (
124 sessions: Record<string, PreviewSessionSnapshot>,
125): PreviewSessionSnapshot | null =>
126 Object.values(sessions)
127 .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt))
128 .at(-1) ?? null;
129
115const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPreviewState => {130const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPreviewState => {
116 if (!current.sessions[tabId]) return current;131 if (!current.sessions[tabId]) return current;
117 const { [tabId]: _closed, ...sessions } = current.sessions;132 const { [tabId]: _closed, ...sessions } = current.sessions;
118 const { [tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId;133 const { [tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId;
119 const nextSnapshot =134 const nextSnapshot = latestSnapshot(sessions);
120 Object.values(sessions)
121 .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt))
122 .at(-1) ?? null;
123 const activeTabId =135 const activeTabId =
124 current.activeTabId === tabId ? (nextSnapshot?.tabId ?? null) : current.activeTabId;136 current.activeTabId === tabId ? (nextSnapshot?.tabId ?? null) : current.activeTabId;
125 const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot;137 const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot;
...@@ -163,7 +175,8 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven...@@ -163,7 +175,8 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven
163 updateThreadPreviewState(ref, (current) => {175 updateThreadPreviewState(ref, (current) => {
164 switch (event.type) {176 switch (event.type) {
165 case "opened":177 case "opened":
166 case "navigated": {178 case "navigated":
179 case "resized": {
167 const snapshot = event.snapshot;180 const snapshot = event.snapshot;
168 if (current.suppressedTabIds.has(snapshot.tabId)) return current;181 if (current.suppressedTabIds.has(snapshot.tabId)) return current;
169 const recentlySeenUrls =182 const recentlySeenUrls =
...@@ -228,10 +241,7 @@ export function applyPreviewServerSnapshot(...@@ -228,10 +241,7 @@ export function applyPreviewServerSnapshot(
228 if (current.suppressedTabIds.has(snapshot.tabId)) return current;241 if (current.suppressedTabIds.has(snapshot.tabId)) return current;
229 const existing = current.sessions[snapshot.tabId];242 const existing = current.sessions[snapshot.tabId];
230 if (existing && existing.updatedAt > snapshot.updatedAt) return current;243 if (existing && existing.updatedAt > snapshot.updatedAt) return current;
231 const recentlySeenUrls =244 const recentlySeenUrls = rememberSnapshotUrl(current.recentlySeenUrls, snapshot);
232 snapshot.navStatus._tag !== "Idle"
233 ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url)
234 : current.recentlySeenUrls;
235 return {245 return {
236 ...current,246 ...current,
237 snapshot,247 snapshot,
...@@ -243,6 +253,76 @@ export function applyPreviewServerSnapshot(...@@ -243,6 +253,76 @@ export function applyPreviewServerSnapshot(
243 });253 });
244}254}
245255
256/**
257 * Merge a server mutation without changing which tab the user is viewing.
258 *
259 * Commands such as resize can target background tabs. Their response is
260 * authoritative for that tab, but it is not a request to focus the tab.
261 */
262export function updatePreviewServerSnapshot(
263 ref: ScopedThreadRef,
264 snapshot: PreviewSessionSnapshot,
265): void {
266 updateThreadPreviewState(ref, (current) => {
267 if (current.suppressedTabIds.has(snapshot.tabId)) return current;
268 const existing = current.sessions[snapshot.tabId];
269 if (existing && existing.updatedAt > snapshot.updatedAt) return current;
270 const sessions = { ...current.sessions, [snapshot.tabId]: snapshot };
271 const activeTabId =
272 current.activeTabId && sessions[current.activeTabId] ? current.activeTabId : snapshot.tabId;
273 const activeSnapshot = sessions[activeTabId] ?? snapshot;
274 return {
275 ...current,
276 sessions,
277 activeTabId,
278 snapshot: activeSnapshot,
279 desktopOverlay: current.desktopByTabId[activeTabId] ?? null,
280 recentlySeenUrls: rememberSnapshotUrl(current.recentlySeenUrls, snapshot),
281 };
282 });
283}
284
285/**
286 * Replace the local session index from an authoritative preview.list result.
287 * Missing tabs are removed while the current active tab is preserved whenever
288 * it still exists in the server result.
289 */
290export function reconcilePreviewServerSessions(
291 ref: ScopedThreadRef,
292 snapshots: ReadonlyArray<PreviewSessionSnapshot>,
293): void {
294 updateThreadPreviewState(ref, (current) => {
295 const sessions: Record<string, PreviewSessionSnapshot> = {};
296 let recentlySeenUrls = current.recentlySeenUrls;
297 for (const snapshot of snapshots) {
298 if (current.suppressedTabIds.has(snapshot.tabId)) continue;
299 const existing = current.sessions[snapshot.tabId];
300 const next = existing && existing.updatedAt > snapshot.updatedAt ? existing : snapshot;
301 sessions[next.tabId] = next;
302 recentlySeenUrls = rememberSnapshotUrl(recentlySeenUrls, next);
303 }
304
305 const fallback = latestSnapshot(sessions);
306 const activeTabId =
307 current.activeTabId && sessions[current.activeTabId]
308 ? current.activeTabId
309 : (fallback?.tabId ?? null);
310 const snapshot = activeTabId ? (sessions[activeTabId] ?? null) : null;
311 const desktopByTabId = Object.fromEntries(
312 Object.entries(current.desktopByTabId).filter(([tabId]) => sessions[tabId] !== undefined),
313 );
314 return {
315 ...current,
316 sessions,
317 activeTabId,
318 snapshot,
319 desktopByTabId,
320 desktopOverlay: activeTabId ? (desktopByTabId[activeTabId] ?? null) : null,
321 recentlySeenUrls,
322 };
323 });
324}
325
246export function applyPreviewDesktopState(326export function applyPreviewDesktopState(
247 ref: ScopedThreadRef,327 ref: ScopedThreadRef,
248 tabId: string,328 tabId: string,
packages/client-runtime/src/state/preview.test.ts created+18
...@@ -0,0 +1,18 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { previewAutomationHostFocusConcurrencyKey } from "./preview.ts";
4
5describe("preview state commands", () => {
6 it("keeps focus updates from replacement host connections independent", () => {
7 const first = previewAutomationHostFocusConcurrencyKey({
8 environmentId: "environment-1",
9 input: { clientId: "client-1", connectionId: "connection-1" },
10 });
11 const replacement = previewAutomationHostFocusConcurrencyKey({
12 environmentId: "environment-1",
13 input: { clientId: "client-1", connectionId: "connection-2" },
14 });
15
16 expect(first).not.toBe(replacement);
17 });
18});
packages/client-runtime/src/state/preview.ts+21-15
...@@ -9,6 +9,14 @@ import {...@@ -9,6 +9,14 @@ import {
9 createEnvironmentRpcSubscriptionAtomFamily,9 createEnvironmentRpcSubscriptionAtomFamily,
10} from "./runtime.ts";10} from "./runtime.ts";
1111
12export const previewAutomationHostFocusConcurrencyKey = (value: {
13 readonly environmentId: string;
14 readonly input: {
15 readonly clientId: string;
16 readonly connectionId: string;
17 };
18}): string => JSON.stringify([value.environmentId, value.input.clientId, value.input.connectionId]);
19
12export function createPreviewEnvironmentAtoms<R, E>(20export function createPreviewEnvironmentAtoms<R, E>(
13 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,21 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
14) {22) {
...@@ -54,6 +62,12 @@ export function createPreviewEnvironmentAtoms<R, E>(...@@ -54,6 +62,12 @@ export function createPreviewEnvironmentAtoms<R, E>(
54 scheduler: lifecycleScheduler,62 scheduler: lifecycleScheduler,
55 concurrency: lifecycleConcurrency,63 concurrency: lifecycleConcurrency,
56 }),64 }),
65 resize: createEnvironmentRpcCommand(runtime, {
66 label: "environment-data:preview:resize",
67 tag: WS_METHODS.previewResize,
68 scheduler: lifecycleScheduler,
69 concurrency: lifecycleConcurrency,
70 }),
57 refresh: createEnvironmentRpcCommand(runtime, {71 refresh: createEnvironmentRpcCommand(runtime, {
58 label: "environment-data:preview:refresh",72 label: "environment-data:preview:refresh",
59 tag: WS_METHODS.previewRefresh,73 tag: WS_METHODS.previewRefresh,
...@@ -82,25 +96,17 @@ export function createPreviewEnvironmentAtoms<R, E>(...@@ -82,25 +96,17 @@ export function createPreviewEnvironmentAtoms<R, E>(
82 scheduler: automationScheduler,96 scheduler: automationScheduler,
83 concurrency: {97 concurrency: {
84 mode: "singleFlight",98 mode: "singleFlight",
85 key: ({ environmentId, input }) => JSON.stringify([environmentId, input.requestId]),99 key: ({ environmentId, input }) =>
86 },100 JSON.stringify([environmentId, input.connectionId, input.requestId]),
87 }),
88 reportAutomationOwner: createEnvironmentRpcCommand(runtime, {
89 label: "environment-data:preview:automation-report-owner",
90 tag: WS_METHODS.previewAutomationReportOwner,
91 scheduler: automationScheduler,
92 concurrency: {
93 mode: "serial",
94 key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]),
95 },101 },
96 }),102 }),
97 clearAutomationOwner: createEnvironmentRpcCommand(runtime, {103 focusAutomationHost: createEnvironmentRpcCommand(runtime, {
98 label: "environment-data:preview:automation-clear-owner",104 label: "environment-data:preview:automation-focus-host",
99 tag: WS_METHODS.previewAutomationClearOwner,105 tag: WS_METHODS.previewAutomationFocusHost,
100 scheduler: automationScheduler,106 scheduler: automationScheduler,
101 concurrency: {107 concurrency: {
102 mode: "serial",108 mode: "latest",
103 key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]),109 key: previewAutomationHostFocusConcurrencyKey,
104 },110 },
105 }),111 }),
106 };112 };
packages/contracts/src/ipc.ts+8-7
...@@ -69,19 +69,20 @@ import type {...@@ -69,19 +69,20 @@ import type {
69 PreviewOpenInput,69 PreviewOpenInput,
70 PreviewRefreshInput,70 PreviewRefreshInput,
71 PreviewReportStatusInput,71 PreviewReportStatusInput,
72 PreviewResizeInput,
72 PreviewSessionSnapshot,73 PreviewSessionSnapshot,
73} from "./preview.ts";74} from "./preview.ts";
74import {75import {
75 PreviewAutomationClickInput,76 PreviewAutomationClickInput,
76 PreviewAutomationEvaluateInput,77 PreviewAutomationEvaluateInput,
77 PreviewAutomationOwner,78 PreviewAutomationHost,
78 PreviewAutomationOwnerIdentity,79 PreviewAutomationHostFocus,
79 PreviewAutomationPressInput,80 PreviewAutomationPressInput,
80 PreviewAutomationRequest,
81 PreviewAutomationResponse,81 PreviewAutomationResponse,
82 PreviewAutomationScrollInput,82 PreviewAutomationScrollInput,
83 PreviewAutomationSnapshot,83 PreviewAutomationSnapshot,
84 PreviewAutomationStatus,84 PreviewAutomationStatus,
85 PreviewAutomationStreamEvent,
85 PreviewAutomationTypeInput,86 PreviewAutomationTypeInput,
86 PreviewAutomationWaitForInput,87 PreviewAutomationWaitForInput,
87} from "./previewAutomation.ts";88} from "./previewAutomation.ts";
...@@ -1154,19 +1155,19 @@ export interface EnvironmentApi {...@@ -1154,19 +1155,19 @@ export interface EnvironmentApi {
1154 preview: {1155 preview: {
1155 open: (input: typeof PreviewOpenInput.Encoded) => Promise<PreviewSessionSnapshot>;1156 open: (input: typeof PreviewOpenInput.Encoded) => Promise<PreviewSessionSnapshot>;
1156 navigate: (input: typeof PreviewNavigateInput.Encoded) => Promise<PreviewSessionSnapshot>;1157 navigate: (input: typeof PreviewNavigateInput.Encoded) => Promise<PreviewSessionSnapshot>;
1158 resize: (input: typeof PreviewResizeInput.Encoded) => Promise<PreviewSessionSnapshot>;
1157 refresh: (input: typeof PreviewRefreshInput.Encoded) => Promise<void>;1159 refresh: (input: typeof PreviewRefreshInput.Encoded) => Promise<void>;
1158 close: (input: typeof PreviewCloseInput.Encoded) => Promise<void>;1160 close: (input: typeof PreviewCloseInput.Encoded) => Promise<void>;
1159 list: (input: typeof PreviewListInput.Encoded) => Promise<PreviewListResult>;1161 list: (input: typeof PreviewListInput.Encoded) => Promise<PreviewListResult>;
1160 reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise<void>;1162 reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise<void>;
1161 automation: {1163 automation: {
1162 connect: (1164 connect: (
1163 input: { clientId: string },1165 input: PreviewAutomationHost,
1164 callback: (request: PreviewAutomationRequest) => void,1166 callback: (event: PreviewAutomationStreamEvent) => void,
1165 options?: { onResubscribe?: () => void },1167 options?: { onResubscribe?: () => void },
1166 ) => () => void;1168 ) => () => void;
1167 respond: (response: PreviewAutomationResponse) => Promise<void>;1169 respond: (response: PreviewAutomationResponse) => Promise<void>;
1168 reportOwner: (owner: PreviewAutomationOwner) => Promise<void>;1170 focusHost: (input: PreviewAutomationHostFocus) => Promise<void>;
1169 clearOwner: (input: PreviewAutomationOwnerIdentity) => Promise<void>;
1170 };1171 };
1171 onEvent: (1172 onEvent: (
1172 callback: (event: PreviewEvent) => void,1173 callback: (event: PreviewEvent) => void,
packages/contracts/src/preview.test.ts+144
...@@ -6,12 +6,26 @@ import {...@@ -6,12 +6,26 @@ import {
6 PreviewEvent,6 PreviewEvent,
7 PreviewNavStatus,7 PreviewNavStatus,
8 PreviewSessionSnapshot,8 PreviewSessionSnapshot,
9 PreviewViewportSetting,
9} from "./preview.ts";10} from "./preview.ts";
11import {
12 PreviewAutomationHost,
13 PreviewAutomationError,
14 PreviewAutomationResizeInput,
15 PreviewAutomationResizeResult,
16 PreviewAutomationStatus,
17} from "./previewAutomation.ts";
1018
11const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent);19const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent);
12const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot);20const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot);
13const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus);21const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus);
14const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer);22const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer);
23const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting);
24const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput);
25const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResult);
26const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost);
27const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError);
28const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus);
1529
16describe("PreviewNavStatus", () => {30describe("PreviewNavStatus", () => {
17 it("decodes Idle", () => {31 it("decodes Idle", () => {
...@@ -68,6 +82,117 @@ describe("PreviewSessionSnapshot", () => {...@@ -68,6 +82,117 @@ describe("PreviewSessionSnapshot", () => {
68 });82 });
69});83});
7084
85describe("PreviewViewportSetting", () => {
86 it("decodes fill, freeform, and preset modes", () => {
87 expect(decodeViewport({ _tag: "fill" })).toEqual({ _tag: "fill" });
88 expect(decodeViewport({ _tag: "freeform", width: 1024, height: 768 })).toEqual({
89 _tag: "freeform",
90 width: 1024,
91 height: 768,
92 });
93 expect(
94 decodeViewport({
95 _tag: "preset",
96 presetId: "iphone-15-pro",
97 width: 393,
98 height: 852,
99 }),
100 ).toMatchObject({ _tag: "preset", presetId: "iphone-15-pro" });
101 });
102
103 it("rejects unsafe dimensions and oversized render areas", () => {
104 expect(() => decodeViewport({ _tag: "freeform", width: 100, height: 800 })).toThrow();
105 expect(() => decodeViewport({ _tag: "freeform", width: 3840, height: 3840 })).toThrow();
106 });
107});
108
109describe("PreviewAutomationResizeInput", () => {
110 it("requires fields that match the selected mode", () => {
111 expect(decodeResizeInput({ mode: "fill" })).toEqual({ mode: "fill" });
112 expect(
113 decodeResizeInput({ mode: "preset", preset: "pixel-7", orientation: "landscape" }),
114 ).toMatchObject({ mode: "preset", preset: "pixel-7" });
115 expect(() => decodeResizeInput({ mode: "preset", preset: "pixel-8" })).toThrow();
116 expect(() => decodeResizeInput({ mode: "freeform", width: 1024 })).toThrow();
117 expect(() => decodeResizeInput({ mode: "fill", width: 1024, height: 768 })).toThrow();
118 });
119
120 it("allows fill-mode measurements below the minimum selectable fixed size", () => {
121 expect(
122 decodeResizeResult({
123 tabId: "preview-t",
124 setting: { _tag: "fill" },
125 viewport: { width: 180, height: 120 },
126 }).viewport,
127 ).toEqual({ width: 180, height: 120 });
128 });
129});
130
131describe("PreviewAutomationHost", () => {
132 it("accepts legacy hosts and current operation advertisements", () => {
133 expect(decodeAutomationHost({ clientId: "legacy", environmentId: "environment-1" })).toEqual({
134 clientId: "legacy",
135 environmentId: "environment-1",
136 });
137 expect(
138 decodeAutomationHost({
139 clientId: "current",
140 environmentId: "environment-1",
141 supportedOperations: ["status", "resize"],
142 }).supportedOperations,
143 ).toEqual(["status", "resize"]);
144 });
145});
146
147describe("PreviewAutomationError", () => {
148 it("preserves a typed non-editable target failure", () => {
149 const error = decodeAutomationError({
150 _tag: "PreviewAutomationTargetNotEditableError",
151 operation: "type",
152 environmentId: "environment-1",
153 threadId: "thread-1",
154 providerSessionId: "provider-session-1",
155 providerInstanceId: "codex",
156 clientId: "client-1",
157 connectionId: "connection-1",
158 requestId: "request-1",
159 tabId: "tab-1",
160 timeoutMs: 1_000,
161 remoteTag: "PreviewAutomationTargetNotEditableError",
162 remoteMessageLength: 12,
163 cause: {},
164 selectorKind: "focused-element",
165 });
166
167 expect(error._tag).toBe("PreviewAutomationTargetNotEditableError");
168 if (error._tag === "PreviewAutomationTargetNotEditableError") {
169 expect(error.selectorKind).toBe("focused-element");
170 expect(error.message).toBe("Preview automation type requires an editable focused element.");
171 }
172 });
173});
174
175describe("PreviewAutomationStatus", () => {
176 it("accepts old hosts without viewport data and exposes it from current hosts", () => {
177 const base = {
178 available: true,
179 visible: false,
180 tabId: "preview-t",
181 url: "https://example.com",
182 title: "Example",
183 loading: false,
184 };
185 expect(decodeAutomationStatus(base)).toEqual(base);
186 expect(
187 decodeAutomationStatus({
188 ...base,
189 viewportSetting: { _tag: "preset", presetId: "pixel-8", width: 412, height: 915 },
190 viewport: { width: 412, height: 915 },
191 }).viewport,
192 ).toEqual({ width: 412, height: 915 });
193 });
194});
195
71describe("PreviewEvent", () => {196describe("PreviewEvent", () => {
72 it("decodes opened", () => {197 it("decodes opened", () => {
73 const event = decodePreviewEvent({198 const event = decodePreviewEvent({
...@@ -104,6 +229,25 @@ describe("PreviewEvent", () => {...@@ -104,6 +229,25 @@ describe("PreviewEvent", () => {
104 }229 }
105 });230 });
106231
232 it("decodes resized with tab viewport state", () => {
233 const event = decodePreviewEvent({
234 type: "resized",
235 threadId: "t",
236 tabId: "preview-t",
237 createdAt: "2026-01-01T00:00:00.000Z",
238 snapshot: {
239 threadId: "t",
240 tabId: "preview-t",
241 navStatus: { _tag: "Idle" },
242 canGoBack: false,
243 canGoForward: false,
244 viewport: { _tag: "freeform", width: 1024, height: 768 },
245 updatedAt: "2026-01-01T00:00:00.000Z",
246 },
247 });
248 expect(event.type).toBe("resized");
249 });
250
107 it("decodes closed without snapshot", () => {251 it("decodes closed without snapshot", () => {
108 const event = decodePreviewEvent({252 const event = decodePreviewEvent({
109 type: "closed",253 type: "closed",
packages/contracts/src/preview.ts+110
...@@ -17,6 +17,100 @@ const Title = Schema.String.check(Schema.isMaxLength(512));...@@ -17,6 +17,100 @@ const Title = Schema.String.check(Schema.isMaxLength(512));
17export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));17export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
18export type PreviewTabId = typeof PreviewTabId.Type;18export type PreviewTabId = typeof PreviewTabId.Type;
1919
20export const PREVIEW_VIEWPORT_MIN_DIMENSION = 240;
21export const PREVIEW_VIEWPORT_MAX_DIMENSION = 3840;
22export const PREVIEW_VIEWPORT_MAX_AREA = 3840 * 2160;
23
24const PreviewViewportDimension = Schema.Int.check(
25 Schema.isBetween({
26 minimum: PREVIEW_VIEWPORT_MIN_DIMENSION,
27 maximum: PREVIEW_VIEWPORT_MAX_DIMENSION,
28 }),
29);
30
31const viewportAreaFilter = Schema.makeFilter(
32 ({ width, height }: { readonly width: number; readonly height: number }) =>
33 width * height <= PREVIEW_VIEWPORT_MAX_AREA ||
34 `Viewport area must not exceed ${PREVIEW_VIEWPORT_MAX_AREA} pixels.`,
35);
36
37export const PreviewViewportSize = Schema.Struct({
38 width: PreviewViewportDimension,
39 height: PreviewViewportDimension,
40}).check(viewportAreaFilter);
41export type PreviewViewportSize = typeof PreviewViewportSize.Type;
42
43/**
44 * The page's measured viewport can be smaller than the minimum selectable
45 * fixed size while fill mode follows a narrow panel. Keep measurement
46 * validation separate from the stricter user-selectable size constraints.
47 */
48export const PreviewRenderedViewportSize = Schema.Struct({
49 width: Schema.Int.check(Schema.isGreaterThan(0)),
50 height: Schema.Int.check(Schema.isGreaterThan(0)),
51});
52export type PreviewRenderedViewportSize = typeof PreviewRenderedViewportSize.Type;
53
54export const PREVIEW_VIEWPORT_PRESET_IDS = [
55 "iphone-se",
56 "iphone-xr",
57 "iphone-12-pro",
58 "iphone-14-pro-max",
59 "pixel-7",
60 "samsung-galaxy-s8-plus",
61 "samsung-galaxy-s20-ultra",
62 "ipad-mini",
63 "ipad-air",
64 "ipad-pro",
65 "surface-pro-7",
66 "surface-duo",
67 "galaxy-z-fold-5",
68 "asus-zenbook-fold",
69 "samsung-galaxy-a51-71",
70 "nest-hub",
71 "nest-hub-max",
72] as const;
73
74export const PreviewViewportPresetId = Schema.Literals(PREVIEW_VIEWPORT_PRESET_IDS);
75export type PreviewViewportPresetId = typeof PreviewViewportPresetId.Type;
76
77/**
78 * Preset IDs shipped before the Chrome-compatible catalog. Existing sessions
79 * can still reconnect with these values, but new resize requests only expose
80 * PREVIEW_VIEWPORT_PRESET_IDS.
81 */
82const LEGACY_PREVIEW_VIEWPORT_PRESET_IDS = [
83 "desktop-1920x1080",
84 "desktop-1440x900",
85 "laptop-1366x768",
86 "laptop-1280x800",
87 "ipad-pro-11",
88 "iphone-15-pro",
89 "pixel-8",
90 "galaxy-s24",
91] as const;
92
93const StoredPreviewViewportPresetId = Schema.Literals([
94 ...PREVIEW_VIEWPORT_PRESET_IDS,
95 ...LEGACY_PREVIEW_VIEWPORT_PRESET_IDS,
96]);
97
98export const PreviewViewportSetting = Schema.Union([
99 Schema.TaggedStruct("fill", {}),
100 Schema.TaggedStruct("freeform", {
101 ...PreviewViewportSize.fields,
102 }).check(viewportAreaFilter),
103 Schema.TaggedStruct("preset", {
104 ...PreviewViewportSize.fields,
105 presetId: StoredPreviewViewportPresetId,
106 }).check(viewportAreaFilter),
107]);
108export type PreviewViewportSetting = typeof PreviewViewportSetting.Type;
109
110export const FILL_PREVIEW_VIEWPORT = {
111 _tag: "fill",
112} as const satisfies PreviewViewportSetting;
113
20export const PreviewNavStatus = Schema.Union([114export const PreviewNavStatus = Schema.Union([
21 Schema.TaggedStruct("Idle", {}),115 Schema.TaggedStruct("Idle", {}),
22 Schema.TaggedStruct("Loading", {116 Schema.TaggedStruct("Loading", {
...@@ -42,6 +136,8 @@ export const PreviewSessionSnapshot = Schema.Struct({...@@ -42,6 +136,8 @@ export const PreviewSessionSnapshot = Schema.Struct({
42 navStatus: PreviewNavStatus,136 navStatus: PreviewNavStatus,
43 canGoBack: Schema.Boolean,137 canGoBack: Schema.Boolean,
44 canGoForward: Schema.Boolean,138 canGoForward: Schema.Boolean,
139 /** Missing snapshots from older servers are treated as fill-panel mode. */
140 viewport: Schema.optional(PreviewViewportSetting),
45 updatedAt: Schema.String,141 updatedAt: Schema.String,
46});142});
47export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type;143export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type;
...@@ -76,6 +172,13 @@ export const PreviewRefreshInput = Schema.Struct({...@@ -76,6 +172,13 @@ export const PreviewRefreshInput = Schema.Struct({
76});172});
77export type PreviewRefreshInput = typeof PreviewRefreshInput.Type;173export type PreviewRefreshInput = typeof PreviewRefreshInput.Type;
78174
175export const PreviewResizeInput = Schema.Struct({
176 threadId: ThreadId,
177 tabId: PreviewTabId,
178 viewport: PreviewViewportSetting,
179});
180export type PreviewResizeInput = typeof PreviewResizeInput.Type;
181
79export const PreviewCloseInput = Schema.Struct({182export const PreviewCloseInput = Schema.Struct({
80 threadId: ThreadId,183 threadId: ThreadId,
81 tabId: Schema.optional(PreviewTabId),184 tabId: Schema.optional(PreviewTabId),
...@@ -110,6 +213,12 @@ const PreviewNavigatedEvent = Schema.Struct({...@@ -110,6 +213,12 @@ const PreviewNavigatedEvent = Schema.Struct({
110 snapshot: PreviewSessionSnapshot,213 snapshot: PreviewSessionSnapshot,
111});214});
112215
216const PreviewResizedEvent = Schema.Struct({
217 ...PreviewEventBaseSchema.fields,
218 type: Schema.Literal("resized"),
219 snapshot: PreviewSessionSnapshot,
220});
221
113const PreviewFailedEvent = Schema.Struct({222const PreviewFailedEvent = Schema.Struct({
114 ...PreviewEventBaseSchema.fields,223 ...PreviewEventBaseSchema.fields,
115 type: Schema.Literal("failed"),224 type: Schema.Literal("failed"),
...@@ -127,6 +236,7 @@ const PreviewClosedEvent = Schema.Struct({...@@ -127,6 +236,7 @@ const PreviewClosedEvent = Schema.Struct({
127export const PreviewEvent = Schema.Union([236export const PreviewEvent = Schema.Union([
128 PreviewOpenedEvent,237 PreviewOpenedEvent,
129 PreviewNavigatedEvent,238 PreviewNavigatedEvent,
239 PreviewResizedEvent,
130 PreviewFailedEvent,240 PreviewFailedEvent,
131 PreviewClosedEvent,241 PreviewClosedEvent,
132]);242]);
packages/contracts/src/previewAutomation.ts+162-31
...@@ -1,7 +1,14 @@...@@ -1,7 +1,14 @@
1import { Schema } from "effect";1import { Schema } from "effect";
22
3import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";3import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4import { PreviewTabId } from "./preview.ts";4import {
5 PREVIEW_VIEWPORT_MAX_AREA,
6 PreviewRenderedViewportSize,
7 PreviewTabId,
8 PreviewViewportPresetId,
9 PreviewViewportSetting,
10 PreviewViewportSize,
11} from "./preview.ts";
5import { ProviderInstanceId } from "./providerInstance.ts";12import { ProviderInstanceId } from "./providerInstance.ts";
613
7const BoundedUrl = Schema.String.check(Schema.isTrimmed())14const BoundedUrl = Schema.String.check(Schema.isTrimmed())
...@@ -18,7 +25,8 @@ const OptionalTimeoutMs = Schema.optional(...@@ -18,7 +25,8 @@ const OptionalTimeoutMs = Schema.optional(
18 .annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }),25 .annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }),
19).annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." });26).annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." });
2027
21export const PreviewAutomationOperation = Schema.Literals([28/** Operations understood by desktop hosts predating viewport resizing. */
29export const PREVIEW_AUTOMATION_V1_OPERATIONS = [
22 "status",30 "status",
23 "open",31 "open",
24 "navigate",32 "navigate",
...@@ -31,7 +39,15 @@ export const PreviewAutomationOperation = Schema.Literals([...@@ -31,7 +39,15 @@ export const PreviewAutomationOperation = Schema.Literals([
31 "waitFor",39 "waitFor",
32 "recordingStart",40 "recordingStart",
33 "recordingStop",41 "recordingStop",
34]);42] as const;
43
44/** Advertised by current desktop hosts for mixed-version routing. */
45export const PREVIEW_AUTOMATION_OPERATIONS = [
46 ...PREVIEW_AUTOMATION_V1_OPERATIONS,
47 "resize",
48] as const;
49
50export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS);
35export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type;51export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type;
3652
37export const PreviewAutomationStatus = Schema.Struct({53export const PreviewAutomationStatus = Schema.Struct({
...@@ -41,6 +57,10 @@ export const PreviewAutomationStatus = Schema.Struct({...@@ -41,6 +57,10 @@ export const PreviewAutomationStatus = Schema.Struct({
41 url: Schema.NullOr(Schema.String),57 url: Schema.NullOr(Schema.String),
42 title: Schema.NullOr(Schema.String),58 title: Schema.NullOr(Schema.String),
43 loading: Schema.Boolean,59 loading: Schema.Boolean,
60 /** Optional for compatibility with desktop hosts predating viewport sizing. */
61 viewportSetting: Schema.optional(PreviewViewportSetting),
62 /** Measured guest-page viewport in CSS pixels when a webview is ready. */
63 viewport: Schema.optional(PreviewRenderedViewportSize),
44});64});
45export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type;65export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type;
4666
...@@ -134,6 +154,80 @@ export const PreviewAutomationNavigateInput = Schema.Struct({...@@ -134,6 +154,80 @@ export const PreviewAutomationNavigateInput = Schema.Struct({
134 });154 });
135export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type;155export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type;
136156
157export const PreviewAutomationResizeInput = Schema.Struct({
158 mode: Schema.Literals(["fill", "freeform", "preset"]).annotate({
159 description:
160 "Viewport mode: fill follows the preview panel, freeform uses exact independently resizable dimensions, and preset uses a named device size.",
161 }),
162 preset: Schema.optional(
163 PreviewViewportPresetId.annotate({
164 description: "Named viewport from Chrome DevTools' standard device catalog.",
165 }),
166 ).annotate({
167 description: "Named device size. Required only when mode is preset.",
168 }),
169 width: Schema.optional(
170 PreviewViewportSize.fields.width.annotate({
171 description: "Freeform viewport width in CSS pixels. Required only in freeform mode.",
172 }),
173 ).annotate({
174 description: "Freeform viewport width in CSS pixels. Required only in freeform mode.",
175 }),
176 height: Schema.optional(
177 PreviewViewportSize.fields.height.annotate({
178 description: "Freeform viewport height in CSS pixels. Required only in freeform mode.",
179 }),
180 ).annotate({
181 description: "Freeform viewport height in CSS pixels. Required only in freeform mode.",
182 }),
183 orientation: Schema.optional(
184 Schema.Literals(["portrait", "landscape"]).annotate({
185 description:
186 "Orientation for a fixed device preset. Defaults to the preset's native orientation.",
187 }),
188 ).annotate({
189 description:
190 "Orientation for a named device preset. It is not accepted in fill or freeform mode.",
191 }),
192 timeoutMs: OptionalTimeoutMs,
193})
194 .check(
195 Schema.makeFilter((input) => {
196 const hasPreset = input.preset !== undefined;
197 const hasWidth = input.width !== undefined;
198 const hasHeight = input.height !== undefined;
199 if (hasWidth !== hasHeight) return "Custom dimensions require both width and height.";
200 if (input.mode === "fill") {
201 return !hasPreset && !hasWidth && input.orientation === undefined
202 ? true
203 : "Fill mode does not accept a preset, dimensions, or orientation.";
204 }
205 if (input.mode === "freeform") {
206 if (!hasWidth || !hasHeight || hasPreset || input.orientation !== undefined) {
207 return "Freeform mode requires width and height and does not accept a preset or orientation.";
208 }
209 } else if (!hasPreset || hasWidth || hasHeight) {
210 return "Preset mode requires a preset and does not accept custom dimensions.";
211 }
212 if (hasWidth && hasHeight && input.width! * input.height! > PREVIEW_VIEWPORT_MAX_AREA) {
213 return `Custom viewport area must not exceed ${PREVIEW_VIEWPORT_MAX_AREA} pixels.`;
214 }
215 return true;
216 }),
217 )
218 .annotate({
219 description:
220 "Sets the active browser tab to fill-panel, independently resizable freeform, or named device-preset sizing.",
221 });
222export type PreviewAutomationResizeInput = typeof PreviewAutomationResizeInput.Type;
223
224export const PreviewAutomationResizeResult = Schema.Struct({
225 tabId: PreviewTabId,
226 setting: PreviewViewportSetting,
227 viewport: PreviewRenderedViewportSize,
228});
229export type PreviewAutomationResizeResult = typeof PreviewAutomationResizeResult.Type;
230
137const Locator = TrimmedNonEmptyString.annotate({231const Locator = TrimmedNonEmptyString.annotate({
138 description:232 description:
139 "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.",233 "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.",
...@@ -411,21 +505,33 @@ export const PreviewAutomationRecordingArtifact = Schema.Struct({...@@ -411,21 +505,33 @@ export const PreviewAutomationRecordingArtifact = Schema.Struct({
411});505});
412export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type;506export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type;
413507
414export const PreviewAutomationOwnerIdentity = Schema.Struct({508export const PreviewAutomationClientId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
415 clientId: TrimmedNonEmptyString,509export type PreviewAutomationClientId = typeof PreviewAutomationClientId.Type;
510export const PreviewAutomationConnectionId = TrimmedNonEmptyString.check(Schema.isMaxLength(64));
511export type PreviewAutomationConnectionId = typeof PreviewAutomationConnectionId.Type;
512
513export const PreviewAutomationHostIdentity = Schema.Struct({
514 clientId: PreviewAutomationClientId,
416 environmentId: EnvironmentId,515 environmentId: EnvironmentId,
417 threadId: ThreadId,
418});516});
419export type PreviewAutomationOwnerIdentity = typeof PreviewAutomationOwnerIdentity.Type;517export type PreviewAutomationHostIdentity = typeof PreviewAutomationHostIdentity.Type;
518
519export const PreviewAutomationHost = Schema.Struct({
520 ...PreviewAutomationHostIdentity.fields,
521 /**
522 * Missing means the pre-capability-negotiation V1 operation set. This lets
523 * a newer server safely coexist with an older desktop during rollout.
524 */
525 supportedOperations: Schema.optional(Schema.Array(PreviewAutomationOperation)),
526});
527export type PreviewAutomationHost = typeof PreviewAutomationHost.Type;
420528
421export const PreviewAutomationOwner = Schema.Struct({529export const PreviewAutomationHostFocus = Schema.Struct({
422 ...PreviewAutomationOwnerIdentity.fields,530 ...PreviewAutomationHostIdentity.fields,
423 tabId: Schema.NullOr(PreviewTabId),531 connectionId: PreviewAutomationConnectionId,
424 visible: Schema.Boolean,532 focused: Schema.Boolean,
425 supportsAutomation: Schema.Boolean,
426 focusedAt: Schema.String,
427});533});
428export type PreviewAutomationOwner = typeof PreviewAutomationOwner.Type;534export type PreviewAutomationHostFocus = typeof PreviewAutomationHostFocus.Type;
429535
430export const PreviewAutomationRequest = Schema.Struct({536export const PreviewAutomationRequest = Schema.Struct({
431 requestId: TrimmedNonEmptyString,537 requestId: TrimmedNonEmptyString,
...@@ -437,7 +543,22 @@ export const PreviewAutomationRequest = Schema.Struct({...@@ -437,7 +543,22 @@ export const PreviewAutomationRequest = Schema.Struct({
437});543});
438export type PreviewAutomationRequest = typeof PreviewAutomationRequest.Type;544export type PreviewAutomationRequest = typeof PreviewAutomationRequest.Type;
439545
546export const PreviewAutomationStreamEvent = Schema.Union([
547 Schema.Struct({
548 type: Schema.Literal("connected"),
549 connectionId: PreviewAutomationConnectionId,
550 }),
551 Schema.Struct({
552 type: Schema.Literal("request"),
553 connectionId: PreviewAutomationConnectionId,
554 request: PreviewAutomationRequest,
555 }),
556]);
557export type PreviewAutomationStreamEvent = typeof PreviewAutomationStreamEvent.Type;
558
440export const PreviewAutomationResponse = Schema.Struct({559export const PreviewAutomationResponse = Schema.Struct({
560 clientId: PreviewAutomationClientId,
561 connectionId: PreviewAutomationConnectionId,
441 requestId: TrimmedNonEmptyString,562 requestId: TrimmedNonEmptyString,
442 ok: Schema.Boolean,563 ok: Schema.Boolean,
443 result: Schema.optional(Schema.Unknown),564 result: Schema.optional(Schema.Unknown),
...@@ -477,6 +598,7 @@ const PreviewAutomationScopeErrorFields = {...@@ -477,6 +598,7 @@ const PreviewAutomationScopeErrorFields = {
477const PreviewAutomationRequestErrorFields = {598const PreviewAutomationRequestErrorFields = {
478 ...PreviewAutomationScopeErrorFields,599 ...PreviewAutomationScopeErrorFields,
479 clientId: TrimmedNonEmptyString,600 clientId: TrimmedNonEmptyString,
601 connectionId: PreviewAutomationConnectionId,
480 requestId: TrimmedNonEmptyString,602 requestId: TrimmedNonEmptyString,
481 tabId: Schema.optional(PreviewTabId),603 tabId: Schema.optional(PreviewTabId),
482 timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)),604 timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)),
...@@ -500,11 +622,12 @@ const PreviewAutomationOptionalRemoteDiagnosticFields = {...@@ -500,11 +622,12 @@ const PreviewAutomationOptionalRemoteDiagnosticFields = {
500 cause: Schema.optional(Schema.Defect()),622 cause: Schema.optional(Schema.Defect()),
501};623};
502624
503export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClass<PreviewAutomationNoFocusedOwnerError>()(625export class PreviewAutomationNoAvailableHostError extends Schema.TaggedErrorClass<PreviewAutomationNoAvailableHostError>()(
504 "PreviewAutomationNoFocusedOwnerError",626 "PreviewAutomationNoAvailableHostError",
505 {627 {
506 ...PreviewAutomationScopeErrorFields,628 ...PreviewAutomationScopeErrorFields,
507 clientId: Schema.optional(TrimmedNonEmptyString),629 clientId: Schema.optional(TrimmedNonEmptyString),
630 connectionId: Schema.optional(PreviewAutomationConnectionId),
508 requestId: Schema.optional(TrimmedNonEmptyString),631 requestId: Schema.optional(TrimmedNonEmptyString),
509 tabId: Schema.optional(PreviewTabId),632 tabId: Schema.optional(PreviewTabId),
510 timeoutMs: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),633 timeoutMs: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
...@@ -512,7 +635,7 @@ export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClas...@@ -512,7 +635,7 @@ export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClas
512 },635 },
513) {636) {
514 override get message(): string {637 override get message(): string {
515 const summary = `No focused preview automation owner is available for ${this.operation} in thread ${this.threadId}.`;638 const summary = `No preview automation host is available for ${this.operation} in environment ${this.environmentId}.`;
516 return summary;639 return summary;
517 }640 }
518}641}
...@@ -598,6 +721,26 @@ export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorCla...@@ -598,6 +721,26 @@ export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorCla
598 }721 }
599}722}
600723
724export class PreviewAutomationTargetNotEditableError extends Schema.TaggedErrorClass<PreviewAutomationTargetNotEditableError>()(
725 "PreviewAutomationTargetNotEditableError",
726 {
727 ...PreviewAutomationRequestErrorFields,
728 ...PreviewAutomationRemoteDiagnosticFields,
729 selectorKind: Schema.optional(Schema.Literals(["focused-element", "locator", "selector"])),
730 selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
731 },
732) {
733 override get message(): string {
734 if (this.selectorKind === "focused-element") {
735 return `Preview automation ${this.operation} requires an editable focused element.`;
736 }
737 if (this.selectorKind !== undefined && this.selectorLength !== undefined) {
738 return `Preview automation ${this.operation} requires an editable ${this.selectorKind} (${this.selectorLength} characters).`;
739 }
740 return `Preview automation ${this.operation} requires an editable target.`;
741 }
742}
743
601export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass<PreviewAutomationResultTooLargeError>()(744export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass<PreviewAutomationResultTooLargeError>()(
602 "PreviewAutomationResultTooLargeError",745 "PreviewAutomationResultTooLargeError",
603 {746 {
...@@ -615,18 +758,6 @@ export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClas...@@ -615,18 +758,6 @@ export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClas
615 }758 }
616}759}
617760
618export class PreviewAutomationHostNotConnectedError extends Schema.TaggedErrorClass<PreviewAutomationHostNotConnectedError>()(
619 "PreviewAutomationHostNotConnectedError",
620 {
621 ...PreviewAutomationScopeErrorFields,
622 clientId: TrimmedNonEmptyString,
623 },
624) {
625 override get message(): string {
626 return `Preview automation host ${this.clientId} is not connected for ${this.operation}.`;
627 }
628}
629
630export class PreviewAutomationClientDisconnectedError extends Schema.TaggedErrorClass<PreviewAutomationClientDisconnectedError>()(761export class PreviewAutomationClientDisconnectedError extends Schema.TaggedErrorClass<PreviewAutomationClientDisconnectedError>()(
631 "PreviewAutomationClientDisconnectedError",762 "PreviewAutomationClientDisconnectedError",
632 PreviewAutomationRequestErrorFields,763 PreviewAutomationRequestErrorFields,
...@@ -668,15 +799,15 @@ export class PreviewAutomationMalformedResponseError extends Schema.TaggedErrorC...@@ -668,15 +799,15 @@ export class PreviewAutomationMalformedResponseError extends Schema.TaggedErrorC
668799
669export const PreviewAutomationError = Schema.Union([800export const PreviewAutomationError = Schema.Union([
670 PreviewAutomationUnavailableError,801 PreviewAutomationUnavailableError,
671 PreviewAutomationNoFocusedOwnerError,802 PreviewAutomationNoAvailableHostError,
672 PreviewAutomationUnsupportedClientError,803 PreviewAutomationUnsupportedClientError,
673 PreviewAutomationTabNotFoundError,804 PreviewAutomationTabNotFoundError,
674 PreviewAutomationTimeoutError,805 PreviewAutomationTimeoutError,
675 PreviewAutomationControlInterruptedError,806 PreviewAutomationControlInterruptedError,
676 PreviewAutomationExecutionError,807 PreviewAutomationExecutionError,
677 PreviewAutomationInvalidSelectorError,808 PreviewAutomationInvalidSelectorError,
809 PreviewAutomationTargetNotEditableError,
678 PreviewAutomationResultTooLargeError,810 PreviewAutomationResultTooLargeError,
679 PreviewAutomationHostNotConnectedError,
680 PreviewAutomationClientDisconnectedError,811 PreviewAutomationClientDisconnectedError,
681 PreviewAutomationRequestQueueClosedError,812 PreviewAutomationRequestQueueClosedError,
682 PreviewAutomationRemoteUnavailableError,813 PreviewAutomationRemoteUnavailableError,
packages/contracts/src/rpc.ts+19-17
...@@ -103,14 +103,15 @@ import {...@@ -103,14 +103,15 @@ import {
103 PreviewOpenInput,103 PreviewOpenInput,
104 PreviewRefreshInput,104 PreviewRefreshInput,
105 PreviewReportStatusInput,105 PreviewReportStatusInput,
106 PreviewResizeInput,
106 PreviewSessionSnapshot,107 PreviewSessionSnapshot,
107} from "./preview.ts";108} from "./preview.ts";
108import {109import {
109 PreviewAutomationError,110 PreviewAutomationError,
110 PreviewAutomationOwner,111 PreviewAutomationHost,
111 PreviewAutomationOwnerIdentity,112 PreviewAutomationHostFocus,
112 PreviewAutomationRequest,
113 PreviewAutomationResponse,113 PreviewAutomationResponse,
114 PreviewAutomationStreamEvent,
114} from "./previewAutomation.ts";115} from "./previewAutomation.ts";
115import {116import {
116 ServerConfigStreamEvent,117 ServerConfigStreamEvent,
...@@ -190,14 +191,14 @@ export const WS_METHODS = {...@@ -190,14 +191,14 @@ export const WS_METHODS = {
190 // Preview methods191 // Preview methods
191 previewOpen: "preview.open",192 previewOpen: "preview.open",
192 previewNavigate: "preview.navigate",193 previewNavigate: "preview.navigate",
194 previewResize: "preview.resize",
193 previewRefresh: "preview.refresh",195 previewRefresh: "preview.refresh",
194 previewClose: "preview.close",196 previewClose: "preview.close",
195 previewList: "preview.list",197 previewList: "preview.list",
196 previewReportStatus: "preview.reportStatus",198 previewReportStatus: "preview.reportStatus",
197 previewAutomationConnect: "previewAutomation.connect",199 previewAutomationConnect: "previewAutomation.connect",
198 previewAutomationRespond: "previewAutomation.respond",200 previewAutomationRespond: "previewAutomation.respond",
199 previewAutomationReportOwner: "previewAutomation.reportOwner",201 previewAutomationFocusHost: "previewAutomation.focusHost",
200 previewAutomationClearOwner: "previewAutomation.clearOwner",
201202
202 // Server meta203 // Server meta
203 serverGetConfig: "server.getConfig",204 serverGetConfig: "server.getConfig",
...@@ -528,6 +529,12 @@ export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, {...@@ -528,6 +529,12 @@ export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, {
528 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),529 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
529});530});
530531
532export const WsPreviewResizeRpc = Rpc.make(WS_METHODS.previewResize, {
533 payload: PreviewResizeInput,
534 success: PreviewSessionSnapshot,
535 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
536});
537
531export const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, {538export const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, {
532 payload: PreviewRefreshInput,539 payload: PreviewRefreshInput,
533 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),540 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
...@@ -550,8 +557,8 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus,...@@ -550,8 +557,8 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus,
550});557});
551558
552export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, {559export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, {
553 payload: PreviewAutomationOwner,560 payload: PreviewAutomationHost,
554 success: PreviewAutomationRequest,561 success: PreviewAutomationStreamEvent,
555 error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),562 error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
556 stream: true,563 stream: true,
557});564});
...@@ -561,14 +568,9 @@ export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomati...@@ -561,14 +568,9 @@ export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomati
561 error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),568 error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
562});569});
563570
564export const WsPreviewAutomationReportOwnerRpc = Rpc.make(WS_METHODS.previewAutomationReportOwner, {571export const WsPreviewAutomationFocusHostRpc = Rpc.make(WS_METHODS.previewAutomationFocusHost, {
565 payload: PreviewAutomationOwner,572 payload: PreviewAutomationHostFocus,
566 error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),573 error: EnvironmentAuthorizationError,
567});
568
569export const WsPreviewAutomationClearOwnerRpc = Rpc.make(WS_METHODS.previewAutomationClearOwner, {
570 payload: PreviewAutomationOwnerIdentity,
571 error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
572});574});
573575
574export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, {576export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, {
...@@ -728,14 +730,14 @@ export const WsRpcGroup = RpcGroup.make(...@@ -728,14 +730,14 @@ export const WsRpcGroup = RpcGroup.make(
728 WsSubscribeTerminalMetadataRpc,730 WsSubscribeTerminalMetadataRpc,
729 WsPreviewOpenRpc,731 WsPreviewOpenRpc,
730 WsPreviewNavigateRpc,732 WsPreviewNavigateRpc,
733 WsPreviewResizeRpc,
731 WsPreviewRefreshRpc,734 WsPreviewRefreshRpc,
732 WsPreviewCloseRpc,735 WsPreviewCloseRpc,
733 WsPreviewListRpc,736 WsPreviewListRpc,
734 WsPreviewReportStatusRpc,737 WsPreviewReportStatusRpc,
735 WsPreviewAutomationConnectRpc,738 WsPreviewAutomationConnectRpc,
736 WsPreviewAutomationRespondRpc,739 WsPreviewAutomationRespondRpc,
737 WsPreviewAutomationReportOwnerRpc,740 WsPreviewAutomationFocusHostRpc,
738 WsPreviewAutomationClearOwnerRpc,
739 WsSubscribePreviewEventsRpc,741 WsSubscribePreviewEventsRpc,
740 WsSubscribeDiscoveredLocalServersRpc,742 WsSubscribeDiscoveredLocalServersRpc,
741 WsSubscribeServerConfigRpc,743 WsSubscribeServerConfigRpc,
packages/shared/package.json+4
...@@ -163,6 +163,10 @@...@@ -163,6 +163,10 @@
163 "types": "./src/preview.ts",163 "types": "./src/preview.ts",
164 "import": "./src/preview.ts"164 "import": "./src/preview.ts"
165 },165 },
166 "./previewViewport": {
167 "types": "./src/previewViewport.ts",
168 "import": "./src/previewViewport.ts"
169 },
166 "./filePreview": {170 "./filePreview": {
167 "types": "./src/filePreview.ts",171 "types": "./src/filePreview.ts",
168 "import": "./src/filePreview.ts"172 "import": "./src/filePreview.ts"
packages/shared/src/previewViewport.test.ts created+70
...@@ -0,0 +1,70 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 PREVIEW_VIEWPORT_PRESETS,
5 previewViewportLabel,
6 previewViewportPresetOrientation,
7 resolvePreviewViewport,
8} from "./previewViewport.ts";
9
10describe("previewViewport", () => {
11 it("resolves fill and exact freeform viewports", () => {
12 expect(resolvePreviewViewport({ mode: "fill" })).toEqual({ _tag: "fill" });
13 expect(resolvePreviewViewport({ mode: "freeform", width: 1024, height: 768 })).toEqual({
14 _tag: "freeform",
15 width: 1024,
16 height: 768,
17 });
18 });
19
20 it("resolves device presets in either orientation", () => {
21 expect(resolvePreviewViewport({ mode: "preset", preset: "iphone-12-pro" })).toEqual({
22 _tag: "preset",
23 width: 390,
24 height: 844,
25 presetId: "iphone-12-pro",
26 });
27 expect(
28 resolvePreviewViewport({
29 mode: "preset",
30 preset: "iphone-12-pro",
31 orientation: "landscape",
32 }),
33 ).toEqual({
34 _tag: "preset",
35 width: 844,
36 height: 390,
37 presetId: "iphone-12-pro",
38 });
39 });
40
41 it("matches Chrome's standard device catalog ordering", () => {
42 expect(PREVIEW_VIEWPORT_PRESETS.map((preset) => preset.label)).toEqual([
43 "iPhone SE",
44 "iPhone XR",
45 "iPhone 12 Pro",
46 "iPhone 14 Pro Max",
47 "Pixel 7",
48 "Samsung Galaxy S8+",
49 "Samsung Galaxy S20 Ultra",
50 "iPad Mini",
51 "iPad Air",
52 "iPad Pro",
53 "Surface Pro 7",
54 "Surface Duo",
55 "Galaxy Z Fold 5",
56 "Asus Zenbook Fold",
57 "Samsung Galaxy A51/71",
58 "Nest Hub",
59 "Nest Hub Max",
60 ]);
61 });
62
63 it("formats settings for compact UI", () => {
64 expect(previewViewportLabel({ _tag: "fill" })).toBe("Fill panel");
65 expect(previewViewportLabel({ _tag: "freeform", width: 393, height: 852 })).toBe("393 × 852");
66 expect(previewViewportPresetOrientation({ _tag: "freeform", width: 852, height: 393 })).toBe(
67 "landscape",
68 );
69 });
70});
packages/shared/src/previewViewport.ts created+186
...@@ -0,0 +1,186 @@
1import type {
2 PreviewAutomationResizeInput,
3 PreviewViewportPresetId,
4 PreviewViewportSetting,
5} from "@t3tools/contracts";
6import { PREVIEW_VIEWPORT_PRESET_IDS } from "@t3tools/contracts";
7
8export interface PreviewViewportPreset {
9 readonly id: PreviewViewportPresetId;
10 readonly label: string;
11 readonly category: "Desktop" | "Tablet" | "Phone";
12 readonly detail: string;
13 readonly width: number;
14 readonly height: number;
15}
16
17type PreviewViewportPresetDefinition = Omit<PreviewViewportPreset, "id">;
18
19// Keep this in Chrome DevTools' default-device order. Dimensions are CSS
20// viewport sizes from Chromium's EmulatedDevices.ts standard catalog.
21const PREVIEW_VIEWPORT_PRESET_DEFINITIONS = {
22 "iphone-se": {
23 label: "iPhone SE",
24 category: "Phone",
25 detail: "375 × 667",
26 width: 375,
27 height: 667,
28 },
29 "iphone-xr": {
30 label: "iPhone XR",
31 category: "Phone",
32 detail: "414 × 896",
33 width: 414,
34 height: 896,
35 },
36 "iphone-12-pro": {
37 label: "iPhone 12 Pro",
38 category: "Phone",
39 detail: "390 × 844",
40 width: 390,
41 height: 844,
42 },
43 "iphone-14-pro-max": {
44 label: "iPhone 14 Pro Max",
45 category: "Phone",
46 detail: "430 × 932",
47 width: 430,
48 height: 932,
49 },
50 "pixel-7": {
51 label: "Pixel 7",
52 category: "Phone",
53 detail: "412 × 915",
54 width: 412,
55 height: 915,
56 },
57 "samsung-galaxy-s8-plus": {
58 label: "Samsung Galaxy S8+",
59 category: "Phone",
60 detail: "360 × 740",
61 width: 360,
62 height: 740,
63 },
64 "samsung-galaxy-s20-ultra": {
65 label: "Samsung Galaxy S20 Ultra",
66 category: "Phone",
67 detail: "412 × 915",
68 width: 412,
69 height: 915,
70 },
71 "ipad-mini": {
72 label: "iPad Mini",
73 category: "Tablet",
74 detail: "768 × 1024",
75 width: 768,
76 height: 1024,
77 },
78 "ipad-air": {
79 label: "iPad Air",
80 category: "Tablet",
81 detail: "820 × 1180",
82 width: 820,
83 height: 1180,
84 },
85 "ipad-pro": {
86 label: "iPad Pro",
87 category: "Tablet",
88 detail: "1024 × 1366",
89 width: 1024,
90 height: 1366,
91 },
92 "surface-pro-7": {
93 label: "Surface Pro 7",
94 category: "Tablet",
95 detail: "912 × 1368",
96 width: 912,
97 height: 1368,
98 },
99 "surface-duo": {
100 label: "Surface Duo",
101 category: "Phone",
102 detail: "540 × 720",
103 width: 540,
104 height: 720,
105 },
106 "galaxy-z-fold-5": {
107 label: "Galaxy Z Fold 5",
108 category: "Phone",
109 detail: "344 × 882",
110 width: 344,
111 height: 882,
112 },
113 "asus-zenbook-fold": {
114 label: "Asus Zenbook Fold",
115 category: "Tablet",
116 detail: "853 × 1280",
117 width: 853,
118 height: 1280,
119 },
120 "samsung-galaxy-a51-71": {
121 label: "Samsung Galaxy A51/71",
122 category: "Phone",
123 detail: "412 × 914",
124 width: 412,
125 height: 914,
126 },
127 "nest-hub": {
128 label: "Nest Hub",
129 category: "Tablet",
130 detail: "1024 × 600",
131 width: 1024,
132 height: 600,
133 },
134 "nest-hub-max": {
135 label: "Nest Hub Max",
136 category: "Tablet",
137 detail: "1280 × 800",
138 width: 1280,
139 height: 800,
140 },
141} as const satisfies Record<PreviewViewportPresetId, PreviewViewportPresetDefinition>;
142
143export const PREVIEW_VIEWPORT_PRESETS: ReadonlyArray<PreviewViewportPreset> =
144 PREVIEW_VIEWPORT_PRESET_IDS.map((id) => ({
145 id,
146 ...PREVIEW_VIEWPORT_PRESET_DEFINITIONS[id],
147 }));
148
149export function resolvePreviewViewport(
150 input: PreviewAutomationResizeInput,
151): PreviewViewportSetting {
152 if (input.mode === "fill") return { _tag: "fill" };
153 if (input.mode === "preset" && input.preset !== undefined) {
154 const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === input.preset);
155 if (!preset) throw new Error(`Unknown preview viewport preset: ${input.preset}`);
156 const landscape = input.orientation === "landscape";
157 const portrait = input.orientation === "portrait";
158 const nativePortrait = preset.height >= preset.width;
159 const shouldSwap = (landscape && nativePortrait) || (portrait && !nativePortrait);
160 return {
161 _tag: "preset",
162 width: shouldSwap ? preset.height : preset.width,
163 height: shouldSwap ? preset.width : preset.height,
164 presetId: preset.id,
165 };
166 }
167 if (input.width === undefined || input.height === undefined) {
168 throw new Error("Custom preview viewport requires width and height");
169 }
170 return {
171 _tag: "freeform",
172 width: input.width,
173 height: input.height,
174 };
175}
176
177export function previewViewportLabel(viewport: PreviewViewportSetting): string {
178 return viewport._tag === "fill" ? "Fill panel" : `${viewport.width} × ${viewport.height}`;
179}
180
181export function previewViewportPresetOrientation(
182 viewport: PreviewViewportSetting,
183): "portrait" | "landscape" | null {
184 if (viewport._tag === "fill" || viewport.width === viewport.height) return null;
185 return viewport.width > viewport.height ? "landscape" : "portrait";
186}
scripts/dev-runner.test.ts+1
...@@ -253,6 +253,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -253,6 +253,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
253 T3CODE_MODE: "web",253 T3CODE_MODE: "web",
254 T3CODE_NO_BROWSER: "0",254 T3CODE_NO_BROWSER: "0",
255 T3CODE_HOST: "0.0.0.0",255 T3CODE_HOST: "0.0.0.0",
256 VITE_DEV_SERVER_URL: "http://127.0.0.1:8526",
256 VITE_WS_URL: "ws://localhost:13773",257 VITE_WS_URL: "ws://localhost:13773",
257 },258 },
258 serverOffset: 0,259 serverOffset: 0,
scripts/dev-runner.ts+5-8
...@@ -158,12 +158,6 @@ const optionalIntegerConfig = (name: string): Config.Config<number | undefined>...@@ -158,12 +158,6 @@ const optionalIntegerConfig = (name: string): Config.Config<number | undefined>
158 Config.option,158 Config.option,
159 Config.map((value) => Option.getOrUndefined(value)),159 Config.map((value) => Option.getOrUndefined(value)),
160 );160 );
161const optionalUrlConfig = (name: string): Config.Config<URL | undefined> =>
162 Config.url(name).pipe(
163 Config.option,
164 Config.map((value) => Option.getOrUndefined(value)),
165 );
166
167const OffsetConfig = Config.all({161const OffsetConfig = Config.all({
168 portOffset: optionalIntegerConfig("T3CODE_PORT_OFFSET"),162 portOffset: optionalIntegerConfig("T3CODE_PORT_OFFSET"),
169 devInstance: optionalStringConfig("T3CODE_DEV_INSTANCE"),163 devInstance: optionalStringConfig("T3CODE_DEV_INSTANCE"),
...@@ -621,8 +615,11 @@ const devRunnerCli = Command.make("dev-runner", {...@@ -621,8 +615,11 @@ const devRunnerCli = Command.make("dev-runner", {
621 ),615 ),
622 devUrl: Flag.string("dev-url").pipe(616 devUrl: Flag.string("dev-url").pipe(
623 Flag.withSchema(Schema.URLFromString),617 Flag.withSchema(Schema.URLFromString),
624 Flag.withDescription("Web dev URL override (forwards to VITE_DEV_SERVER_URL)."),618 Flag.withDescription(
625 Flag.withFallbackConfig(optionalUrlConfig("VITE_DEV_SERVER_URL")),619 "Explicit web dev URL override (forwards to VITE_DEV_SERVER_URL). Ambient VITE_DEV_SERVER_URL values are ignored so a parent dev app cannot redirect the child runner.",
620 ),
621 Flag.optional,
622 Flag.map(Option.getOrUndefined),
626 ),623 ),
627 dryRun: Flag.boolean("dry-run").pipe(624 dryRun: Flag.boolean("dry-run").pipe(
628 Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."),625 Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."),